Uh oh!
There was an error while loading. Please reload this page.
feat(automation): stamp status: 'stranded' on the resume catch arm and pin the re-armed run's exactly-once — the #13937 services half (shape 4) - #15237
Conversation
… pin the re-armed run's exactly-once (the #13937 services half) The shape-4 ruling keeps resumeInternal's consumption order; the state it leaves behind when a downstream node throws now carries the contract's name (AutomationResult.status: 'stranded', #14384) on the one exit that journals a consumed suspension. Stale "unruled" comments cite the ruling. The existing verb (restoreConsumedSuspension) is the exit; its double-run pins are added, including the cross-replica stale-journal case, which is RED at this commit by design (the precedence fix follows in the next commit). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
… row before its per-process journal A hot copy left on the replica that stranded a run could re-arm the run after another replica restored, resumed and finished it — the next resume then re-ran every node after the pause. The terminal row is the record; a row that exists without a snapshot is the last word and the hot copy is dropped. The two stale-journal pins added in the previous commit go red -> green here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
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 f3ea21e48b0d90bfe5a59c4e7e5c362b8395d9ce && git checkout f3ea21e48b0d90bfe5a59c4e7e5c362b8395d9ce
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fbecffe1d81c517d9f6db9ade6ce94a4497b699b 6a768ff80cfd7ca95b390fe4de1edc12f9f748b5 && git checkout -B drift-repro fbecffe1d81c517d9f6db9ade6ce94a4497b699b && git merge --no-ff 6a768ff80cfd7ca95b390fe4de1edc12f9f748b5
node scripts/docs-audit/affected-docs.mjs --json fbecffe1d81c517d9f6db9ade6ce94a4497b699b
|
…randed-run-operator-verb
…re — pause-node provenance, over-budget drop, two-witness read Eight pins over the production store class and the repo's fake ObjectQL engine; all eight are RED at this commit by design (the durable-first read of the previous commit re-arms at the node that threw, and a dropped snapshot reads as the run having moved on). The changeset's "no difference" claim is replaced with what is now known. The fix follows in the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…durable row as two witnesses; the stranded row records the pause node and any dropped snapshot - The stranded run's terminal row carries the PAUSE node in node_id (it carried the last step, the node that threw, which the object store read back as the snapshot's node: a restore from the row re-armed the failed node and the next resume skipped it). - The object store records an over-budget snapshot drop IN the row, with the pause it belonged to (RunRecord.consumedSuspensionDropped), instead of a bare NULL that read as the run having moved on. - The engine prefers its hot copy when it and the row describe the same pause; between different pauses the newest strand wins, judged by whether this process's own history write landed (ConsumedSuspension.persisted); a snapshot-less, notice-less row discards the hot copy only when that write did land. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…he two-pause ledgers The two-witness pins expected the ledger without the tail re-run that a restore + resume legitimately performs; the engine was right, the count was not. Pause-node assertions were already green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…dispatch predicate; record the new pinned double check:engine-double-contract's own two prescriptions: open the fake's update() with assertEngineUpdateDispatch(data, options), and let the pinned ledger learn the file (--write). The store's own update calls pass the predicate: the object-store file stays 8/8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…randed-run-operator-verb
…n_run's node_id and variables_json The durable row's own declarations now agree with their writer: on the one terminal-row class that carries a consumed suspension, node_id is the PAUSED node (the Runs surface titles and highlights the row with it), and variables_json is either the restorable snapshot or the store's drop notice, which is not one. Same carve-out pattern node_type's description already carried. The changeset names the visible Runs-surface change. Source-only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13937
Services half of the shape-4 ruling (maintainer 2026-09-01, director batch #21, comment 5491060439). Draft, Clause-②:
needs:contract-review— seat-internal contract review gates landing; this seat does not undraft, arm or enqueue. Patch round 1 (contract review FAIL, comment 5537097925; disposition 5537113651) was addressed atc5025d04— both blocking items, the changeset claim, and the dropped "exemption" wording; the delta re-review (comment 5538166459) found both MET and the widened surface sound. Patch round 2 (its one blocking item) is addressed at head6a768ff8— source-only: the durable row's own declarations carry the stranded-class carve-out, the changeset names the visible Runs-surface change, and the absence claim below is corrected.origin/mainmerged twice (31a6012e,7308e037— #15152's test-layer tsc, no overlap), no rebase, no force-push.What the tree said before a line was written (PM assumption 1 falsified)
The dispatch measured "no operator re-arm verb exists yet" by grepping
rearmonengine.ts. The verb exists under the vocabulary "restore":AutomationEngine.restoreConsumedSuspension(#13909 slice 2, PR #13951; docblock: "The operator exit from a run a resume left terminally unresumable … This verb is the leaving"), barrel-exported withSuspensionRestoreResult/SuspensionRestoreRefusal, pinned by the 654-lineconsumed-suspension-restore.test.ts. So ruling point 1's "explicit operator verb" is already onmain; this PR adds no second verb.What was NOT on the tree — named by the spec seat when it landed the contract half (#14636; the JSDoc on
AutomationResult.status: "the engine begins stamping it when #13937's services half (the re-arm verb and the catch-arm stamp inresumeInternal) lands"): the catch arm returned nostatus, so the contract advertised a member with zero producers (Prime Directive 10: declared ≠ enforced).What this PR does
The catch-arm stamp (
resumeInternal): the one exit that journals a consumed suspension returns{ success: false, status: 'stranded', … }. Stamped on that exit only — cascade-failed ancestors (failSuspendedRun, no journal) are NOT stranded; trigger-time rejections stay'failed'; refusals above the consumption point carry no status. Verified sound by the review (§1) and not reopened here.The run's RECORDED status stays
failed—ExecutionStatus(6 members) ≠AutomationResult.status(4) ≠RunRecord.status(2); consistent, verified by the review, unchanged.Ruling reflected in the engine's own prose — the "[Decision] Workflow resume ordering: a thrown node today leaves the run terminally unresumable — which of three shapes, given that the current order buys exactly-once across a crash? #13937, unruled" comments cite the ruling and its consequences.
The double-run pins the ruling made non-negotiable, on BOTH store classes now:
stranded-run-status.test.ts(InMemorySuspendedRunStore) andstranded-run-object-store.test.ts(ObjectStoreSuspendedRunStoreover the repo's fake ObjectQL engine — the class the review found the defect on).The exactly-once repair of
restoreConsumedSuspension, rewritten to the review's two pass conditions. Argued on its merits, ⛔ not under any named exemption (the earlier body cited a "four-condition exemption"; no such codified rule exists and the phrase is withdrawn): the ruling's whole point is that a re-armed run must never become double-runnable; pin 4 found that the shipped verb could re-arm a run another replica had finished (the hazard class named in the dispatch), and the pins that found it go red without a fix, so a plain revert is not free. The codified rule — the file surface — is respected and its widening declared below. The reviewer's alternative (a separate card) remains the seat's call; the measurement it asked for is in "The fork" below.Three parts, each pinned on the object store:
node_id— the node that threw — anddeserializeConsumedSuspensionrebuilt the snapshot's node from that column, so any restore from the row re-armed the run at the failed node and the next resume SKIPPED it while reporting the run completed (PROBE C).recordLognow writes the PAUSE node there for a strand's record (nodeId: consumedSuspension?.nodeId ?? lastStep?.nodeId); the throwing node stays in the row's step log anderror. Correction (round 2): my earlier claim that "no reader of a terminal row'snode_idexists outside that deserializer" was false — the code-side readers are indeed none (runRecordToLogEntryomits it; runtime, rest, plugin-approvals read none), but the object's own declarationtitleFormat '{flow_name} · {node_id}'and itshighlightFieldsrender the column on the Runs surface, which objectui consumes generically. So for the stranded row class this change ALTERS what that surface displays: the row is titled and highlighted by the paused node instead of the node that threw. That is the intended reading (the row names the pause an operator can re-arm, and it now agrees with thenode_typecarve-out service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909 already wrote), and it is declared where the column is declared: thenode_idandvariables_jsondescriptions insys-automation-run.object.tscarry the stranded-class carve-out followingnode_type's pattern, and the changeset names the visible change. Pinned same-replica AND from the row alone, and it closes the review's §5.6 pre-existing wrong-node re-arm on base as a consequence of "on every path".serializeConsumedSuspensionnow records the drop IN THE ROW:variables_jsonholds exactly one reserved key ($consumedSuspensionDropped: bytes, budget, the pause'snodeIdandcorrelation);RunRecord.consumedSuspensionDroppedreads it back (ConsumedSuspensionDropNotice, barrel-exported so a host store can write it);deserializeConsumedSuspensionrebuilds nothing from it. The replica holding the hot copy restores; any other replica is refusedNO_CONSUMED_SUSPENSIONwith a reason naming the budget and the remedy, deleting nothing. The store's warn line now says the same.claimSuspensioncompares) — a same-replica read can no longer prefer the less faithful durable copy. Between DIFFERENT pauses the newest strand wins, judged without clocks:ConsumedSuspension.persisted('pending'→'landed'/'failed', settled byrecordLog's own write promise, guarded by identity so a later strand's entry never inherits an older write's outcome) is the engine's memory of what the store was handed — the automation/approvals: 多副本集群下审批流每级节点(除首级)被重复创建 —— approve 后恢复读到滞后一拍的流运行态,同级要批两次(单副本零重复) #13617 exception, verbatim: a row the store never received says nothing, so a hot copy whose write never landed beats an older row, a landed hot copy yields to the later strand another replica recorded, and a snapshot-less, notice-less row reads as "the run moved on" (hot copy dropped) only when this process's own write landed. Unreadable history still refusesSTORE_UNAVAILABLEand deletes nothing.Not changed, deliberately: the consumption order (ruling point 1);
packages/spec;plugin-approvals(scope reading confirmed by the review); the REST resume door (#15221); this package'spackage.json/tsconfig*.json(sibling #15048's); the review's §5 items routed by the seat as a grouped finding (the store-less 50-entry journal cap; the restore→resume→crash re-arm hazard) — ⛔ not widened into.The fork — measured, not forked
Blocking item 1 did require repairing the object-store snapshot's
node_idprovenance. It is one line inrecordLogplus documentation onRunRecord.nodeIdand, in round 2, the two field-description carve-outs on the object — the column's only code-side reader is the deserializer, and its one other consumer, the Runs surface's row title, is declared and carved out where the column is declared (measured above) — so it is not larger than this card and is carried here rather than split. If the seat prefers the reviewer's own-card route for the whole repair (part 5), the three parts and their pins are separable from parts 1–4 by commit:749c4969(object-store pins, red) →1ad59b97(the repair) →ff0e13f6/c5025d04(ledger corrections).File surface (widened, declared)
Claimed:
packages/services/service-automation/src/engine.ts+ tests +.changeset. Added in round 1:packages/services/service-automation/src/suspended-run-store.ts(the store half of both blocking items),src/index.ts(one type export),scripts/engine-double-contract.pinned.json(two additivepinnedrows the gate itself writes for the new fake —--write, its own prescription; ⛔ not the shrink-only DEBT baseline, untouched). Added in round 2:packages/services/service-automation/src/sys-automation-run.object.ts(two field descriptions and their comments; no field, type, index or lifecycle change). No sibling claim holds any of them (#15048's files are disjoint and its own change is merged).Reverse verification — committed trees, direction predicted before each run
d918beeb(stamp + in-memory pins, no repair):Tests 2 failed | 31 passed (33)— the two stale-journal pins atstale.restored. Matched.31a6012e(durable-first read) + the object-store pins as first written (committed as749c4969):Tests 8 failed (8)— every object-store pin red, each on the predicted shape:expected 'tail' to be 'hold'(provenance),expected undefined to be defined(drop notice), a reason withoutbudget,['tail']where the failed node should have re-run,expected 'tail2' to be 'hold2'. The two later test-only edits (ff0e13f6: ledger counts that include the failed node's successful re-run; polling for the completed row) sit after those failing assertions and change none of the red causes.1ad59b97(the repair): whole packageTests 2 failed | 1240 passed (1242)— both failures were miscounted ledgers in the two-pause pins (the engine re-runstailsuccessfully before reachinghold2, exactly as it should; the pause-node assertions were already green); corrected inff0e13f6.ff0e13f6,c5025d04and6a768ff8(round 2, declarations only): whole packageTest Files 105 passed (105) · Tests 1242 passed (1242),os-verify-lockVERDICT command-exit 0.Tests import
./engine.js/./suspended-run-store.jsrelatively (src), so red→green across commits is the proof the change reached executed code; the dependency closure (pnpm --filter '@objectstack/service-automation^...' build) was built first in the rebuilt worktree, VERDICT command-exit 0.Verification at final commit
6a768ff8main(fix(service-automation): compile the test layer with tsc, and repair the TS2341 x3 it hid #15152 landed this package'stypecheckscript andtsconfig.test.json), the package's ownpnpm --filter @objectstack/service-automation typecheck(tsc --noEmit && pnpm check:test-typecheck) at6a768ff8:os-verify-lockVERDICT command-exit 0;check:test-typecheck: OK — @objectstack/service-automation's test layer compiles under tsconfig.test.json; 0 file(s) / 0 error(s). (Round 1's scratch-config reading — 557 files, 3 pre-existing errors in the sibling's file, 0 in this diff — is superseded; the sibling's fix is merged and the count is 0.)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat6a768ff8(9 changed paths vs merge base1226f2e2; the object file adds no family) → 50 commands (six families joined via the ledger file:agent-test-spelling,bash32-floor,cli-command-ids,entry-guard,parse-guard,pnpm-filter-targets). Exit codes captured redirect-then-read (timeout 300 bash -c CMD > LOG 2>&1; ex=$?), verdicts read from each gate's own line: 48 green, includingcheck:engine-double-contract(the new fake'sdeleteandupdateroute throughassertEngineDeleteDispatch/assertEngineUpdateDispatch; the store's own calls pass the producer predicate),check:nul-bytes,check:cross-package-test-inputs,check:test-source-alias,check:type-check-coverage,check:slot-lookup. 2PREREQUISITE NOT MET(exit 3), NOT MEASURED locally —check:dual-build-cjs-loads(49 packages withoutdist/) andcheck:type-check-debt --re-measure(13 unbuilt dependency type entry points), both needing the repo-widepnpm buildCI performs. 0 red.pnpm lintis CI's run; no narrowing claimed.sys-automation-run.object.tsdiff is comment and description lines only (no field, type, index or lifecycle change).Changeset
@objectstack/service-automation: minor — a new observable value on a public method's result, plus one new exported type (ConsumedSuspensionDropNotice). The sentence the review measured false ("single-process … no difference") is replaced with what is known: in-memory and store-less deployments observe no difference; on the object store, restores re-arm the pause node on every path and cross-replica restores of a finished run are refused. Round 2 adds the visible Runs-surface change: a stranded run's row is titled and highlighted by its paused node (titleFormatis built fromnode_id); ordinary completed / failed rows are unchanged.Scope reading:
assertRunResumablepin — NOT in scope under shape 4Confirmed by the review. The symbol lives only in
plugin-approvals(0 hits inservice-automation); ruling point 3 pins the flip in the batch that reopens shape 2, which is excluded.plugin-approvalsuntouched.Findings (none fixed here)
status: 'stranded'verdict — the wire mirror's member is unreachable on the wire #15221 — the REST resume door's 400FLOW_FAILEDenvelope dropsstatus: 'stranded'; the wire mirror member is unreachable through any door (runtime lane).restoreConsumedSuspensioncannot reach a nested run — a stranded child's cascade-failed ancestors are consumed without a snapshot, so restoring the child continues into a dead parent #15222 (sub-issue of service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909) — the verb cannot reach a nested run.cancelled(andtimed_out) intofailed— after a restart the Runs surface cannot tell a cancelled run from a failed one #15223 (finding) — the durable run-history row foldscancelled/timed_outintofailed.#13953 (doorless operator verbs) remains open; the verb and the verdict stay engine-level only.
Implemented-by:
claude/issue-13937-stranded-run-operator-verb(mode:subagent)🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y