Uh oh!
There was an error while loading. Please reload this page.
fix(core): make successThreshold bind from every status that records a failure - #12031
Conversation
…, not just two of them `PluginHealthMonitor` read `successThreshold` only while status was `unhealthy` or `degraded`. The first success wrote `recovering` — a status that gate did not name — so the second success took the outer `else` and reached `healthy` without consulting the counter; `failed` was skipped the same way and recovered on its first success. A declared value of 5 was indistinguishable from 2, and from 1 when recovery started at `failed`. The gate is now a map exhaustive over `PluginHealthStatus`: `degraded`, `unhealthy`, `failed` and `recovering` all keep the counter in force, and a status added to the spec fails to compile until it picks a side. `healthy` and `unknown` still promote on the first success — the count is declared as a recovery criterion and neither records a failure to recover from. Ref #11955 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b19eb25dab0674650f49ab5dee7556161d0db9a6 && git checkout b19eb25dab0674650f49ab5dee7556161d0db9a6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1e79aa4f812708d5d097ca33d9b7040e02b958c6 b797b30566f607154e20d60929df13c5c40279d6 && git checkout -B drift-repro 1e79aa4f812708d5d097ca33d9b7040e02b958c6 && git merge --no-ff b797b30566f607154e20d60929df13c5c40279d6
node scripts/docs-audit/affected-docs.mjs --json 1e79aa4f812708d5d097ca33d9b7040e02b958c6 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11955
PluginHealthMonitorconsultedsuccessThresholdonly while a plugin's status wasunhealthyordegraded. The first success in a recovery wroterecovering— a statusthat gate did not name — so the second success took the outer
elseand reachedhealthywithout the counter being read at all.failedwas in neither set either, so aplugin whose check threw recovered on its first success.
unhealthy/degradedsuccessThresholdsaidfailed/recoveringsuccessThresholdsaidA declared
successThreshold: 5was indistinguishable from2. The default is1—exactly the value at which the defect is invisible — so every case below declares
3.Which reading, and why the declaration decides it
The card was explicit that it does not decide between (a) counting from every
non-healthy status and (b) keeping
recoveringnon-counting, and that no ADR, commentor test states an intent. It asked for the reading the declaration supports, or a fork if
no declared text distinguishes them. Declared text does distinguish them, in three places:
recoveringis declared as a process, not a resting place.PluginHealthStatusSchemaannotates it "Plugin is in recovery process" — recovery under way, not finished. Under
(b) that is the one status in which the recovery criterion is never evaluated, which
contradicts its own annotation.
.describe('Consecutive successes needed to mark healthy')renders verbatim intocontent/docs/references/kernel/plugin-lifecycle-advanced.mdx:112. Under (b) thatpublished sentence is false for every declared value above 2.
failed's exit is already treated as counted by landed code.autoRestartnever fires for a health check that throws or times out — only for one that returns a failure #11852's fix (landed in983edf1) clears
successCounterson the thrown route. Zeroing a counter that is neverread from the status that route writes is dead code; the reset is only meaningful if the
exit from
failedis gated on it. That landed diff is committed intent, and it is theinterlock the card names.
So: (a), scoped to what the declaration actually says. The counter is now consulted on
the way out of every status that records an observed failure —
degraded,unhealthy,failed,recovering.healthyandunknowndeliberately still promote on the first success. The key's ownJSDoc scopes it to recovery — "Number of consecutive successes to recover from unhealthy
state" — and
unknown("Health status cannot be determined", the statusregisterPluginwrites) records no failure to recover from. Making
unknowncount would also have notruthful label to sit in while counting: it would report a never-failed plugin as
recovering, or need a new status — i.e. widening the public surface, which this card isnot. That boundary is pinned by a test so it cannot drift silently.
What changed
packages/core/src/health-monitor.ts— the gate is now a map that is exhaustive overPluginHealthStatus. A status added toPluginHealthStatusSchemafails to compile untilthis file places it on one side or the other, so the gate cannot quietly acquire a second
bypass the way
recoveringdid. No spec, schema or public surface changed; this is theenforcement side of an already-declared, already-documented config member catching up.
Non-vacuity — two ablations, direction predicted before running
Both mutations were proven on disk by anchored
grep -ccounts read before any result,rebuilt, and restored under
trap … EXIT INT TERM, with the tree verified clean after.unhealthy || degraded(from the base commit)unknownboundary test stays greenTests 5 failed | 13 passed, exactly those fivesuccessCountersreset fromrecordFailedRoundTests 1 failed | 17 passed, onlystarts the count over after a throw interrupts a recoveryThe second one is the point of the ⭐ in the card: #11852 shipped its reset with no
behavioural pin because the counter's only read site was unreachable with a stale non-zero
value, so any test would have passed for the wrong reason. Gating
failedon the countermakes that reset observable, and the ablation shows it is now load-bearing.
The subject is imported by the relative specifier
./health-monitor.jsinside its ownpackage, so vitest resolves
src/and nodist/sits on its path;@objectstack/corewasrebuilt in each leg anyway, and
scripts/ablation-dist-preflight.mjsconfirmed the firstmutation's marker was absent from all 12 built files.
One honest note, on my own harness rather than on the change. Leg 1's restore was written as
git checkout -- FILE, which restores from the index — and the mutation step,git checkout BASE_SHA -- FILE, had written the mutated content to the index as well as theworktree. So the trap ran, reported success, and left the file mutated. The post-ablation
clean-tree check is what caught it; the file was restored with
git checkout HEAD -- FILE,and leg 2 used that form from the start. No reading was taken on a mutated tree.
(Placeholders above are spelled without angle brackets deliberately: this body's first
revision wrote them as bracketed placeholders and GitHub's sanitizer ate every one of them,
turning that paragraph into a sentence about
git checkout --andgit checkout --.)Verification — all at
b797b30566pnpm --filter @objectstack/core test— 38 files, 950 tests passed (12 pre-existinghealth-monitor tests unchanged: at the default
successThreshold: 1every route isbyte-for-byte what it was).
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackagainst the actualdiff (3 paths). All 14 path-matched families plus the 6 convention-triggered ones ran
green, exit codes captured before any pipe — including
check:type-check-debt, whose own verdict line reads"32 ledger entr(ies) re-measured in 360.6s, 1898 raw tsc error(s) total, none above its
recorded number" (
@objectstack/coreis a ledger entry, so this diff could have moved it).check:nul-bytesgreen, plus a direct control-byte scan of the three changed files.Generated by Claude Code