Skip to content

successThreshold stops being read the moment recovery starts, so it can never require more than 2 consecutive successes #11955

Description

@os-warren

Found while implementing #11852 (routing both health-check failure routes through the same
autoRestart handling). Outside that card's surface — it is fenced to restart eligibility and the
failure counters — so this is filed rather than fixed.

What was measured

packages/core/src/health-monitor.ts at f540920189, in performHealthCheck's success branch:

constcurrentStatus=this.healthStatus.get(pluginName);if(currentStatus==='unhealthy'||currentStatus==='degraded'){constsuccessCount=this.successCounters.get(pluginName)||0;if(successCount>=config.successThreshold){this.healthStatus.set(pluginName,'healthy');}else{this.healthStatus.set(pluginName,'recovering');}}else{this.healthStatus.set(pluginName,'healthy');}

successThreshold is consulted only when the current status is unhealthy or degraded. The first
success moves the plugin to recovering — which is in neither set — so the second success takes
the else branch and goes straight to healthy without reading the counter at all. failed is in
neither set either, so a plugin that threw recovers on its first success.

So the declared value is capped in practice:

Status when the successes startConsecutive successes actually required
unhealthy / degraded2, whatever successThreshold says
failed / recovering1, whatever successThreshold says

A declared successThreshold: 5 is indistinguishable from 2.

Declared contract

packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts:

successThreshold: z.number().int().min(1).default(1).describe('Consecutive successes needed to mark healthy'),

content/docs/references/kernel/plugin-lifecycle-advanced.mdx:112 repeats it. Neither says the
count stops being read once recovery has begun, and content/docs/protocol/kernel/lifecycle.mdx
describes the failure side of the threshold machinery without describing this side.

The default is 1, which is exactly the value at which the defect is invisible — every declared
value above it is the one that misbehaves.

Why it is filed next to #11852

This is what makes the second asymmetry #11852 names — the catch path not clearing
successCountersunobservable through the public API today. The read site above is the only
place successCounters is consulted, and it is unreachable with a stale non-zero counter: the
returned-failure route always zeroes the counter before unhealthy/degraded can be entered, and
the throw route sets failed, which the branch short-circuits past. #11852's fix performs the reset
(correct counter hygiene, and it becomes load-bearing the moment this card is addressed), but no
behavioural pin can distinguish it while the read site cannot be reached. That is recorded in that
PR rather than papered over with a test that would pass for the wrong reason.

What is NOT claimed

Which behaviour is intended is not determined here. "Consult successThreshold from every
non-healthy status" is the reading the describe() text suggests, but recovering may have been
meant as a terminal-ish display state rather than a counting one, and no ADR, comment or test states
an intent. health-monitor.test.ts asserts nothing about successThreshold on any route.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions