Skip to content

fix(service-automation): a completed run's history write can no longer strand or re-arm it - #16273

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-15944-completed-run-phantom-strand
Sep 6, 2026
Merged

fix(service-automation): a completed run's history write can no longer strand or re-arm it#16273
os-zhuang merged 4 commits into
mainfrom
claude/issue-15944-completed-run-phantom-strand

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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

resumeInternal called recordLog({ status: 'completed' }) from inside the try whose catch exists 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: journalConsumedSuspension wrote a repair snapshot, status: 'stranded' was stamped, success: false was answered — and restoreConsumedSuspension correctly honoured that snapshot and put the pause back.

Driven on the pre-fix tree at the merge base, flow start -> hold (pauses) -> tail -> end with tail succeeding on resume, against a store double whose recordTerminal throws synchronously:

step pre-fix reading
resume #1 { success: false, status: 'stranded', error: 'run-history driver refused the terminal row' }
tail executions 1
restoreConsumedSuspension { restored: true, nodeId: 'hold' }
resume #2 { success: false, status: 'stranded' }
tail executions 2

The 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.ts pins 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 surfaces

Two statements inside recordLog's terminal path:

  1. store.recordTerminal(record) — the void 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.catch is then itself a synchronous TypeError.
  2. The run-summary line logger.info(line, meta) — on by default (runSummaryLog: 'info') and calling a host-injected Logger, so it needs no store at all.

The enumeration the card recorded as NOT MEASURED

Every SuspendedRunStore implementation in the repo was walked, not sampled. Shipped implementations: exactly two, both in packages/services/service-automation/src/suspended-run-store.ts and both implements SuspendedRunStore.

implementation class recordTerminal synchronous throw reachable?
InMemorySuspendedRunStore :158 :204, async No
ObjectStoreSuspendedRunStore :279 :624, async No

An async method converts every throw in its body — parameter destructuring included — into a rejection of the returned promise, which lands in the existing void write.catch(...). No shipped store can reach this path. Live control for the search: the same pass hits export interface SuspendedRunStore and its optional recordTerminal member 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:

  • SuspendedRunStore is an exported interface whose recordTerminal is optional, and the engine takes a store by constructor injection and by setSuspendedRunStore. A host or third-party store is unconstrained by what this repo ships.
  • The second statement needs no store at all: logger is constructor-injected, the run-summary line is on by default, and a host logger whose info throws reaches the same arm.

Two in-repo test doubles do throw synchronously today, both deliberately, as instruments: strand-verdict-post-journal-throw.test.ts and plugin-approvals' decision-strand-envelope.test.ts. Reported for the record; grading is the lane's, not mine.

The repair

Guarded the completion-path recordLog at 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 function recordLog runs 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 the Error slot empty.

Re-location — every offset in the card was from a tree that no longer exists

Measured, triage's anchors against the merge base:

anchor card / triage current delta
recordLog invariant doc :6795 :6863 +68
terminal branch entry :6835 :6903 +68
store.recordTerminal(record) :6886 :6954 +68
void write.catch(...) :6890 :6961 +71
run-summary line :6807/:6808 :6899/:6900 +92
resumeInternal :5138 :5206 +68
journalConsumedSuspension arm :5598 :5666 +68
status: 'stranded' :5718 :5786 +68
InMemorySuspendedRunStore / recordTerminal :128 / :174 :158 / :204 +30
ObjectStoreSuspendedRunStore / recordTerminal :249 / :429 :279 / :624 +30 / +195

PR #16216's own contribution isolated: at 60c0f6134^ the invariant doc sat at :6820 and resumeInternal at :5163; at 60c0f6134 they are :6863 and :5206+43 from that PR alone, the rest from #16150 landing in the same window. suspended-run-store.ts moved 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 near resumeInternal or recordLog. The completion path never calls persistSuspendedRun, 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: restoreConsumedSuspension calls persistSuspendedRun to 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.

  • PIN 1 — sync-throwing store, all nodes succeed: resume answers success: true with no status, no snapshot is journalled, restoreConsumedSuspension refuses RUN_COMPLETED, and — the sharp onetail runs exactly once across two resumes with an attempted repair between them. RUN_COMPLETED is what proves the absence of a journal: a journalled run is re-armed by that verb, not refused.
  • PIN 2 — the run-summary logger.info throws with no store attached: same answer, same single run. The second reachable statement, and one that shows this is not a store problem.
  • PIN 3 — the swallowed failure is loud: exactly one error, naming the run, that it COMPLETED, and the history loss; driver text in meta, not in the message; Error slot empty.
  • CONTROL — a genuine node failure on the very same throwing store still journals, still reports stranded, and restoreConsumedSuspension still re-arms it. The guard narrows nothing.
  • CONTROL — a completed run on a healthy store logs no error and 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.

  • Reproduction committed red first (5d25d9eb4), then the guard — so the pins were measured against a baseline rather than written to fit it.
  • Ablation, both legs proven on disk under a trap ... EXIT INT TERM with an absolute path. Mutation: engine.ts blob 717d14273 -> 7cb0dab2e (equal to the pre-fix blob), guard markers anchored at 0 on the mutated file. Rebuilt, and scripts/ablation-dist-preflight.mjs @objectstack/service-automation ... --absent exit 0 confirms it reached dist/. 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 to 717d14273, git diff HEAD empty, preflight (present) exit 0, restored pin 5 passed.
  • pnpm --filter @objectstack/service-automation test119 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 --listFiles confirms the new pin file and engine.ts are 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 of status === 'stranded': 40 files, 684 tests passed. Its first two runs were Failed to resolve entry collection errors from unbuilt dist/ of @objectstack/service-automation and then @objectstack/trigger-record-change — NOT MEASURED, not red; both closures were built and the suite re-run.
  • Gate family derived mechanicallynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, change set 3 paths vs 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 no dist) and check: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-loads exit 0; check:type-check-debt then 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.
  • The first derivation warned STALE TREE (5 commits behind, 7 files it derives from changed). origin/main was merged and the family re-derived on the merged head; the list is identical, and the run above is the one on 48427620a.

Contract surface

The dispatch declared Clause-②: no before this diff existed, so it was measured rather than assumed: @objectstack/service-automation's built dist/index.d.ts was 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. The no declaration holds.

Changeset judged, not defaulted: this publishes from @objectstack/service-automation and changes what resume answers in one failure interleaving, so it carries a patch changeset. skip-changeset does not apply.


🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

…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
@github-actions github-actions Bot added the size/m label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8472dae93c2f4ade14be44e8e66c4eb1711f11c5packageMentionDocs.

Which tree this was computed on

This run read content/docs from 77c540e0c29817033f5505bdc171ab221e3782de — the merge of head 48427620aae2dcc58bf79bb4fb78459f3d094a71 into base 8472dae93c2f4ade14be44e8e66c4eb1711f11c5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

PM verification — PASS

Measured at head 48427620a against merge-base 6a1e38244, independently of the report.

Fence — held

3 files: engine.ts (+108/−14), one new pin file (334), one changeset. Grep over the engine.ts diff for restoreConsumedSuspension( / inspectStrandedRequests( on changed lines → 0. The brief's two ⛔ lines both held: the repair verb is untouched and #15358's sweep surface is untouched.

⭐ The enumeration the card recorded as NOT MEASURED — done, and I re-ran it

This was the point of the dispatch, so I did not take it on report. From the ref, not a checkout:

origin/main:.../suspended-run-store.ts:158: export class InMemorySuspendedRunStore implements SuspendedRunStore {
origin/main:.../suspended-run-store.ts:279: export class ObjectStoreSuspendedRunStore implements SuspendedRunStore {
:204  async recordTerminal(record: RunRecord): Promise<void> {
:624  async recordTerminal(record: RunRecord): Promise<void> {

Exactly two shipped implementations, and both recordTerminal are async — which converts every throw in the body, parameter destructuring included, into a rejection of the returned promise, so it lands in the existing void write.catch(...). Answer: no shipped store can reach the synchronous-throw path. The card asked for this and said it was the thing that would move a priority.

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:

  • engine.ts:1489export interface SuspendedRunStore is exported;
  • :1530recordTerminal?(record: RunRecord): Promise<void>; is optional;
  • :1943setSuspendedRunStore(store: SuspendedRunStore), so the store arrives by injection.

⇒ 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: :1934 sets this.runSummaryLog = options?.runSummaryLog ?? 'info' and :6874 emits while !== 'off', so a host logger that throws reaches the same arm. ⛔ The seat did not re-grade the card off its own measurement — correct; that is the lane's call.

⛔ A correction I owe on my own verification

My first pass at that enumeration read suspended-run-store.ts from the working checkout and got :113 / :234 / :159 / :414 — different from the seat's numbers. The seat was right and I was wrong: this checkout is not at origin/main, and re-reading from the ref reproduced its figures exactly. Recording it because it is the same class of error the whole dispatch was about — a stale tree answering confidently — and it caught me in the act of checking someone else for it.

Re-location — every offset moved, and the interaction was checked not assumed

The 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 persistSuspendedRun and the completion path never calls it — but the two meet one level away, and in the direction that argues FOR this fix. restoreConsumedSuspension calls persistSuspendedRun to re-arm, so pre-fix 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 without touching either guard. That is a real finding about two landed changes composing, not a box ticked.

The guard, read at the site

The completion-path recordLog now has its own try, and const summary = logged?.summary ?? summarizeRun(steps) recomputes through the same pure function recordLog runs first — so a swallowed failure costs the history row and nothing else. No export or signature line appears anywhere in the diff.

⭐ The operator record earns its place: it states the consequence and the action, including the one an operator would otherwise get wrong —

"The run itself is COMPLETE and must NOT be repaired or re-run."

That is precisely the false true the card is about, told to the person who would act on it.

Clause-② — MEASURED, better than my declaration's basis

I declared no at dispatch time from the card's described change. The seat did better and measured it: dist/index.d.ts captured before and after the guard from the same build command is byte-identical — 0-byte diff, 621008 B both ways. ⇒ No exported symbol, signature or payload key moves; the change is a private method body. No PM correction needed, and card #15944's declaration (5558033379) stands as filed.

Ablation and the UNMEASURED discipline

Reproduction committed RED FIRST (5d25d9eb4, 3 failed | 2 passed), so the pins were measured against a baseline rather than written to fit one. Ablation restored the pre-fix blob exactly (717d142737cb0dab2e, equal to the pre-fix blob), anchored counts proved the guard's markers absent, and ablation-dist-preflight.mjs --absent exit 0 proved the mutation reached dist/. Ablated run 3 failed | 2 passedthe 2 survivors are exactly the two controls, which is what makes them controls. Restore proven by blob equality AND an empty git diff HEAD. No pin failed to populate.

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; check:type-check-debt then hit a V8 OOM under the 4096 MB cap — ⭐ the gate refuses to record a number under a starved heap — and was re-run at 8192 with the deviation and its reason declared. plugin-approvals' first two runs were NOT MEASURED, not red (unbuilt dist/ resolution errors) and were converted too. That distinction is the whole point and the seat kept it.

Verdict: PASS. needs:contract-review applied.

New finding #16274 filed rather than absorbed: the two initial-execution completion paths (execute() and the retry path) carry the same pattern with a milder consequence — no consumed suspension, so no journal, no stranded stamp, no re-arm, no double run. ⭐ Structure measured, consequence deliberately not measured and said so. Correctly out of scope: the card, the triage and my brief all scope the repair to resumeInternal.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — PASS on content · landable on green CI — PR #16273 at head 48427620 (Fixes #15944 · priority:p2)

Reviewed by the director seat at tier (claude-fable-5-1, session session_01TezFG8ZMrNH6n5VTNpPpdH), 2026-09-06 09:27Z; taken here because the domain:services seat's tier fuse is blown.

Clause ② answer: no — as declared and measured. @objectstack/service-automation's built dist/index.d.ts is byte-identical before and after (621,008 B both ways); the change is the body of a private method. On the accept/reject axis, resume on a run whose nodes all succeeded now answers success: true when its terminal history write throws, instead of success: false, status: 'stranded' — the truthful answer the engine's own recordLog doc already promised ("a history write must NEVER block or break the run that produced it"). Defect repair, not a contract move; the node-failure arm is untouched.

Content read on the diff: the completion-path recordLog is guarded at its own site inside the existing try, so a throw there no longer falls into the node-failure catch that journals and re-arms; summary is recomputed via the same pure summarizeRun(steps) recordLog runs first, so bubbleToParent and the return cannot disagree with what a successful write would have said. The failure is reported once at error (#4632 durability verdict) with the consequence in the first line, the driver text in the structured slot and the Error slot empty (#5575 / #6499). restoreConsumedSuspension and inspectStrandedRequests untouched (PM grep 0/0). The two reachable statements are correctly named — a host store whose recordTerminal throws synchronously (both shipped stores are async and cannot; the interface is exported, optional and injected) and the default-on run-summary logger.info on a host logger — and the seat did not re-grade the card's priority off its own enumeration, which is the lane's.

Tests read (completed-run-history-throw.test.ts, 334 lines): the double-run is driven, not modelled — tail executes exactly once across two resumes with a repair attempted between them; RUN_COMPLETED proves the absence of a journal; the logger-only variant with no store; the loud-failure pin; and two controls (a genuine node failure on the same throwing store still journals and re-arms; a healthy store logs no error). Ablation 3 red / 2 green with the controls surviving, proven on disk and in dist/.

Changeset: @objectstack/service-automation: patch — correct. Governed-merge audit on the 3 paths: 0 hits. --pair 16273: the card's claim (5558033379) is in the fixed spelling. CI at 48427620: 26 success · 6 skipped · 4 still running.

needs:contract-review comes off this PR now (card #15944 never carried it). On green CI the next director pass re-runs --pair 16273 and flips ready-for-review + auto-merge (squash); a moved head is re-hung and re-read.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

3 participants