Skip to content

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

Merged
os-warren merged 9 commits into
mainfrom
claude/issue-13937-stranded-run-operator-verb
Sep 4, 2026
Merged

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
os-warren merged 9 commits into
mainfrom
claude/issue-13937-stranded-run-operator-verb

Conversation

@os-warren

@os-warren os-warren commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

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 at c5025d04 — 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 head 6a768ff8 — 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/main merged 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 rearm on engine.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 with SuspensionRestoreResult / SuspensionRestoreRefusal, pinned by the 654-line consumed-suspension-restore.test.ts. So ruling point 1's "explicit operator verb" is already on main; 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 in resumeInternal) lands"): the catch arm returned no status, so the contract advertised a member with zero producers (Prime Directive 10: declared ≠ enforced).

What this PR does

  1. 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.

  2. The run's RECORDED status stays failedExecutionStatus (6 members) ≠ AutomationResult.status (4) ≠ RunRecord.status (2); consistent, verified by the review, unchanged.

  3. 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.

  4. The double-run pins the ruling made non-negotiable, on BOTH store classes now: stranded-run-status.test.ts (InMemorySuspendedRunStore) and stranded-run-object-store.test.ts (ObjectStoreSuspendedRunStore over the repo's fake ObjectQL engine — the class the review found the defect on).

  5. 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:

    • Provenance (blocking item 1). A stranded run's terminal row carried the LAST STEP in node_id — the node that threw — and deserializeConsumedSuspension rebuilt 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). recordLog now 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 and error. Correction (round 2): my earlier claim that "no reader of a terminal row's node_id exists outside that deserializer" was false — the code-side readers are indeed none (runRecordToLogEntry omits it; runtime, rest, plugin-approvals read none), but the object's own declaration titleFormat '{flow_name} · {node_id}' and its highlightFields render 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 the node_type carve-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: the node_id and variables_json descriptions in sys-automation-run.object.ts carry the stranded-class carve-out following node_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".
    • The drop notice (blocking item 2). An over-budget snapshot (256 KiB) used to leave bare NULLs — indistinguishable from a run that completed or was cancelled after a restore, and the first cut read it as "moved on" and deleted the hot copy (PROBE A). serializeConsumedSuspension now records the drop IN THE ROW: variables_json holds exactly one reserved key ($consumedSuspensionDropped: bytes, budget, the pause's nodeId and correlation); RunRecord.consumedSuspensionDropped reads it back (ConsumedSuspensionDropNotice, barrel-exported so a host store can write it); deserializeConsumedSuspension rebuilds nothing from it. The replica holding the hot copy restores; any other replica is refused NO_CONSUMED_SUSPENSION with a reason naming the budget and the remedy, deleting nothing. The store's warn line now says the same.
    • Two witnesses, one truth (the read). The hot copy is preferred whenever it and the row describe the SAME pause (node + correlation, the identity claimSuspension compares) — 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 by recordLog'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 refuses STORE_UNAVAILABLE and 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's package.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_id provenance. It is one line in recordLog plus documentation on RunRecord.nodeId and, 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 additive pinned rows 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 at stale.restored. Matched.
  • 31a6012e (durable-first read) + the object-store pins as first written (committed as 749c4969): 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 without budget, ['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 package Tests 2 failed | 1240 passed (1242) — both failures were miscounted ledgers in the two-pause pins (the engine re-runs tail successfully before reaching hold2, exactly as it should; the pause-node assertions were already green); corrected in ff0e13f6.
  • ff0e13f6, c5025d04 and 6a768ff8 (round 2, declarations only): whole package Test Files 105 passed (105) · Tests 1242 passed (1242), os-verify-lock VERDICT command-exit 0.

Tests import ./engine.js / ./suspended-run-store.js relatively (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 6a768ff8

  • Package tests: above.
  • Type-check: after merging main (fix(service-automation): compile the test layer with tsc, and repair the TS2341 x3 it hid #15152 landed this package's typecheck script and tsconfig.test.json), the package's own pnpm --filter @objectstack/service-automation typecheck (tsc --noEmit && pnpm check:test-typecheck) at 6a768ff8: os-verify-lock VERDICT 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.)
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 6a768ff8 (9 changed paths vs merge base 1226f2e2; 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, including check:engine-double-contract (the new fake's delete and update route through assertEngineDeleteDispatch / 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. 2 PREREQUISITE NOT MET (exit 3), NOT MEASURED locallycheck:dual-build-cjs-loads (49 packages without dist/) and check:type-check-debt --re-measure (13 unbuilt dependency type entry points), both needing the repo-wide pnpm build CI performs. 0 red. pnpm lint is CI's run; no narrowing claimed.
  • No control characters in the 9 changed files; remote head equals local; working tree clean; the sys-automation-run.object.ts diff 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 (titleFormat is built from node_id); ordinary completed / failed rows are unchanged.

Scope reading: assertRunResumable pin — NOT in scope under shape 4

Confirmed by the review. The symbol lives only in plugin-approvals (0 hits in service-automation); ruling point 3 pins the flip in the batch that reopens shape 2, which is excluded. plugin-approvals untouched.

Findings (none fixed here)

#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

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

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 19 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-automation/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/approvals.mdx (via nodeId (symbol, a field of interface ConsumedSuspensionDropNotice))
  • content/docs/automation/flows.mdx (via nodeId (symbol, a field of interface ConsumedSuspensionDropNotice))
  • content/docs/kernel/cluster.mdx (via nodeId (symbol, a field of interface ConsumedSuspensionDropNotice))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17.mdx (via AutomationEngine (symbol, a top-level class), nodeId (symbol, a field of interface ConsumedSuspensionDropNotice))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-automation/src/index.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 fbecffe1d81c517d9f6db9ade6ce94a4497b699bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from f3ea21e48b0d90bfe5a59c4e7e5c362b8395d9ce — the merge of head 6a768ff80cfd7ca95b390fe4de1edc12f9f748b5 into base fbecffe1d81c517d9f6db9ade6ce94a4497b699b, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fbecffe1d81c517d9f6db9ade6ce94a4497b699b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling and removed size/l labels Sep 4, 2026
…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
…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
@os-warren
os-warren marked this pull request as ready for review September 4, 2026 11:53
@os-warren
os-warren enabled auto-merge September 4, 2026 11:53
@os-warren
os-warren added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 5964124 Sep 4, 2026
41 checks passed
@os-warren
os-warren deleted the claude/issue-13937-stranded-run-operator-verb branch September 4, 2026 12:20
This was referenced Sep 4, 2026
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/xl tests tooling

Projects

None yet

2 participants