Skip to content

Commit 5964124

Browse files
os-warrenclaude
andauthored
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)
* feat(automation): stamp status 'stranded' on the resume catch arm and 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 * fix(automation): restoreConsumedSuspension reads the durable terminal 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 * test(automation): pin the operator exit on ObjectStoreSuspendedRunStore — 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 * fix(automation): restoreConsumedSuspension reads its journal and the 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 * test(automation): count the successful re-run of the failed node in the 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 * test(automation): pin the object-store fake's update to the engine's 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 * docs(automation): declare the stranded-row carve-out on sys_automation_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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e9b8cbb commit 5964124

9 files changed

Lines changed: 1272 additions & 72 deletions
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
---
4+
5+
feat(automation): a resume that consumed the pause and then failed downstream answers `status: 'stranded'` (#13937)
6+
7+
The services half of the #13937 shape-4 ruling (maintainer 2026-09-01):
8+
`resumeInternal`'s consumption order is kept — the suspension is consumed
9+
before downstream nodes run, which is what buys exactly-once across a crash —
10+
and the state that order leaves behind when a downstream node throws now
11+
carries the platform-level name #14384 put on the contract.
12+
13+
`AutomationEngine.resume()` (and every engine continuation that reaches the
14+
same catch arm) returns `{ success: false, status: 'stranded', … }` where it
15+
returned no `status` at all. Stamped on that one exit only: the pause a
16+
durable decision was waiting on is gone, the run is recorded `failed`, and it
17+
can be re-armed only by the explicit operator verb
18+
`restoreConsumedSuspension` (#13909 slice 2, already published) — never by
19+
`resume` (which answers `RUN_NOT_FOUND`) and never automatically. Distinct
20+
from `'failed'` on purpose: that one says the run ran and was rejected; this
21+
one says a recorded continuation stopped mid-flight and an operator has
22+
something to repair. The result's verdict and the restore verb are held to
23+
agree by test: a stranded result is exactly a restorable run.
24+
25+
Not changed: the run's RECORDED status (the run log, `getRun`, `listRuns`, the
26+
durable `sys_automation_run` history row) stays `failed` — that vocabulary is
27+
`ExecutionStatus` in `@objectstack/spec`, which the ruling did not widen; the
28+
durable discriminator for the condition remains the snapshot the terminal row
29+
carries. No resume semantics move for any pausing node type; shapes 2 and 3
30+
of the decision stay excluded.
31+
32+
Also in this change, under the same ruling's exactly-once guarantee, two
33+
repairs to how `restoreConsumedSuspension` finds a stranded run's snapshot:
34+
35+
- The durable run-history row of a stranded run now records the PAUSE node in
36+
`node_id`. It recorded the node that threw — the run's last step — and the
37+
object store read that column back as the snapshot's node, so a restore
38+
from the row (after a restart, or on another replica) re-armed the run at
39+
the failed node and the next resume skipped it while reporting the run
40+
completed. The throwing node stays in the row's step log and `error`.
41+
Visible on the Runs surface: `sys_automation_run`'s row title and highlight
42+
set are built from `node_id` (`titleFormat '{flow_name} · {node_id}'`), so a
43+
stranded run's row now names the PAUSED node — the one an operator can
44+
re-arm — where it named the node that threw; ordinary completed / failed
45+
rows are unchanged. The `node_id` and `variables_json` field descriptions
46+
carry this carve-out, the way `node_type`'s already did.
47+
- The verb reads the durable row and its own per-process journal as two
48+
witnesses of one strand instead of trusting either alone. The hot copy is
49+
preferred when both describe the same pause (it is the verbatim object the
50+
failure was journalled from). A row that carries no snapshot is read as
51+
"the run moved on" only when this process's own history write landed —
52+
the replica that stranded a run used to keep a hot copy that could re-arm
53+
the run after another replica had restored, resumed and finished it, and
54+
the next resume re-ran every node after the pause. A snapshot the object
55+
store could not persist (over its 256 KiB row budget) is now recorded in
56+
the row as dropped, with the pause it belonged to, so the replica holding
57+
the hot copy still restores and any other replica is refused with a reason
58+
that names the budget and the remedy.
59+
60+
In-memory and store-less deployments observe no behaviour difference. On the
61+
object store, same-replica restores re-arm the pause node on every path, and
62+
restores from the row alone do too; restores across replicas of a run that
63+
finished elsewhere are refused.

packages/services/service-automation/src/consumed-suspension-restore.test.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@
1414
* the REST run surface has no cancel or retry route, and none of the engine's
1515
* public methods moved such a run out.
1616
*
17-
* ⛔ **This file changes nothing about that ordering.** Which ordering is right
18-
* is #13937, unruled and in the maintainer's hands. What is pinned here is the
19-
* EXIT: `restoreConsumedSuspension` puts the consumed suspension back so the
20-
* run is resumable again, under the ordering exactly as it is.
17+
* ⛔ **This file changes nothing about that ordering.** #13937 ruled it (shape
18+
* 4, maintainer 2026-09-01): the order stays, for exactly-once across a crash,
19+
* and this verb is the operator exit. What is pinned here is that EXIT:
20+
* `restoreConsumedSuspension` puts the consumed suspension back so the run is
21+
* resumable again, under the ordering exactly as it is. The NAME the ruling
22+
* gave the state (`AutomationResult.status: 'stranded'`) is pinned beside it
23+
* in `stranded-run-status.test.ts`.
2124
*
2225
* ## What is pinned, and why each one is here
2326
*
@@ -554,17 +557,19 @@ describe('#13909 — across a restart: the deployment shape this exists for', ()
554557
});
555558
});
556559

557-
describe('#13909 — what this slice deliberately does NOT do', () => {
558-
it('leaves AutomationResult.status and the run\'s recorded status alone', async () => {
560+
describe('#13909 — what this verb deliberately does NOT do', () => {
561+
it('names the condition on the RESULT only — the run\'s recorded status stays failed', async () => {
559562
const { engine } = newEngine(new InMemorySuspendedRunStore());
560563
const started = await engine.execute('strand_flow', ctx);
561564
const runId = started.runId as string;
562565
const failed = await engine.resume(runId);
563566

564-
// ⛔ No new platform status is minted for the condition — naming it is
565-
// an explicit same-batch sub-item of #13937, because what it should be
566-
// called depends on which resume-ordering shape is ruled.
567-
expect(failed.status).toBeUndefined();
567+
// The #13937 shape-4 ruling put the name on `AutomationResult.status`
568+
// (#14384) and the catch arm now stamps it (the producer half, pinned
569+
// in full in `stranded-run-status.test.ts`). It did NOT widen
570+
// `ExecutionStatus`: the run's recorded lifecycle stays `failed`, in
571+
// the log and in the durable history row.
572+
expect(failed.status).toBe('stranded');
568573
expect((await engine.getRun(runId))?.status).toBe('failed');
569574
});
570575

@@ -574,10 +579,11 @@ describe('#13909 — what this slice deliberately does NOT do', () => {
574579
const runId = started.runId as string;
575580

576581
// The consumption still precedes the traversal: measured from inside
577-
// the downstream node, the suspension is already gone. If a future
578-
// change made the pause survive a downstream throw (#13937 shape 2),
579-
// THIS is the assertion that should be reconsidered — deliberately, not
580-
// by accident.
582+
// the downstream node, the suspension is already gone. #13937 ruled
583+
// this order KEPT (shape 4); shape 2 — the pause surviving a downstream
584+
// throw — reopens only together with a durable claim/lease that keeps
585+
// exactly-once, and THIS is the assertion such a change must flip
586+
// deliberately, in the same batch, not by accident.
581587
let suspendedDuringTraversal: boolean | undefined;
582588
registerDownstream(engine, {
583589
throws: true,

0 commit comments

Comments
 (0)