fix(automation): keep the stranded verdict when post-journal bookkeeping throws — a repairable strand must not report repairable: false - #15949
Conversation
…the stranded stamp loses the verdict Pins written against the CORRECT behaviour so that running them at HEAD is the reproduction: today resume() throws the secondary failure and reports no run-state discriminator at all, while restoreConsumedSuspension on that same run succeeds — a false negative on a repair instruction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…g throws Once journalConsumedSuspension has written the repair snapshot, the run IS repairable, and the status: 'stranded' stamp is the only thing that tells a consumer so. Two statements between them could throw out of the whole arm -- recordLog's run-summary line and a store whose recordTerminal throws synchronously -- and a throw there made the approvals door report repairable: false about a run restoreConsumedSuspension puts back. A false negative on a repair instruction stops an operator from fixing a run that is fixable, so the window is now guarded: the bookkeeping may fail, loudly at error with the run and its repair verb named, and the verdict still ships. The guard opens AFTER the journal, so only a run that demonstrably has a snapshot can reach the stamp; every exit above the consumption point still carries no status, and cascade-failed ancestors are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…kkeeping throw Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…rand-repairable-window
📓 Docs Drift Check1 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 — 5 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 2082feb6bcb6102fbc14b63783c4df538125d87d && git checkout 2082feb6bcb6102fbc14b63783c4df538125d87d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 97afcb5ccc061eb271cb37fda3bc468459eac7c2 6dd2f40a0f6aa5eed180b018d9f36716829152ff && git checkout -B drift-repro 97afcb5ccc061eb271cb37fda3bc468459eac7c2 && git merge --no-ff 6dd2f40a0f6aa5eed180b018d9f36716829152ff
node scripts/docs-audit/affected-docs.mjs --json 97afcb5ccc061eb271cb37fda3bc468459eac7c2 |
… visible The changeset is what reaches release notes, and the operator-facing consequence was only in the PR body. It now says that a run that COMPLETED is journalled and reported stranded when its completed history row throws, that repairing such a run re-runs the flow, that the phantom and its double run were measurable before this change rather than created by it, and that it is filed as #15944. Text only. engine.ts is byte-identical at blob e6fa6ff, and no pin, ablation leg or source line moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Clause-② contract review — PR #15949 (card #15555): PASSTier. 1. The false negative, end to end at the real door — PASS (blocking bar)GREEN at HEAD: door suite 2. Guard placement — the stamp keys on the journal, in both directions — PASS (blocking bar)By reading: the
3. The synchronous-throw claim — PASS (blocking bar)
4. Ablation legs — PASS; the mutation blobs match the dev's
The preflight is what it claims. 5. The reproduction, run unfixed — PASSThe pin file at its reproduction commit ( 6. The changeset and the phantom disclosure — PASSStanding guard: no unqualified "unchanged / as before / in both directions". The two "still" clauses name what they claim (the verdict ships; a journal throw propagates; exits above the consumption point carry no status; cascade-failed ancestors "which journal nothing" stay non-repairable) — each measured in §2. The durability caveat is accurate: PIN 1's run has no terminal row and the repair rides the in-memory journal.
7. Scope — PASSThe file set is the four named; nothing under Regression control (not asked; cheap once built)At HEAD after all restores: Non-blocking observations
Tree state. Verdict: PASS. 🤖 Generated with Claude Code https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y Generated by Claude Code |
PM sign-off — undrafted and armedPASS, and this is the most thoroughly re-taken review of the session: the reviewer did not check the numbers, it rebuilt them. The three things I most wanted independently establishedThe disclosure I required is accurate, and that was verified rather than accepted. I made the implementer add a paragraph stating that this change makes a pre-existing fault visible rather than creating it — a completed run journalled and reported The guard does not over-reach, which was the way this fix could have been worse than the bug. Four constructed boundary cases, green on HEAD and PRE: a journal throw still propagates ( Two ablation blobs came out byte-identical to the implementer's — M2 The subtle mechanism, confirmed two waysThe window exists because ⭐ And PIN 3 is not a tautology — it pins the absence of an error log, which is the assertion most likely to pass for free, and M2 (deleting the guard's Method notes worth keeping
Non-blocking, recorded not folded inBoundary case B4: a parent whose cascade row throws after its pause is consumed ends with no pause, no journal and no row. Pre-existing; it is now reported at ⛔ This does not close #15556. It is Undrafted and auto-merge armed. Generated by Claude Code |
Fixes #15555 — the throw window between the repair journal and the
status: 'stranded'stamp, reproduced first and then closed.The card reproduces, at the door and in the engine
resumeInternal's catch arm journals the consumed suspension — the snapshotrestoreConsumedSuspensionputs back — and only then stampsstatus: 'stranded'. Between them sit statements that throw out of the whole arm:recordLog's terminal run-summary line (logger.info, on by default),store.recordTerminal(record)whose synchronous throw escapes (thevoid write.catch(...)beneath it only ever sees a returned promise's rejection), and thenawait this.failAncestors(...).So "a repair snapshot was journalled" and "the engine said stranded" were not one fact. Every consumer derives repairability from the second one —
plugin-approvalscomputes its operator-facing flag asrepairable = status === 'stranded'— so a throw in that window reportedrepairable: falseabout a run the repair verb recovers.Measured at the pre-fix shape (the
M1leg below drives exactly it):resume, store's terminal write throwing{ success: false, status: 'stranded' },errornaming the noderestoreConsumedSuspensionon that same runrestored: truerestored: truerepairable: falserepairable: trueThe first and third rows together are what make this a false negative rather than a conservative default: the operator was told not to attempt a repair that succeeds. The usual worry is a false
true; this is the other direction.What
repairablekeys on, and why the journal — not the stamp — is the factThe dispatch asked the question directly. The journal is what makes a run repairable; the stamp is a separate write that can be lost. This change makes the stamp key on the journal by construction: the guard opens on the line after
journalConsumedSuspensionreturns, so only a run that demonstrably has a snapshot can reach the stamp.⛔ Not "assume repairable when the failure is unknown" — that would invert the ruling's honest default and promise a repair for a lost run. A throw from the journal call itself still propagates, and every exit above the consumption point still carries no status at all (pinned).
⛔ And the journal is not moved down beside the stamp instead, the card's other suggested shape.
recordLogis what carries the snapshot into the durable row, so journalling after it would leave arecordLogfailure with no snapshot anywhere — turning this false negative into a true one by destroying the repair rather than by reporting it.What this changes
One window in
resumeInternal's catch arm. The bookkeeping may still fail; it now says so aterror, naming the run, what did not land, and the verb that repairs the strand — while the verdict ships. Consequence and fix in the first line, said once per stranded run, per AGENTS.md's durability rule; theErrorslot stays empty and the thrown text goes to the structured slot.⛔ No contract moves.
AutomationResultandApprovalDecisionResultare untouched —status: 'stranded'is the existing #13937 discriminator on the exit that already produced it. ⛔ Cascade-failed ancestors are untouched: they go throughfailSuspendedRun, journal nothing, and stay correctly non-repairable.Pins, and both mutation legs
New:
packages/services/service-automation/src/strand-verdict-post-journal-throw.test.ts(5 cases — the window driven through both statements that reach it, the loudness of the swallowed failure, and two controls). Extended:decision-strand-envelope.test.tsPIN 4, which drives the window through the realApprovalService.decidedoor.Every leg proved on disk by a
git hash-objectdelta plus marker counts, restored withgit checkout HEAD -- ABSOLUTE_PATHunder atrap … EXIT INT TERM, and each restore proved by an emptygit diff HEADand the blob back at HEAD'se6fa6ff8f. TheM1leg rebuiltdist/and proved the mutation reached the artifact (ablation-dist-preflight … --absent, exit 0), because the door suite resolves@objectstack/service-automationthroughexportstodist/.engine.tsblobe6fa6ff8f(= HEAD)M1guard removed (the pre-fix shape)976d8ec78repairable: expected false to be trueM2guard kept, itslogger.errordeleted2dc4b81ebM3thesummaryfallback removed09f67bd25M1is the reproduction, re-taken mechanically: it reddens the pins and only the pins, and it is the leg that showsrepairable: falseat the door.M2shows the loudness half is pinned separately from the verdict half.M3shows the second hunk is load-bearing. Both controls stay green under all three.⛔ What this does NOT do, and one finding it surfaced
repairablestays a point-in-time fact. This does not make it durable, and the changeset says so: in the driven case the terminal history row is exactly what failed, so the repair rides on the in-memory journal and a restart loses it. That residual is recorded on the card as explicitly not the card, and the wording nit it implies belongs to PR #15436.A separate defect, in the opposite direction, is filed as #15944 rather than folded in here. The completion-path
recordLogsits inside the sametry, so a history-write throw on a run whose nodes all succeeded is handled as a node failure: it journals a repair snapshot, reportsstranded, andrestoreConsumedSuspensionthen re-arms a completed run — measured re-running the downstream node a second time, which crosses #13937's "never double-runnable" invariant.M1leg measured it on the pre-fix tree and the reading is byte-identical there — a store refusing only thecompletedrow already answeredstatus: 'stranded'withrestored: truebefore this change. What this change alters is only which store failures report the phantom instead of throwing over it. #15944 carries both trees' readings and an advisory direction; ⛔ nothing here settles it.#15358 is adjacent and untouched. It is the same over-reporting direction on the
inspectStrandedRequestssweep, and #15944 is the engine-side producer of the same class. ⛔ Neither is decided here. ⛔ Nothing here folds in #15221 either.Verification
Every exit code captured right after a single redirected command (
cmd > log 2>&1; EXIT=$?), never through a pipe. All of it re-run on the final commit, after mergingorigin/main.@objectstack/service-automation114 files / 1382 tests passed;@objectstack/plugin-approvals38 files / 672 tests passed. An earlier approvals run reported one file failing to load —Failed to resolve entry for package "@objectstack/trigger-record-change", an unbuilt dependency of that suite, not a test verdict; building the approvals closure cleared it, and nothing red remains to attribute.plugin-approvals's basetsconfig.jsondoes not includedecision-strand-envelope.test.ts(checked with--listFiles), so that leg says nothing about it; the third leg of the package'stypecheckscript does —check:test-typecheckcompiles the test layer undertsconfig.test.json, exit 0.service-automation's base config does reach the new test file (--listFiles, 1 hit).scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(exit 0, no stale-tree warning after the merge). Run locally, all exit 0:check:nul-bytes·check:durability-log-level(29 seams, all loud) ·check:engine-double-contract(789 pinned / 133 debt / 3 exempt; no new double to pin, the added store subclass is not one) ·check:cross-package-test-inputs·check:test-source-alias·check:changeset-gate-self-tests·check:doc-authoring·check:logger-receiver-detach·check:optional-error-sink·check:resume-authority-declared·check:error-code-casing·check:partof-closing-keyword.node scripts/check-adr-0087-registration.mjs --base origin/main --head e9ee5a226→ exit 0, with--self-test→ exit 0 as its control in the same session.pnpm lintsweep — are left to CI, which runs the farm exactly once regardless. That is a narrowing, stated, not an omission.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code