@smartmemory/stratum 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/cli/flow.js +61 -0
  2. package/dist/cli/flow.js.map +1 -0
  3. package/dist/cli/learn.js +9 -16
  4. package/dist/cli/learn.js.map +1 -1
  5. package/dist/cli/query_gate.js +7 -2
  6. package/dist/cli/query_gate.js.map +1 -1
  7. package/dist/cli/stratum.js +3 -1
  8. package/dist/cli/stratum.js.map +1 -1
  9. package/dist/connectors/background.js +2 -2
  10. package/dist/connectors/background.js.map +1 -1
  11. package/dist/connectors/claude.js +2 -0
  12. package/dist/connectors/claude.js.map +1 -1
  13. package/dist/connectors/codex.js +4 -0
  14. package/dist/connectors/codex.js.map +1 -1
  15. package/dist/connectors/foreground_registry.js +589 -0
  16. package/dist/connectors/foreground_registry.js.map +1 -0
  17. package/dist/connectors/index.js +1 -0
  18. package/dist/connectors/index.js.map +1 -1
  19. package/dist/connectors/proc_identity.js +28 -0
  20. package/dist/connectors/proc_identity.js.map +1 -1
  21. package/dist/connectors/runner.js +2 -0
  22. package/dist/connectors/runner.js.map +1 -1
  23. package/dist/contracts/events.json +27 -1
  24. package/dist/contracts/mcp-surface.json +176 -6
  25. package/dist/engine/checkpoint.js +1 -1
  26. package/dist/engine/checkpoint.js.map +1 -1
  27. package/dist/engine/engine.js +860 -195
  28. package/dist/engine/engine.js.map +1 -1
  29. package/dist/engine/flow_cancel.js +158 -0
  30. package/dist/engine/flow_cancel.js.map +1 -0
  31. package/dist/engine/run_lock.js +628 -0
  32. package/dist/engine/run_lock.js.map +1 -0
  33. package/dist/engine/state.js +43 -2
  34. package/dist/engine/state.js.map +1 -1
  35. package/dist/ir/refs.js +12 -0
  36. package/dist/ir/refs.js.map +1 -1
  37. package/dist/ir/schema.js +9 -0
  38. package/dist/ir/schema.js.map +1 -1
  39. package/dist/ir/validate.js +243 -29
  40. package/dist/ir/validate.js.map +1 -1
  41. package/dist/learn/smartmemory_egress.js +3 -1
  42. package/dist/learn/smartmemory_egress.js.map +1 -1
  43. package/dist/mcp/server.js +193 -4
  44. package/dist/mcp/server.js.map +1 -1
  45. package/package.json +1 -1
@@ -6,6 +6,8 @@ import { join, resolve } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { z } from "zod";
8
8
  import { runAgent } from "../connectors/runner.js";
9
+ import { processIdentity } from "../connectors/proc_identity.js";
10
+ import { procStartTime } from "../connectors/proc_identity.js";
9
11
  import { extractReferences } from "../ir/refs.js";
10
12
  import { validateSpec } from "../ir/validate.js";
11
13
  import { LearnEgress } from "../learn/smartmemory_egress.js";
@@ -14,8 +16,9 @@ import { buildFlowTerminalEvent, buildGateResolutionEvent } from "../policy/even
14
16
  import { emitPolicyEvent as postPolicyEvent } from "../policy/smartmemory_client.js";
15
17
  import { BUDGET_KEYS, BudgetLedger, validConnectorTelemetry, validUsage } from "./ledger.js";
16
18
  import { commitCheckpoint, revertCheckpoint } from "./checkpoint.js";
19
+ import { acquireRunLock, cancelLockWaitMs, readDriverLeaseSync, releaseDriverLeaseSync, writeDriverLeaseSync } from "./run_lock.js";
17
20
  import { buildReceipt, findReceipt, ReceiptValidationError, spineSpent } from "./receipts.js";
18
- import { StateStore } from "./state.js";
21
+ import { assertRunId, burnIssuances, StateStore } from "./state.js";
19
22
  const execFileAsync = promisify(execFile);
20
23
  /**
21
24
  * The fixed shape every S1 `evaluate:` step must return. Engine-owned and
@@ -89,8 +92,28 @@ export class StratumEngine {
89
92
  // V1 loop ownership is in-process like runLocks; startup rehydrates ownership
90
93
  // for detached runs marked in their durable state.
91
94
  bgFlows = new Map();
95
+ // Run ids whose cross-process file lock this engine currently holds. `persist` asserts
96
+ // membership: every write to a run record happens inside a locked section, and this
97
+ // assertion is what makes that claim checkable rather than a comment.
98
+ heldLocks = new Set();
99
+ // Driver-lease tokens for the runs this engine has pinned (R4-4). A lease naming another
100
+ // LIVE process means that process owns the in-memory copy of the run, and a cancel from
101
+ // here is refused rather than raced.
102
+ driverLeases = new Map();
103
+ lockOptions;
104
+ hooks;
105
+ identity;
106
+ selfStartTime;
107
+ selfIdentityReady;
92
108
  constructor(options) {
93
109
  this.store = new StateStore(options.stateRoot);
110
+ this.lockOptions = options.lockOptions ?? {};
111
+ this.hooks = options.hooks ?? {};
112
+ this.identity = this.lockOptions.identity ?? processIdentity;
113
+ this.selfIdentityReady = (this.lockOptions.selfStartTime ?? (() => procStartTime(process.pid)))()
114
+ .then((value) => { if (value !== undefined)
115
+ this.selfStartTime = value; })
116
+ .catch(() => undefined);
94
117
  this.evaluator = options.evaluator;
95
118
  if (options.judge)
96
119
  this.judge = options.judge;
@@ -101,6 +124,10 @@ export class StratumEngine {
101
124
  ...options.learnEgressOptions,
102
125
  store: this.store,
103
126
  withReceiptUpdate: (runId, update) => this.withReceiptUpdate(runId, update),
127
+ // F3: a drain snapshot is a READ. Routed through the update path it re-persisted the
128
+ // record, and on a cancelled run that meant the guard above rejected a call that was
129
+ // never going to write anything.
130
+ withReceiptRead: (runId, read) => this.withRunLock(runId, async () => read(await this.loadRun(runId))),
104
131
  });
105
132
  this.learnEgressStartup = this.learnEgress.enabled()
106
133
  ? this.learnEgress.drainAll().catch((error) => {
@@ -114,24 +141,106 @@ export class StratumEngine {
114
141
  return active.run;
115
142
  return this.store.load(runId);
116
143
  }
144
+ /** Pinning is SYNCHRONOUS by design (see scheduleFanout), so the lease it declares is
145
+ * written synchronously too — a deferred async write would leave a window in which a
146
+ * pinned run looks unowned to a second process. */
117
147
  retainRun(runId, run) {
118
148
  const active = this.activeRuns.get(runId);
119
- if (active)
149
+ if (active) {
120
150
  active.refs += 1;
121
- else
122
- this.activeRuns.set(runId, { run, refs: 1 });
151
+ return;
152
+ }
153
+ // An UNLEASED pin is the exact state the lease exists to make impossible: this process
154
+ // holds the run's in-memory object, and nothing on disk says so, so a second process reads
155
+ // "unowned", cancels against its own copy, and the two diverge. Refusing to pin is the
156
+ // only safe answer to "we cannot declare ownership" — the previous `return` pinned anyway.
157
+ if (this.selfStartTime === undefined) {
158
+ throw Object.assign(new Error(`cannot establish process identity; run ${runId} cannot be pinned`), { code: "RUN_LOCK_IDENTITY_UNAVAILABLE" });
159
+ }
160
+ // Lease FIRST, then register the pin: a throwing claim must leave no pin behind. The error
161
+ // propagates — a swallowed one produced precisely the unleased pin above, and reported
162
+ // success while doing it.
163
+ //
164
+ // The token we already hold for this run is passed so `writeDriverLeaseSync` can tell OUR
165
+ // leftover from a live sibling's lease. Identity alone cannot: two engines in one process
166
+ // share a pid and a start time, so the old same-identity reclaim let the second one delete
167
+ // the first's live lease and drive the same run (F1).
168
+ const token = writeDriverLeaseSync(this.store.root, runId, this.selfStartTime, this.driverLeases.get(runId));
169
+ this.activeRuns.set(runId, { run, refs: 1 });
170
+ this.driverLeases.set(runId, token);
171
+ }
172
+ /** Resolve any INCUMBENT driver lease, immediately before a pin claims one (R4-4).
173
+ *
174
+ * `writeDriverLeaseSync` now claims with `link()` and refuses to overwrite, so the identity
175
+ * question has to be ANSWERED rather than papered over by a `rename()`. It is answered here
176
+ * because it is async — a probe of another process — while `retainRun` is synchronous by
177
+ * design. Every async pin site runs this inside its own locked section, so the answer cannot
178
+ * go stale between the probe and the claim. */
179
+ async prepareLease(runId) {
180
+ const lease = readDriverLeaseSync(this.store.root, runId);
181
+ if (lease === undefined)
182
+ return;
183
+ if (this.driverLeases.get(runId) === lease.token)
184
+ return; // ours, by TOKEN
185
+ // There is deliberately no "same pid and start time, therefore ours" arm here (F1). Two
186
+ // engine instances in one process answer that test identically, so it let the second one
187
+ // reclaim a lease the first was actively driving. A same-identity lease whose token we do
188
+ // not hold is a LIVE lease, and the probe below says so.
189
+ const state = await this.identity(lease.pid, lease.startTime);
190
+ if (state === "dead") {
191
+ releaseDriverLeaseSync(this.store.root, runId, lease.token);
192
+ return;
193
+ }
194
+ throw Object.assign(new Error(`run ${runId} is driven by pid ${lease.pid}; it cannot be pinned from this process`), { code: "DRIVER_LEASE_HELD", holderPid: lease.pid });
123
195
  }
124
196
  releaseRun(runId) {
125
197
  const active = this.activeRuns.get(runId);
126
198
  if (!active)
127
199
  return;
128
200
  active.refs -= 1;
129
- if (active.refs <= 0)
130
- this.activeRuns.delete(runId);
201
+ if (active.refs > 0)
202
+ return;
203
+ this.activeRuns.delete(runId);
204
+ const token = this.driverLeases.get(runId);
205
+ if (token === undefined)
206
+ return;
207
+ // Reached from `finally` blocks, so it must not throw — but it must not go quiet either:
208
+ // a lease left behind wedges every other process off this run.
209
+ //
210
+ // The token is forgotten only once the release has actually SUCCEEDED (F5). Deleting it
211
+ // first meant a transient unlink failure left a lease on disk that this engine no longer
212
+ // recognised: `prepareLease` and `claimDriverLease` compare by token, so their own leftover
213
+ // read back as a live foreign lease naming this very pid, and the run became unpinnable and
214
+ // uncancellable from the one process that owned it. Retaining it keeps the reclaim path open.
215
+ try {
216
+ releaseDriverLeaseSync(this.store.root, runId, token);
217
+ this.driverLeases.delete(runId);
218
+ }
219
+ catch (error) {
220
+ process.stderr.write(`stratum: unable to release driver lease for ${runId}: ${message(error)}\n`);
221
+ }
131
222
  }
132
- withRunLock(runId, action) {
223
+ /** The in-process promise chain still serialises same-process callers cheaply; the file
224
+ * lock is what makes the exclusion cross-process. */
225
+ withRunLock(runId, action, options) {
226
+ assertRunId(runId);
133
227
  const previous = this.runLocks.get(runId) ?? Promise.resolve();
134
- const result = previous.then(action);
228
+ const result = previous.then(async () => {
229
+ await this.selfIdentityReady;
230
+ // The cached identity, not a fresh probe: `procStartTime` shells out to python/ps on
231
+ // darwin, and every locked section on the hot path would otherwise pay for it again.
232
+ const release = await acquireRunLock(this.store.root, runId, {
233
+ ...this.lockOptions, ...options, selfStartTime: () => Promise.resolve(this.selfStartTime),
234
+ });
235
+ this.heldLocks.add(runId);
236
+ try {
237
+ return await action();
238
+ }
239
+ finally {
240
+ this.heldLocks.delete(runId);
241
+ await release();
242
+ }
243
+ });
135
244
  const tail = result.catch(() => undefined);
136
245
  this.runLocks.set(runId, tail);
137
246
  void tail.then(() => {
@@ -143,6 +252,12 @@ export class StratumEngine {
143
252
  async withReceiptUpdate(runId, update) {
144
253
  return this.withRunLock(runId, async () => {
145
254
  const run = await this.loadRun(runId);
255
+ // Same reason as `usageReport`: refuse BEFORE `update` touches the shared object, not
256
+ // after, at the persist. The run object is pinned and shared, so a mutation applied and
257
+ // then rejected is a mutation the rest of the engine can still see.
258
+ if (run.status === "cancelled" || run.cancelRequested === true) {
259
+ throw Object.assign(new Error(`run ${runId} is cancelled; no further receipt updates are accepted`), { code: "PERSIST_ON_CANCELLED_RUN" });
260
+ }
146
261
  const result = await update(run);
147
262
  await this.persist(run);
148
263
  return result;
@@ -202,8 +317,14 @@ export class StratumEngine {
202
317
  // different process cwd after restart.
203
318
  ...(options.workspaceRoot !== undefined ? { workspaceRoot: resolve(options.workspaceRoot) } : {}),
204
319
  };
205
- await this.persist(run);
206
- return this.withRevisionDigest(await this.advance(run, effectiveValidation.value, effectiveValidation.contracts), run);
320
+ // R3-3a: the initial persist AND the first advance are inside the lock. The run id is
321
+ // minted above, and acquireRunLock mkdir -p's the state root, so the lock may legitimately
322
+ // precede the run file's existence. The advance must be inside too: it is what issues the
323
+ // first dispatch tokens.
324
+ return this.withRunLock(run.id, async () => {
325
+ await this.persist(run);
326
+ return this.withRevisionDigest(await this.advance(run, effectiveValidation.value, effectiveValidation.contracts), run);
327
+ });
207
328
  }
208
329
  async flowRunBg(specInput, input, options = {}) {
209
330
  const validation = validateSpec(specInput);
@@ -223,26 +344,48 @@ export class StratumEngine {
223
344
  }
224
345
  }
225
346
  const first = await this.plan(validation.value, input, options);
226
- const run = await this.withRunLock(first.runId, async () => {
347
+ await this.hooks.beforePin?.(first.runId);
348
+ // ONE locked transaction: load, mark, claim the lease, pin, launch. Splitting it — as this
349
+ // did, pinning after the lock was released — leaves a window in which the run is loaded,
350
+ // unpinned and unleased, so a cancel from anywhere settles it durably and the driver then
351
+ // pins the stale object it loaded before that and writes `running` back over the settle.
352
+ // The launch is INSIDE too: `driveBg` is not awaited, and its own first `withRunLock`
353
+ // queues behind this section, so it observes whatever the durable record says afterwards.
354
+ await this.withRunLock(first.runId, async () => {
227
355
  const current = await this.loadRun(first.runId);
228
- current.bgDriven = true;
229
- await this.persist(current);
230
- return current;
231
- });
232
- const bg = { status: "running", cancelRequested: false, pendingGates: [] };
233
- this.bgFlows.set(first.runId, bg);
234
- // Pin before launch so the loop and any fanout always share one run object.
235
- this.retainRun(first.runId, run);
236
- const loop = this.driveBg(first.runId, first);
237
- bg.loop = loop;
238
- void loop.finally(() => {
239
- this.releaseRun(first.runId);
240
- if (bg.loop === loop)
241
- delete bg.loop;
356
+ // OWNERSHIP BEFORE BOOKKEEPING (F2). The durable `bgDriven` mark used to be written
357
+ // first, so a lease this process could not claim left the run marked as driven by a
358
+ // driver that never started — a run rehydrated on the next boot as a live bg flow with
359
+ // nothing behind it. Nothing is mutated until the lease and the pin are both held, and
360
+ // anything that fails after them is rolled back here.
361
+ await this.prepareLease(first.runId);
362
+ this.retainRun(first.runId, current);
363
+ const bg = { status: "running", cancelRequested: false, pendingGates: [] };
364
+ this.bgFlows.set(first.runId, bg);
365
+ try {
366
+ current.bgDriven = true;
367
+ await this.persist(current);
368
+ }
369
+ catch (error) {
370
+ delete current.bgDriven;
371
+ this.bgFlows.delete(first.runId);
372
+ this.releaseRun(first.runId);
373
+ throw error;
374
+ }
375
+ const loop = this.driveBg(first.runId, first);
376
+ bg.loop = loop;
377
+ void loop.finally(() => {
378
+ this.releaseRun(first.runId);
379
+ if (bg.loop === loop)
380
+ delete bg.loop;
381
+ });
242
382
  });
243
383
  return { runId: first.runId, status: "running" };
244
384
  }
245
385
  async rehydrateBgFlows() {
386
+ // The pins below are synchronous and write a driver lease, which needs this process's
387
+ // identity resolved first.
388
+ await this.selfIdentityReady;
246
389
  for (const runId of await this.store.list()) {
247
390
  let run;
248
391
  try {
@@ -267,26 +410,62 @@ export class StratumEngine {
267
410
  // its own (background) driver, not abort the whole scan. driveBg self-discovers
268
411
  // the live state via reAdvance (which also re-schedules any in-flight fanout),
269
412
  // so no explicit resume is needed; the synthesized initial's ledger is never
270
- // read (driveBg re-derives it). retainRun and the launch are adjacent with no
271
- // throwing await between them, so the retain can never leak.
413
+ // read (driveBg re-derives it).
414
+ //
415
+ // The load above is only a FILTER. Everything that decides ownership — the
416
+ // authoritative re-load, the lease claim, the pin and the launch — happens inside one
417
+ // locked section below, because the scan is long and a cancel that lands during it must
418
+ // be observed rather than overwritten by a driver pinning a pre-cancel snapshot.
272
419
  //
273
420
  // AT-LEAST-ONCE across restart: an in-flight connector was durable as `ready`,
274
421
  // so the driver re-dispatches it — a step may run twice, and that second
275
422
  // physical dispatch is NOT re-ledgered (a dispatch budget may under-count by the
276
423
  // in-flight-at-crash count). Callers doing writes must be idempotent. A worktree
277
- // fanout merge retains its pre-existing crash window (accepted residual). This
278
- // assumes SINGLE-PROCESS ownership — the prior engine is gone; two live engines
279
- // on one state root are unsupported in v1 (same single-owner model as runLocks).
280
- const bg = { status: "running", cancelRequested: false, pendingGates: [] };
281
- this.bgFlows.set(run.id, bg);
282
- this.retainRun(run.id, run);
283
- const loop = this.driveBg(run.id, { status: "running", runId: run.id, ledger: { spent: {} } });
284
- bg.loop = loop;
285
- void loop.finally(() => {
286
- this.releaseRun(run.id);
287
- if (bg.loop === loop)
288
- delete bg.loop;
289
- });
424
+ // fanout merge retains its pre-existing crash window (accepted residual).
425
+ await this.hooks.beforePin?.(run.id);
426
+ try {
427
+ await this.withRunLock(run.id, async () => {
428
+ const current = await this.store.load(run.id);
429
+ // Re-read under the lock: `current`, not the snapshot the scan filtered on.
430
+ if (!current.bgDriven || this.bgFlows.has(current.id))
431
+ return;
432
+ if (current.status !== "running") {
433
+ this.bgFlows.set(current.id, { status: current.status, cancelRequested: false, pendingGates: [] });
434
+ return;
435
+ }
436
+ if (current.cancelRequested === true) {
437
+ this.bgFlows.set(current.id, { status: "cancelled", cancelRequested: true, pendingGates: [] });
438
+ return;
439
+ }
440
+ // Lease and pin BEFORE the `bgFlows` insert (F2): a failing lease write used to leave
441
+ // a `running` entry naming a driver that never launched, and every later poll and
442
+ // cancel read that entry as a live loop in this process.
443
+ await this.prepareLease(current.id);
444
+ this.retainRun(current.id, current);
445
+ const bg = { status: "running", cancelRequested: false, pendingGates: [] };
446
+ this.bgFlows.set(current.id, bg);
447
+ let loop;
448
+ try {
449
+ loop = this.driveBg(current.id, { status: "running", runId: current.id, ledger: { spent: {} } });
450
+ }
451
+ catch (error) {
452
+ this.bgFlows.delete(current.id);
453
+ this.releaseRun(current.id);
454
+ throw error;
455
+ }
456
+ bg.loop = loop;
457
+ void loop.finally(() => {
458
+ this.releaseRun(current.id);
459
+ if (bg.loop === loop)
460
+ delete bg.loop;
461
+ });
462
+ });
463
+ }
464
+ catch (error) {
465
+ // A live lease elsewhere means another process owns this run: leave it alone rather
466
+ // than aborting the whole scan. Everything else is equally per-run.
467
+ process.stderr.write(`stratum: not rehydrating flow '${run.id}': ${message(error)}\n`);
468
+ }
290
469
  }
291
470
  }
292
471
  async stepDone(runId, stepId, result, dispatchToken) {
@@ -435,6 +614,15 @@ export class StratumEngine {
435
614
  throw new ReceiptValidationError('usdSource "legacy" is reserved for engine-synthesized receipts');
436
615
  }
437
616
  const run = await this.loadRun(runId);
617
+ // R4-1 BEFORE duplicate detection and before any mutation. `persist` refuses a cancelled
618
+ // record, but by the time it does, this method has already advanced `receiptCounter`,
619
+ // debited the ledger and appended the receipt and its events to the in-memory object the
620
+ // rest of the engine shares — so the throw leaves the pinned run mutated, and an
621
+ // automatic retry finds its own receipt, takes the duplicate branch, and reports SUCCESS
622
+ // for a receipt that was never durably recorded.
623
+ if (run.status === "cancelled" || run.cancelRequested === true) {
624
+ throw Object.assign(new Error(`run ${runId} is cancelled; no further receipts are accepted`), { code: "PERSIST_ON_CANCELLED_RUN" });
625
+ }
438
626
  const candidate = input;
439
627
  if (typeof input !== "object" || input === null || Array.isArray(input)
440
628
  || typeof candidate?.dispatchId !== "string" || candidate.dispatchId.length === 0) {
@@ -497,6 +685,7 @@ export class StratumEngine {
497
685
  this.assertExternalMutationAllowed(runId, "commit");
498
686
  return await this.withRunLock(runId, async () => {
499
687
  const run = await this.loadCheckpointRun(runId);
688
+ this.assertCancelledCheckpointRefusal(run, "commit");
500
689
  this.assertNoForegroundFanout(run, "commit");
501
690
  const normalized = label.trim();
502
691
  if (!normalized)
@@ -519,6 +708,7 @@ export class StratumEngine {
519
708
  this.assertExternalMutationAllowed(runId, "revert");
520
709
  return await this.withRunLock(runId, async () => {
521
710
  const run = await this.loadCheckpointRun(runId);
711
+ this.assertCancelledCheckpointRefusal(run, "revert");
522
712
  this.assertNoForegroundFanout(run, "revert");
523
713
  const normalized = label.trim();
524
714
  // Money spent is spent: a revert restores state, never spend. Capture the live
@@ -573,6 +763,19 @@ export class StratumEngine {
573
763
  }
574
764
  async resumeLocked(runId) {
575
765
  const run = await this.loadRun(runId);
766
+ // D5. Without this a cancelled run re-emits `resumed`, re-arms every in-flight fanout
767
+ // below, and lands in advance's {status:"running"} limbo. assertExternalMutationAllowed
768
+ // cannot cover it: a foreground run has no bgFlows entry.
769
+ if (run.cancelRequested === true || run.status === "cancelled") {
770
+ throw new Error(`run ${runId} is cancelled; resume is not permitted`);
771
+ }
772
+ // Resume is the TAKEOVER entry point: it re-arms in-flight fanouts, and arming one pins the
773
+ // run. The pin used to meet the incumbent lease with no way to resolve it — `scheduleFanout`
774
+ // is synchronous, so it cannot probe an owner — and the only thing that got past a leftover
775
+ // lease was the same-pid reclaim F1 removes. A crashed driver's lease would therefore have
776
+ // wedged every later resume. Resolve it HERE, where the probe can be awaited: a dead owner
777
+ // is reclaimed, a live one still refuses.
778
+ await this.prepareLease(runId);
576
779
  const computedDigest = digest(run.spec);
577
780
  if (run.revisionDigest !== undefined && run.revisionDigest !== computedDigest) {
578
781
  throw new Error("persisted revision digest does not match the effective specification");
@@ -596,7 +799,7 @@ export class StratumEngine {
596
799
  // holds: audit is the consumer's discovery surface (D5) and a token minted
597
800
  // on the live object must stay invisible until its save lands.
598
801
  const run = await this.store.load(runId);
599
- return { runId, status: run.status, events: structuredClone(run.events), steps: structuredClone(run.steps), flowSpent: structuredClone(run.flowSpent), ...(run.output !== undefined ? { output: structuredClone(run.output) } : {}) };
802
+ return { runId, status: run.status, events: structuredClone(run.events), steps: structuredClone(run.steps), flowSpent: structuredClone(run.flowSpent), ...(run.output !== undefined ? { output: structuredClone(run.output) } : {}), ...(run.carry !== undefined ? { carry: structuredClone(run.carry) } : {}) };
600
803
  }
601
804
  /** Restart-safe read-only wait surface: events are sliced from the persisted spine. */
602
805
  async flowPoll(runId, cursor = 0) {
@@ -654,41 +857,202 @@ export class StratumEngine {
654
857
  }
655
858
  return { status: bg.status };
656
859
  }
657
- async gateResolve(runId, stepId, decision, gateToken, userId) {
658
- const response = await this.withRunLock(runId, () => this.gateResolveLocked(runId, stepId, decision, gateToken));
659
- const resolvedRun = await this.loadRun(runId);
660
- if (resolvedRun.bundle_id !== undefined) {
661
- const round = resolvedRun.events.filter((event) => event.type === "gate_resolved" && event.stepId === stepId).length;
662
- this.firePolicyEvent(buildGateResolutionEvent({
663
- runId,
664
- bundleId: resolvedRun.bundle_id,
665
- stepId,
666
- round,
667
- outcome: decision,
668
- ...(userId !== undefined ? { resolvedByUserId: userId } : {}),
669
- }));
860
+ /** R4-4. A live lease held by ANOTHER process means that process owns the in-memory copy of
861
+ * this run: refuse rather than race it. A provably dead owner's lease is removed and we
862
+ * proceed, because the crashed driver's memory is gone and disk is truth again. An
863
+ * `unknown` identity is never reclaimed — we do not break a lease we cannot disprove. */
864
+ async claimDriverLease(runId) {
865
+ const lease = readDriverLeaseSync(this.store.root, runId);
866
+ if (lease === undefined)
867
+ return;
868
+ if (this.driverLeases.get(runId) === lease.token)
869
+ return; // ours
870
+ const state = await this.identity(lease.pid, lease.startTime);
871
+ if (state === "dead") {
872
+ releaseDriverLeaseSync(this.store.root, runId, lease.token);
873
+ return;
670
874
  }
671
- const bg = this.bgFlows.get(runId);
672
- if (bg?.status === "paused_gate" && response.status !== "ready" && response.status !== "running") {
673
- bg.status = response.status;
674
- bg.pendingGates = [];
875
+ throw Object.assign(new Error(`run ${runId} is driven by pid ${lease.pid}; cancel must be issued from that process`), { code: "CANCELLATION_UNCONFIRMED", reason: "engine_dispatch_active", holderPid: lease.pid });
876
+ }
877
+ /** R3-7. Admission for a foreground agent about to be spawned against this flow.
878
+ *
879
+ * Deliberately NOT a boolean: `false` would conflate "healthy", "does not exist" and
880
+ * "could not read the record", and only the first is a reason to start an agent. An
881
+ * unreadable record fails CLOSED, because admitting an uncancellable agent is the failure
882
+ * this check exists to prevent. */
883
+ async admitFlowAgent(runId) {
884
+ let run;
885
+ try {
886
+ run = await this.withRunLock(runId, () => this.loadRun(runId));
675
887
  }
676
- else if (bg?.status === "paused_gate") {
888
+ catch (error) {
889
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
890
+ throw Object.assign(new Error(`flow ${runId} is not running`), { code: "FLOW_NOT_RUNNING" });
891
+ }
892
+ throw Object.assign(new Error(`flow ${runId} admission check failed: ${message(error)}`), { code: "FLOW_ADMISSION_FAILED" });
893
+ }
894
+ if (run.status !== "running" || run.cancelRequested === true) {
895
+ throw Object.assign(new Error(`flow ${runId} is ${run.status === "running" ? "cancelled" : run.status}`), { code: "FLOW_NOT_RUNNING" });
896
+ }
897
+ }
898
+ /** Foreground cancel, addressable by flow id from any process (STRAT-FLOW-CANCEL-FG).
899
+ * Unlike flowCancelBg this SETTLES the run rather than abandoning it, and it works with no
900
+ * bgFlows entry — which is the whole point: a compose team build is a foreground
901
+ * consumer-fanout run, and `compose build --abort` runs in a different process.
902
+ *
903
+ * One locked section, one persist. No mark-then-settle two-phase: under the lock there is
904
+ * nothing to race, so the durable mark and the settle ARE the same write. */
905
+ async flowCancel(runId, reason) {
906
+ return this.withRunLock(runId, async () => {
907
+ // DURABLE STATUS FIRST, and BEFORE the lease decision. Two reasons, both load-bearing:
908
+ //
909
+ // A terminal run must not be MUTATED at all, and a lease refusal is a mutation refusal
910
+ // for a mutation nobody asked for. Worse, an already-cancelled run whose driver's lease
911
+ // is still live would raise `engine_dispatch_active` — so the orchestrator's re-sweep,
912
+ // which is the documented recovery from a CANCELLATION_TEARDOWN_TIMEOUT, would fail on
913
+ // the very run it just cancelled. `already_cancelled` is what lets that sweep run.
914
+ //
915
+ // The decision is read from DISK, not from a pinned object: another process may have
916
+ // completed this run.
917
+ const persisted = await this.store.load(runId);
918
+ if (persisted.status !== "running") {
919
+ return {
920
+ runId,
921
+ status: persisted.status,
922
+ flowSettled: persisted.status === "cancelled",
923
+ settledByThisCall: false,
924
+ reason: `already_${persisted.status}`,
925
+ ledger: this.ledgerInfo(persisted),
926
+ };
927
+ }
928
+ // Only NOW, with the run known to be durably running, does the lease decide whether this
929
+ // process may apply the cancel at all.
930
+ await this.claimDriverLease(runId);
931
+ // Mutate the object the rest of the engine is using. When a fanout of OURS holds a pin,
932
+ // loadRun returns that instance, so its cooperative brake and its item settle see the
933
+ // burn on the very same object — which is the whole reason the lease restricts this
934
+ // path to the driving process (R4-4).
935
+ const run = await this.loadRun(runId);
936
+ await this.terminalCancel(run, reason);
937
+ return { runId, status: run.status, flowSettled: true, settledByThisCall: true, ledger: this.ledgerInfo(run) };
938
+ }, { timeoutMs: cancelLockWaitMs() });
939
+ }
940
+ async gateResolve(runId, stepId, decision, gateToken, userId) {
941
+ // A PAUSED background flow resolves its gate and re-kicks its driver in ONE locked
942
+ // transaction, and the driver lease is claimed FIRST (F1). Resolving the gate first —
943
+ // even in its own earlier locked section — consumed and PERSISTED the gate token before
944
+ // this process knew it could drive the run at all: a refused claim then left a run whose
945
+ // successor step was ready, whose durable gate token was already spent, and whose loop had
946
+ // never started. Nothing could resolve the gate again to produce a driver. Claiming first
947
+ // means a refusal leaves the durable gate exactly as it was, so the SAME token retries.
948
+ const bg = this.bgFlows.get(runId);
949
+ const rekick = bg !== undefined && bg.status === "paused_gate";
950
+ if (rekick)
951
+ await this.hooks.beforePin?.(runId);
952
+ let policy;
953
+ const response = await this.withRunLock(runId, async () => {
954
+ if (!rekick || bg === undefined) {
955
+ const plain = await this.gateResolveLocked(runId, stepId, decision, gateToken);
956
+ policy = await this.gatePolicySnapshot(runId, stepId);
957
+ return plain;
958
+ }
959
+ // Claim and pin BEFORE the gate is touched. A throw here has mutated nothing.
960
+ await this.prepareLease(runId);
961
+ this.retainRun(runId, await this.loadRun(runId));
962
+ let resolved;
963
+ try {
964
+ resolved = await this.gateResolveLocked(runId, stepId, decision, gateToken);
965
+ }
966
+ catch (error) {
967
+ this.releaseRun(runId);
968
+ throw error;
969
+ }
970
+ policy = await this.gatePolicySnapshot(runId, stepId);
971
+ const durable = await this.store.load(runId);
972
+ if (resolved.status !== "ready" && resolved.status !== "running") {
973
+ bg.status = resolved.status;
974
+ bg.pendingGates = [];
975
+ this.releaseRun(runId);
976
+ return resolved;
977
+ }
978
+ if (durable.status !== "running" || durable.cancelRequested === true) {
979
+ bg.status = durable.status === "running" ? "cancelled" : durable.status;
980
+ bg.pendingGates = [];
981
+ this.releaseRun(runId);
982
+ return resolved;
983
+ }
984
+ const priorStatus = bg.status;
985
+ const priorGates = bg.pendingGates;
677
986
  bg.status = "running";
678
987
  bg.pendingGates = [];
679
- const run = await this.loadRun(runId);
680
- // Re-kick only after gateResolve releases the run lock; stepDone must interleave.
681
- this.retainRun(runId, run);
682
- const loop = this.driveBg(runId, response);
988
+ let loop;
989
+ try {
990
+ loop = this.driveBg(runId, resolved);
991
+ }
992
+ catch (error) {
993
+ bg.status = priorStatus;
994
+ bg.pendingGates = priorGates;
995
+ this.releaseRun(runId);
996
+ throw error;
997
+ }
683
998
  bg.loop = loop;
684
999
  void loop.finally(() => {
685
1000
  this.releaseRun(runId);
686
1001
  if (bg.loop === loop)
687
1002
  delete bg.loop;
688
1003
  });
1004
+ return resolved;
1005
+ });
1006
+ if (policy !== undefined) {
1007
+ this.firePolicyEvent(buildGateResolutionEvent({
1008
+ runId,
1009
+ bundleId: policy.bundleId,
1010
+ stepId,
1011
+ round: policy.round,
1012
+ outcome: decision,
1013
+ ...(userId !== undefined ? { resolvedByUserId: userId } : {}),
1014
+ }));
689
1015
  }
690
1016
  return response;
691
1017
  }
1018
+ /** The bundle/round pair the policy event needs, read INSIDE the gate's locked transaction.
1019
+ * Read afterwards it could count a later round appended by the loop this call just launched. */
1020
+ async gatePolicySnapshot(runId, stepId) {
1021
+ const run = await this.loadRun(runId);
1022
+ if (run.bundle_id === undefined)
1023
+ return undefined;
1024
+ return {
1025
+ bundleId: run.bundle_id,
1026
+ round: run.events.filter((event) => event.type === "gate_resolved" && event.stepId === stepId).length,
1027
+ };
1028
+ }
1029
+ /** Every on_revise write this gate declares, resolved against the UN-RESET scope.
1030
+ * All expressions are resolved before any of them is applied, so a missing source
1031
+ * output fails the decision with no partial mutation. */
1032
+ resolveCarryOnRevise(scope, gateId, gateToken) {
1033
+ const declarations = scope.flow.carry;
1034
+ if (declarations === undefined)
1035
+ return [];
1036
+ const writes = [];
1037
+ for (const [name, declaration] of Object.entries(declarations)) {
1038
+ // Object.hasOwn on the on_revise map too: a gate step legitimately named
1039
+ // `constructor` must not pick up an inherited member as a declaration (R2-3).
1040
+ const onRevise = declaration.on_revise;
1041
+ if (onRevise === undefined || !Object.hasOwn(onRevise, gateId))
1042
+ continue; // gates that declare nothing leave the entry untouched
1043
+ const expression = onRevise[gateId];
1044
+ const value = this.resolve(this.carryReference(expression), scope);
1045
+ if (value === undefined) {
1046
+ throw new Error(`carry_revise_unresolved: carry ${JSON.stringify(name)} at gate ${JSON.stringify(gateId)} resolved to no value`);
1047
+ }
1048
+ // sourceStep/sourceEpoch name the INITIAL declaration's source (D6), so the write
1049
+ // survives every later advance until that source is itself reset (D7).
1050
+ const initial = this.carryReference(declaration.initial);
1051
+ const source = initial.kind === "step" ? { sourceStep: initial.stepId, sourceEpoch: scope.steps[initial.stepId]?.epoch ?? 0 } : {};
1052
+ writes.push({ name, value, provenance: { kind: "revise", ...source, gate: gateId, gateToken, at: now() } });
1053
+ }
1054
+ return writes;
1055
+ }
692
1056
  async gateResolveLocked(runId, stepId, decision, gateToken) {
693
1057
  // Runtime guard for JS callers: an unknown decision must be rejected, not
694
1058
  // fall through the ternary chain onto the kill route.
@@ -713,8 +1077,35 @@ export class StratumEngine {
713
1077
  if (state.gateToken !== gateToken) {
714
1078
  throw new Error("gate decision is stale: issued for a superseded gate round");
715
1079
  }
716
- delete state.gateToken;
717
1080
  const target = decision === "approve" ? step.gate.on_approve : decision === "revise" ? step.gate.on_revise : step.gate.on_kill;
1081
+ // ---- STRAT-LOOP-CARRY R1-6: MUTATION-FREE PREFLIGHT ---------------------
1082
+ // Everything that can throw runs here, while the gate token is still valid and no
1083
+ // event has been appended. loadRun returns the LIVE run object while a fanout is
1084
+ // active, so a throw after this point would strand the gate: the token would be
1085
+ // consumed and the decision unrepeatable.
1086
+ let carryWrites = [];
1087
+ let reviseTotal = 0;
1088
+ let reviseGateRounds = 0;
1089
+ if (decision === "revise") {
1090
+ reviseGateRounds = state.iterations ?? 0;
1091
+ reviseTotal = (scope.parent ? scope.parent.state.sub?.rounds ?? 0 : run.rounds ?? 0) + 1;
1092
+ const flowLimit = scope.flow.max_rounds;
1093
+ const gateLimit = step.gate.max_rounds;
1094
+ if (target === null || flowLimit === undefined || reviseTotal > flowLimit || (gateLimit !== undefined && reviseGateRounds + 1 > gateLimit)) {
1095
+ // Rounds exhaustion terminalises the run deliberately; it is the one preflight
1096
+ // outcome that mutates, and nothing runs after it.
1097
+ delete state.gateToken;
1098
+ this.event(run, "gate_resolved", stepId, { decision, target });
1099
+ return scope.parent
1100
+ ? this.failScope(run, validated.value, validated.contracts, scope, "gate revision rounds exhausted")
1101
+ : this.terminalFailure(run, { attempt: 0, reason: "gate revision rounds exhausted" });
1102
+ }
1103
+ // Resolves every declared expression against the UN-RESET scope. Throws
1104
+ // `carry_revise_unresolved` before any mutation if one has no value.
1105
+ carryWrites = this.resolveCarryOnRevise(scope, step.id, gateToken);
1106
+ }
1107
+ // ---- END PREFLIGHT; EVERYTHING BELOW MUTATES ----------------------------
1108
+ delete state.gateToken;
718
1109
  this.event(run, "gate_resolved", stepId, { decision, target });
719
1110
  if (decision === "kill") {
720
1111
  state.status = "succeeded";
@@ -726,22 +1117,21 @@ export class StratumEngine {
726
1117
  }
727
1118
  }
728
1119
  else if (decision === "revise") {
729
- const total = (scope.parent ? scope.parent.state.sub?.rounds ?? 0 : run.rounds ?? 0) + 1;
730
- const gateRounds = state.iterations ?? 0;
731
- const flowLimit = scope.flow.max_rounds;
732
- const gateLimit = step.gate.max_rounds;
733
- if (target === null || flowLimit === undefined || total > flowLimit || (gateLimit !== undefined && gateRounds + 1 > gateLimit)) {
734
- return scope.parent
735
- ? this.failScope(run, validated.value, validated.contracts, scope, "gate revision rounds exhausted")
736
- : this.terminalFailure(run, { attempt: 0, reason: "gate revision rounds exhausted" });
737
- }
738
1120
  if (scope.parent)
739
- scope.parent.state.sub.rounds = total;
1121
+ scope.parent.state.sub.rounds = reviseTotal;
740
1122
  else
741
- run.rounds = total;
1123
+ run.rounds = reviseTotal;
1124
+ if (carryWrites.length > 0) {
1125
+ const carryStore = this.carryScope(run, validated.value, scope).carry;
1126
+ for (const write of carryWrites) {
1127
+ const provenance = { ...write.provenance, round: reviseTotal };
1128
+ carryStore[write.name] = { value: write.value, provenance };
1129
+ this.event(run, "carry_updated", step.id, { name: write.name, reason: "revise", provenance });
1130
+ }
1131
+ }
742
1132
  this.resetFrom(run, scope, target);
743
1133
  // The target's descendants include this gate; retain its local revision counter.
744
- scope.steps[step.id].iterations = gateRounds + 1;
1134
+ scope.steps[step.id].iterations = reviseGateRounds + 1;
745
1135
  await this.persist(run);
746
1136
  return this.advance(run, validated.value, validated.contracts, scope);
747
1137
  }
@@ -858,6 +1248,19 @@ export class StratumEngine {
858
1248
  bg.status = response.status;
859
1249
  return;
860
1250
  }
1251
+ // R2-11: bg.cancelRequested above is IN-MEMORY, and a second process cannot set it.
1252
+ // Without this a bg run cancelled cross-process settles durably while flowBgPoll
1253
+ // reports `running` forever — the two halves disagreeing is exactly the hazard
1254
+ // rehydrateBgFlows exists to prevent across restarts.
1255
+ // Deliberately store.load, NOT loadRun: a bg-driven run is PINNED by flowRunBg, so
1256
+ // loadRun would hand back this driver's own in-memory object and could never observe
1257
+ // a settle performed anywhere else. The durable record is the only source that can.
1258
+ if (response.status === "cancelled" || (await this.store.load(runId).catch(() => undefined))?.status === "cancelled") {
1259
+ bg.cancelRequested = true;
1260
+ bg.status = "cancelled";
1261
+ bg.pendingGates = [];
1262
+ return;
1263
+ }
861
1264
  // Pause on gates only when the run is QUIESCENT: no in-flight fanout can
862
1265
  // still settle behind the exited driver. Decided under the run lock so it
863
1266
  // cannot interleave inside settleFanout's locked flip+advance — otherwise a
@@ -897,7 +1300,68 @@ export class StratumEngine {
897
1300
  bg.status = "failed";
898
1301
  }
899
1302
  }
1303
+ /** Idempotent `initial` materialisation. Re-writes only when the source step's epoch
1304
+ * differs from the recorded one, so:
1305
+ * - a source that re-runs (which can only happen after a reset bumped its epoch)
1306
+ * replaces its now-stale derived value;
1307
+ * - a revise write, stamped with the initial source's CURRENT epoch, survives every
1308
+ * later advance until that source is itself reset.
1309
+ * `scope` MUST come from `carryScope` so `scope.carry === run.carry` (R2-1). */
1310
+ materialiseCarry(run, scope) {
1311
+ const declarations = scope.flow.carry;
1312
+ if (declarations === undefined)
1313
+ return { kind: "unchanged" };
1314
+ const store = scope.carry;
1315
+ if (store === undefined)
1316
+ return { kind: "unchanged" };
1317
+ const staged = [];
1318
+ for (const [name, declaration] of Object.entries(declarations)) {
1319
+ const reference = this.carryReference(declaration.initial);
1320
+ // Object.hasOwn: a declared variable named `toString` must not read as materialised
1321
+ // just because Object.prototype has one (R2-3).
1322
+ const existing = Object.hasOwn(store, name) ? store[name] : undefined;
1323
+ let provenance;
1324
+ if (reference.kind === "step") {
1325
+ const source = scope.steps[reference.stepId];
1326
+ if (source?.status !== "succeeded")
1327
+ continue;
1328
+ const sourceEpoch = source.epoch ?? 0;
1329
+ if (existing !== undefined && existing.provenance.sourceEpoch === sourceEpoch)
1330
+ continue;
1331
+ provenance = { kind: "initial", sourceStep: reference.stepId, sourceEpoch, at: now() };
1332
+ }
1333
+ else {
1334
+ if (existing !== undefined)
1335
+ continue; // input-sourced: write once
1336
+ provenance = { kind: "initial", at: now() };
1337
+ }
1338
+ const value = this.resolve(reference, scope);
1339
+ // NEVER create an entry from an absent value (R1-3): a materialised-but-undefined
1340
+ // carry would fail later at a confusing site, or hand `undefined` to a fanout.
1341
+ if (value === undefined) {
1342
+ return { kind: "failed", reason: `carry_initial_unresolved: carry ${JSON.stringify(name)} resolved to no value from ${declaration.initial}` };
1343
+ }
1344
+ staged.push({ name, value, provenance });
1345
+ }
1346
+ if (staged.length === 0)
1347
+ return { kind: "unchanged" };
1348
+ for (const write of staged) {
1349
+ store[write.name] = { value: write.value, provenance: write.provenance };
1350
+ this.event(run, "carry_updated", write.provenance.sourceStep, { name: write.name, reason: "initial", provenance: write.provenance });
1351
+ }
1352
+ return { kind: "written" };
1353
+ }
900
1354
  async advance(run, spec, contracts, scope = this.rootScope(run, spec)) {
1355
+ // STRAT-LOOP-CARRY D7: the re-entry after every dispatching settle, and BEFORE the
1356
+ // scope loop, so a ${carry} fanout sees the value in the same pass it activates.
1357
+ // carryScope patches THIS scope object when it is the root one (R2-1) — building a
1358
+ // detached root scope here would leave the live scope's carry undefined.
1359
+ // `advance` does not persist on every path, so a write persists itself.
1360
+ const materialised = this.materialiseCarry(run, this.carryScope(run, spec, scope));
1361
+ if (materialised.kind === "failed")
1362
+ return this.failScope(run, spec, contracts, scope, materialised.reason);
1363
+ if (materialised.kind === "written")
1364
+ await this.persist(run);
901
1365
  await this.advanceScopeLoop(run, spec, contracts, scope);
902
1366
  if (run.status !== "running")
903
1367
  return this.response(run);
@@ -1028,6 +1492,12 @@ export class StratumEngine {
1028
1492
  state.output = output;
1029
1493
  state.attempts.push({ attempt: 1, at: now(), result: output });
1030
1494
  this.event(run, "result", this.scopedId(scope, step.id), { attempt: 1, result: output });
1495
+ // R1-2: a later step in THIS pass may read the value. R2-5: never a partial write.
1496
+ const setCarry = this.materialiseCarry(run, this.carryScope(run, spec, scope));
1497
+ if (setCarry.kind === "failed") {
1498
+ await this.failScope(run, spec, contracts, scope, setCarry.reason);
1499
+ break;
1500
+ }
1031
1501
  changed = true;
1032
1502
  await this.persist(run);
1033
1503
  continue;
@@ -1168,6 +1638,11 @@ export class StratumEngine {
1168
1638
  state.output = parsed.data;
1169
1639
  state.attempts.push({ attempt, at: now(), result: parsed.data });
1170
1640
  this.event(run, "result", this.scopedId(scope, step.id), { attempt, result: parsed.data });
1641
+ const evalCarry = this.materialiseCarry(run, this.carryScope(run, spec, scope));
1642
+ if (evalCarry.kind === "failed") {
1643
+ await this.failScope(run, spec, contracts, scope, evalCarry.reason);
1644
+ break;
1645
+ }
1171
1646
  await this.persist(run);
1172
1647
  changed = true;
1173
1648
  continue;
@@ -1223,12 +1698,16 @@ export class StratumEngine {
1223
1698
  const key = `${run.id}:${stepId}:${run.steps[stepId]?.fanoutEpoch ?? 0}`;
1224
1699
  if (this.scheduledFanouts.has(key))
1225
1700
  return;
1226
- this.scheduledFanouts.add(key);
1227
1701
  // Pin SYNCHRONOUSLY with the scheduler's own run object: from here until
1228
1702
  // release, loadRun hands this exact instance to every entry point, so an
1229
1703
  // independent stepDone proceeds during a slow fanout and mutates the same
1230
1704
  // instance — never a divergent disk copy, never blocked behind the batch.
1705
+ //
1706
+ // The pin comes BEFORE the key is marked scheduled (F2). Marked first, a failing lease
1707
+ // write left the key set with no execution behind it, and the epoch-keyed guard above then
1708
+ // refused to schedule the fanout ever again — the step simply never ran.
1231
1709
  this.retainRun(run.id, run);
1710
+ this.scheduledFanouts.add(key);
1232
1711
  const scheduledFanout = run.steps[stepId]?.fanout;
1233
1712
  queueMicrotask(() => {
1234
1713
  void this.executeFanout(run, stepId).catch(async (error) => {
@@ -1467,148 +1946,212 @@ export class StratumEngine {
1467
1946
  async executeFanoutItem(run, spec, contracts, flow, step, state, item, value, fanoutRef) {
1468
1947
  if (!step.fanout)
1469
1948
  throw new Error("fanout missing after validation");
1949
+ const fanout = step.fanout;
1470
1950
  // A revise can invalidate this fanout at any await point; once stale, the
1471
1951
  // item belongs to a dead epoch — stop recording into it (already-reserved
1472
- // dispatch costs stay in the flow ledger: they were really spent).
1473
- const stale = () => run.cancelRequested === true || state.fanout !== fanoutRef;
1952
+ // dispatch costs stay in the flow ledger: they were really spent). A cancel
1953
+ // is the other way in: the settle sets both `cancelRequested` and `status`.
1954
+ const stale = () => run.cancelRequested === true || run.status === "cancelled" || state.fanout !== fanoutRef;
1955
+ // R2-2/R4-5: the connector await is the ONE thing outside the lock. Everything that
1956
+ // ADMITS, ACCEPTS or RECORDS an attempt runs inside a locked transaction, so a cancel
1957
+ // landing during the await is seen at the one point where its answer becomes durable.
1958
+ const lock = (action) => this.withRunLock(run.id, action);
1474
1959
  item.status = "running";
1475
1960
  let cwd = run.workspaceRoot;
1476
1961
  try {
1477
- if (step.fanout.isolation === "worktree") {
1478
- if (!cwd)
1479
- throw new Error("worktree fanout requires workspaceRoot");
1480
- const previousWorktree = item.worktree;
1481
- const directory = await mkdtemp(join(tmpdir(), `stratum-${run.id.slice(0, 8)}-${item.index}-`));
1482
- await rm(directory, { recursive: true, force: true });
1483
- await execFileAsync("git", ["-C", cwd, "worktree", "add", "--detach", directory, "HEAD"]);
1484
- if (previousWorktree && previousWorktree !== directory) {
1485
- await this.teardownWorktree(cwd, previousWorktree);
1486
- }
1487
- item.worktree = directory;
1488
- cwd = directory;
1962
+ if (fanout.isolation === "worktree") {
1963
+ const prepared = await lock(async () => {
1964
+ if (stale())
1965
+ return undefined;
1966
+ if (!cwd)
1967
+ throw new Error("worktree fanout requires workspaceRoot");
1968
+ const previousWorktree = item.worktree;
1969
+ const directory = await mkdtemp(join(tmpdir(), `stratum-${run.id.slice(0, 8)}-${item.index}-`));
1970
+ await rm(directory, { recursive: true, force: true });
1971
+ await execFileAsync("git", ["-C", cwd, "worktree", "add", "--detach", directory, "HEAD"]);
1972
+ if (previousWorktree && previousWorktree !== directory) {
1973
+ await this.teardownWorktree(cwd, previousWorktree);
1974
+ }
1975
+ item.worktree = directory;
1976
+ return directory;
1977
+ });
1978
+ if (prepared === undefined)
1979
+ return;
1980
+ cwd = prepared;
1489
1981
  }
1490
1982
  let previous = undefined;
1491
1983
  let finalStageSkipped = false;
1492
- for (const [stageIndex, stage] of step.fanout.steps.entries()) {
1984
+ for (const [stageIndex, stage] of fanout.steps.entries()) {
1493
1985
  if (stale())
1494
1986
  return;
1495
- item.stage = stageIndex;
1496
- item.epoch = state.epoch ?? 0;
1497
- delete item.dispatchToken;
1498
- delete item.acceptedDispatchToken;
1499
- if (stage.when !== undefined) {
1500
- const enabled = this.evaluateFanout(stage.when, run, value, previous, cwd);
1501
- if (enabled !== true) {
1502
- this.event(run, "fanout_item_skipped", step.id, { itemIndex: item.index, stage: stageIndex });
1503
- if (stageIndex === step.fanout.steps.length - 1)
1504
- finalStageSkipped = true;
1505
- continue;
1987
+ const gate = await lock(async () => {
1988
+ if (stale())
1989
+ return "abandon";
1990
+ item.stage = stageIndex;
1991
+ item.epoch = state.epoch ?? 0;
1992
+ delete item.dispatchToken;
1993
+ delete item.acceptedDispatchToken;
1994
+ if (stage.when !== undefined) {
1995
+ const enabled = this.evaluateFanout(stage.when, run, value, previous, cwd);
1996
+ if (enabled !== true) {
1997
+ this.event(run, "fanout_item_skipped", step.id, { itemIndex: item.index, stage: stageIndex });
1998
+ return "skip";
1999
+ }
1506
2000
  }
2001
+ return "run";
2002
+ });
2003
+ if (gate === "abandon")
2004
+ return;
2005
+ if (gate === "skip") {
2006
+ if (stageIndex === fanout.steps.length - 1)
2007
+ finalStageSkipped = true;
2008
+ continue;
1507
2009
  }
1508
2010
  let success = false;
1509
2011
  let lastFailure;
1510
2012
  const maximum = stage.attempts ?? step.attempts ?? 2;
1511
2013
  for (let stageAttempt = 1; stageAttempt <= maximum; stageAttempt += 1) {
1512
- const attempt = item.attempts.length + 1;
1513
- let prompt;
1514
- try {
1515
- prompt = this.renderFanout(stage.do, run, value, previous);
1516
- }
1517
- catch (error) {
1518
- lastFailure = { attempt, reason: message(error) };
1519
- this.recordFanoutAttempt(run, step, item, stageIndex, attempt, false, "connector", lastFailure);
2014
+ const admission = await lock(async () => {
2015
+ if (stale())
2016
+ return { kind: "abandon" };
2017
+ const attempt = item.attempts.length + 1;
2018
+ const carried = lastFailure;
2019
+ let prompt;
2020
+ try {
2021
+ prompt = this.renderFanout(stage.do, run, value, previous);
2022
+ }
2023
+ catch (error) {
2024
+ lastFailure = { attempt, reason: message(error) };
2025
+ this.recordFanoutAttempt(run, step, item, stageIndex, attempt, false, "connector", lastFailure);
2026
+ return { kind: "continue" };
2027
+ }
2028
+ const reserve = this.debit(run, step, state, { dispatches: 1 }, "reserve");
2029
+ if (reserve) {
2030
+ lastFailure = { attempt, reason: `${reserve} budget exhausted` };
2031
+ this.recordFanoutAttempt(run, step, item, stageIndex, attempt, false, "budget", lastFailure);
2032
+ // Flow-ledger exhaustion is TERMINAL for the run — it must never be
2033
+ // absorbed as one failed item that a tolerant `require` outweighs.
2034
+ if (reserve === "flow")
2035
+ await this.terminalBudget(run, lastFailure);
2036
+ return { kind: "break" };
2037
+ }
2038
+ item.dispatchToken = randomUUID();
2039
+ this.event(run, "fanout_ledger_debit", step.id, { itemIndex: item.index, amount: { dispatches: 1 } });
2040
+ this.event(run, "fanout_item_dispatched", step.id, { itemIndex: item.index, stage: stageIndex, attempt });
2041
+ // Durable BEFORE the (possibly long) connector await: a restart or a
2042
+ // fresh poller must see the dispatched lifecycle event, not a
2043
+ // pending item — the event spine is restart-proof.
2044
+ await this.persist(run);
2045
+ return { kind: "dispatch", prompt, attempt, ...(carried ? { previousFailure: carried } : {}) };
2046
+ });
2047
+ if (admission.kind === "abandon")
2048
+ return;
2049
+ if (admission.kind === "continue")
1520
2050
  continue;
1521
- }
1522
- const reserve = this.debit(run, step, state, { dispatches: 1 }, "reserve");
1523
- if (reserve) {
1524
- lastFailure = { attempt, reason: `${reserve} budget exhausted` };
1525
- this.recordFanoutAttempt(run, step, item, stageIndex, attempt, false, "budget", lastFailure);
1526
- // Flow-ledger exhaustion is TERMINAL for the run — it must never be
1527
- // absorbed as one failed item that a tolerant `require` outweighs.
1528
- if (reserve === "flow")
1529
- await this.terminalBudget(run, lastFailure);
2051
+ if (admission.kind === "break")
1530
2052
  break;
1531
- }
1532
- item.dispatchToken = randomUUID();
1533
- this.event(run, "fanout_ledger_debit", step.id, { itemIndex: item.index, amount: { dispatches: 1 } });
1534
- this.event(run, "fanout_item_dispatched", step.id, { itemIndex: item.index, stage: stageIndex, attempt });
1535
- // Durable BEFORE the (possibly long) connector await: a restart or a
1536
- // fresh poller must see the dispatched lifecycle event, not a
1537
- // pending item — the event spine is restart-proof.
1538
- await this.persist(run);
1539
- let result;
2053
+ const { prompt, attempt } = admission;
1540
2054
  const rawContract = stage.out !== undefined ? spec.contracts[stage.out] : undefined;
2055
+ // R4-5: capture, do not act. Neither arm touches the run — the failure path used to
2056
+ // call recordFanoutAttempt in a bare catch, recording an attempt against a run that
2057
+ // may already have been cancelled.
2058
+ let outcome;
1541
2059
  try {
1542
- result = await this.connector({
1543
- agent: stage.agent ?? "claude", prompt, ...(cwd !== undefined ? { cwd } : {}), attempt,
1544
- ...(lastFailure ? { previousFailure: lastFailure } : {}),
1545
- ...(rawContract !== undefined ? { outSchema: rawContract } : {}),
1546
- sandbox: step.fanout.isolation === "worktree" ? "workspace-write" : "read-only",
1547
- });
2060
+ outcome = { ok: true, result: await this.connector({
2061
+ agent: stage.agent ?? "claude", prompt, ...(cwd !== undefined ? { cwd } : {}), attempt,
2062
+ ...(admission.previousFailure ? { previousFailure: admission.previousFailure } : {}),
2063
+ ...(rawContract !== undefined ? { outSchema: rawContract } : {}),
2064
+ sandbox: fanout.isolation === "worktree" ? "workspace-write" : "read-only",
2065
+ }) };
1548
2066
  }
1549
2067
  catch (error) {
1550
- if (stale())
1551
- return;
1552
- lastFailure = { attempt, reason: message(error) };
1553
- this.recordFanoutAttempt(run, step, item, stageIndex, attempt, false, "connector", lastFailure);
1554
- continue;
2068
+ outcome = { ok: false, error };
1555
2069
  }
1556
- if (stale())
1557
- return;
1558
- const outcome = await this.settleFanoutAttempt(run, spec, contracts, step, state, item, value, previous, stageIndex, attempt, result, cwd);
1559
- if (stale())
2070
+ const disposition = await lock(async () => {
2071
+ if (stale())
2072
+ return "abandon";
2073
+ if (!outcome.ok) {
2074
+ lastFailure = { attempt, reason: message(outcome.error) };
2075
+ this.recordFanoutAttempt(run, step, item, stageIndex, attempt, false, "connector", lastFailure);
2076
+ return "retry";
2077
+ }
2078
+ const settled = await this.settleFanoutAttempt(run, spec, contracts, step, state, item, value, previous, stageIndex, attempt, outcome.result, cwd);
2079
+ if (stale())
2080
+ return "abandon";
2081
+ if (!settled.success) {
2082
+ lastFailure = settled.failure;
2083
+ return "retry";
2084
+ }
2085
+ return "accepted";
2086
+ });
2087
+ if (disposition === "abandon")
1560
2088
  return;
1561
- if (!outcome.success) {
1562
- lastFailure = outcome.failure;
2089
+ if (disposition === "retry")
1563
2090
  continue;
1564
- }
1565
- previous = result.output;
2091
+ previous = outcome.ok ? outcome.result.output : undefined;
1566
2092
  success = true;
1567
2093
  break;
1568
2094
  }
1569
2095
  if (!success) {
1570
- item.status = "failed";
1571
- item.failure = lastFailure ?? { attempt: item.attempts.length + 1, reason: "fanout stage failed" };
1572
- delete item.dispatchToken;
2096
+ const abandoned = await lock(async () => {
2097
+ if (stale())
2098
+ return true;
2099
+ item.status = "failed";
2100
+ item.failure = lastFailure ?? { attempt: item.attempts.length + 1, reason: "fanout stage failed" };
2101
+ delete item.dispatchToken;
2102
+ return false;
2103
+ });
2104
+ void abandoned;
1573
2105
  return undefined;
1574
2106
  }
1575
2107
  }
1576
- if (finalStageSkipped) {
1577
- // The fanout output element type is the LAST stage's contract; an item
1578
- // whose final stage was `when`-skipped has no such value — it is a
1579
- // skipped item (null in the output array), never a success `require`
1580
- // can count, and its partial worktree work is never merged.
1581
- item.status = "skipped";
2108
+ await lock(async () => {
2109
+ if (stale())
2110
+ return;
2111
+ if (finalStageSkipped) {
2112
+ // The fanout output element type is the LAST stage's contract; an item
2113
+ // whose final stage was `when`-skipped has no such value — it is a
2114
+ // skipped item (null in the output array), never a success `require`
2115
+ // can count, and its partial worktree work is never merged.
2116
+ item.status = "skipped";
2117
+ delete item.dispatchToken;
2118
+ return;
2119
+ }
2120
+ if (item.worktree) {
2121
+ // Include newly-created files in the patch without staging their contents.
2122
+ // Persisted on the item BEFORE it turns succeeded, so a restart between
2123
+ // item completion and merge still has every patch.
2124
+ await execFileAsync("git", ["-C", item.worktree, "add", "-N", "."]);
2125
+ // Diff against HEAD so STAGED changes are captured too — an agent that
2126
+ // ran `git add` in its worktree must not have its work silently lost.
2127
+ // Node's default 1 MiB maxBuffer would fail any item touching a large
2128
+ // or binary file; 64 MiB bounds the patch without breaking real work.
2129
+ const patch = (await execFileAsync("git", ["-C", item.worktree, "diff", "--binary", "HEAD"], { maxBuffer: 64 * 1024 * 1024 })).stdout;
2130
+ if (patch)
2131
+ item.patch = patch;
2132
+ }
2133
+ item.output = previous;
2134
+ item.status = "succeeded";
2135
+ if (item.dispatchToken !== undefined)
2136
+ item.acceptedDispatchToken = item.dispatchToken;
1582
2137
  delete item.dispatchToken;
1583
- return;
1584
- }
1585
- if (item.worktree) {
1586
- // Include newly-created files in the patch without staging their contents.
1587
- // Persisted on the item BEFORE it turns succeeded, so a restart between
1588
- // item completion and merge still has every patch.
1589
- await execFileAsync("git", ["-C", item.worktree, "add", "-N", "."]);
1590
- // Diff against HEAD so STAGED changes are captured too — an agent that
1591
- // ran `git add` in its worktree must not have its work silently lost.
1592
- // Node's default 1 MiB maxBuffer would fail any item touching a large
1593
- // or binary file; 64 MiB bounds the patch without breaking real work.
1594
- const patch = (await execFileAsync("git", ["-C", item.worktree, "diff", "--binary", "HEAD"], { maxBuffer: 64 * 1024 * 1024 })).stdout;
1595
- if (patch)
1596
- item.patch = patch;
1597
- }
1598
- item.output = previous;
1599
- item.status = "succeeded";
1600
- if (item.dispatchToken !== undefined)
1601
- item.acceptedDispatchToken = item.dispatchToken;
1602
- delete item.dispatchToken;
2138
+ });
1603
2139
  }
1604
2140
  finally {
1605
- if (run.cancelRequested === true)
1606
- delete item.dispatchToken;
1607
- if (item.worktree && run.workspaceRoot) {
1608
- await this.teardownWorktree(run.workspaceRoot, item.worktree);
1609
- delete item.worktree;
1610
- }
1611
- await this.persist(run);
2141
+ await lock(async () => {
2142
+ if (run.cancelRequested === true)
2143
+ delete item.dispatchToken;
2144
+ // Worktree teardown still runs on the abandon path: a cancelled run must not leak
2145
+ // worktrees just because it may not write.
2146
+ if (item.worktree && run.workspaceRoot) {
2147
+ await this.teardownWorktree(run.workspaceRoot, item.worktree);
2148
+ delete item.worktree;
2149
+ }
2150
+ // R4-1/R4-4: the settle already wrote the final record — and on the same object,
2151
+ // since the lease guarantees this process performed it. Nothing to add.
2152
+ if (run.status !== "cancelled")
2153
+ await this.persist(run);
2154
+ });
1612
2155
  }
1613
2156
  }
1614
2157
  async teardownWorktree(workspaceRoot, directory) {
@@ -2089,7 +2632,7 @@ export class StratumEngine {
2089
2632
  }
2090
2633
  dependencies(step) {
2091
2634
  const output = new Set(step.after ?? []);
2092
- for (const value of stringLeaves(step)) {
2635
+ for (const { value } of stringLeaves(step)) {
2093
2636
  for (const extracted of extractReferences(value) ?? [])
2094
2637
  if (extracted.reference.kind === "step")
2095
2638
  output.add(extracted.reference.stepId);
@@ -2136,6 +2679,7 @@ export class StratumEngine {
2136
2679
  stage: item.stage,
2137
2680
  isFinalStage: item.stage === step.fanout.steps.length - 1,
2138
2681
  itemIndex: item.index,
2682
+ item: values[item.index],
2139
2683
  generation: item.generation,
2140
2684
  contract: closure,
2141
2685
  contractDigest: closure === null ? null : digest(closure),
@@ -2253,7 +2797,19 @@ export class StratumEngine {
2253
2797
  return parse && !parse.success ? parse.error.message : parse ? undefined : "output contract missing";
2254
2798
  }
2255
2799
  rootScope(run, spec) {
2256
- return { input: run.input, steps: run.steps, flow: this.flowFor(run, spec), flowName: run.flowName };
2800
+ return { input: run.input, steps: run.steps, carry: run.carry, flow: this.flowFor(run, spec), flowName: run.flowName };
2801
+ }
2802
+ /** The root scope carry is written through, sharing ONE object with the run so a write
2803
+ * made during this pass is visible to every later read through the LIVE scope (R2-1).
2804
+ * Null-prototype so an inherited name like `toString` can never masquerade as a
2805
+ * declared variable (R2-3); a reloaded run's plain object is guarded by Object.hasOwn. */
2806
+ carryScope(run, spec, scope) {
2807
+ const root = scope.parent === undefined ? scope : this.rootScope(run, spec);
2808
+ if (root.flow.carry === undefined)
2809
+ return root; // never persist an empty carry map
2810
+ run.carry ??= Object.create(null);
2811
+ root.carry = run.carry;
2812
+ return root;
2257
2813
  }
2258
2814
  childScope(spec, parentStep, parentState) {
2259
2815
  if (parentStep.run === undefined || parentState.sub === undefined)
@@ -2436,11 +2992,32 @@ export class StratumEngine {
2436
2992
  await this.persist(run);
2437
2993
  return this.advance(run, spec, contracts, root);
2438
2994
  }
2995
+ /** The single reference in a carry `initial` / `on_revise` value. Validation has
2996
+ * already proved the shape (CARRY_REF_INVALID), so a failure here is a bug. */
2997
+ carryReference(value) {
2998
+ const extracted = extractReferences(value);
2999
+ const reference = extracted?.length === 1 && extracted[0].fullValue ? extracted[0].reference : undefined;
3000
+ if (!reference)
3001
+ throw new Error("invalid carry reference after validation");
3002
+ return reference;
3003
+ }
2439
3004
  resolve(reference, scope) {
2440
3005
  if (reference.kind === "input")
2441
3006
  return access(scope.input, reference.path);
2442
3007
  if (reference.kind === "step")
2443
3008
  return access(scope.steps[reference.stepId]?.output, reference.path);
3009
+ if (reference.kind === "carry") {
3010
+ // Object.hasOwn, never `?.[name]`: a persisted run reloads as a PLAIN object
3011
+ // (state.ts JSON.parse), so a variable legitimately named `toString` or
3012
+ // `constructor` would otherwise resolve to an inherited function (R2-3).
3013
+ const store = scope.carry;
3014
+ if (store === undefined || !Object.hasOwn(store, reference.name)) {
3015
+ // A named, actionable failure replaces the generic "must resolve to an array"
3016
+ // that `over` would otherwise produce two frames up (D1).
3017
+ throw new Error(`carry variable ${JSON.stringify(reference.name)} is not materialised`);
3018
+ }
3019
+ return access(store[reference.name].value, reference.path);
3020
+ }
2444
3021
  throw new Error("fanout references are outside P1 engine scope");
2445
3022
  }
2446
3023
  context(_run, scope) {
@@ -2454,6 +3031,15 @@ export class StratumEngine {
2454
3031
  }
2455
3032
  assertExternalMutationAllowed(runId, operation) {
2456
3033
  const bg = this.bgFlows.get(runId);
3034
+ // R4-6: commit and revert take this pre-check BEFORE the lock, and "cancelled" is not in
3035
+ // the terminal allowlist below — so a cancelled BG run would report `is background-driven`
3036
+ // while a cancelled FOREGROUND run reported `flow_cancelled`. Two errors for one
3037
+ // condition, neither of them true. Let a cancelled run through to
3038
+ // assertCancelledCheckpointRefusal, which runs inside the lock and is the truth.
3039
+ // stepDone and resume keep refusing here: both have their own cancelled refusal further in,
3040
+ // and a bg-driven run must not be externally pumped whatever its status.
3041
+ if (bg?.status === "cancelled" && (operation === "commit" || operation === "revert"))
3042
+ return;
2457
3043
  if (bg !== undefined && bg.status !== "completed" && bg.status !== "failed" && bg.status !== "budget_exhausted") {
2458
3044
  throw new Error(`run ${runId} is background-driven; external ${operation} is not permitted (poll via flow_bg_poll)`);
2459
3045
  }
@@ -2530,6 +3116,23 @@ export class StratumEngine {
2530
3116
  // it settles onto the restored state. Refuse both while a fanout is in flight (a fanout
2531
3117
  // step stays `running` from dispatch through settlement), the same quiescence the
2532
3118
  // detached driver already requires. bg-driven runs are covered by the ownership guard.
3119
+ /** D5/R2-10. A cancelled run is terminal. `cancelRequested` is a CHECKPOINT_FIELD, so a
3120
+ * revert could otherwise restore `cancelRequested: false` and resurrect a run whose agents
3121
+ * have already been killed; and a commit would snapshot a half-torn-down run as if it were
3122
+ * a recovery point. Round 0's carve-out for commit ("snapshotting a terminal run is the
3123
+ * documented recovery case") does not survive: that case is a run that failed on its own,
3124
+ * whose state is coherent — a cancelled run's is mid-teardown by construction.
3125
+ *
3126
+ * R4-6: this runs INSIDE the lock, and it is the first thing that can report on a
3127
+ * cancelled run. assertExternalMutationAllowed's terminal allowlist does not contain
3128
+ * "cancelled", so a cancelled BG run would otherwise report `is background-driven` while a
3129
+ * cancelled foreground run reported `flow_cancelled` — two errors for one condition,
3130
+ * neither of them true. */
3131
+ assertCancelledCheckpointRefusal(run, operation) {
3132
+ if (run.status !== "cancelled" && run.cancelRequested !== true)
3133
+ return;
3134
+ throw new CheckpointOperationError("flow_cancelled", `Flow '${run.id}' is cancelled; ${operation} is not permitted`);
3135
+ }
2533
3136
  assertNoForegroundFanout(run, operation) {
2534
3137
  if (run.status !== "running")
2535
3138
  return;
@@ -2581,6 +3184,32 @@ export class StratumEngine {
2581
3184
  this.emitFlowTerminal(run);
2582
3185
  return this.response(run);
2583
3186
  }
3187
+ /** D3. `failure` stays UNSET: the status is honest, so inventing a FailureContext would put
3188
+ * a lie in the audit trail (and requiredFailure would invent a worse one).
3189
+ *
3190
+ * Cancel is exempt from both mutation guards, DELIBERATELY: not assertNoForegroundFanout —
3191
+ * cancel exists precisely to break the stuck consumer-fanout lifecycle that guard protects
3192
+ * — and not assertExternalMutationAllowed, because a bg-driven run is cancellable from
3193
+ * outside. Do not "fix" the omission; it reintroduces the wedge this feature exists to
3194
+ * break. Assumes the run lock is held: every caller is inside a locked section. */
3195
+ async terminalCancel(run, reason) {
3196
+ run.cancelRequested = true;
3197
+ const burned = burnIssuances(run);
3198
+ run.status = "cancelled";
3199
+ this.event(run, "flow_cancelled", undefined, { by: "fg", ...(reason !== undefined ? { reason } : {}), burned });
3200
+ // R4-1: the ONE sanctioned write of a cancelled record.
3201
+ await this.persistTerminalCancel(run);
3202
+ this.emitFlowTerminal(run);
3203
+ // A bg run cancelled through the fg surface must report consistently to flowBgPoll — but
3204
+ // only in THIS process; another engine's bgFlows map is reached by driveBg's own check.
3205
+ const bg = this.bgFlows.get(run.id);
3206
+ if (bg) {
3207
+ bg.cancelRequested = true;
3208
+ bg.status = "cancelled";
3209
+ bg.pendingGates = [];
3210
+ }
3211
+ return this.response(run);
3212
+ }
2584
3213
  async terminalFailure(run, failure) {
2585
3214
  run.status = "failed";
2586
3215
  run.failure = failure;
@@ -2628,6 +3257,11 @@ export class StratumEngine {
2628
3257
  return { status: "completed", runId: run.id, output: run.output, ledger };
2629
3258
  if (run.status === "budget_exhausted")
2630
3259
  return { status: "budget_exhausted", runId: run.id, failure: requiredFailure(run), ledger };
3260
+ // Above the failed fallthrough: requiredFailure would otherwise INVENT
3261
+ // {attempt: 0, reason: "run failed without context"} and report a cancelled run as a
3262
+ // failure with a fabricated reason (C15).
3263
+ if (run.status === "cancelled")
3264
+ return { status: "cancelled", runId: run.id, ledger };
2631
3265
  return { status: "failed", runId: run.id, failure: requiredFailure(run), ledger };
2632
3266
  }
2633
3267
  withRevisionDigest(response, run) {
@@ -2642,7 +3276,30 @@ export class StratumEngine {
2642
3276
  event(run, type, stepId, detail) {
2643
3277
  run.events.push({ at: now(), type, ...(stepId ? { stepId } : {}), ...(detail !== undefined ? { detail } : {}) });
2644
3278
  }
2645
- persist(run) {
3279
+ /** Every persist happens inside a locked section. A persist outside one is a lost update
3280
+ * waiting to happen, and the three write paths this assertion caught (plan's initial
3281
+ * persist, the engine-fanout admission writes, and `stratum learn egress`) are why it is a
3282
+ * check rather than a comment. */
3283
+ assertLockHeld(runId) {
3284
+ if (this.heldLocks.has(runId))
3285
+ return;
3286
+ const detail = `run ${runId}: persist outside a locked section`;
3287
+ if (process.env.NODE_ENV === "production") {
3288
+ process.stderr.write(`stratum: ${detail}\n`);
3289
+ return;
3290
+ }
3291
+ throw new Error(detail);
3292
+ }
3293
+ /** The single sanctioned save of a cancelled run: terminalCancel's own. */
3294
+ persistTerminalCancel(run) { return this.persist(run, true); }
3295
+ persist(run, sanctioned = false) {
3296
+ this.assertLockHeld(run.id);
3297
+ // R4-1: after a settle the durable record is final. Refusing is right; refusing SILENTLY
3298
+ // is not — a swallowed usageReport or receipt update is indistinguishable from one that
3299
+ // succeeded, so every unsanctioned caller reaching here surfaces instead of vanishing.
3300
+ if (run.status === "cancelled" && !sanctioned) {
3301
+ throw Object.assign(new Error(`run ${run.id} is cancelled; no further writes are accepted`), { code: "PERSIST_ON_CANCELLED_RUN" });
3302
+ }
2646
3303
  const previous = this.persistLocks.get(run.id) ?? Promise.resolve();
2647
3304
  const result = previous
2648
3305
  .then(() => this.store.save(run))
@@ -2674,22 +3331,27 @@ function access(value, path) {
2674
3331
  }
2675
3332
  return current;
2676
3333
  }
2677
- function stringLeaves(step) {
3334
+ export function stringLeaves(step) {
2678
3335
  const values = [];
2679
- const collect = (value) => {
3336
+ const collect = (value, expression = false) => {
2680
3337
  if (typeof value === "string")
2681
- values.push(value);
3338
+ values.push({ value, expression });
2682
3339
  else if (Array.isArray(value))
2683
- value.forEach(collect);
3340
+ value.forEach((entry) => collect(entry, expression));
2684
3341
  else if (typeof value === "object" && value !== null)
2685
- Object.values(value).forEach(collect);
3342
+ Object.values(value).forEach((entry) => collect(entry, expression));
2686
3343
  };
2687
3344
  if (step.do !== undefined)
2688
3345
  collect(step.do);
2689
3346
  if (step.when !== undefined)
2690
- collect(step.when);
3347
+ collect(step.when, true);
2691
3348
  if (step.set !== undefined)
2692
- collect(step.set);
3349
+ collect(step.set, true);
3350
+ if (step.iterate?.until !== undefined)
3351
+ collect(step.iterate.until, true);
3352
+ for (const predicate of step.ensure ?? [])
3353
+ if ("expr" in predicate)
3354
+ collect(predicate.expr, true);
2693
3355
  // The engine's dependency edges must mirror the validator's: subflow `with`
2694
3356
  // templates and fanout over/stage templates reference steps too — a fanout
2695
3357
  // over "${prep.output.items}" must wait for prep, not fail at resolve time.
@@ -2702,7 +3364,10 @@ function stringLeaves(step) {
2702
3364
  for (const stage of step.fanout.steps) {
2703
3365
  collect(stage.do);
2704
3366
  if (stage.when !== undefined)
2705
- collect(stage.when);
3367
+ collect(stage.when, true);
3368
+ for (const predicate of stage.ensure ?? [])
3369
+ if ("expr" in predicate)
3370
+ collect(predicate.expr, true);
2706
3371
  }
2707
3372
  }
2708
3373
  return values;