fix(service-automation): a completed run's history write can no longer strand or re-arm it - #16273
Conversation
…re guarding it
Reproduction first, on current head: a run whose nodes all succeeded is
answered `{ success: false, status: 'stranded' }` when its terminal history
write throws synchronously, `restoreConsumedSuspension` re-arms it, and the
next resume runs the downstream node a second time.
Committed red on purpose so the guard that follows has a baseline the pins
were measured against rather than written to fit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…r strand or re-arm it
`resumeInternal`'s completion path called `recordLog({ status: 'completed' })`
from inside the `try` whose `catch` exists for node failures, so a throw out of
a history write on a run that finished successfully was handled as a node
failure: a repair snapshot was journalled, `status: 'stranded'` stamped,
`success: false` answered, and `restoreConsumedSuspension` then honoured the
snapshot and re-armed the pause so the next resume re-ran every downstream node.
Guard the completion-path `recordLog` at its own site, restoring the invariant
that call's own doc comment states. The failure is reported at `error` with its
consequence and fix, and the run summary is recomputed by the same pure function
`recordLog` runs first. `restoreConsumedSuspension` is untouched: it judged
correctly on the evidence it was handed, and no journal is written for a
completed run at all now.
Also records the false-`true` half of the window in the sibling comment block,
which described only the false-`false` direction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…mpleted-run-phantom-strand
📓 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 77c540e0c29817033f5505bdc171ab221e3782de && git checkout 77c540e0c29817033f5505bdc171ab221e3782de
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8472dae93c2f4ade14be44e8e66c4eb1711f11c5 48427620aae2dcc58bf79bb4fb78459f3d094a71 && git checkout -B drift-repro 8472dae93c2f4ade14be44e8e66c4eb1711f11c5 && git merge --no-ff 48427620aae2dcc58bf79bb4fb78459f3d094a71
node scripts/docs-audit/affected-docs.mjs --json 8472dae93c2f4ade14be44e8e66c4eb1711f11c5 |
PM verification — PASSMeasured at head Fence — held3 files: ⭐ The enumeration the card recorded as NOT MEASURED — done, and I re-ran itThis was the point of the dispatch, so I did not take it on report. From the ref, not a checkout: Exactly two shipped implementations, and both ⭐ And the seat did not let that answer overreach, which is the part I want on the record. It refuses to close the trigger width, and I verified each leg of the refusal:
⇒ A host or third-party store is unconstrained by the in-repo census. And the second statement on that path needs no store at all: ⛔ A correction I owe on my own verificationMy first pass at that enumeration read Re-location — every offset moved, and the interaction was checked not assumedThe card's anchors all shifted (+68 on the engine path; the store anchors +30/+195 under #16128). The seat isolated #16216's own contribution at +43 and attributed the rest to #16150 — that is the discipline I asked for, done at the level of "which landing moved it", not just "it moved". ⭐ On the #16216 interaction, the seat found something better than "no interaction": its hunks are confined to The guard, read at the siteThe completion-path ⭐ The operator record earns its place: it states the consequence and the action, including the one an operator would otherwise get wrong —
That is precisely the false Clause-② — MEASURED, better than my declaration's basisI declared Ablation and the UNMEASURED disciplineReproduction committed RED FIRST ( 56/56 gates green, family derived mechanically and re-derived after a STALE TREE warning (5 commits behind) rather than trusted. Two answered exit 3 = PREREQUISITE NOT MET and were converted into real measurements, not reported as passes; Verdict: PASS. New finding #16274 filed rather than absorbed: the two initial-execution completion paths ( Generated by Claude Code |
Contract review (clause ②) — PASS on content · landable on green CI — PR #16273 at head
|
Fixes #15944
A run whose nodes all succeeded was journalled for repair, reported
stranded, and re-armed when its terminal run-history write threw — and the "repair" then re-ran every node after the pause.The defect, verified on current head rather than inherited
resumeInternalcalledrecordLog({ status: 'completed' })from inside thetrywhosecatchexists for node failures. A throw out of a history write on a run that had already finished was therefore handled as though a node had thrown:journalConsumedSuspensionwrote a repair snapshot,status: 'stranded'was stamped,success: falsewas answered — andrestoreConsumedSuspensioncorrectly honoured that snapshot and put the pause back.Driven on the pre-fix tree at the merge base, flow
start -> hold (pauses) -> tail -> endwithtailsucceeding on resume, against a store double whoserecordTerminalthrows synchronously:resume#1{ success: false, status: 'stranded', error: 'run-history driver refused the terminal row' }tailexecutionsrestoreConsumedSuspension{ restored: true, nodeId: 'hold' }resume#2{ success: false, status: 'stranded' }tailexecutionsThe history driver's own text was presented as the run's error, and the downstream node ran twice. This crosses the #13937 shape-4 invariant — a re-armed run must never become double-runnable — and the defence
stranded-run-status.test.tspins for it cannot fire on this path: it reads the durable terminal row first, and the durable terminal row is precisely what failed to land.What reaches that
catch, and why both are host surfacesTwo statements inside
recordLog's terminal path:store.recordTerminal(record)— thevoid write.catch(...)beneath it only ever sees a returned promise's rejection, so a store that throws synchronously, before returning a promise, escapes. A store returning a non-thenable escapes the same way:write.catchis then itself a synchronousTypeError.logger.info(line, meta)— on by default (runSummaryLog: 'info') and calling a host-injectedLogger, so it needs no store at all.The enumeration the card recorded as NOT MEASURED
Every
SuspendedRunStoreimplementation in the repo was walked, not sampled. Shipped implementations: exactly two, both inpackages/services/service-automation/src/suspended-run-store.tsand bothimplements SuspendedRunStore.recordTerminalInMemorySuspendedRunStore:158:204,asyncObjectStoreSuspendedRunStore:279:624,asyncAn
asyncmethod converts every throw in its body — parameter destructuring included — into a rejection of the returned promise, which lands in the existingvoid write.catch(...). No shipped store can reach this path. Live control for the search: the same pass hitsexport interface SuspendedRunStoreand its optionalrecordTerminalmember declaration, so "only two implementations" is a reading and not a failed grep.That does not close the trigger width, for two reasons the enumeration itself surfaces:
SuspendedRunStoreis an exported interface whoserecordTerminalis optional, and the engine takes a store by constructor injection and bysetSuspendedRunStore. A host or third-party store is unconstrained by what this repo ships.loggeris constructor-injected, the run-summary line is on by default, and a host logger whoseinfothrows reaches the same arm.Two in-repo test doubles do throw synchronously today, both deliberately, as instruments:
strand-verdict-post-journal-throw.test.tsandplugin-approvals'decision-strand-envelope.test.ts. Reported for the record; grading is the lane's, not mine.The repair
Guarded the completion-path
recordLogat its own site, restoring the invariant that call's own doc comment states — "a history write must NEVER block or break the run that produced it." The run summary is recomputed by the same pure functionrecordLogruns first, so the two spellings cannot disagree; the same shape the strand arm already uses.The swallowed failure stays loud:
error, per AGENTS.md "Degradation log levels" (the row claims to persist and did not, while the caller reads a healthy completed run), with the consequence and the fix in the first line, the driver text in the structured slot and theErrorslot empty.restoreConsumedSuspensionis not weakened. It judged correctly on the evidence it was handed; the evidence was wrong, and a completed run now journals none.inspectStrandedRequests([Decision]inspectStrandedRequestsnow over-reports: it keys onstatus === 'failed'while the platform gained an authoritative strand discriminator — a cascade-failed run the engine calls NOT stranded is reported as one #15358) is untouched. Adjacent, and nothing here settles it.stranded, and is still repairable.Re-location — every offset in the card was from a tree that no longer exists
Measured, triage's anchors against the merge base:
recordLoginvariant doc:6795:6863:6835:6903store.recordTerminal(record):6886:6954void write.catch(...):6890:6961:6807/:6808:6899/:6900resumeInternal:5138:5206journalConsumedSuspensionarm:5598:5666status: 'stranded':5718:5786InMemorySuspendedRunStore/recordTerminal:128/:174:158/:204ObjectStoreSuspendedRunStore/recordTerminal:249/:429:279/:624PR #16216's own contribution isolated: at
60c0f6134^the invariant doc sat at:6820andresumeInternalat:5163; at60c0f6134they are:6863and:5206— +43 from that PR alone, the rest from #16150 landing in the same window.suspended-run-store.tsmoved under #16128, a different commit.Does #16216 interact? Its change is
persistSuspendedRun's catch re-seating the suspension map entry on a failed durable save — hunks confined to:2049–:2290, nowhere nearresumeInternalorrecordLog. The completion path never callspersistSuspendedRun, so there is no interaction on the path being fixed. The two do meet one level away, and in the direction that argues for this fix:restoreConsumedSuspensioncallspersistSuspendedRunto re-arm, so on the pre-fix tree a phantom repair whose durable save also failed would have been re-seated in memory by #16216 and kept alive in-process. Removing the journal at the source closes that compound too, without touching either guard.Pins
New:
packages/services/service-automation/src/completed-run-history-throw.test.ts(5 cases). The defect is driven, not modelled.resumeanswerssuccess: truewith nostatus, no snapshot is journalled,restoreConsumedSuspensionrefusesRUN_COMPLETED, and — the sharp one —tailruns exactly once across two resumes with an attempted repair between them.RUN_COMPLETEDis what proves the absence of a journal: a journalled run is re-armed by that verb, not refused.logger.infothrows with no store attached: same answer, same single run. The second reachable statement, and one that shows this is not a store problem.error, naming the run, that it COMPLETED, and the history loss; driver text inmeta, not in the message;Errorslot empty.stranded, andrestoreConsumedSuspensionstill re-arms it. The guard narrows nothing.errorand lands its history row, so PIN 3 measures the guard firing and not "the engine logs on every run".Verification
Union run at
48427620a, the final head.5d25d9eb4), then the guard — so the pins were measured against a baseline rather than written to fit it.trap ... EXIT INT TERMwith an absolute path. Mutation:engine.tsblob717d14273->7cb0dab2e(equal to the pre-fix blob), guard markers anchored at 0 on the mutated file. Rebuilt, andscripts/ablation-dist-preflight.mjs @objectstack/service-automation ... --absentexit 0 confirms it reacheddist/. Ablated pin: 3 failed / 2 passed — the two CONTROLs are the ones that survive, which is what makes them controls. Restore:git checkout HEAD --the absolute path, blob back to717d14273,git diff HEADempty, preflight (present) exit 0, restored pin 5 passed.pnpm --filter @objectstack/service-automation test— 119 files, 1419 tests passed.pnpm --filter @objectstack/service-automation typecheck— passed, including the test layer (tsconfig.test.json, 0 files / 0 errors in the debt ledger).tsc --listFilesconfirms the new pin file andengine.tsare in the compiled set, so this is a reading about the new test rather than around it.pnpm --filter @objectstack/plugin-approvals test— the named downstream consumer ofstatus === 'stranded': 40 files, 684 tests passed. Its first two runs wereFailed to resolve entrycollection errors from unbuiltdist/of@objectstack/service-automationand then@objectstack/trigger-record-change— NOT MEASURED, not red; both closures were built and the suite re-run.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, change set3 pathsvs merge base, 56 commands. All 56 run with the exit code captured immediately after a single redirected command, never through a pipe. 54 green first pass. Two returned exit 3 = PREREQUISITE NOT MET, neither read as a pass:check:dual-build-cjs-loads(43 packages had nodist) andcheck:type-check-debt(12 workspace dependencies unbuilt). The workspace closure was built (turbo run build, 71/71 tasks) and both re-run:check:dual-build-cjs-loadsexit 0;check:type-check-debtthen OOMed at the 4096 MB resource cap and refused to record a number under a starved heap, so it was re-run at 8192 MB — exit 0, 12 ledger entries re-measured, none above its recorded number.origin/mainwas merged and the family re-derived on the merged head; the list is identical, and the run above is the one on48427620a.Contract surface
The dispatch declared
Clause-②: nobefore this diff existed, so it was measured rather than assumed:@objectstack/service-automation's builtdist/index.d.tswas captured before and after the guard from the same build command — byte-identical, 0-byte diff, 621008 B both ways. No exported symbol, signature or payload key moves; the change is a private method body. Thenodeclaration holds.Changeset judged, not defaulted: this publishes from
@objectstack/service-automationand changes whatresumeanswers in one failure interleaving, so it carries apatchchangeset.skip-changesetdoes not apply.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code