Skip to content

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

Closed

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:

const currentStatus = this.healthStatus.get(pluginName);
if (currentStatus === 'unhealthy' || currentStatus === 'degraded') {
  const successCount = 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.

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land
    on Aug 25, 2026
  2. claude commented on Aug 25, 2026

    @claude
    Contributor

    Concentrated triage round: finding → pm:queue + domain:engine stands, Bug — declared≠enforced on a config key: successThreshold is consulted only while status ∈ {unhealthy, degraded}, and the first success moves status to recovering (in neither set), so the second success bypasses the counter entirely; the key can never demand more than 2. Premise re-verified on origin/main (health-monitor.ts:137). Hard serial: after #11852 (in flight, pm:dispatched, same health-check path in the same file) — dispatch only after its PR lands, on the merged ref.


    Generated by Claude Code

  3. added
    pm:queueReady for the PM dispatch loop
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 25, 2026
  4. added theissue type on Aug 25, 2026
  5. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 25, 2026
  6. self-assigned this
    on Aug 25, 2026
  7. os-warren commented on Aug 25, 2026

    @os-warren
    CollaboratorAuthor

    Claim: PM loop round 3
    Session: session_01W6HFzyH98W1YaQXhJUJt6o
    Branch: claude/issue-11955-success-threshold-read
    Worktree: objectstack-issue-11955
    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 — successThreshold is a declared, documented config member (z.number().int().min(1).default(1), "Consecutive successes needed to mark healthy") that is enforced only from two of four statuses. Making it bind is declared ≠ enforced catching up on the enforcement side; no contract accept/reject door moves and no public surface widens.
    Serial constraints cleared: none — health-monitor.ts is touched by no open PR (its last occupant, PR #11956, merged and its branch was auto-deleted). Disjoint from both cards in flight (schema-drift.ts #11535, sql-driver-aggregation-conformance.test.ts #11456) and from the open driver PRs.

    Premise re-measured on origin/main — after the refactor that could have moved it

    ⚠️ This card was filed against PR #11956's head, and #11956 has since merged, refactoring performHealthCheck's failure routes. The obvious risk is that the refactor moved or fixed the success branch and left this card stale. It did not — measured, ⛔ not assumed:

    origin/main:packages/core/src/health-monitor.ts
      :139   if (currentStatus === 'unhealthy' || currentStatus === 'degraded') {
      :141     if (successCount >= config.successThreshold) {
      :145     } else { … 'recovering' }
      :147   } else {
      :148     this.healthStatus.set(pluginName, 'healthy');
    

    successThreshold is still read only inside the unhealthy || degraded gate. recovering and failed are in neither set, so they take the outer else and reach healthy without the counter being consulted at all. #11956's change is visible one line below (failureRoute = 'returned') and is confined to the failure routes.

    ⇒ The defect is exactly as filed. A declared successThreshold: 5 is still indistinguishable from 2 — and from 1 when recovery starts at failed.

    Why this card matters more than its size

    ⭐ It is the reason #11852 shipped a correct fix with no behavioural pin. That card fixed the catch path's missing successCounters reset, and could not test it: 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 reported that honestly instead of writing the reassuring test.

    ⇒ Closing this makes that reset observable, and load-bearing. ⚠️ Whoever takes it should read #11852's landed diff first — the two interlock, and a fix here that ignores the reset would re-open the asymmetry from the other side.

    ⚠️ The one thing this card does NOT decide, and must not decide silently

    The card is explicit that intent is undetermined:

    "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.

    ⇒ Two defensible shapes, and they differ in observable behaviour:

    • (a) count from every non-healthy status (recovering and failed included) — the reading the declared text supports;
    • (b) keep recovering non-counting and fix only the arithmetic.

    Pick the one the declaration supports, state which you picked and why in the PR body, and pin the chosen semantics. ⚠️ If measurement shows the two are not distinguishable by any declared text — i.e. this is a genuine product question rather than a defect — that is a fork: stop and report, and it becomes a decision card. ⛔ Do not pick a side quietly because one is easier to test.

    ⚠️ Note the default is 1, exactly the value at which the defect is invisible. Every declared value above it is the one that misbehaves — so a pin at the default proves nothing.


    Generated by Claude Code

  8. os-warren commented on Aug 25, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 11955,
      "status": "done",
      "branch": "claude/issue-11955-success-threshold-read",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12031",
      "head_sha": "b797b30566",
      "premise_still_valid": true,
      "summary": "Premise re-measured on origin/main at 1620c1de28 and holds exactly as filed: successThreshold was read only inside the `unhealthy || degraded` gate, so `recovering` and `failed` took the outer else and reached `healthy` with the counter unread. The (a)/(b) fork is NOT a product question — three pieces of declared text distinguish them, so no escalation: (1) `recovering` is annotated 'Plugin is in recovery process', i.e. under way, so (b) makes it the one status in which the recovery criterion is never evaluated; (2) the published `.describe()` line 'Consecutive successes needed to mark healthy' is false under (b) for every declared value above 2; (3) #11852's landed reset of successCounters on the thrown route is dead code unless the exit from `failed` is counter-gated, so the landed diff is committed intent. Chose (a), scoped to statuses that record an OBSERVED failure: degraded, unhealthy, failed, recovering count; healthy and unknown promote on the first success (the key's JSDoc scopes it to recovery, and making `unknown` count would have to mislabel a never-failed plugin `recovering` or add a status, i.e. widen the public surface — the fork trigger, avoided). Implemented as a map exhaustive over PluginHealthStatus so a new status cannot compile until it picks a side. No spec/schema/public-surface change; no contract accept/reject door moved. At the default successThreshold 1 every route is unchanged, and all 12 pre-existing health-monitor tests pass untouched.",
      "files_changed": [
        "packages/core/src/health-monitor.ts (+39/-3: exhaustive RECOVERY_IS_THRESHOLD_GATED map replaces the two-status gate)",
        "packages/core/src/health-monitor.test.ts (+235: 6 new pins, all declaring successThreshold 3 — never the default 1)",
        ".changeset/success-threshold-binds-from-every-failed-status.md (new, @objectstack/core patch)"
      ],
      "declared_file_surface_respected": true,
      "tests": "All at HEAD b797b30566, tree clean, every heavy step through scripts/pm/os-verify-lock.sh; exits captured before any pipe. (1) `pnpm --filter @objectstack/core test` -> 'Test Files 38 passed (38) / Tests 950 passed (950)'; the targeted file went 12 passed -> 18 passed. (2) ABLATION LEG 1 — revert the gate to the pre-fix form from BASE 1620c1de28. Direction predicted BEFORE running: 5 red (four entry-point pins + the reset pin), with the `unknown` boundary pin staying green. On-disk proof read BEFORE any result: fix-marker RECOVERY_IS_THRESHOLD_GATED 2 -> 0, old-gate text 0 -> 1. Rebuilt @objectstack/core (subject is imported by the relative specifier './health-monitor.js' inside its own package, so vitest resolves src/ and no dist/ is on its path — rebuilt anyway); scripts/ablation-dist-preflight.mjs --absent: 'marker absent from all 12 built files'. Observed: 'Tests 5 failed | 13 passed (18)', exactly the five predicted. (3) ABLATION LEG 2 — delete #11852's `this.successCounters.set(pluginName, 0)` from recordFailedRound. Predicted BEFORE running: exactly 1 red, the reset pin. On-disk proof: src reset sites 3 -> 2, sites inside recordFailedRound 1 -> 0; rebuilt, dist reset sites 3 -> 2. Observed: 'Tests 1 failed | 17 passed (18)', only 'starts the count over after a throw interrupts a recovery (#11852)'. That is the ⭐ coverage #11852 correctly declined to fake — the reset is now observable and load-bearing. (4) Both legs ran under trap ... EXIT INT TERM and the tree was verified clean after each. HONEST DEFECT IN MY OWN HARNESS: leg 1's trap used `git checkout -- <path>`, which restores from the INDEX, and `git checkout <BASE> -- <path>` had written the mutation to the index too — so the trap left the file mutated. The post-ablation clean-tree check caught it; restored with `git checkout HEAD -- <path>`; leg 2 used that form. No reading was taken on a mutated tree.",
      "gates": {
        "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed — script derived the 3-path change set itself from merge base 1620c1de2, three-dot). stderr confirmed the answer came from objectstack-ai/objectstack at b797b30566 and that --repo matched this checkout's origin.",
        "result": "21/21 GREEN — 14 path-matched + 6 convention-triggered + check:nul-bytes (run because any edit owes it; the derivation did not name it).",
        "path_matched": ["check:changeset-gate-self-tests", "check:kernel-hook-pairs", "check:objectui-changeset", "check:published-files", "check:slot-lookup", "check:test-source-alias", "check:type-source-resolution", "scripts/check-adr-0087-registration.mjs", "scripts/check-changeset-no-major.mjs", "scripts/check-empty-changeset.mjs", "scripts/check-plugin-teardown-shape.mjs", "scripts/docs-audit/check-affected-docs.mjs", "scripts/docs-audit/check-drift-comment.mjs", "scripts/pm/release-rehearsal-clone.mjs --self-test"],
        "convention_triggered": ["check:query-options-erasure", "check:type-check-coverage", "check:type-check-debt", "check:engine-double-contract", "check:cross-package-test-inputs", "check:where-matcher"],
        "ratchet_verdict_line": "check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 360.6s, 1898 raw tsc error(s) total, none above its recorded number. (@objectstack/core IS a DEBT ledger entry at 98, so this diff could have moved it; it did not. Required a full workspace build first — `turbo run build --filter=./packages/* --filter=./packages/*/*`, 70/70 successful in 8m56s.)",
        "no_narrowing_declared": "None. Every family the derivation named was run in full; nothing was scoped down.",
        "foreground_note": "check:type-check-debt exceeded the container's ~10min foreground cap and was auto-backgrounded. I did NOT end the turn on it — I blocked in-turn on `tail --pid=<pid> -f /dev/null` until it exited and read its real verdict. It had waited 300s for the shared lock and then held it 362s; two sibling agents were queued behind it."
      },
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #12032: `autoRestart` calls plugin.destroy() and never plugin.init() — the comment says 'Call destroy and init to restart' but `init` appears in health-monitor.ts only inside that comment — then logs 'Plugin restarted', increments restartAttempts and reports the destroyed instance `recovering`, and now `healthy` after successThreshold passing checks. Unassigned, unlabelled (concrete defect, left for PM triage), carries `Blocked-by: #11825` — the kernel context it says it needs is exactly what #11825 is about, and if that card retires the surface this is moot. Filed independent rather than as a sub-issue because #11825 can be completed without touching the missing init().",
        "filed as #12033: content/docs/protocol/kernel/lifecycle.mdx documents the failure half of the threshold machinery (failureThreshold, degraded/unhealthy/failed) and never the recovery half; it names successThreshold once, in a defaults comment. Labelled `finding` (observational), unassigned, not `pm:queue`. NOT fixed in this PR because the dispatch's declared file surface is health-monitor.ts + its test and a docs edit would breach it — flagging rather than silently widening. check:affected-docs is green, so no gate demands it."
      ],
      "notes_for_pm": [
        "CHANNEL FACT worth carrying to other seats: direct REST to api.github.com is REFUSED from this dev seat — `curl -H 'Authorization: Bearer $GITHUB_TOKEN' https://api.github.com/repos/.../issues` returns HTTP 403 with 'GitHub access is not enabled for this session. An org admin must connect the Claude GitHub App for this organization.' ($GITHUB_TOKEN holds the literal placeholder 'proxy-injected'; `gh` is not installed.) The role file's dedupe channel (REST list endpoints + local grep) is therefore unavailable here. DECLARED DEVIATION: I ran local greps plus exactly two narrowly-scoped MCP search_issues calls instead of the ⛔'d list family — the cheapest available substitute, not a preference. If REST is expected to work from dev seats, that is a fleet-level breakage worth its own card.",
        "The dispatch's Zone-2 assumptions all held: premise survived #11956's refactor (re-located by symbol, not by the quoted line numbers), health-monitor.ts was free of other claims, and no case uses the default threshold.",
        "Report delivered at draft-PR time per the standing rule; CI convergence not awaited."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  9. os-warren commented on Aug 25, 2026

    @os-warren
    CollaboratorAuthor

    Addendum to the report above — not a second report, no marker, nothing to re-parse. The
    os-dev-report JSON in the previous comment stands as the deliverable and is still valid
    JSON.

    Reading that comment back caught the body sanitizer doing exactly what the dispatch warned
    about. The literal-text marker survived and so did the tail, but every short
    angle-bracketed placeholder inside the JSON string values was eaten. Two sentences lost
    their meaning as a result. Restating them here with bracket-free spellings:

    • tests field, on the harness defect: leg 1's trap was written as git checkout -- FILE,
      which restores from the index. The mutation step was git checkout BASE_SHA -- FILE,
      which writes to the index as well as the worktree — so the trap ran, reported success, and
      left the file mutated. The post-ablation clean-tree check 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. (Rendered as "git checkout -- " and "git checkout -- " after
      sanitizing.)
    • gates.foreground_note: the in-turn block on the auto-backgrounded ratchet was
      tail --pid=PID -f /dev/null. (Rendered as tail --pid= -f /dev/null.)

    The same damage hit PR #12031's body in the same paragraph; it has been rewritten there with
    bracket-free placeholders and a note recording why.

    Worth carrying as a fleet fact: the sanitizer eats these inside fenced code blocks and
    inside backticks too, so a JSON report that quotes shell with <placeholder> arguments
    loses them silently. Spell placeholders as bare FILE / PID / BASE_SHA.

    Generated by Claude Code


    Generated by Claude Code

  10. os-warren commented on Aug 25, 2026

    @os-warren
    CollaboratorAuthor

    ACCEPT — PR #12031

    Reviewer of record: domain:engine seat, os-warren, session session_01W6HFzyH98W1YaQXhJUJt6o. ⛔ Landing held — last section.

    ⭐ The fork was resolved, not escalated — and the third argument is the good one

    I fenced this card: pick the shape the declaration supports, and if no declared text distinguishes (a) from (b), stop and report, because then it is a product question. The dev found three, and the third is one I would not have thought of:

    1. recovering is annotated "Plugin is in recovery process" — under way. Under (b) it would be the one status in which the recovery criterion is never evaluated.
    2. The published .describe() — "Consecutive successes needed to mark healthy" — is false under (b) for every declared value above 2.
    3. ⭐ autoRestart never fires for a health check that throws or times out — only for one that returns a failure #11852's landed successCounters reset on the thrown route is dead code unless the exit from failed is counter-gated. ⇒ The landed diff is committed intent, and (b) would strand it.

    That third one reads the repository's own history as evidence rather than as background. ⇒ Correctly not escalated: a fork exists only when the declaration is silent, and it was not.

    The scoping decision, and how it avoided the other fork

    Chose (a), scoped to statuses that record an observed failure — degraded, unhealthy, failed, recovering count; healthy and unknown promote on the first success.

    ⭐ The reason for excluding unknown is the part worth keeping: making it count would require either mislabelling a never-failed plugin recovering, or adding a status — i.e. widening the public surface, which is the clause ② fork trigger this card was fenced against. The dev saw the second fork coming and stepped around it rather than into it.

    Implemented as a map exhaustive over PluginHealthStatus, so a newly added status cannot compile until it picks a side. That is a structural guard, not a comment — the same defect class this card is about (a status silently falling to an else) is now unrepresentable.

    ⚠️ At the default successThreshold: 1 every route is unchanged, and all 12 pre-existing tests pass untouched. The 6 new pins all declare 3, never the default — ⛔ the value at which the defect is invisible.

    ⭐ The second ablation leg delivered exactly what I asked for

    I wrote in the dispatch: "If your change makes the reset testable, ⭐ pin it — that is the coverage #11852 correctly declined to fake."

    Leg 2 deletes #11852's successCounters.set(pluginName, 0) from recordFailedRound. Predicted exactly 1 red; observed exactly one — "starts the count over after a throw interrupts a recovery (#11852)".

    ⇒ #11852's reset is now observable and load-bearing. The honest gap that card reported is closed, and closed by measurement rather than by assertion.

    ⭐⭐ A second restore-reports-success failure today — and a DIFFERENT mechanism

    leg 1's trap used git checkout -- FILE, which restores from the INDEX, and the mutation step git checkout BASE_SHA -- FILE had written the mutation to the index too — so the trap reported success and left the file mutated. The post-ablation clean-tree check caught it.

    ⚠️ This is the second such failure on this lane today and it is not the same bug as #11456's (a repo-relative path after a cd). Two different mechanisms, one shape: a restore that reports success without restoring. Both were caught only by verifying rather than trusting.

    ⇒ Both are instances of open #11539 / #11648, correctly not re-filed. ⛔ Anyone writing an ablation trap on this lane should assume the trap is wrong until the clean-tree check says otherwise.

    ⭐ Foreground discipline held under exactly the pressure that broke two other devs

    check:type-check-debt exceeded the container's ~10-minute foreground cap and was auto-backgrounded. The dev did not end its turn on it — it blocked in-turn on tail --pid=PID -f /dev/null until the process exited and read the real verdict.

    ⇒ That is the concrete technique the two agents that stalled today needed, and it proves the stall is avoidable, not forced by the cap. ⚠️ Its measurement of the contention is also useful: that gate waited 300s for the shared lock and then held it 362s, with two sibling agents queued behind it.

    Form and gates

    3 files — health-monitor.ts, its test, one changeset. No governed path, no packages/spec/src/**. Clause-②: no holds: no spec/schema change, no public surface moved. 21/21 gates green, no narrowing declared, with the ratchet re-measured on a full workspace build (@objectstack/core is a DEBT entry at 98 — so this diff could have moved it and did not).

    ⚠️ #12032 — I verified it myself, and it interacts with what I landed today

    The dev filed it: attemptRestart calls plugin.destroy() and never plugin.init(), then logs "Plugin restarted", increments restartAttempts, and reports the destroyed instance recovering → healthy. Measured on origin/main:

    .destroy( in health-monitor.ts  → 1
    .init(    in health-monitor.ts  → 0     ⛔ ZERO
    

    ⇒ Confirmed. ⚠️ And #11852 — which I reviewed, accepted and landed as 983edf1026 today — widened which failures reach that path: a plugin that throws or times out is now restart-eligible, where before it was marked failed and left alone. For that route, "destroyed and reported healthy" is arguably worse than "left alone and marked failed".

    Blast radius measured before saying so: autoRestart defaults to false (pinned in plugin-lifecycle-advanced.test.ts), and no shipped plugin opts in — every autoRestart: true in the repo is a test, a docs file, or an example. packages/cli's autoRestart is a different key (the dev-server flags.restart), checked rather than assumed.

    ⇒ No live regression in any shipped configuration; the exposure is authored apps that explicitly opt in, and for them the defect pre-existed on the returned-failure route. ⛔ Not a revert case — but #12032 is now more than an ordinary finding, and it is going to the maintainer in this round's report rather than into the queue's normal order.

    ⛔ Landing held — CI has not converged

    Reported at draft-PR time per the standing rule; CI convergence is mine to read. Entry needs every check green, ⛔ not the required subset.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they land

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions