@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.
- package/dist/cli/flow.js +61 -0
- package/dist/cli/flow.js.map +1 -0
- package/dist/cli/learn.js +9 -16
- package/dist/cli/learn.js.map +1 -1
- package/dist/cli/query_gate.js +7 -2
- package/dist/cli/query_gate.js.map +1 -1
- package/dist/cli/stratum.js +3 -1
- package/dist/cli/stratum.js.map +1 -1
- package/dist/connectors/background.js +2 -2
- package/dist/connectors/background.js.map +1 -1
- package/dist/connectors/claude.js +2 -0
- package/dist/connectors/claude.js.map +1 -1
- package/dist/connectors/codex.js +4 -0
- package/dist/connectors/codex.js.map +1 -1
- package/dist/connectors/foreground_registry.js +589 -0
- package/dist/connectors/foreground_registry.js.map +1 -0
- package/dist/connectors/index.js +1 -0
- package/dist/connectors/index.js.map +1 -1
- package/dist/connectors/proc_identity.js +28 -0
- package/dist/connectors/proc_identity.js.map +1 -1
- package/dist/connectors/runner.js +2 -0
- package/dist/connectors/runner.js.map +1 -1
- package/dist/contracts/events.json +27 -1
- package/dist/contracts/mcp-surface.json +176 -6
- package/dist/engine/checkpoint.js +1 -1
- package/dist/engine/checkpoint.js.map +1 -1
- package/dist/engine/engine.js +860 -195
- package/dist/engine/engine.js.map +1 -1
- package/dist/engine/flow_cancel.js +158 -0
- package/dist/engine/flow_cancel.js.map +1 -0
- package/dist/engine/run_lock.js +628 -0
- package/dist/engine/run_lock.js.map +1 -0
- package/dist/engine/state.js +43 -2
- package/dist/engine/state.js.map +1 -1
- package/dist/ir/refs.js +12 -0
- package/dist/ir/refs.js.map +1 -1
- package/dist/ir/schema.js +9 -0
- package/dist/ir/schema.js.map +1 -1
- package/dist/ir/validate.js +243 -29
- package/dist/ir/validate.js.map +1 -1
- package/dist/learn/smartmemory_egress.js +3 -1
- package/dist/learn/smartmemory_egress.js.map +1 -1
- package/dist/mcp/server.js +193 -4
- package/dist/mcp/server.js.map +1 -1
- package/package.json +1 -1
package/dist/engine/engine.js
CHANGED
|
@@ -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
|
-
|
|
122
|
-
|
|
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
|
|
130
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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).
|
|
271
|
-
//
|
|
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).
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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 =
|
|
1121
|
+
scope.parent.state.sub.rounds = reviseTotal;
|
|
740
1122
|
else
|
|
741
|
-
run.rounds =
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
await
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
|
1984
|
+
for (const [stageIndex, stage] of fanout.steps.entries()) {
|
|
1493
1985
|
if (stale())
|
|
1494
1986
|
return;
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
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 (
|
|
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
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
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
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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
|
|
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;
|