fix(db): repair exit-zero timeouts that 0214 missed after adapter migration (BLO-22922) - #1400
fix(db): repair exit-zero timeouts that 0214 missed after adapter migration (BLO-22922)#1400allyblockcast[bot] wants to merge 1 commit into
Conversation
…ration (BLO-22922) Migration 0214 gated its repair on a live join to mutable current agent state (`agents.adapter_type = 'opencode_k8s'`). The only affected agent was moved to claude_k8s on 2026-08-12, six days before 0214 shipped on 2026-08-18, so the statement matched zero rows, reported success, and left every mislabelled run untouched. Verified post-deploy: sampled exit-zero timeout rows from 2026-08-06..08 still read status='timed_out' with error_code='timeout' and carry no outcomeCorrection stamp, despite satisfying every other 0214 clause. 0221 retains 0214's row-level predicate verbatim and drops only the adapter join. A historical data repair must not depend on present-day agent configuration, and exit_code = 0 beside status='timed_out' AND error_code='timeout' is self-contradictory regardless of adapter. The payload guard still leaves rows carrying real adapter or publication errors for an operator to inspect. Idempotent with respect to 0214. The runtime guard (isFalseAdapterTimeoutResult) was already adapter-agnostic, so no forward-looking behaviour changes here. Test asserts the repair lands when the agent's current adapter_type is claude_k8s; it fails if the adapter join is reintroduced. Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
🔗 Paperclip issue: BLO-22922 |
1 similar comment
|
🔗 Paperclip issue: BLO-22922 |
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 310d559
Looks good. The core claim checks out under verification rather than assertion:
- Predicate is verbatim. Diffed against
0214_repair_successful_runs_mislabeled_timed_out.sqlat this head — all five row-level clauses (status,exit_code,error_code, the anchorederrorregex, and the 16-element?|exclusion array) are byte-identical. Only theagentsjoin is dropped, exactly as the description states. - The broadened scope matches deployed runtime policy.
isFalseAdapterTimeoutResult(server/src/services/heartbeat.ts:9325) gates ontimedOut && exitCode === 0 && errorCode === 'timeout' && /^Timed out after [0-9]+s$/ && !hasStructuredAdapterFailureEvidence(...)— field-for-field equivalent to the SQL, and already adapter-agnostic fleet-wide viaisSuccessfulAdapterResult. So 0221 makes history agree with the policy the runtime already applies going forward, rather than inventing a new one. - The
exit 137negative fixture is a real production shape, not a strawman:vendor/paperclip-adapter-claude-k8s/src/server/execute.ts:1117mapsexitCode === 137to SIGKILL/OOMKilled and143to SIGTERM, which is how the k8s terminated-state surfaces a genuine over-deadline kill.exit_code = 0also excludesNULL, so hard-killed runs are untouched on both paths. - Mechanics are sound. No numbering collision (master ends at 0220); journal entry
idx:221 / version:"7"is unique, tag-unique, and monotonic against 0220 (the 17 pre-existingwheninversions elsewhere are untouched). Thefull-table-mutation-large-tableignore id is a real rule incheck-migration-safety.ts:17.RETURNING wakeup_request_idyieldsNULLfor run rows without a wakeup, which thewake.id = ...join discards correctly.
Critical Issues (0)
Important Issues (0)
Suggestions (3)
-
[gstack/review]
packages/db/src/migrations/0221_repair_exit_zero_timeouts_after_adapter_migration.sql:39— theoutcomeCorrectionstamp is byte-identical to 0214's, so after this lands there is no way to tell which migration repaired a given row. In production that is moot (0214 matched zero rows), but any staging/dev database where an agent was still onopencode_k8son 2026-08-18 will hold two indistinguishable populations. Given that this PR exists because 0214's silent no-op was hard to detect, a discriminator such as"migration":"0221"in the stamp turns the next post-deploy audit into one query instead of a row-shape inference. -
[native-codex]
packages/db/src/migrations/0221_repair_exit_zero_timeouts_after_adapter_migration.sql:50— the exclusion array carries'stop_reason'but not camelCase'stopReason', while the runtime'shasStructuredAdapterFailureEvidencechecks both. A row withresult_json.stopReason = 'error'(orfailed/cancelled) would be held back by the runtime guard but repaired here — and line 36 then stripsstopReason, removing the evidence. Note the obvious fix is wrong: adding'stopReason'to this key-presence array would exclude every production row (they all carrystopReason: "timeout") and re-break the repair exactly as 0214 did. Parity would need a value-based test mirroring the runtime regex, e.g.NOT (result_json->>'stopReason' ~* '^(error([_-].*)?|failed|failure|cancelled|canceled|timed[_-]?out)$'). Reasonable to defer given the deliberate verbatim-inheritance, but worth recording as a known divergence. -
[pr-review-toolkit/errors]
packages/db/src/migrations/0221_repair_exit_zero_timeouts_after_adapter_migration.sql:43— the runtime comparesresult.errorMessage.trim()against the same regex; the SQL applies no trim, and Postgres~anchors$at end-of-string (not end-of-line). A stored'Timed out after 3600s\n'therefore passes the runtime check but fails here and stays mislabelled. This errs conservative (under-repair, not over-repair), but under-repairing silently is the precise failure mode 0214 exhibited —btrim("run"."error") ~ '...'would close it.
Strengths
- The root-cause analysis is the valuable part: identifying that a historical repair was gated on mutable current agent state, and that the fix is to make the predicate self-identifying, is a generalizable lesson worth the header comment it got. That comment will save the next reader real time.
- The falsification step — reintroducing the join to confirm the new test fails, then removing it to confirm it passes — is the difference between a test that documents behavior and one that actually pins the regression. 0214 shipped without it and was a no-op for six days.
- Negative fixtures are well chosen and non-redundant:
exit 137(real kill),is_error/subtypepayload (ambiguous, operator-owned),NULLerror, plus thekept: "yes"sentinel proving unrelatedresult_jsonkeys survive the-operators. The wakeup cascade is asserted in both directions. - Retaining the row predicate verbatim, rather than "improving" it while fixing the scope, keeps the blast-radius argument auditable against 0214 — and the near-identical measured counts (691 → ~689) are good corroboration that the join was the only thing excluding rows.
Recommended Action
- No Critical or Important issues — this is mergeable as-is.
- Consider the stamp discriminator (Suggestion 1) before merge; it is a one-line change to a file that becomes immutable once applied, and it directly serves the post-deploy verification this PR's own history shows is necessary.
- Suggestions 2 and 3 are guard-parity drift between the SQL and
heartbeat.ts; both are safe to defer, but worth a follow-up so the two predicates do not diverge further.
Posted as a formal COMMENT review: GitHub bars a pull request's author from approving its own PR, and this PR is authored by the Ally App. reviewDecision is empty (no required-review protection on master), so no approval identity is needed to land it.
Issue: https://paperclip.blockcast.net/BLO/issues/BLO-22922
Problem
Migration
0214_repair_successful_runs_mislabeled_timed_out.sqlshipped to production on 2026-08-18 at 11:08Z and was a silent no-op. It repaired zero rows while reporting success.The repair was gated on a live join to mutable current agent state:
The only affected agent (Ally) was moved off
opencode_k8stoclaude_k8son 2026-08-12 — six days before 0214 finally cleared its deploy gate. By the time the statement ran, no agent row still readopencode_k8s, so nothing matched.Evidence it did not run
Sampled
heartbeat_runsrows from the affected window (2026-08-06 → 08-08) via/api/heartbeat-runs/:runId. 10 of 10 sampled rows still read:statustimed_outexit_code0error_codetimeouterrorTimed out after {600,3600,7200}sresult_jsonkeysconfigFreshness, effectiveTimeoutSec, stopReason, timeoutConfigured, timeoutFired, timeoutSourceresult_json.outcomeCorrectionEvery row satisfies each of 0214's row-level clauses —
exit_code = 0,error_code = 'timeout',error ~ '^Timed out after [0-9]+s$', and no excluded payload key (stopReason≠stop_reason, so the exclusion array is not tripped). The sole unsatisfied clause is the adapter join, and no row carries theoutcomeCorrectionstamp 0214 would have written.Change
0221retains 0214's row-level predicate verbatim and drops only the adapter join.A historical data repair must not depend on present-day agent configuration. The remaining predicate is self-identifying:
exit_code = 0alongsidestatus = 'timed_out'ANDerror_code = 'timeout'is contradictory on its face — a process that exited 0 did not hit its deadline. This is exactly the invariant the issue states: "A run withexitCode === 0must not producestatus='timed_out'."Blast radius is bounded by the retained guards:
error,is_error,subtype,message, …) as-is for an operator.137, not0, so real timeouts are untouched.succeededand no longer match.No forward-looking behaviour changes. The runtime guard
isFalseAdapterTimeoutResult(server/src/services/heartbeat.ts:9325) keys purely on result shape and was already adapter-agnostic, so the fix that prevents new mislabels is correct fleet-wide. Only the historical repair was mis-scoped.Verification
packages/db/src/heartbeat-exit-zero-timeout-repair-migration.test.ts— asserts the repair lands when the agent's currentadapter_typeisclaude_k8s(the production shape 0214 missed), and that a137exit and a structured-failure payload both retaintimed_out.Falsified rather than assumed: reintroducing the adapter join makes the new test fail with
- "status": "succeeded"/+ "status": "timed_out", and removing it makes it pass.Guardrail this adds
The class of defect is "a migration that runs successfully but matches zero rows is indistinguishable from one that repaired everything." 0214's own test seeded
adapter_type = 'opencode_k8s', hard-coding the very condition production failed to satisfy — so it could never have caught this. The new test pins the post-adapter-migration shape instead.🤖 Generated with Claude Code