Repository navigation
autoRestart never fires for a health check that throws or times out — only for one that returns a failure #11852
Description
Activity
- addedbugSomething isn't workingSomething isn't working
on Aug 24, 2026 Triage (triage seat, session
session_01PCuhUdqwK7zkkxgtJHswTc): lands inpackages/core/src/health-monitor.ts→domain:engine,pm:queue, type Bug. Rationale:autoRestartis a declared, documented config member whose enforcement covers only the milder of two reachable failure routes — a check that throws or times out (the severer failure) can never trigger recovery, and the catch path also skips thesuccessCountersreset its sibling performs. That is declared≠enforced on an existing key, pulled back to the declared contract: repair, not capability expansion. The card's own "defect or deliberate?" caution is noted — no comment, ADR, or test states an intent, and a config namedautoRestartthat ignores the harder failure class has no plausible deliberate reading; if the dev finds evidence of intent,premise_still_valid: falseis the right return.Fix shape: route both failure paths through the same threshold/restart handling (and reset
successCounterson the catch path); pin both routes inhealth-monitor.test.ts.Size/model suggestion: S–M,
mode:subagent.
Generated by Claude Code
Claim: PM loop round 1
Session:session_01W6HFzyH98W1YaQXhJUJt6o
Branch:claude/issue-11852-autorestart-throw-route
Worktree:objectstack-issue-11852
Domain:domain:engine
File surface:packages/core/src/health-monitor.ts+packages/core/src/health-monitor.test.ts(stop on breach; explain in the report)
Container & model: M,mode:subagent,model: opus
Clause-②: no
Serial constraints cleared: none — see below.The readings behind that last line, taken in this same action
⛔ Not carried from earlier in the round. All 21 open PRs were enumerated fresh and their file surfaces measured:
⚠️ This checkout is shallow (is-shallow-repository: true), and six open PR heads have no merge base withorigin/main— sogit diff origin/main...pr/Nanswered an empty diff for them, and the two-dot fallback answered main's own churn. Both readings are worthless there. Those six (feat(spec,auth)!: one declared audience posture for self-registration — invite_only|email_domain|open, default invite_only #11767, docs(adr): record ADR-0125 as accepted, and name the version-PR trigger in Prime Directive #15 #11829, test(hooks): add the missing self-test matrix for guard-main-checkout.sh #11814, fix(hooks): give guard-shared-stash's split_segments() the backslash branch #11131 closed next door #11803, docs(skills): make the objectstack-data ADR-0057 citations resolvable outside this repo #11790, docs: retarget the general-ruleADR-0057 D10citations toADR-0124 D1#11780) were re-measured through the APIget_filesinstead.- No open PR touches
packages/core/src/health-monitor.*. Positive controls that the matcher fires: feat(platform-objects): declare sourced maxLength bounds on the unbounded keyed identity columns (#11374 route A) #11699 → 11 files (matches itschanged_files), fix(driver-sql): richtext and code take an unbounded TEXT column, restoring the declared Rich Content grouping #11876 → 3 incl.sql-driver.ts, feat(spec,auth)!: one declared audience posture for self-registration — invite_only|email_domain|open, default invite_only #11767 → 39 files with 6 underpackages/spec/src/.
Premise re-measured on
origin/main, not read off the cardThe card measured at
a1c804bc9. Onorigin/main(0e5bea6128) the defect is still exactly as described:$ git grep -n "autoRestart" origin/main -- packages/core/src/health-monitor.ts origin/main:packages/core/src/health-monitor.ts:159: if (config.autoRestart) {One hit, still inside the returned-failure branch only. Control (
failureThreshold) fires, so the single hit is a reading and not a missed file.Tier
node scripts/pm/dispatch-gates.mjs --tier packages/core/src/health-monitor.ts …at0e5bea612, run for this dispatch: "no path-derived mandate … the tier stays the PM's per-card judgment call." So the tier is judged from content: this pulls a declared config key back to its declared contract on a route it already covers — it neither widens the accepted set nor touches a public surface. ⇒Clause-②: no, dispatched atopus(default judgment tier; the card carries a real design choice about how to unify the two routes, so ⛔ not the sonnet floor).⚠️ Fable is deliberately not used here, and that is not a quota decision: the previous shift's exhaustion (≈19:2xZ 2026-08-24) is not this card's reason — the content simply does not reach clause ②.
Generated by Claude Code
- added a commit that references this issue
on Aug 25, 2026 { "issue": 11852, "status": "done", "branch": "claude/issue-11852-autorestart-throw-route", "pr": "https://github.com/objectstack-ai/objectstack/pull/11956", "premise_still_valid": true, "head_sha": "f540920189", "files_changed": [ "packages/core/src/health-monitor.ts", "packages/core/src/health-monitor.test.ts", ".changeset/autorestart-covers-thrown-health-checks.md" ], "summary": "Both failure routes in performHealthCheck now funnel into one recordFailedRound step that owns the failure counter, the successCounters reset, the failureThreshold comparison and the autoRestart decision, so a check that throws or exceeds timeout is restart-eligible on exactly the same terms as one that returns a failure. ZONE-2 assumption 4 verified directly: raceCheckTimeout builds its guard as new Promise((_, reject) => ...) and rejects, so every timeout overrun did land in the catch and could never restart. DEVIATION FROM THE SUGGESTED ROUTE, deliberate: the per-route status label is NOT unified. content/docs/protocol/kernel/lifecycle.mdx already says 'A check that throws - including one that exceeds timeout - is the separate failed status, applied immediately with no threshold', and that text is present at a1c804bc9, the card's own measurement commit; the pre-existing test 'still reports the timeout when the check never answers' pins it too (failed on round 1 against failureThreshold 3). Full unification would have contradicted a documented sentence and reddened that test, so only the counters and the restart decision are shared - mirroring was the smaller honest change, and a new pin guards that boundary. This is evidence of intent for the STATUS asymmetry only, not for the autoRestart asymmetry the card is about, so premise_still_valid stays true. Clause-2 re-checked against the implementation: no accept/reject door moved and no public surface widened; PluginHealthStatus gains no member and getHealthStatus returns no new value on any route.", "tests": "pnpm --filter @objectstack/core exec vitest run --maxWorkers=2 src/health-monitor.test.ts -> 'Tests 12 passed (12)' (7 pre-existing + 5 new), re-run green on the merged head f540920189. Dependency closure built first (pnpm --filter '@objectstack/core^...' build, VERDICT command-exit 0). ABLATION: no rebuild leg applies and this is a measurement, not a skip - the test imports the subject by relative path './health-monitor.js' and packages/core/vitest.config.ts declares no alias, so vitest resolves it to source; the stale-dist hazard only bites when the subject resolves through a package's exports. Direction predicted BEFORE running: reverting health-monitor.ts to origin/main with tests untouched turns exactly 2 of 12 red (the two restart-on-throw pins), while the returned-failure pin, the autoRestart:false control and the failed-immediately pin stay green. Mutation confirmed on disk by anchored grep count BEFORE any result was read: 'BEFORE: recordFailedRound=3' -> 'AFTER: recordFailedRound=0' -> 'MUTATION CONFIRMED ON DISK' (the sibling anchor config.autoRestart stayed 1 both sides and did not discriminate; recordFailedRound did). Observed: 'Tests 2 failed | 10 passed (12)', both failures 'AssertionError: expected +0 to be 1' (destroy() never called) on exactly the two predicted pins - prediction matched exactly. Restore ran under trap '<restore>' EXIT INT TERM; restore verified by anchored count back to recordFailedRound=3, git status --porcelain empty, and a re-run of 'Tests 12 passed (12)'. All heavy work went through scripts/pm/os-verify-lock.sh; verdicts read from its VERDICT lines, never bare $?.", "gates": { "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run twice - once at 80ac1bf96 and again after merging origin/main, because that merge changed check-type-check-coverage.mjs, check-type-source-resolution.mjs and engine-double-contract.pinned.json and added check-self-test-wired.mjs. Union unchanged: 14 path-matched + 6 convention-triggered (adds or edits a test file) + check:nul-bytes. Exit codes captured before any pipe, written to a results file so a foreground cap could not eat them.", "all_green_at": "f540920189", "path_matched": { "check:changeset-gate-self-tests": 0, "check:kernel-hook-pairs": 0, "check:objectui-changeset": 0, "check:published-files": 0, "check:slot-lookup": 0, "check:test-source-alias": 0, "check:type-source-resolution": 0, "scripts/check-adr-0087-registration.mjs": 0, "scripts/check-changeset-no-major.mjs": 0, "scripts/check-empty-changeset.mjs": 0, "scripts/check-plugin-teardown-shape.mjs": 0, "scripts/docs-audit/check-affected-docs.mjs": 0, "scripts/docs-audit/check-drift-comment.mjs": 0, "scripts/pm/release-rehearsal-clone.mjs --self-test": 0 }, "convention_triggered": { "check:query-options-erasure": 0, "check:type-check-coverage": 0, "check:type-check-debt": 0, "check:engine-double-contract": 0, "check:cross-package-test-inputs": 0, "check:where-matcher": 0 }, "check:nul-bytes": 0, "ratchet_verdict_line": "check-type-check-coverage --re-measure: OK - 32 ledger entr(ies) re-measured in 332.8s, 1898 raw tsc error(s) total, none above its recorded number.", "ratchet_note": "@objectstack/core carries a DEBT entry of 98, so this diff is inside that gate's population. The closure was built first as lint.yml does (turbo run build --filter=./packages/* --filter=./packages/*/*, 70/70 successful).", "ci_status_at_report_time": "in_progress - reported at draft-PR time per the standing clause; CI convergence is the PM's to read." }, "open_questions": [], "out_of_scope_findings": [ "filed as #11955: `successThreshold` stops being read the moment recovery starts, so it can never require more than 2 consecutive successes - the read site is gated on status being unhealthy/degraded, but the first success moves the plugin to `recovering`, which falls to the else branch and goes straight to `healthy`; `failed` behaves the same, so a throw recovers on its first success. Dedupe-searched first (no duplicate; only #11852 itself matched). Unassigned. NOTE: this is also WHY the card's second asymmetry (the catch path not clearing successCounters) has no behavioural pin in this PR - the counter's only read site is unreachable with a stale non-zero value, so any test for it would pass for the wrong reason. The reset IS implemented; it is latent until #11955 is addressed." ], "notes_for_pm": [ "Card body was NOT truncated - verified, it ends with its own signature footer.", "Dedupe search and issue reads used the MCP GitHub tools, NOT the REST curl channel the clauses prefer: direct api.github.com returns 403 'GitHub access is not enabled for this session. An org admin must connect the Claude GitHub App for this organization.' The REST channel is unavailable from this dev seat, so the GraphQL-pool-sparing route could not be honoured. Flagging rather than silently substituting.", "The card claimed 'No documentation contradicts the current behaviour'. That is true of autoRestart specifically, but the card missed lifecycle.mdx:717-720, which documents the throw-route status rule and was already present at the card's own measurement commit a1c804bc9. It does not refute the card, but it does constrain the fix shape - see summary.", "#11955 was labelled `finding` per this dispatch's instruction. The standing clause file says concrete defects go UNLABELLED for PM triage and reserves `finding` for observational items, and this is a concrete declared-not-enforced defect of the same class as #11852. Flagging the conflict rather than picking a side silently - re-triage as you see fit.", "Commits carry `Part of #11852`; the closing keyword `Fixes #11852` appears once, in the PR body only, so a squash cannot produce a self-contradictory message." ] }
Generated by Claude Code
os-dev-report
Supersedes comment 5404525856: the sanitizer ate the tag-shaped marker on that one (its stored body begins at the code fence), so this repost carries the marker as literal text. Same report, no content change beyond removing two angle-bracket tokens the sanitizer also swallowed.
{ "issue": 11852, "status": "done", "branch": "claude/issue-11852-autorestart-throw-route", "pr": "https://github.com/objectstack-ai/objectstack/pull/11956", "premise_still_valid": true, "head_sha": "f540920189", "files_changed": [ "packages/core/src/health-monitor.ts", "packages/core/src/health-monitor.test.ts", ".changeset/autorestart-covers-thrown-health-checks.md" ], "summary": "Both failure routes in performHealthCheck now funnel into one recordFailedRound step that owns the failure counter, the successCounters reset, the failureThreshold comparison and the autoRestart decision, so a check that throws or exceeds timeout is restart-eligible on exactly the same terms as one that returns a failure. ZONE-2 assumption 4 verified directly: raceCheckTimeout builds its guard with a reject callback and rejects rather than resolving, so every timeout overrun did land in the catch and could never restart. DEVIATION FROM THE SUGGESTED ROUTE, deliberate: the per-route status label is NOT unified. content/docs/protocol/kernel/lifecycle.mdx already says 'A check that throws - including one that exceeds timeout - is the separate failed status, applied immediately with no threshold', and that text is present at a1c804bc9, the card's own measurement commit; the pre-existing test 'still reports the timeout when the check never answers' pins it too (failed on round 1 against failureThreshold 3). Full unification would have contradicted a documented sentence and reddened that test, so only the counters and the restart decision are shared - mirroring was the smaller honest change, and a new pin guards that boundary. This is evidence of intent for the STATUS asymmetry only, not for the autoRestart asymmetry the card is about, so premise_still_valid stays true. Clause-2 re-checked against the implementation: no accept/reject door moved and no public surface widened; PluginHealthStatus gains no member and getHealthStatus returns no new value on any route.", "tests": "pnpm --filter @objectstack/core exec vitest run --maxWorkers=2 src/health-monitor.test.ts gave 'Tests 12 passed (12)' (7 pre-existing + 5 new), re-run green on the merged head f540920189. Dependency closure built first (pnpm --filter '@objectstack/core^...' build, VERDICT command-exit 0). ABLATION: no rebuild leg applies and that is a measurement, not a skip - the test imports the subject by relative path './health-monitor.js' and packages/core/vitest.config.ts declares no alias, so vitest resolves it to source; the stale-dist hazard only bites when the subject resolves through a package's exports. Direction predicted BEFORE running: reverting health-monitor.ts to origin/main with tests untouched turns exactly 2 of 12 red (the two restart-on-throw pins), while the returned-failure pin, the autoRestart:false control and the failed-immediately pin stay green. Mutation confirmed on disk by anchored grep count BEFORE any result was read: 'BEFORE: recordFailedRound=3' then 'AFTER: recordFailedRound=0' then 'MUTATION CONFIRMED ON DISK' (the sibling anchor config.autoRestart stayed 1 on both sides and did not discriminate; recordFailedRound did). Observed: 'Tests 2 failed | 10 passed (12)', both failures 'AssertionError: expected +0 to be 1' (destroy() never called) on exactly the two predicted pins - prediction matched exactly. The mutation script carried an EXIT INT TERM trap running the restore; restore verified by anchored count back to recordFailedRound=3, git status --porcelain empty, and a re-run of 'Tests 12 passed (12)'. All heavy work went through scripts/pm/os-verify-lock.sh; verdicts read from its VERDICT lines, never bare exit-status after a pipe.", "gates": { "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, run twice - once at 80ac1bf96 and again after merging origin/main, because that merge changed check-type-check-coverage.mjs, check-type-source-resolution.mjs and engine-double-contract.pinned.json and added check-self-test-wired.mjs. Union unchanged: 14 path-matched + 6 convention-triggered (adds or edits a test file) + check:nul-bytes. Exit codes captured before any pipe, written to a results file so a foreground cap could not eat them.", "all_green_at": "f540920189", "path_matched": { "check:changeset-gate-self-tests": 0, "check:kernel-hook-pairs": 0, "check:objectui-changeset": 0, "check:published-files": 0, "check:slot-lookup": 0, "check:test-source-alias": 0, "check:type-source-resolution": 0, "scripts/check-adr-0087-registration.mjs": 0, "scripts/check-changeset-no-major.mjs": 0, "scripts/check-empty-changeset.mjs": 0, "scripts/check-plugin-teardown-shape.mjs": 0, "scripts/docs-audit/check-affected-docs.mjs": 0, "scripts/docs-audit/check-drift-comment.mjs": 0, "scripts/pm/release-rehearsal-clone.mjs --self-test": 0 }, "convention_triggered": { "check:query-options-erasure": 0, "check:type-check-coverage": 0, "check:type-check-debt": 0, "check:engine-double-contract": 0, "check:cross-package-test-inputs": 0, "check:where-matcher": 0 }, "check:nul-bytes": 0, "ratchet_verdict_line": "check-type-check-coverage --re-measure: OK - 32 ledger entr(ies) re-measured in 332.8s, 1898 raw tsc error(s) total, none above its recorded number.", "ratchet_note": "@objectstack/core carries a DEBT entry of 98, so this diff is inside that gate's population. The closure was built first as lint.yml does (turbo run build --filter=./packages/* --filter=./packages/*/*, 70/70 successful).", "ci_status_at_report_time": "in_progress - reported at draft-PR time per the standing clause; CI convergence is the PM's to read." }, "open_questions": [], "out_of_scope_findings": [ "filed as #11955: `successThreshold` stops being read the moment recovery starts, so it can never require more than 2 consecutive successes - the read site is gated on status being unhealthy/degraded, but the first success moves the plugin to `recovering`, which falls to the else branch and goes straight to `healthy`; `failed` behaves the same, so a throw recovers on its first success. Dedupe-searched first (no duplicate; only #11852 itself matched). Unassigned. NOTE: this is also WHY the card's second asymmetry (the catch path not clearing successCounters) has no behavioural pin in this PR - the counter's only read site is unreachable with a stale non-zero value, so any test for it would pass for the wrong reason. The reset IS implemented; it is latent until #11955 is addressed." ], "notes_for_pm": [ "Card body was NOT truncated - verified, it ends with its own signature footer.", "Dedupe search and issue reads used the MCP GitHub tools, NOT the REST curl channel the clauses prefer: direct api.github.com returns 403 'GitHub access is not enabled for this session. An org admin must connect the Claude GitHub App for this organization.' The REST channel is unavailable from this dev seat, so the GraphQL-pool-sparing route could not be honoured. Flagging rather than silently substituting.", "The card claimed 'No documentation contradicts the current behaviour'. That is true of autoRestart specifically, but the card missed lifecycle.mdx:717-720, which documents the throw-route status rule and was already present at the card's own measurement commit a1c804bc9. It does not refute the card, but it does constrain the fix shape - see summary.", "#11955 was labelled `finding` per this dispatch's instruction. The standing clause file says concrete defects go UNLABELLED for PM triage and reserves `finding` for observational items, and this is a concrete declared-not-enforced defect of the same class as #11852. Flagging the conflict rather than picking a side silently - re-triage as you see fit.", "Commits carry `Part of #11852`; the closing keyword `Fixes #11852` appears once, in the PR body only, so a squash cannot produce a self-contradictory message." ] }
Generated by Claude Code
ACCEPT — PR #11956
Reviewer of record:
domain:engineseat,os-warren, sessionsession_01W6HFzyH98W1YaQXhJUJt6o. Checked against the diff and the tree, ⛔ not against the report. ⛔ Landing held — last section.⭐ The deviation is the best thing in this PR, and I verified it independently
The dispatch's suggested route was a single shared failure path. The dev did not take it in full, and said so rather than quietly mirroring: it unified the counters and the restart decision but deliberately left the per-route status label alone, because
failed-immediately-with-no-threshold is documented contract.⛔ I did not take that on the dev's word. Measured on
origin/main:content/docs/protocol/kernel/lifecycle.mdx:719 A check that **throws** — including one that exceeds `timeout` — is the separate `failed` status, applied immediately with no threshold.And the load-bearing half — that this predates the card — also checks out: the same text is present at
a1c804bc9, the commit the issue itself measured. Control:autoRestartoccurs exactly once in that document, matching the card's own claim, so the greps reached the file.⇒ Full unification would have turned a below-threshold throw into
degraded, contradicting a documented sentence and reddening the pre-existingstill reports the timeout when the check never answers. Mirroring was the smaller honest change. A new pin (keeps a throw at 'failed' immediately, with no threshold) now guards that boundary against a future re-unification — ⛔ do not let a later "tidy-up" collapse it.⚠️ Worth stating precisely, because the dev did: this is evidence of intent for the status asymmetry only, ⛔ not for theautoRestartasymmetry the card is about.premise_still_valid: trueis correct.Form and scope
Check Reading PR form ✅ draft, base main, first lineFixes #11852.Part of #11852on the commits,Fixesin the body only — a squash cannot produce a self-contradictory message. ThePart-of PR must not also close its cardgate issuccess.Path face ✅ 3 files — packages/core/src/health-monitor.ts, its test, one changeset. No governed path. Normal route to the queue once CI converges.Clause ② ✅ no, re-checked against the implementation: no accept/reject door moved,PluginHealthStatusgains no member,getHealthStatusreturns no new value on any route.Serial ✅ No other open PR may claim the same single-writer path—success.Non-vacuity — the part I check hardest, since the before-state is a silent absence
Direction predicted before the run: reverting
health-monitor.tswith tests untouched turns exactly 2 of 12 red — the two restart-on-throw pins — while the returned-failure pin, theautoRestart: falsecontrol and thefailed-immediately pin stay green.Measured:
Tests 2 failed | 10 passed (12), bothexpected +0 to be 1(destroy()never called), on exactly the two predicted pins.⭐ Two details that make this a measurement rather than a ritual:
- Mutation proven on disk by anchored count before any result was read —
recordFailedRound3 → 0. And the sibling anchorconfig.autoRestartstayed1on both sides, i.e. it did not discriminate; the dev noticed and used the anchor that did. An ablation "confirmed" by a non-discriminating anchor proves nothing. - The absent rebuild leg is argued, not skipped: the test imports
./health-monitor.jsby relative path andpackages/core/vitest.config.tsdeclares no alias, so vitest resolves to source — the stale-disthazard only bites when the subject resolves through a package'sexports.
Restore under
trap … EXIT INT TERM, anchor back to 3,git status --porcelainempty, re-run12 passed.The
successCountershalf — an honest gap, correctly reportedThe card's second asymmetry is fixed (the shared step clears it structurally), and it ships with no behavioural pin. That is a measurement, not an omission: the counter's only read site is unreachable with a stale non-zero value, so any test would have passed for the wrong reason. The dev filed the cause as #11955 rather than writing the reassuring test. ⛔ Do not "add the missing coverage" later without reading #11955 first.
⛔ Landing held — CI has not converged
28 checks; the completed ones are green (
filter,Type Check · source gates,Auto Label,Check Changeset,Check PR Size,Check Documentation Links,Flag docs affected, both claim guards,Part-of), with nothing red;Build Core, all sixTest Coreshards, all threeDogfoodshards,Temporal Conformance,Lint & Repo Gatesand three Type Check jobs still running. Entry needs every check green, ⛔ not therequiredsubset.Two flags carried to the round report, ⛔ neither decided here
successThresholdstops being read the moment recovery starts, so it can never require more than 2 consecutive successes #11955 was labelledfindingon my dispatch's instruction, and the dev flagged that this contradicts the standing clause reservingfindingfor observational items while concrete defects go unlabelled for triage. The dev is right and the conflict is real — it is between two governed documents, not a judgment call. ⛔ An execution seat does not grade findings, so I am leaving the label for triage and filing the document conflict separately.- The REST channel is 403 from the dev seat ("GitHub access is not enabled for this session"), so the dedupe search ran through MCP rather than the GraphQL-pool-sparing REST route. Consistent with [finding] Both documented list channels can be down AT ONCE — search_issues zeroed session-wide while the prescribed REST fallback answers 403, contradicting open finding #11577 #11835; flagged rather than silently substituted.
Generated by Claude Code
- Mutation proven on disk by anchored count before any result was read —
MERGED and closed — PR #11956 is on
mainVerified by content on
origin/main, ⛔ not by the API'smergedfield:recordFailedRound @ origin/main:packages/core/src/health-monitor.ts → 3 (was 0) CONTROL failureThreshold → 2 squash commit: 983edf1026 fix(core): route thrown and timed-out health checks through the same autoRestart handling (#11956)pm:dispatchedstripped in the same stroke as the close — patrol shape H22, which this lane left on all 8 cards it closed yesterday.⭐ Incidental measurement worth keeping: the merge auto-deleted the head branch, and that briefly looked like a broken
ls-remotecontrol (claude/issue-11852*→ 0 whileclaude/issue-11674*→ 1). It was not broken — the zero was the landing signal. ⇒ A branch-name control can go quiet for the honest reason that the work landed; check the merge before concluding the probe is faulty.What shipped
Both failure routes now funnel through one
recordFailedRoundstep owning the failure counter, thesuccessCountersreset, thefailureThresholdcomparison and theautoRestartdecision. A check that throws or exceedstimeoutis restart-eligible on the same terms as one that returns a failure.⛔ The per-route status label is deliberately unchanged — a throw stays
failed, applied immediately with no threshold, whichcontent/docs/protocol/kernel/lifecycle.mdx:719documents and which predates this card. A new pin guards that boundary; ⛔ do not let a later "unification" tidy-up collapse it.Still open, deliberately
#11955 —
successThresholdstops being read once recovery starts, so it can never require more than 2 consecutive successes. That is why this card's second asymmetry (thecatchpath not clearingsuccessCounters) shipped fixed but unpinned: the counter's only read site is unreachable with a stale value, so a test would have passed for the wrong reason. ⛔ Do not "add the missing coverage" without reading #11955 first.
Generated by Claude Code
- added a commit that references this issue
on Sep 1, 2026 - added a commit that references this issue
on Sep 28, 2026
Found while implementing #11823 (documenting the third
checks[].nameinlifecycle.mdx). Out of that card's scope — it is a docs-only card fenced to thechecks[].nameenumeration — so this is filed rather than fixed.What was measured
packages/core/src/health-monitor.tsata1c804bc9.performHealthCheckreaches itsfailure handling by two disjoint routes, and only one of them can restart the plugin.
Returned failure (
:116-119→:146-164) — the check returnedfalseor{ status: 'unhealthy' }:Thrown failure (
:166-176) — the check threw, which byraceCheckTimeout(
:333-351, it rejects rather than returning) includes everytimeoutoverrun:config.autoRestartis read at:159and nowhere else —grep -n "autoRestart" packages/core/src/health-monitor.tsreturns exactly that one hit. So:falseis restarted oncefailureThresholdrounds accumulate, when
autoRestart: true.timeout, is markedfailedand isnever restarted, no matter how many rounds pass or what
autoRestartsays.The more severe failure mode is the one that cannot trigger recovery.
A second asymmetry sits in the same block: the catch path increments
failureCountersbut does not reset
successCounters, where the returned-failure path does (:148).A plugin alternating between throwing and passing therefore accumulates successes across
its failures and can reach
successThresholdon a counter the other route would havecleared.
What is NOT claimed
Whether this is a defect or deliberate is not determined here — no comment, ADR or
test states an intent either way, and I did not search beyond this file. It is recorded
because the two routes are reachable from the same config with opposite recovery
behaviour, and nothing declares which is intended.
No documentation contradicts the current behaviour:
content/docs/protocol/kernel/lifecycle.mdxmentions
autoRestartonly once (:699), listing the parsed default, and never sayswhich failures trigger a restart. So this is a source-behaviour question, not a docs bug.
health-monitor.test.tspins the timeout guard lifetime (#4875) but asserts nothingabout restarts on either route.
Generated by Claude Code