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 start | Consecutive successes actually required |
|---|
unhealthy / degraded | 2, whatever successThreshold says |
failed / recovering | 1, 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
successCounters — unobservable 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.
Found while implementing #11852 (routing both health-check failure routes through the same
autoRestarthandling). Outside that card's surface — it is fenced to restart eligibility and thefailure counters — so this is filed rather than fixed.
What was measured
packages/core/src/health-monitor.tsatf540920189, inperformHealthCheck's success branch:successThresholdis consulted only when the current status isunhealthyordegraded. The firstsuccess moves the plugin to
recovering— which is in neither set — so the second success takesthe
elsebranch and goes straight tohealthywithout reading the counter at all.failedis inneither set either, so a plugin that threw recovers on its first success.
So the declared value is capped in practice:
unhealthy/degradedsuccessThresholdsaysfailed/recoveringsuccessThresholdsaysA declared
successThreshold: 5is indistinguishable from2.Declared contract
packages/spec/src/kernel/plugin-lifecycle-advanced.zod.ts:content/docs/references/kernel/plugin-lifecycle-advanced.mdx:112repeats it. Neither says thecount stops being read once recovery has begun, and
content/docs/protocol/kernel/lifecycle.mdxdescribes 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 declaredvalue above it is the one that misbehaves.
Why it is filed next to #11852
This is what makes the second asymmetry #11852 names — the
catchpath not clearingsuccessCounters— unobservable through the public API today. The read site above is the onlyplace
successCountersis consulted, and it is unreachable with a stale non-zero counter: thereturned-failure route always zeroes the counter before
unhealthy/degradedcan be entered, andthe 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
successThresholdfrom everynon-healthy status" is the reading the
describe()text suggests, butrecoveringmay have beenmeant as a terminal-ish display state rather than a counting one, and no ADR, comment or test states
an intent.
health-monitor.test.tsasserts nothing aboutsuccessThresholdon any route.