@smartmemory/stratum 0.3.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 (81) hide show
  1. package/dist/cli/guard.js +91 -0
  2. package/dist/cli/guard.js.map +1 -0
  3. package/dist/cli/mcp_install.js +302 -0
  4. package/dist/cli/mcp_install.js.map +1 -0
  5. package/dist/cli/query_gate.js +316 -0
  6. package/dist/cli/query_gate.js.map +1 -0
  7. package/dist/cli/stratum.js +291 -0
  8. package/dist/cli/stratum.js.map +1 -0
  9. package/dist/connectors/background.js +531 -0
  10. package/dist/connectors/background.js.map +1 -0
  11. package/dist/connectors/base.js +12 -0
  12. package/dist/connectors/base.js.map +1 -0
  13. package/dist/connectors/claude-bg-worker.js +99 -0
  14. package/dist/connectors/claude-bg-worker.js.map +1 -0
  15. package/dist/connectors/claude.js +170 -0
  16. package/dist/connectors/claude.js.map +1 -0
  17. package/dist/connectors/codex.js +319 -0
  18. package/dist/connectors/codex.js.map +1 -0
  19. package/dist/connectors/index.js +7 -0
  20. package/dist/connectors/index.js.map +1 -0
  21. package/dist/connectors/proc_identity.js +90 -0
  22. package/dist/connectors/proc_identity.js.map +1 -0
  23. package/dist/connectors/runner.js +63 -0
  24. package/dist/connectors/runner.js.map +1 -0
  25. package/dist/contracts/events.json +65 -0
  26. package/dist/contracts/mcp-surface.json +215 -0
  27. package/dist/engine/checkpoint.js +50 -0
  28. package/dist/engine/checkpoint.js.map +1 -0
  29. package/dist/engine/engine.js +2356 -0
  30. package/dist/engine/engine.js.map +1 -0
  31. package/dist/engine/ledger.js +36 -0
  32. package/dist/engine/ledger.js.map +1 -0
  33. package/dist/engine/state.js +42 -0
  34. package/dist/engine/state.js.map +1 -0
  35. package/dist/eval/expr.js +679 -0
  36. package/dist/eval/expr.js.map +1 -0
  37. package/dist/eval/files.js +130 -0
  38. package/dist/eval/files.js.map +1 -0
  39. package/dist/guard/canonical.js +116 -0
  40. package/dist/guard/canonical.js.map +1 -0
  41. package/dist/guard/errors.js +80 -0
  42. package/dist/guard/errors.js.map +1 -0
  43. package/dist/guard/evidence.js +475 -0
  44. package/dist/guard/evidence.js.map +1 -0
  45. package/dist/guard/fingerprint.js +13 -0
  46. package/dist/guard/fingerprint.js.map +1 -0
  47. package/dist/guard/lock.js +360 -0
  48. package/dist/guard/lock.js.map +1 -0
  49. package/dist/guard/store.js +396 -0
  50. package/dist/guard/store.js.map +1 -0
  51. package/dist/guard/transition.js +477 -0
  52. package/dist/guard/transition.js.map +1 -0
  53. package/dist/ir/refs.js +69 -0
  54. package/dist/ir/refs.js.map +1 -0
  55. package/dist/ir/schema.js +123 -0
  56. package/dist/ir/schema.js.map +1 -0
  57. package/dist/ir/validate.js +595 -0
  58. package/dist/ir/validate.js.map +1 -0
  59. package/dist/judge/codex_judged.js +85 -0
  60. package/dist/judge/codex_judged.js.map +1 -0
  61. package/dist/judge/fixture_judged.js +62 -0
  62. package/dist/judge/fixture_judged.js.map +1 -0
  63. package/dist/judge/judged.js +89 -0
  64. package/dist/judge/judged.js.map +1 -0
  65. package/dist/judge/pricing.js +22 -0
  66. package/dist/judge/pricing.js.map +1 -0
  67. package/dist/mcp/contracts.js +162 -0
  68. package/dist/mcp/contracts.js.map +1 -0
  69. package/dist/mcp/main.js +7 -0
  70. package/dist/mcp/main.js.map +1 -0
  71. package/dist/mcp/server.js +370 -0
  72. package/dist/mcp/server.js.map +1 -0
  73. package/dist/migrate/check.js +164 -0
  74. package/dist/migrate/check.js.map +1 -0
  75. package/dist/parallel/certificate.js +37 -0
  76. package/dist/parallel/certificate.js.map +1 -0
  77. package/dist/parallel/evaluate.js +73 -0
  78. package/dist/parallel/evaluate.js.map +1 -0
  79. package/dist/speckit/compiler.js +162 -0
  80. package/dist/speckit/compiler.js.map +1 -0
  81. package/package.json +47 -0
@@ -0,0 +1,2356 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { runAgent } from "../connectors/runner.js";
8
+ import { extractReferences } from "../ir/refs.js";
9
+ import { validateSpec } from "../ir/validate.js";
10
+ import { BudgetLedger, validUsage } from "./ledger.js";
11
+ import { commitCheckpoint, revertCheckpoint } from "./checkpoint.js";
12
+ import { StateStore } from "./state.js";
13
+ const execFileAsync = promisify(execFile);
14
+ export class CheckpointOperationError extends Error {
15
+ errorType;
16
+ available;
17
+ constructor(errorType, message, available) {
18
+ super(message);
19
+ this.name = "CheckpointOperationError";
20
+ this.errorType = errorType;
21
+ if (available !== undefined)
22
+ this.available = available;
23
+ }
24
+ }
25
+ export class SpecValidationError extends Error {
26
+ errors;
27
+ constructor(errors) {
28
+ super("spec validation failed");
29
+ this.errors = errors;
30
+ }
31
+ }
32
+ export class StratumEngine {
33
+ store;
34
+ evaluator;
35
+ judge;
36
+ connector;
37
+ // Serializes load-modify-save per run: plan may hand out several ready steps, so
38
+ // stepDone/resume can race in-process. The state root is owned by one engine process in v1.
39
+ runLocks = new Map();
40
+ persistLocks = new Map();
41
+ scheduledFanouts = new Set();
42
+ // While a fanout executes, its run object is the in-process authority: every
43
+ // entry point mutates THIS instance (not a fresh disk copy), so the fanout
44
+ // can release the run lock across connector awaits without divergent copies.
45
+ activeRuns = new Map();
46
+ // V1 loop ownership is in-process like runLocks; startup rehydrates ownership
47
+ // for detached runs marked in their durable state.
48
+ bgFlows = new Map();
49
+ constructor(options) {
50
+ this.store = new StateStore(options.stateRoot);
51
+ this.evaluator = options.evaluator;
52
+ if (options.judge)
53
+ this.judge = options.judge;
54
+ this.connector = options.connector ?? defaultConnector;
55
+ }
56
+ async loadRun(runId) {
57
+ const active = this.activeRuns.get(runId);
58
+ if (active)
59
+ return active.run;
60
+ return this.store.load(runId);
61
+ }
62
+ retainRun(runId, run) {
63
+ const active = this.activeRuns.get(runId);
64
+ if (active)
65
+ active.refs += 1;
66
+ else
67
+ this.activeRuns.set(runId, { run, refs: 1 });
68
+ }
69
+ releaseRun(runId) {
70
+ const active = this.activeRuns.get(runId);
71
+ if (!active)
72
+ return;
73
+ active.refs -= 1;
74
+ if (active.refs <= 0)
75
+ this.activeRuns.delete(runId);
76
+ }
77
+ withRunLock(runId, action) {
78
+ const previous = this.runLocks.get(runId) ?? Promise.resolve();
79
+ const result = previous.then(action);
80
+ const tail = result.catch(() => undefined);
81
+ this.runLocks.set(runId, tail);
82
+ void tail.then(() => {
83
+ if (this.runLocks.get(runId) === tail)
84
+ this.runLocks.delete(runId);
85
+ });
86
+ return result;
87
+ }
88
+ async plan(specInput, input, options = {}) {
89
+ const validation = validateSpec(specInput);
90
+ if (!validation.ok)
91
+ throw new SpecValidationError(validation.errors);
92
+ const flowName = validation.value.flows.entry;
93
+ const flow = validation.value.flows[flowName];
94
+ if (!flow)
95
+ throw new Error("entry flow missing after validation");
96
+ const steps = Object.create(null);
97
+ for (const step of flow.steps)
98
+ steps[step.id] = { status: "pending", attempts: [], spent: {} };
99
+ const run = {
100
+ id: randomUUID(), spec: validation.value, revisionDigest: digest(validation.value), generationCounter: 0,
101
+ input, flowName, status: "running", flowSpent: {}, steps,
102
+ events: [{ at: now(), type: "planned" }],
103
+ // Canonicalize at plan time: a relative root must never re-resolve against a
104
+ // different process cwd after restart.
105
+ ...(options.workspaceRoot !== undefined ? { workspaceRoot: resolve(options.workspaceRoot) } : {}),
106
+ };
107
+ await this.persist(run);
108
+ return this.withRevisionDigest(await this.advance(run, validation.value, validation.contracts), run);
109
+ }
110
+ async flowRunBg(specInput, input, options = {}) {
111
+ const validation = validateSpec(specInput);
112
+ if (!validation.ok)
113
+ throw new SpecValidationError(validation.errors);
114
+ for (const [flowName, flow] of Object.entries(validation.value.flows)) {
115
+ if (flowName === "entry" || typeof flow === "string")
116
+ continue;
117
+ for (const [index, step] of flow.steps.entries()) {
118
+ if (step.fanout?.dispatch !== "consumer")
119
+ continue;
120
+ throw new SpecValidationError([{
121
+ code: "consumer_dispatch_bg_unsupported",
122
+ path: `flows.${flowName}.steps[${index}].fanout.dispatch`,
123
+ message: "consumer fanout dispatch is not supported for background flows",
124
+ }]);
125
+ }
126
+ }
127
+ const first = await this.plan(validation.value, input, options);
128
+ const run = await this.withRunLock(first.runId, async () => {
129
+ const current = await this.loadRun(first.runId);
130
+ current.bgDriven = true;
131
+ await this.persist(current);
132
+ return current;
133
+ });
134
+ const bg = { status: "running", cancelRequested: false, pendingGates: [] };
135
+ this.bgFlows.set(first.runId, bg);
136
+ // Pin before launch so the loop and any fanout always share one run object.
137
+ this.retainRun(first.runId, run);
138
+ const loop = this.driveBg(first.runId, first);
139
+ bg.loop = loop;
140
+ void loop.finally(() => {
141
+ this.releaseRun(first.runId);
142
+ if (bg.loop === loop)
143
+ delete bg.loop;
144
+ });
145
+ return { runId: first.runId, status: "running" };
146
+ }
147
+ async rehydrateBgFlows() {
148
+ for (const runId of await this.store.list()) {
149
+ let run;
150
+ try {
151
+ run = await this.store.load(runId);
152
+ }
153
+ catch (error) {
154
+ process.stderr.write(`stratum: unable to load persisted flow '${runId}': ${message(error)}\n`);
155
+ continue;
156
+ }
157
+ if (!run.bgDriven || this.bgFlows.has(run.id))
158
+ continue;
159
+ if (run.status !== "running") {
160
+ this.bgFlows.set(run.id, { status: run.status, cancelRequested: false, pendingGates: [] });
161
+ continue;
162
+ }
163
+ if (run.cancelRequested === true) {
164
+ this.bgFlows.set(run.id, { status: "cancelled", cancelRequested: true, pendingGates: [] });
165
+ continue;
166
+ }
167
+ // Launch the driver WITHOUT awaiting per-run advancement: a slow/stalled run
168
+ // must never block server startup, and a malformed persisted run must fail in
169
+ // its own (background) driver, not abort the whole scan. driveBg self-discovers
170
+ // the live state via reAdvance (which also re-schedules any in-flight fanout),
171
+ // so no explicit resume is needed; the synthesized initial's ledger is never
172
+ // read (driveBg re-derives it). retainRun and the launch are adjacent with no
173
+ // throwing await between them, so the retain can never leak.
174
+ //
175
+ // AT-LEAST-ONCE across restart: an in-flight connector was durable as `ready`,
176
+ // so the driver re-dispatches it — a step may run twice, and that second
177
+ // physical dispatch is NOT re-ledgered (a dispatch budget may under-count by the
178
+ // in-flight-at-crash count). Callers doing writes must be idempotent. A worktree
179
+ // fanout merge retains its pre-existing crash window (accepted residual). This
180
+ // assumes SINGLE-PROCESS ownership — the prior engine is gone; two live engines
181
+ // on one state root are unsupported in v1 (same single-owner model as runLocks).
182
+ const bg = { status: "running", cancelRequested: false, pendingGates: [] };
183
+ this.bgFlows.set(run.id, bg);
184
+ this.retainRun(run.id, run);
185
+ const loop = this.driveBg(run.id, { status: "running", runId: run.id, ledger: { spent: {} } });
186
+ bg.loop = loop;
187
+ void loop.finally(() => {
188
+ this.releaseRun(run.id);
189
+ if (bg.loop === loop)
190
+ delete bg.loop;
191
+ });
192
+ }
193
+ }
194
+ async stepDone(runId, stepId, result, dispatchToken) {
195
+ // Sole-mutator enforcement (STRAT-TS-FLOW-BG-OWNERSHIP): while a run is
196
+ // actively bg-driven, the driver owns its mutation surface — an external
197
+ // stepDone would race an in-flight connector dispatch and could commit a
198
+ // stale result against a reset attempt. Poll via flow_bg_poll instead;
199
+ // gates are the one exception and resolve through gateResolve.
200
+ // Refuse for every non-cleanly-terminal bg state: running, paused_gate, AND
201
+ // cancelled (a cancelled run is durably abandoned but may still hold a
202
+ // `ready` step, so an external pump could mutate it). Only a genuinely
203
+ // finished bg run (completed/failed/budget_exhausted) falls through, where
204
+ // stepDone raises the normal "not awaiting" error anyway.
205
+ this.assertExternalMutationAllowed(runId, "stepDone");
206
+ return this.stepDoneOwned(runId, stepId, result, undefined, dispatchToken);
207
+ }
208
+ /** Lock-wrapped stepDone used by the bg driver itself, bypassing the
209
+ * sole-mutator guard on the public entry point. */
210
+ stepDoneOwned(runId, stepId, result, expectedEpoch, dispatchToken) {
211
+ return this.withRunLock(runId, () => this.stepDoneLocked(runId, stepId, result, expectedEpoch, dispatchToken));
212
+ }
213
+ async stepDoneLocked(runId, stepId, result, expectedEpoch, dispatchToken) {
214
+ const run = await this.loadRun(runId);
215
+ if (run.cancelRequested === true)
216
+ throw new Error(`run ${runId} is cancelled; outstanding step issuances cannot be resolved`);
217
+ const validated = this.validationFor(run);
218
+ const located = this.locateStep(run, validated.value, stepId);
219
+ const scope = located?.scope;
220
+ const step = located?.step;
221
+ const state = located?.state;
222
+ if (located?.item !== undefined) {
223
+ if (!scope || !step || !state || run.status !== "running")
224
+ throw new Error("step is not awaiting a client result");
225
+ return this.consumerFanoutStepDone(run, validated.value, validated.contracts, scope.flow, step, state, located.item, result, expectedEpoch, dispatchToken);
226
+ }
227
+ if (!step || !state || step.do === undefined || state.status !== "ready" || run.status !== "running") {
228
+ throw new Error("step is not awaiting a client result");
229
+ }
230
+ if (!scope)
231
+ throw new Error("step scope missing after lookup");
232
+ if (expectedEpoch !== undefined && (state.epoch ?? 0) !== expectedEpoch) {
233
+ throw new Error("step result is stale: dispatched for a superseded epoch");
234
+ }
235
+ if (dispatchToken === undefined) {
236
+ throw new Error("step result is stale: missing dispatch token");
237
+ }
238
+ if (state.dispatchToken !== dispatchToken) {
239
+ throw new Error("step result is stale: dispatched for a superseded issuance");
240
+ }
241
+ const attempt = state.attempts.length + 1;
242
+ const telemetry = result.telemetry;
243
+ if (!validConnectorTelemetry(telemetry)) {
244
+ return this.failAttempt(run, validated.value, validated.contracts, scope, step, state, attempt, "invalid connector telemetry (nothing recorded)", {}, result.output);
245
+ }
246
+ const reported = result.usage ?? {};
247
+ // A shape-invalid report is untrustworthy — nothing recorded, attempt fails with feedback.
248
+ if (!validUsage(reported))
249
+ return this.failAttempt(run, validated.value, validated.contracts, scope, step, state, attempt, "invalid usage ledger entry (nothing recorded)", {}, result.output, telemetry);
250
+ // The engine reserves one dispatch per attempt itself; a client-reported count would
251
+ // double-charge. Valid keys still settle below — the attempt consumed them regardless.
252
+ const claimedDispatches = reported.dispatches !== undefined;
253
+ const usage = { ...reported };
254
+ delete usage.dispatches;
255
+ // "settle": the agent already ran, so over-limit usage is still recorded in both ledgers.
256
+ const budgetFailure = this.debit(run, step, state, usage, "settle", scope);
257
+ if (budgetFailure === "flow") {
258
+ const failure = { attempt, reason: "flow budget exhausted" };
259
+ state.attempts.push({ attempt, at: now(), failure, ...telemetryFields(telemetry), ...(hasBudget(usage) ? { usage } : {}) });
260
+ state.status = "failed";
261
+ state.failure = failure;
262
+ delete state.dispatchToken;
263
+ this.event(run, "result", this.scopedId(scope, step.id), { attempt, failure });
264
+ return this.terminalBudget(run, failure);
265
+ }
266
+ if (budgetFailure === "subflow")
267
+ return this.failSubflowBudget(run, validated.value, validated.contracts, scope, step, state, attempt, "subflow budget exhausted", usage, result.output, telemetry);
268
+ if (budgetFailure === "task")
269
+ return this.failAttempt(run, validated.value, validated.contracts, scope, step, state, attempt, "task budget exhausted", usage, result.output, telemetry);
270
+ if (claimedDispatches)
271
+ return this.failAttempt(run, validated.value, validated.contracts, scope, step, state, attempt, "dispatches are engine-accounted; do not report them in usage (other keys were recorded)", usage, result.output, telemetry);
272
+ if (result.failure !== undefined)
273
+ return this.failAttempt(run, validated.value, validated.contracts, scope, step, state, attempt, result.failure, usage, undefined, telemetry);
274
+ const contractError = this.contractError(step, result.output, validated.contracts);
275
+ if (contractError)
276
+ return this.failAttempt(run, validated.value, validated.contracts, scope, step, state, attempt, contractError, usage, result.output, telemetry);
277
+ const ensureOutcome = await this.runEnsures(run, step, state, result.output, scope);
278
+ if (ensureOutcome?.kind === "flow_budget") {
279
+ const failure = { attempt, reason: "flow budget exhausted (judged predicate)" };
280
+ state.attempts.push({ attempt, at: now(), failure, ...telemetryFields(telemetry), ...(hasBudget(usage) ? { usage } : {}) });
281
+ state.status = "failed";
282
+ state.failure = failure;
283
+ delete state.dispatchToken;
284
+ this.event(run, "result", this.scopedId(scope, step.id), { attempt, failure });
285
+ return this.terminalBudget(run, failure);
286
+ }
287
+ if (ensureOutcome?.kind === "subflow_budget")
288
+ return this.failSubflowBudget(run, validated.value, validated.contracts, scope, step, state, attempt, ensureOutcome.reason, usage, result.output, telemetry);
289
+ if (ensureOutcome)
290
+ return this.failAttempt(run, validated.value, validated.contracts, scope, step, state, attempt, ensureOutcome.reason, usage, result.output, telemetry);
291
+ if (step.iterate !== undefined) {
292
+ const until = this.ensurePredicate(step.iterate.until, run, result.output, scope);
293
+ if (!until.holds) {
294
+ const completedIterations = (state.iterations ?? 0) + 1;
295
+ state.iterations = completedIterations;
296
+ const failure = { attempt, reason: `iterate until ${JSON.stringify(step.iterate.until)} failed: ${until.reason}` };
297
+ // Identical output can never satisfy a deterministic `until` predicate, so
298
+ // spinning to iterate.max on unchanged evidence is pointless — exhaust now
299
+ // (failAttempt marks the reason with the identical-evidence note).
300
+ const previousResult = state.attempts[state.attempts.length - 1]?.result;
301
+ const identicalEvidence = result.output !== undefined && previousResult !== undefined && deepEqual(result.output, previousResult);
302
+ if (completedIterations >= step.iterate.max || identicalEvidence) {
303
+ // Max exhaustion is a normal validation failure, including on_fail routing.
304
+ return this.failAttempt(run, validated.value, validated.contracts, scope, step, state, attempt, failure.reason, usage, result.output, telemetry, true);
305
+ }
306
+ state.attempts.push({ attempt, at: now(), failure, ...(result.output !== undefined ? { result: result.output } : {}), ...telemetryFields(telemetry), ...(hasBudget(usage) ? { usage } : {}) });
307
+ state.failure = failure;
308
+ state.status = "pending";
309
+ delete state.dispatchToken;
310
+ this.event(run, "result", this.scopedId(scope, step.id), { attempt, failure, iterate: { iteration: completedIterations, max: step.iterate.max } });
311
+ await this.persist(run);
312
+ return this.advance(run, validated.value, validated.contracts, scope);
313
+ }
314
+ }
315
+ state.attempts.push({ attempt, at: now(), result: result.output, ...telemetryFields(telemetry), ...(hasBudget(usage) ? { usage } : {}) });
316
+ state.output = result.output;
317
+ state.status = "succeeded";
318
+ this.event(run, "result", this.scopedId(scope, step.id), { attempt, result: result.output });
319
+ const flowError = this.flowOutputError(scope, validated.contracts, step.id);
320
+ if (flowError) {
321
+ state.status = "ready";
322
+ delete state.output;
323
+ state.attempts.pop();
324
+ return this.failAttempt(run, validated.value, validated.contracts, scope, step, state, attempt, flowError, usage, result.output, telemetry);
325
+ }
326
+ if (state.dispatchToken !== undefined)
327
+ state.acceptedDispatchToken = state.dispatchToken;
328
+ delete state.dispatchToken;
329
+ await this.persist(run);
330
+ return this.advance(run, validated.value, validated.contracts, scope);
331
+ }
332
+ async commit(runId, label) {
333
+ this.assertExternalMutationAllowed(runId, "commit");
334
+ return await this.withRunLock(runId, async () => {
335
+ const run = await this.loadCheckpointRun(runId);
336
+ this.assertNoForegroundFanout(run, "commit");
337
+ const normalized = label.trim();
338
+ if (!normalized)
339
+ throw new CheckpointOperationError("invalid_label", "label must be a non-empty string");
340
+ commitCheckpoint(run, normalized);
341
+ await this.persist(run);
342
+ const flow = this.flowFor(run, this.validationFor(run).value);
343
+ const index = flow.steps.findIndex((step) => !terminal(run.steps[step.id].status));
344
+ return {
345
+ status: "committed",
346
+ flow_id: run.id,
347
+ label: normalized,
348
+ step_number: (index < 0 ? flow.steps.length : index) + 1,
349
+ current_step_id: index < 0 ? null : flow.steps[index].id,
350
+ checkpoints: (run.checkpoints ?? []).map((entry) => entry.label),
351
+ };
352
+ });
353
+ }
354
+ async revert(runId, label) {
355
+ this.assertExternalMutationAllowed(runId, "revert");
356
+ return await this.withRunLock(runId, async () => {
357
+ const run = await this.loadCheckpointRun(runId);
358
+ this.assertNoForegroundFanout(run, "revert");
359
+ const normalized = label.trim();
360
+ if (!revertCheckpoint(run, normalized)) {
361
+ // Insertion order, matching Python (list(state.checkpoints.keys())) and the commit
362
+ // envelope's `checkpoints` — not sorted, and robust to numeric labels (array, not object).
363
+ const available = (run.checkpoints ?? []).map((entry) => entry.label);
364
+ throw new CheckpointOperationError("checkpoint_not_found", `No checkpoint '${normalized}' on flow '${runId}'`, available);
365
+ }
366
+ this.rotateRestoredIssuances(run);
367
+ await this.persist(run);
368
+ return { ...await this.reAdvanceLocked(runId), reverted_to: normalized };
369
+ });
370
+ }
371
+ async resume(runId) {
372
+ // Sole-mutator enforcement, same as stepDone/commit/revert: a bg-driven run's
373
+ // in-flight step is durably `ready`, so an external resume would hand that same
374
+ // work to a second executor while the driver's dispatch is still running.
375
+ // Python returns bg_owned here (server.py:1057); cancelled runs stay abandoned.
376
+ this.assertExternalMutationAllowed(runId, "resume");
377
+ return this.withRunLock(runId, async () => {
378
+ const response = await this.resumeLocked(runId);
379
+ const run = await this.loadRun(runId);
380
+ return this.withRevisionDigest(response, run);
381
+ });
382
+ }
383
+ async resumeLocked(runId) {
384
+ const run = await this.loadRun(runId);
385
+ const computedDigest = digest(run.spec);
386
+ if (run.revisionDigest !== undefined && run.revisionDigest !== computedDigest) {
387
+ throw new Error("persisted revision digest does not match the effective specification");
388
+ }
389
+ run.revisionDigest = computedDigest;
390
+ run.generationCounter ??= 0;
391
+ this.backfillIssuanceTokens(run);
392
+ const validated = this.validationFor(run);
393
+ this.event(run, "resumed");
394
+ await this.persist(run);
395
+ if (run.status !== "running")
396
+ return this.response(run);
397
+ const flow = this.flowFor(run, validated.value);
398
+ for (const step of flow.steps)
399
+ if (step.fanout && run.steps[step.id]?.status === "running")
400
+ this.scheduleFanout(run, step.id);
401
+ return this.advance(run, validated.value, validated.contracts);
402
+ }
403
+ async audit(runId) {
404
+ // Durable read, deliberately bypassing the in-memory pin an active fanout
405
+ // holds: audit is the consumer's discovery surface (D5) and a token minted
406
+ // on the live object must stay invisible until its save lands.
407
+ const run = await this.store.load(runId);
408
+ return { runId, status: run.status, events: structuredClone(run.events), steps: structuredClone(run.steps), flowSpent: structuredClone(run.flowSpent), ...(run.output !== undefined ? { output: structuredClone(run.output) } : {}) };
409
+ }
410
+ /** Restart-safe read-only wait surface: events are sliced from the persisted spine. */
411
+ async flowPoll(runId, cursor = 0) {
412
+ if (!Number.isInteger(cursor) || cursor < 0)
413
+ throw new Error("invalid event cursor");
414
+ const run = await this.loadRun(runId);
415
+ return {
416
+ runId,
417
+ status: run.status,
418
+ events: structuredClone(run.events.slice(cursor)),
419
+ nextCursor: run.events.length,
420
+ ledger: this.ledgerInfo(run),
421
+ ...(run.output !== undefined ? { output: structuredClone(run.output) } : {}),
422
+ ...(run.failure !== undefined ? { failure: structuredClone(run.failure) } : {}),
423
+ };
424
+ }
425
+ async flowBgPoll(runId, cursor = 0) {
426
+ const flow = await this.flowPoll(runId, cursor);
427
+ const bg = this.bgFlows.get(runId);
428
+ if (!bg)
429
+ throw new Error(`background flow ${runId} not found`);
430
+ return {
431
+ ...flow,
432
+ bg: {
433
+ status: bg.status,
434
+ cancelRequested: bg.cancelRequested,
435
+ pendingGates: [...bg.pendingGates],
436
+ },
437
+ };
438
+ }
439
+ async flowCancelBg(runId) {
440
+ const bg = this.bgFlows.get(runId);
441
+ if (!bg)
442
+ throw new Error(`background flow ${runId} not found`);
443
+ bg.cancelRequested = true;
444
+ // Durable cooperative flag: an in-flight fanout batch observes this on the
445
+ // shared run instance and stops dispatching further items (already-dispatched
446
+ // items finish). The driver loop observes bg.cancelRequested at its boundary.
447
+ await this.withRunLock(runId, async () => {
448
+ const run = await this.loadRun(runId);
449
+ if (run.status === "running" && !run.cancelRequested) {
450
+ run.cancelRequested = true;
451
+ await this.persist(run);
452
+ }
453
+ });
454
+ // A gate-paused flow has no live loop to observe the flag, so cancel abandons
455
+ // the hand-off here instead of wedging at paused_gate forever.
456
+ if (bg.status === "paused_gate") {
457
+ bg.status = "cancelled";
458
+ bg.pendingGates = [];
459
+ }
460
+ return { status: bg.status };
461
+ }
462
+ async gateResolve(runId, stepId, decision, gateToken) {
463
+ const response = await this.withRunLock(runId, () => this.gateResolveLocked(runId, stepId, decision, gateToken));
464
+ const bg = this.bgFlows.get(runId);
465
+ if (bg?.status === "paused_gate" && response.status !== "ready" && response.status !== "running") {
466
+ bg.status = response.status;
467
+ bg.pendingGates = [];
468
+ }
469
+ else if (bg?.status === "paused_gate") {
470
+ bg.status = "running";
471
+ bg.pendingGates = [];
472
+ const run = await this.loadRun(runId);
473
+ // Re-kick only after gateResolve releases the run lock; stepDone must interleave.
474
+ this.retainRun(runId, run);
475
+ const loop = this.driveBg(runId, response);
476
+ bg.loop = loop;
477
+ void loop.finally(() => {
478
+ this.releaseRun(runId);
479
+ if (bg.loop === loop)
480
+ delete bg.loop;
481
+ });
482
+ }
483
+ return response;
484
+ }
485
+ async gateResolveLocked(runId, stepId, decision, gateToken) {
486
+ // Runtime guard for JS callers: an unknown decision must be rejected, not
487
+ // fall through the ternary chain onto the kill route.
488
+ if (decision !== "approve" && decision !== "revise" && decision !== "kill")
489
+ throw new Error(`invalid gate decision ${JSON.stringify(decision)}`);
490
+ const run = await this.loadRun(runId);
491
+ // Gates are the one exception to the bg sole-mutator guard, so they must honor
492
+ // the durable cancel flag themselves: a cancelled run is abandoned, and a
493
+ // decision on its still-waiting gate must not complete it or issue new work.
494
+ if (run.cancelRequested === true)
495
+ throw new Error(`run ${runId} is cancelled; gate ${stepId} cannot be resolved`);
496
+ const validated = this.validationFor(run);
497
+ const located = this.locateStep(run, validated.value, stepId);
498
+ const scope = located?.scope;
499
+ const step = located?.step;
500
+ const state = located?.state;
501
+ if (!scope || !step?.gate || !state || state.status !== "waiting_gate" || run.status !== "running")
502
+ throw new Error("gate is not awaiting a decision");
503
+ if (gateToken === undefined) {
504
+ throw new Error("gate decision is stale: missing gate token");
505
+ }
506
+ if (state.gateToken !== gateToken) {
507
+ throw new Error("gate decision is stale: issued for a superseded gate round");
508
+ }
509
+ delete state.gateToken;
510
+ const target = decision === "approve" ? step.gate.on_approve : decision === "revise" ? step.gate.on_revise : step.gate.on_kill;
511
+ this.event(run, "gate_resolved", stepId, { decision, target });
512
+ if (decision === "kill") {
513
+ state.status = "succeeded";
514
+ if (target === null) {
515
+ const reason = `gate ${stepId} killed flow`;
516
+ return scope.parent
517
+ ? this.failScope(run, validated.value, validated.contracts, scope, reason)
518
+ : this.terminalFailure(run, { attempt: 0, reason });
519
+ }
520
+ }
521
+ else if (decision === "revise") {
522
+ const total = (scope.parent ? scope.parent.state.sub?.rounds ?? 0 : run.rounds ?? 0) + 1;
523
+ const gateRounds = state.iterations ?? 0;
524
+ const flowLimit = scope.flow.max_rounds;
525
+ const gateLimit = step.gate.max_rounds;
526
+ if (target === null || flowLimit === undefined || total > flowLimit || (gateLimit !== undefined && gateRounds + 1 > gateLimit)) {
527
+ return scope.parent
528
+ ? this.failScope(run, validated.value, validated.contracts, scope, "gate revision rounds exhausted")
529
+ : this.terminalFailure(run, { attempt: 0, reason: "gate revision rounds exhausted" });
530
+ }
531
+ if (scope.parent)
532
+ scope.parent.state.sub.rounds = total;
533
+ else
534
+ run.rounds = total;
535
+ this.resetFrom(scope.flow, scope.steps, target);
536
+ // The target's descendants include this gate; retain its local revision counter.
537
+ scope.steps[step.id].iterations = gateRounds + 1;
538
+ await this.persist(run);
539
+ return this.advance(run, validated.value, validated.contracts, scope);
540
+ }
541
+ else {
542
+ state.status = "succeeded";
543
+ }
544
+ if (decision === "approve" && target === null) {
545
+ if (!scope.parent)
546
+ return this.completeTerminalGate(run, scope.flow, validated.contracts);
547
+ const output = this.resolveFlowOutput(scope);
548
+ const parsed = validated.contracts[scope.flow.output.contract]?.safeParse(output);
549
+ if (!parsed?.success)
550
+ return this.failScope(run, validated.value, validated.contracts, scope, parsed?.error.message ?? "flow output contract missing");
551
+ return this.completeSubflow(run, validated.value, validated.contracts, scope, output);
552
+ }
553
+ if (target !== null) {
554
+ const targetState = scope.steps[target];
555
+ if (!targetState)
556
+ throw new Error("gate target missing after validation");
557
+ targetState.routed = { attempt: 0, reason: `gate ${decision}` };
558
+ }
559
+ await this.persist(run);
560
+ return this.advance(run, validated.value, validated.contracts, scope);
561
+ }
562
+ async completeTerminalGate(run, flow, contracts) {
563
+ const output = this.resolveFlowOutput({ input: run.input, steps: run.steps, flow });
564
+ const parsed = contracts[flow.output.contract]?.safeParse(output);
565
+ if (!parsed?.success)
566
+ return this.terminalFailure(run, { attempt: 0, reason: parsed?.error.message ?? "flow output contract missing" });
567
+ run.output = output;
568
+ run.status = "completed";
569
+ this.event(run, "completed", undefined, { output });
570
+ await this.persist(run);
571
+ return this.response(run);
572
+ }
573
+ /** Re-derive a run's response after async fanout/subflow progress without
574
+ * emitting a `resumed` event on every detached-driver poll. */
575
+ reAdvance(runId) {
576
+ return this.withRunLock(runId, () => this.reAdvanceLocked(runId));
577
+ }
578
+ async reAdvanceLocked(runId) {
579
+ const current = await this.loadRun(runId);
580
+ if (current.status !== "running")
581
+ return this.response(current);
582
+ const validated = this.validationFor(current);
583
+ const flow = this.flowFor(current, validated.value);
584
+ for (const step of flow.steps)
585
+ if (step.fanout && current.steps[step.id]?.status === "running")
586
+ this.scheduleFanout(current, step.id);
587
+ return this.advance(current, validated.value, validated.contracts);
588
+ }
589
+ // SOLE-MUTATOR INVARIANT (v1): while a run is bg-driven, this driver owns its
590
+ // mutation surface — a session polls (flowBgPoll) and resolves gates
591
+ // (gateResolve), but must NOT externally call stepDone on it. The defensive
592
+ // epoch-bound settlement below rejects any result dispatched before a revise.
593
+ async driveBg(runId, initial) {
594
+ const bg = this.bgFlows.get(runId);
595
+ if (!bg)
596
+ return;
597
+ let response = initial;
598
+ try {
599
+ while (true) {
600
+ if (bg.cancelRequested) {
601
+ bg.status = "cancelled";
602
+ return;
603
+ }
604
+ if (response.status === "ready") {
605
+ const steps = response.ready;
606
+ if (steps.length === 0)
607
+ throw new Error("ready response contained no steps");
608
+ const run = await this.loadRun(runId);
609
+ const results = await Promise.all(steps.map(async (step) => {
610
+ let result;
611
+ try {
612
+ result = await this.connector({
613
+ agent: step.agent,
614
+ prompt: step.do,
615
+ attempt: step.attempt,
616
+ ...(run.workspaceRoot !== undefined ? { cwd: run.workspaceRoot } : {}),
617
+ ...(step.previousFailure !== undefined ? { previousFailure: step.previousFailure } : {}),
618
+ sandbox: "read-only",
619
+ });
620
+ }
621
+ catch (error) {
622
+ result = { failure: message(error) };
623
+ }
624
+ return { step, result };
625
+ }));
626
+ for (const { step, result } of results) {
627
+ try {
628
+ await this.stepDoneOwned(runId, step.id, result, step.epoch, step.dispatchToken);
629
+ }
630
+ catch (error) {
631
+ // Swallow ONLY genuine supersession — the run ended, the step already
632
+ // advanced, or a revise bumped its epoch (a stale-epoch rejection);
633
+ // reAdvance reconciles those below. A throw while the step is still
634
+ // ready at the SAME epoch (e.g. a malformed connector result) is a
635
+ // real driver failure and must terminalize, not spin forever.
636
+ // Resolve via locateStep: subflow child ids are scoped (parent/child)
637
+ // and live in parentState.sub.steps, not the root steps map.
638
+ const current = await this.loadRun(runId);
639
+ const state = this.locateStep(current, this.validationFor(current).value, step.id)?.state;
640
+ const superseded = current.status !== "running" || current.cancelRequested === true || state === undefined
641
+ || state.status !== "ready" || (state.epoch ?? 0) !== step.epoch || state.dispatchToken !== step.dispatchToken;
642
+ if (!superseded)
643
+ throw error;
644
+ }
645
+ }
646
+ response = await this.reAdvance(runId);
647
+ continue;
648
+ }
649
+ if (response.status === "completed" || response.status === "failed" || response.status === "budget_exhausted") {
650
+ bg.status = response.status;
651
+ return;
652
+ }
653
+ // Pause on gates only when the run is QUIESCENT: no in-flight fanout can
654
+ // still settle behind the exited driver. Decided under the run lock so it
655
+ // cannot interleave inside settleFanout's locked flip+advance — otherwise a
656
+ // driver could pause with a stale set (missing a gate the settlement is about
657
+ // to activate) or, worse, exit into paused_gate while a fanout terminalizes
658
+ // the run, leaving bg wedged at paused_gate with no driver left to observe it.
659
+ const gates = await this.withRunLock(runId, async () => {
660
+ const current = await this.loadRun(runId);
661
+ if (current.status !== "running")
662
+ return [];
663
+ const spec = this.validationFor(current).value;
664
+ if (this.anyFanoutRunning(current, spec))
665
+ return null;
666
+ return this.collectWaitingGates(current, spec);
667
+ });
668
+ if (gates && gates.length > 0) {
669
+ bg.status = "paused_gate";
670
+ bg.pendingGates = gates;
671
+ return;
672
+ }
673
+ await delay(25);
674
+ response = await this.reAdvance(runId);
675
+ }
676
+ }
677
+ catch (error) {
678
+ try {
679
+ await this.withRunLock(runId, async () => {
680
+ const run = await this.loadRun(runId);
681
+ if (run.status === "running")
682
+ await this.terminalFailure(run, { attempt: 0, reason: `background driver failed: ${message(error)}` });
683
+ });
684
+ }
685
+ catch { /* persistence failure is already the terminal boundary */ }
686
+ // Flip the registry status only AFTER the durable terminalization settles, so
687
+ // a poller that observes "failed" can trust the persisted state is written —
688
+ // consistent with the response-driven terminal paths above.
689
+ bg.status = "failed";
690
+ }
691
+ }
692
+ async advance(run, spec, contracts, scope = this.rootScope(run, spec)) {
693
+ await this.advanceScopeLoop(run, spec, contracts, scope);
694
+ if (run.status !== "running")
695
+ return this.response(run);
696
+ if (run.cancelRequested === true)
697
+ return { status: "running", runId: run.id, ledger: this.ledgerInfo(run) };
698
+ if (scope.parent) {
699
+ const finished = await this.settleSubflow(run, spec, contracts, scope);
700
+ if (finished)
701
+ return finished;
702
+ // This child cannot finish yet — every other scope still advances at the root.
703
+ return this.advance(run, spec, contracts);
704
+ }
705
+ // Root: advance EVERY active subflow — independent `run:` steps progress
706
+ // concurrently; list order is never an implicit dependency. A completed
707
+ // child transitions its parent step and re-advances the root (recursion
708
+ // bounded by the number of run steps).
709
+ for (const step of scope.flow.steps) {
710
+ if (run.status !== "running")
711
+ return this.response(run);
712
+ const state = scope.steps[step.id];
713
+ if (step.run === undefined || state.status !== "running" || !state.sub)
714
+ continue;
715
+ const child = this.childScope(spec, step, state);
716
+ await this.advanceScopeLoop(run, spec, contracts, child);
717
+ if (run.status !== "running")
718
+ return this.response(run);
719
+ const finished = await this.settleSubflow(run, spec, contracts, child);
720
+ if (finished)
721
+ return finished;
722
+ }
723
+ const ready = this.collectReady(run, spec);
724
+ if (ready.length > 0)
725
+ return { status: "ready", runId: run.id, ready, ledger: this.ledgerInfo(run) };
726
+ if (scope.flow.steps.some((step) => {
727
+ const status = scope.steps[step.id].status;
728
+ return status === "running" || status === "waiting_gate";
729
+ }))
730
+ return { status: "running", runId: run.id, ledger: this.ledgerInfo(run) };
731
+ if (scope.flow.steps.every((step) => terminal(scope.steps[step.id].status))) {
732
+ const output = this.resolveFlowOutput(scope);
733
+ const outputError = contracts[scope.flow.output.contract]?.safeParse(output);
734
+ if (!outputError?.success)
735
+ return this.failScope(run, spec, contracts, scope, outputError?.error.message ?? "flow output contract missing");
736
+ run.output = output;
737
+ run.status = "completed";
738
+ this.event(run, "completed", undefined, { output });
739
+ await this.persist(run);
740
+ return this.response(run);
741
+ }
742
+ return this.failScope(run, spec, contracts, scope, "no runnable steps remain");
743
+ }
744
+ /** When every step of a child scope is terminal, settle it into the parent run step. */
745
+ async settleSubflow(run, spec, contracts, scope) {
746
+ if (!scope.parent)
747
+ return undefined;
748
+ if (!scope.flow.steps.every((step) => terminal(scope.steps[step.id].status)))
749
+ return undefined;
750
+ const output = this.resolveFlowOutput(scope);
751
+ const outputError = contracts[scope.flow.output.contract]?.safeParse(output);
752
+ if (!outputError?.success)
753
+ return this.failScope(run, spec, contracts, scope, outputError?.error.message ?? "flow output contract missing");
754
+ return this.completeSubflow(run, spec, contracts, scope, output);
755
+ }
756
+ async advanceScopeLoop(run, spec, contracts, scope) {
757
+ const flow = scope.flow;
758
+ let changed = true;
759
+ while (changed && run.status === "running" && run.cancelRequested !== true) {
760
+ changed = false;
761
+ for (const step of flow.steps) {
762
+ const state = scope.steps[step.id];
763
+ if (state.status !== "pending")
764
+ continue;
765
+ if (!this.isActivated(step, scope)) {
766
+ if (this.unreachableOnFailTarget(step, scope)) {
767
+ state.status = "skipped";
768
+ this.event(run, "skipped", this.scopedId(scope, step.id), { reason: "on_fail target was never routed" });
769
+ changed = true;
770
+ await this.persist(run);
771
+ }
772
+ continue;
773
+ }
774
+ if (!this.dependenciesDone(step, scope))
775
+ continue;
776
+ if (step.when !== undefined) {
777
+ let enabled;
778
+ try {
779
+ enabled = this.evaluator.evaluate(step.when, this.context(run, scope));
780
+ }
781
+ catch (error) {
782
+ await this.failScope(run, spec, contracts, scope, `when evaluation failed: ${message(error)}`);
783
+ break;
784
+ }
785
+ if (enabled !== true) {
786
+ state.status = "skipped";
787
+ this.event(run, "skipped", this.scopedId(scope, step.id));
788
+ changed = true;
789
+ await this.persist(run);
790
+ continue;
791
+ }
792
+ }
793
+ if (step.set !== undefined) {
794
+ const output = {};
795
+ try {
796
+ for (const [key, expression] of Object.entries(step.set))
797
+ output[key] = this.evaluator.evaluate(expression, this.context(run, scope));
798
+ }
799
+ catch (error) {
800
+ await this.failScope(run, spec, contracts, scope, `set evaluation failed: ${message(error)}`);
801
+ break;
802
+ }
803
+ const error = this.contractError(step, output, contracts);
804
+ if (error) {
805
+ await this.failScope(run, spec, contracts, scope, error);
806
+ break;
807
+ }
808
+ // Set steps are pure: an ensure failure is deterministic, so it terminalizes.
809
+ const setEnsure = await this.runEnsures(run, step, state, output, scope);
810
+ if (setEnsure?.kind === "flow_budget") {
811
+ await this.terminalBudget(run, { attempt: 0, reason: "flow budget exhausted (judged predicate)" });
812
+ break;
813
+ }
814
+ if (setEnsure) {
815
+ await this.failScope(run, spec, contracts, scope, setEnsure.reason);
816
+ break;
817
+ }
818
+ state.status = "succeeded";
819
+ state.output = output;
820
+ state.attempts.push({ attempt: 1, at: now(), result: output });
821
+ this.event(run, "result", this.scopedId(scope, step.id), { attempt: 1, result: output });
822
+ changed = true;
823
+ await this.persist(run);
824
+ continue;
825
+ }
826
+ if (step.gate !== undefined) {
827
+ state.status = "waiting_gate";
828
+ state.gateToken = randomUUID();
829
+ this.event(run, "gate_waiting", this.scopedId(scope, step.id));
830
+ await this.persist(run);
831
+ changed = true;
832
+ continue;
833
+ }
834
+ if (step.fanout !== undefined) {
835
+ let items;
836
+ try {
837
+ const over = this.resolveFanoutOver(step.fanout.over, run, scope);
838
+ if (!Array.isArray(over))
839
+ throw new Error("fanout over must resolve to an array");
840
+ items = over;
841
+ }
842
+ catch (error) {
843
+ // Real attempt numbering — a hardcoded 1 would retry this
844
+ // deterministic resolution failure forever.
845
+ await this.failAttempt(run, spec, contracts, scope, step, state, state.attempts.length + 1, message(error), {});
846
+ changed = true;
847
+ break;
848
+ }
849
+ state.status = "running";
850
+ state.fanout = {
851
+ items: items.map((_, index) => ({
852
+ index, status: "pending", attempts: [], generation: this.nextGeneration(run), epoch: state.epoch ?? 0,
853
+ })),
854
+ };
855
+ await this.persist(run);
856
+ if (step.fanout.dispatch === "consumer") {
857
+ await this.promoteConsumerItems(run, spec, contracts, flow, step, state);
858
+ if (state.fanout.items.every((item) => terminalFanoutItem(item.status))) {
859
+ await this.settleFanout(run, spec, contracts, flow, step, state, state.fanout);
860
+ }
861
+ }
862
+ else {
863
+ for (const item of state.fanout.items)
864
+ this.event(run, "fanout_item_ready", step.id, { itemIndex: item.index });
865
+ this.scheduleFanout(run, step.id);
866
+ }
867
+ // The fanout runs off the microtask queue — later independent steps
868
+ // still activate in this same pass.
869
+ changed = true;
870
+ continue;
871
+ }
872
+ if (step.run !== undefined) {
873
+ try {
874
+ const callee = spec.flows[step.run];
875
+ if (!callee || typeof callee === "string")
876
+ throw new Error("subflow missing after validation");
877
+ const input = this.renderValue(step.with ?? {}, scope);
878
+ const parsed = this.validationFor(run).inputs[step.run]?.safeParse(input);
879
+ if (!parsed?.success)
880
+ throw new Error(parsed?.error.message ?? "subflow input contract missing");
881
+ const steps = Object.create(null);
882
+ for (const child of callee.steps)
883
+ steps[child.id] = { status: "pending", attempts: [], spent: {} };
884
+ state.sub = { input: parsed.data, steps };
885
+ state.status = "running";
886
+ await this.persist(run);
887
+ // The child scope advances in the root's subflow pass — later
888
+ // independent steps in THIS scope activate first.
889
+ changed = true;
890
+ continue;
891
+ }
892
+ catch (error) {
893
+ await this.failAttempt(run, spec, contracts, scope, step, state, state.attempts.length + 1, message(error), {}, undefined, undefined, true);
894
+ changed = true;
895
+ break;
896
+ }
897
+ }
898
+ if (step.do === undefined) {
899
+ await this.failScope(run, spec, contracts, scope, "construct is outside P1 engine scope");
900
+ break;
901
+ }
902
+ // Render BEFORE reserving: a render failure dispatches nothing, so it must not
903
+ // debit a dispatch — and its attempt record carries no usage.
904
+ let attempt;
905
+ try {
906
+ this.render(step.do, scope);
907
+ attempt = state.attempts.length + 1;
908
+ }
909
+ catch (error) {
910
+ await this.failAttempt(run, spec, contracts, scope, step, state, state.attempts.length + 1, message(error), {});
911
+ changed = true;
912
+ break;
913
+ }
914
+ const debit = this.debit(run, step, state, { dispatches: 1 }, "reserve", scope);
915
+ if (debit === "flow") {
916
+ await this.terminalBudget(run, { attempt: state.attempts.length + 1, reason: "flow budget exhausted" });
917
+ break;
918
+ }
919
+ if (debit === "subflow") {
920
+ await this.failSubflowBudget(run, spec, contracts, scope, step, state, state.attempts.length + 1, "subflow budget exhausted", {});
921
+ changed = true;
922
+ break;
923
+ }
924
+ if (debit === "task") {
925
+ // Over-limit reservation: nothing dispatched, nothing ledgered, no usage on the record.
926
+ await this.failAttempt(run, spec, contracts, scope, step, state, state.attempts.length + 1, "task budget exhausted", {});
927
+ changed = true;
928
+ break;
929
+ }
930
+ state.dispatchToken = randomUUID();
931
+ delete state.acceptedDispatchToken;
932
+ state.status = "ready";
933
+ this.event(run, "ready", this.scopedId(scope, step.id), { attempt });
934
+ await this.persist(run);
935
+ changed = true;
936
+ }
937
+ }
938
+ }
939
+ scheduleFanout(run, stepId) {
940
+ const validated = this.validationFor(run);
941
+ const scheduledStep = this.flowFor(run, validated.value).steps.find((step) => step.id === stepId);
942
+ if (scheduledStep?.fanout?.dispatch === "consumer")
943
+ return;
944
+ // Epoch-keyed: a revise that invalidated a live fanout must not be blocked
945
+ // from scheduling the fresh one by the stale execution still draining.
946
+ const key = `${run.id}:${stepId}:${run.steps[stepId]?.fanoutEpoch ?? 0}`;
947
+ if (this.scheduledFanouts.has(key))
948
+ return;
949
+ this.scheduledFanouts.add(key);
950
+ // Pin SYNCHRONOUSLY with the scheduler's own run object: from here until
951
+ // release, loadRun hands this exact instance to every entry point, so an
952
+ // independent stepDone proceeds during a slow fanout and mutates the same
953
+ // instance — never a divergent disk copy, never blocked behind the batch.
954
+ this.retainRun(run.id, run);
955
+ const scheduledFanout = run.steps[stepId]?.fanout;
956
+ queueMicrotask(() => {
957
+ void this.executeFanout(run, stepId).catch(async (error) => {
958
+ // A connector/git error must become a durable flow failure, never an
959
+ // unhandled side channel — but only while THIS execution still owns
960
+ // the step; a revise-invalidated epoch's late error must not fail the
961
+ // freshly reset run.
962
+ try {
963
+ await this.withRunLock(run.id, async () => {
964
+ if (run.status === "running" && run.steps[stepId]?.fanout === scheduledFanout) {
965
+ await this.terminalFailure(run, { attempt: 0, reason: `fanout execution failed: ${message(error)}` });
966
+ }
967
+ });
968
+ }
969
+ catch { /* persistence failure is already the terminal boundary */ }
970
+ }).finally(() => {
971
+ this.releaseRun(run.id);
972
+ this.scheduledFanouts.delete(key);
973
+ });
974
+ });
975
+ }
976
+ async promoteConsumerItems(run, spec, contracts, flow, step, state) {
977
+ if (!step.fanout || step.fanout.dispatch !== "consumer" || !state.fanout || run.status !== "running")
978
+ return;
979
+ let assigned = state.fanout.items.filter((item) => item.status === "ready" || item.status === "running").length;
980
+ for (const item of state.fanout.items) {
981
+ if (assigned >= step.fanout.concurrency || run.status !== "running")
982
+ break;
983
+ if (item.status !== "pending")
984
+ continue;
985
+ await this.prepareConsumerItem(run, spec, contracts, flow, step, state, item);
986
+ const prepared = item;
987
+ if (prepared.status === "ready" || prepared.status === "running")
988
+ assigned += 1;
989
+ }
990
+ }
991
+ async prepareConsumerItem(run, spec, contracts, _flow, step, state, item) {
992
+ if (!step.fanout || step.fanout.dispatch !== "consumer")
993
+ throw new Error("consumer fanout missing after validation");
994
+ const values = this.resolveFanoutOver(step.fanout.over, run);
995
+ if (!Array.isArray(values))
996
+ throw new Error("fanout over must resolve to an array");
997
+ while (run.status === "running") {
998
+ const stageIndex = item.stage ?? 0;
999
+ const stage = step.fanout.steps[stageIndex];
1000
+ if (!stage)
1001
+ throw new Error("consumer fanout stage is out of range");
1002
+ item.stage = stageIndex;
1003
+ item.epoch = state.epoch ?? 0;
1004
+ if (stage.when !== undefined) {
1005
+ const enabled = this.evaluateFanout(stage.when, run, values[item.index], item.output);
1006
+ if (enabled !== true) {
1007
+ this.event(run, "fanout_item_skipped", step.id, { itemIndex: item.index, stage: stageIndex });
1008
+ delete item.dispatchToken;
1009
+ if (stageIndex === step.fanout.steps.length - 1) {
1010
+ item.status = "skipped";
1011
+ await this.persist(run);
1012
+ return;
1013
+ }
1014
+ item.stage = stageIndex + 1;
1015
+ continue;
1016
+ }
1017
+ }
1018
+ const attempt = item.attempts.length + 1;
1019
+ try {
1020
+ this.renderFanout(stage.do, run, values[item.index], item.output);
1021
+ }
1022
+ catch (error) {
1023
+ const failure = { attempt, reason: message(error) };
1024
+ this.recordFanoutAttempt(run, step, item, stageIndex, attempt, false, "connector", failure);
1025
+ item.failure = failure;
1026
+ const used = item.attempts.filter((record) => record.stage === stageIndex).length;
1027
+ if (used >= (stage.attempts ?? step.attempts ?? 2)) {
1028
+ item.status = "failed";
1029
+ await this.persist(run);
1030
+ return;
1031
+ }
1032
+ continue;
1033
+ }
1034
+ const reserve = this.debit(run, step, state, { dispatches: 1 }, "reserve");
1035
+ if (reserve !== undefined) {
1036
+ const failure = { attempt, reason: `${reserve} budget exhausted` };
1037
+ this.recordFanoutAttempt(run, step, item, stageIndex, attempt, false, "budget", failure);
1038
+ item.failure = failure;
1039
+ if (reserve === "flow") {
1040
+ item.status = "failed";
1041
+ await this.terminalBudget(run, failure);
1042
+ return;
1043
+ }
1044
+ const used = item.attempts.filter((record) => record.stage === stageIndex).length;
1045
+ if (used >= (stage.attempts ?? step.attempts ?? 2)) {
1046
+ item.status = "failed";
1047
+ await this.persist(run);
1048
+ return;
1049
+ }
1050
+ continue;
1051
+ }
1052
+ item.status = "ready";
1053
+ item.dispatchToken = randomUUID();
1054
+ delete item.acceptedDispatchToken;
1055
+ this.event(run, "fanout_ledger_debit", step.id, { itemIndex: item.index, amount: { dispatches: 1 } });
1056
+ this.event(run, "fanout_item_ready", step.id, { itemIndex: item.index });
1057
+ await this.persist(run);
1058
+ return;
1059
+ }
1060
+ }
1061
+ async consumerFanoutStepDone(run, spec, contracts, flow, step, state, item, result, expectedEpoch, dispatchToken) {
1062
+ if (!step.fanout || step.fanout.dispatch !== "consumer" || !state.fanout || item.status !== "ready") {
1063
+ throw new Error("step is not awaiting a client result");
1064
+ }
1065
+ if (dispatchToken === undefined)
1066
+ throw new Error("dispatchToken is required for a consumer fanout item");
1067
+ if (item.dispatchToken !== dispatchToken)
1068
+ throw new Error("step result is stale: dispatched for a superseded issuance");
1069
+ if (expectedEpoch !== undefined && (item.epoch ?? state.epoch ?? 0) !== expectedEpoch) {
1070
+ throw new Error("step result is stale: dispatched for a superseded epoch");
1071
+ }
1072
+ const stageIndex = item.stage;
1073
+ const stage = stageIndex === undefined ? undefined : step.fanout.steps[stageIndex];
1074
+ if (stageIndex === undefined || !stage)
1075
+ throw new Error("consumer fanout stage is out of range");
1076
+ const values = this.resolveFanoutOver(step.fanout.over, run);
1077
+ if (!Array.isArray(values))
1078
+ throw new Error("fanout over must resolve to an array");
1079
+ const attempt = item.attempts.length + 1;
1080
+ const outcome = await this.settleFanoutAttempt(run, spec, contracts, step, state, item, values[item.index], item.output, stageIndex, attempt, result);
1081
+ if (!outcome.success) {
1082
+ item.failure = outcome.failure;
1083
+ if (run.status !== "running")
1084
+ return this.response(run);
1085
+ const stageAttempts = item.attempts.filter((record) => record.stage === stageIndex).length;
1086
+ if (stageAttempts < (stage.attempts ?? step.attempts ?? 2)) {
1087
+ item.status = "pending";
1088
+ await this.prepareConsumerItem(run, spec, contracts, flow, step, state, item);
1089
+ }
1090
+ else {
1091
+ item.status = "failed";
1092
+ delete item.dispatchToken;
1093
+ }
1094
+ }
1095
+ else {
1096
+ item.output = result.output;
1097
+ delete item.failure;
1098
+ if (stageIndex === step.fanout.steps.length - 1) {
1099
+ item.status = "succeeded";
1100
+ item.acceptedDispatchToken = dispatchToken;
1101
+ delete item.dispatchToken;
1102
+ }
1103
+ else {
1104
+ item.stage = stageIndex + 1;
1105
+ item.status = "pending";
1106
+ delete item.dispatchToken;
1107
+ delete item.acceptedDispatchToken;
1108
+ await this.prepareConsumerItem(run, spec, contracts, flow, step, state, item);
1109
+ }
1110
+ }
1111
+ if (terminalFanoutItem(item.status))
1112
+ await this.promoteConsumerItems(run, spec, contracts, flow, step, state);
1113
+ await this.persist(run);
1114
+ if (state.fanout.items.every((candidate) => terminalFanoutItem(candidate.status))) {
1115
+ return (await this.settleFanout(run, spec, contracts, flow, step, state, state.fanout)) ?? this.advance(run, spec, contracts);
1116
+ }
1117
+ return this.advance(run, spec, contracts);
1118
+ }
1119
+ async executeFanout(run, stepId) {
1120
+ if (run.status !== "running")
1121
+ return;
1122
+ const validated = this.validationFor(run);
1123
+ const flow = this.flowFor(run, validated.value);
1124
+ const step = flow.steps.find((candidate) => candidate.id === stepId);
1125
+ const state = run.steps[stepId];
1126
+ if (!step?.fanout || !state?.fanout || state.status !== "running")
1127
+ return;
1128
+ const values = this.resolveFanoutOver(step.fanout.over, run);
1129
+ if (!Array.isArray(values))
1130
+ throw new Error("fanout over must resolve to an array");
1131
+ // Staleness token: a revise deletes/replaces state.fanout, so workers and
1132
+ // settlement compare against this exact object and abandon on mismatch.
1133
+ const fanoutRef = state.fanout;
1134
+ let next = 0;
1135
+ const workers = Array.from({ length: Math.min(step.fanout.concurrency, values.length) }, async () => {
1136
+ // `run.cancelRequested` is a cooperative brake: a background cancel stops
1137
+ // dispatching further items (the in-flight one finishes) without hard-kill.
1138
+ while (next < values.length && run.status === "running" && !run.cancelRequested && state.fanout === fanoutRef) {
1139
+ const index = next++;
1140
+ const item = fanoutRef.items[index];
1141
+ // A restart re-schedules the whole fanout; items that already reached a
1142
+ // terminal status must never re-dispatch (their patches are persisted).
1143
+ if (item.status === "succeeded" || item.status === "failed" || item.status === "skipped")
1144
+ continue;
1145
+ await this.executeFanoutItem(run, validated.value, validated.contracts, flow, step, state, item, values[index], fanoutRef);
1146
+ }
1147
+ });
1148
+ await Promise.all(workers);
1149
+ if (state.fanout !== fanoutRef)
1150
+ return; // invalidated mid-flight — the fresh epoch owns the step now
1151
+ // A cancelled batch must not settle (no spurious `require` failure or merge);
1152
+ // the run is left running-but-abandoned, consistent with the cancelled driver.
1153
+ if (run.cancelRequested)
1154
+ return;
1155
+ // Aggregation (merge, require, advance) mutates cross-step state — back
1156
+ // under the run lock like every other advancement path.
1157
+ await this.withRunLock(run.id, () => this.settleFanout(run, validated.value, validated.contracts, flow, step, state, fanoutRef));
1158
+ }
1159
+ async settleFanout(run, spec, contracts, flow, step, state, fanoutRef) {
1160
+ if (run.status !== "running" || !step.fanout || state.fanout !== fanoutRef)
1161
+ return;
1162
+ const values = this.resolveFanoutOver(step.fanout.over, run);
1163
+ if (!Array.isArray(values))
1164
+ throw new Error("fanout over must resolve to an array");
1165
+ // `require` is judged BEFORE any patch touches the parent workspace — a
1166
+ // failing batch must leave the workspace untouched, not half-merged.
1167
+ const succeeded = fanoutRef.items.filter((item) => item.status === "succeeded").length;
1168
+ const required = step.fanout.require === "all" ? values.length : step.fanout.require === "any" ? 1 : step.fanout.require;
1169
+ if (succeeded < required) {
1170
+ // `attempts` on a fanout step bounds PER-ITEM stage retries (already
1171
+ // consumed above) — an unmet `require` never re-dispatches the whole
1172
+ // batch; it takes the on_fail/terminal path directly.
1173
+ return this.failAttempt(run, spec, contracts, this.rootScope(run, spec), step, state, state.attempts.length + 1, `fanout require ${String(step.fanout.require)} not met (${succeeded}/${values.length} succeeded)`, {}, undefined, undefined, true);
1174
+ }
1175
+ if (step.fanout.dispatch === "engine" && step.fanout.isolation === "worktree") {
1176
+ try {
1177
+ await this.mergeFanoutPatches(run, step, state);
1178
+ }
1179
+ catch (error) {
1180
+ return this.terminalFailure(run, { attempt: state.attempts.length + 1, reason: `fanout merge failed: ${message(error)}` });
1181
+ }
1182
+ }
1183
+ state.output = fanoutRef.items.map((item) => item.status === "succeeded" ? item.output ?? null : null);
1184
+ state.status = "succeeded";
1185
+ state.attempts.push({ attempt: state.attempts.length + 1, at: now(), result: state.output });
1186
+ this.event(run, "result", step.id, { attempt: state.attempts.length, result: state.output });
1187
+ await this.persist(run);
1188
+ return this.advance(run, spec, contracts);
1189
+ }
1190
+ async executeFanoutItem(run, spec, contracts, flow, step, state, item, value, fanoutRef) {
1191
+ if (!step.fanout)
1192
+ throw new Error("fanout missing after validation");
1193
+ // A revise can invalidate this fanout at any await point; once stale, the
1194
+ // item belongs to a dead epoch — stop recording into it (already-reserved
1195
+ // dispatch costs stay in the flow ledger: they were really spent).
1196
+ const stale = () => run.cancelRequested === true || state.fanout !== fanoutRef;
1197
+ item.status = "running";
1198
+ let cwd = run.workspaceRoot;
1199
+ try {
1200
+ if (step.fanout.isolation === "worktree") {
1201
+ if (!cwd)
1202
+ throw new Error("worktree fanout requires workspaceRoot");
1203
+ const previousWorktree = item.worktree;
1204
+ const directory = await mkdtemp(join(tmpdir(), `stratum-${run.id.slice(0, 8)}-${item.index}-`));
1205
+ await rm(directory, { recursive: true, force: true });
1206
+ await execFileAsync("git", ["-C", cwd, "worktree", "add", "--detach", directory, "HEAD"]);
1207
+ if (previousWorktree && previousWorktree !== directory) {
1208
+ await this.teardownWorktree(cwd, previousWorktree);
1209
+ }
1210
+ item.worktree = directory;
1211
+ cwd = directory;
1212
+ }
1213
+ let previous = undefined;
1214
+ let finalStageSkipped = false;
1215
+ for (const [stageIndex, stage] of step.fanout.steps.entries()) {
1216
+ if (stale())
1217
+ return;
1218
+ item.stage = stageIndex;
1219
+ item.epoch = state.epoch ?? 0;
1220
+ delete item.dispatchToken;
1221
+ delete item.acceptedDispatchToken;
1222
+ if (stage.when !== undefined) {
1223
+ const enabled = this.evaluateFanout(stage.when, run, value, previous, cwd);
1224
+ if (enabled !== true) {
1225
+ this.event(run, "fanout_item_skipped", step.id, { itemIndex: item.index, stage: stageIndex });
1226
+ if (stageIndex === step.fanout.steps.length - 1)
1227
+ finalStageSkipped = true;
1228
+ continue;
1229
+ }
1230
+ }
1231
+ let success = false;
1232
+ let lastFailure;
1233
+ const maximum = stage.attempts ?? step.attempts ?? 2;
1234
+ for (let stageAttempt = 1; stageAttempt <= maximum; stageAttempt += 1) {
1235
+ const attempt = item.attempts.length + 1;
1236
+ let prompt;
1237
+ try {
1238
+ prompt = this.renderFanout(stage.do, run, value, previous);
1239
+ }
1240
+ catch (error) {
1241
+ lastFailure = { attempt, reason: message(error) };
1242
+ this.recordFanoutAttempt(run, step, item, stageIndex, attempt, false, "connector", lastFailure);
1243
+ continue;
1244
+ }
1245
+ const reserve = this.debit(run, step, state, { dispatches: 1 }, "reserve");
1246
+ if (reserve) {
1247
+ lastFailure = { attempt, reason: `${reserve} budget exhausted` };
1248
+ this.recordFanoutAttempt(run, step, item, stageIndex, attempt, false, "budget", lastFailure);
1249
+ // Flow-ledger exhaustion is TERMINAL for the run — it must never be
1250
+ // absorbed as one failed item that a tolerant `require` outweighs.
1251
+ if (reserve === "flow")
1252
+ await this.terminalBudget(run, lastFailure);
1253
+ break;
1254
+ }
1255
+ item.dispatchToken = randomUUID();
1256
+ this.event(run, "fanout_ledger_debit", step.id, { itemIndex: item.index, amount: { dispatches: 1 } });
1257
+ this.event(run, "fanout_item_dispatched", step.id, { itemIndex: item.index, stage: stageIndex, attempt });
1258
+ // Durable BEFORE the (possibly long) connector await: a restart or a
1259
+ // fresh poller must see the dispatched lifecycle event, not a
1260
+ // pending item — the event spine is restart-proof.
1261
+ await this.persist(run);
1262
+ let result;
1263
+ const rawContract = stage.out !== undefined ? spec.contracts[stage.out] : undefined;
1264
+ try {
1265
+ result = await this.connector({
1266
+ agent: stage.agent ?? "claude", prompt, ...(cwd !== undefined ? { cwd } : {}), attempt,
1267
+ ...(lastFailure ? { previousFailure: lastFailure } : {}),
1268
+ ...(rawContract !== undefined ? { outSchema: rawContract } : {}),
1269
+ sandbox: step.fanout.isolation === "worktree" ? "workspace-write" : "read-only",
1270
+ });
1271
+ }
1272
+ catch (error) {
1273
+ if (stale())
1274
+ return;
1275
+ lastFailure = { attempt, reason: message(error) };
1276
+ this.recordFanoutAttempt(run, step, item, stageIndex, attempt, false, "connector", lastFailure);
1277
+ continue;
1278
+ }
1279
+ if (stale())
1280
+ return;
1281
+ const outcome = await this.settleFanoutAttempt(run, spec, contracts, step, state, item, value, previous, stageIndex, attempt, result, cwd);
1282
+ if (stale())
1283
+ return;
1284
+ if (!outcome.success) {
1285
+ lastFailure = outcome.failure;
1286
+ continue;
1287
+ }
1288
+ previous = result.output;
1289
+ success = true;
1290
+ break;
1291
+ }
1292
+ if (!success) {
1293
+ item.status = "failed";
1294
+ item.failure = lastFailure ?? { attempt: item.attempts.length + 1, reason: "fanout stage failed" };
1295
+ delete item.dispatchToken;
1296
+ return undefined;
1297
+ }
1298
+ }
1299
+ if (finalStageSkipped) {
1300
+ // The fanout output element type is the LAST stage's contract; an item
1301
+ // whose final stage was `when`-skipped has no such value — it is a
1302
+ // skipped item (null in the output array), never a success `require`
1303
+ // can count, and its partial worktree work is never merged.
1304
+ item.status = "skipped";
1305
+ delete item.dispatchToken;
1306
+ return;
1307
+ }
1308
+ if (item.worktree) {
1309
+ // Include newly-created files in the patch without staging their contents.
1310
+ // Persisted on the item BEFORE it turns succeeded, so a restart between
1311
+ // item completion and merge still has every patch.
1312
+ await execFileAsync("git", ["-C", item.worktree, "add", "-N", "."]);
1313
+ // Diff against HEAD so STAGED changes are captured too — an agent that
1314
+ // ran `git add` in its worktree must not have its work silently lost.
1315
+ // Node's default 1 MiB maxBuffer would fail any item touching a large
1316
+ // or binary file; 64 MiB bounds the patch without breaking real work.
1317
+ const patch = (await execFileAsync("git", ["-C", item.worktree, "diff", "--binary", "HEAD"], { maxBuffer: 64 * 1024 * 1024 })).stdout;
1318
+ if (patch)
1319
+ item.patch = patch;
1320
+ }
1321
+ item.output = previous;
1322
+ item.status = "succeeded";
1323
+ if (item.dispatchToken !== undefined)
1324
+ item.acceptedDispatchToken = item.dispatchToken;
1325
+ delete item.dispatchToken;
1326
+ }
1327
+ finally {
1328
+ if (run.cancelRequested === true)
1329
+ delete item.dispatchToken;
1330
+ if (item.worktree && run.workspaceRoot) {
1331
+ await this.teardownWorktree(run.workspaceRoot, item.worktree);
1332
+ delete item.worktree;
1333
+ }
1334
+ await this.persist(run);
1335
+ }
1336
+ }
1337
+ async teardownWorktree(workspaceRoot, directory) {
1338
+ try {
1339
+ await execFileAsync("git", ["-C", workspaceRoot, "worktree", "remove", "--force", directory]);
1340
+ }
1341
+ catch (error) {
1342
+ process.stderr.write(`stratum: unable to remove worktree '${directory}': ${message(error)}\n`);
1343
+ await execFileAsync("git", ["-C", workspaceRoot, "worktree", "prune"]).catch((pruneError) => {
1344
+ process.stderr.write(`stratum: unable to prune worktree registrations: ${message(pruneError)}\n`);
1345
+ });
1346
+ }
1347
+ }
1348
+ /** Shared settlement kernel for connector-owned and consumer-owned fanout
1349
+ * attempts. Dispatch ownership ends at the result envelope; usage, contract,
1350
+ * ensure, audit, and terminal budget semantics stay identical here. */
1351
+ async settleFanoutAttempt(run, spec, contracts, step, state, item, value, previous, stageIndex, attempt, result, workspaceRoot) {
1352
+ if (!step.fanout)
1353
+ throw new Error("fanout missing after validation");
1354
+ const stage = step.fanout.steps[stageIndex];
1355
+ if (!stage)
1356
+ throw new Error("fanout stage is out of range");
1357
+ if (!validConnectorTelemetry(result.telemetry) || !validUsage(result.usage ?? {})) {
1358
+ const failure = { attempt, reason: "invalid connector telemetry or usage" };
1359
+ this.recordFanoutAttempt(run, step, item, stageIndex, attempt, false, "usage", failure, result);
1360
+ return { success: false, failure };
1361
+ }
1362
+ const usage = { ...(result.usage ?? {}) };
1363
+ const reportedDispatches = usage.dispatches !== undefined;
1364
+ delete usage.dispatches;
1365
+ const settled = this.debit(run, step, state, usage, "settle");
1366
+ if (hasBudget(usage))
1367
+ this.event(run, "fanout_ledger_debit", step.id, { itemIndex: item.index, amount: usage });
1368
+ const stageStep = { ...step, do: stage.do, out: stage.out, ensure: stage.ensure, budget: step.budget };
1369
+ const contractFailure = this.contractError(stageStep, result.output, contracts);
1370
+ const failureReason = settled === "flow" ? "flow budget exhausted" : settled === "task" ? "task budget exhausted"
1371
+ : reportedDispatches ? "dispatches are engine-accounted; do not report them in usage"
1372
+ : result.failure ?? contractFailure;
1373
+ const ensure = failureReason === undefined
1374
+ ? await this.runEnsures(run, stageStep, state, result.output, this.rootScope(run, spec), {
1375
+ itemIndex: item.index,
1376
+ stage: stageIndex,
1377
+ item: value,
1378
+ prev: previous,
1379
+ ...(workspaceRoot !== undefined ? { workspaceRoot } : {}),
1380
+ })
1381
+ : undefined;
1382
+ const reason = failureReason
1383
+ ?? (ensure?.kind === "fail" || ensure?.kind === "subflow_budget" ? ensure.reason : ensure?.kind === "flow_budget" ? "flow budget exhausted" : undefined);
1384
+ if (reason !== undefined) {
1385
+ const kind = contractFailure !== undefined ? "contract"
1386
+ : ensure?.kind === "fail" ? "ensure"
1387
+ : settled !== undefined || ensure?.kind === "flow_budget" || ensure?.kind === "subflow_budget" ? "budget"
1388
+ : !validConnectorTelemetry(result.telemetry) || !validUsage(result.usage ?? {}) ? "usage" : "connector";
1389
+ const failure = { attempt, reason };
1390
+ this.recordFanoutAttempt(run, step, item, stageIndex, attempt, false, kind, failure, result, usage);
1391
+ if (ensure?.kind === "flow_budget" || settled === "flow")
1392
+ await this.terminalBudget(run, failure);
1393
+ return { success: false, failure };
1394
+ }
1395
+ item.attempts.push({
1396
+ attempt,
1397
+ at: now(),
1398
+ stage: stageIndex,
1399
+ result: result.output,
1400
+ ...telemetryFields(result.telemetry),
1401
+ ...(hasBudget(usage) ? { usage } : {}),
1402
+ });
1403
+ this.event(run, "fanout_attempt_result", step.id, { itemIndex: item.index, stage: stageIndex, attempt, success: true });
1404
+ return { success: true };
1405
+ }
1406
+ recordFanoutAttempt(run, step, item, stage, attempt, success, failureKind, failure, result, usage) {
1407
+ item.attempts.push({ attempt, at: now(), stage, failure, failureKind, ...(result?.output !== undefined ? { result: result.output } : {}), ...telemetryFields(result?.telemetry), ...(usage && hasBudget(usage) ? { usage } : {}) });
1408
+ delete item.dispatchToken;
1409
+ this.event(run, "fanout_attempt_result", step.id, { itemIndex: item.index, stage, attempt, success, failure: { kind: failureKind, reason: failure.reason } });
1410
+ }
1411
+ async mergeFanoutPatches(run, step, state) {
1412
+ if (!step.fanout || !run.workspaceRoot || !state.fanout)
1413
+ throw new Error("worktree fanout requires workspaceRoot");
1414
+ for (const command of step.fanout.pre_merge ?? [])
1415
+ await execFileAsync("sh", ["-lc", command], { cwd: run.workspaceRoot });
1416
+ const pending = state.fanout.items
1417
+ .filter((item) => item.status === "succeeded" && item.patch)
1418
+ .sort((a, b) => a.index - b.index);
1419
+ for (const item of pending) {
1420
+ const patch = item.patch;
1421
+ try {
1422
+ const patchRoot = await mkdtemp(join(tmpdir(), "stratum-merge-"));
1423
+ const patchPath = join(patchRoot, "item.patch");
1424
+ try {
1425
+ await writeFile(patchPath, patch, "utf8");
1426
+ await execFileAsync("git", ["-C", run.workspaceRoot, "apply", "--index", "--3way", patchPath]);
1427
+ }
1428
+ finally {
1429
+ await rm(patchRoot, { recursive: true, force: true });
1430
+ }
1431
+ this.event(run, "fanout_merge", step.id, { itemIndex: item.index, success: true });
1432
+ // Merge progress is durable per item: a restart resumes with only the
1433
+ // unapplied patches, never re-applying one that already landed. (The
1434
+ // window between `git apply` and this persist is an accepted residual.)
1435
+ delete item.patch;
1436
+ await this.persist(run);
1437
+ }
1438
+ catch (error) {
1439
+ this.event(run, "fanout_merge", step.id, { itemIndex: item.index, success: false, reason: message(error) });
1440
+ throw error;
1441
+ }
1442
+ }
1443
+ }
1444
+ async failAttempt(run, spec, contracts, scope, step, state, attempt, reason, usage, result, telemetry, forceExhausted = false) {
1445
+ const previousResult = state.attempts[state.attempts.length - 1]?.result;
1446
+ const identicalEvidence = result !== undefined && previousResult !== undefined && deepEqual(result, previousResult);
1447
+ const failure = { attempt, reason: identicalEvidence ? `${reason} (no retry: identical evidence)` : reason };
1448
+ state.attempts.push({ attempt, at: now(), failure, ...(result !== undefined ? { result } : {}), ...telemetryFields(telemetry), ...(hasBudget(usage) ? { usage } : {}) });
1449
+ state.failure = failure;
1450
+ delete state.dispatchToken;
1451
+ delete state.acceptedDispatchToken;
1452
+ this.event(run, "result", this.scopedId(scope, step.id), { attempt, failure });
1453
+ const maximum = step.attempts ?? 2;
1454
+ if (!forceExhausted && !identicalEvidence && attempt < maximum) {
1455
+ state.status = "pending";
1456
+ await this.persist(run);
1457
+ return this.advance(run, spec, contracts, scope);
1458
+ }
1459
+ state.status = "failed";
1460
+ if (step.on_fail !== undefined) {
1461
+ const target = scope.steps[step.on_fail];
1462
+ if (!target)
1463
+ throw new Error("on_fail target missing after validation");
1464
+ target.routed = failure;
1465
+ this.event(run, "routed", this.scopedId(scope, step.id), { target: this.scopedId(scope, step.on_fail), failure });
1466
+ await this.persist(run);
1467
+ return this.advance(run, spec, contracts, scope);
1468
+ }
1469
+ if (scope.parent)
1470
+ return this.failParentRunStep(run, spec, contracts, scope, failure);
1471
+ return this.terminalFailure(run, failure);
1472
+ }
1473
+ failSubflowBudget(run, spec, contracts, scope, step, state, attempt, reason, usage, result, telemetry) {
1474
+ if (!scope.parent)
1475
+ return this.failAttempt(run, spec, contracts, scope, step, state, attempt, reason, usage, result, telemetry, true);
1476
+ const failure = { attempt, reason };
1477
+ state.attempts.push({ attempt, at: now(), failure, ...(result !== undefined ? { result } : {}), ...telemetryFields(telemetry), ...(hasBudget(usage) ? { usage } : {}) });
1478
+ state.failure = failure;
1479
+ state.status = "failed";
1480
+ delete state.dispatchToken;
1481
+ delete state.acceptedDispatchToken;
1482
+ this.event(run, "result", this.scopedId(scope, step.id), { attempt, failure });
1483
+ return this.failParentRunStep(run, spec, contracts, scope, failure);
1484
+ }
1485
+ /** Evaluates a step's ensure list in order; the first failing predicate wins. */
1486
+ async runEnsures(run, step, state, output, scope = this.rootScope(run, this.validationFor(run).value), fanoutItem) {
1487
+ for (const predicate of step.ensure ?? []) {
1488
+ if ("judged" in predicate) {
1489
+ const { statement, stakes } = predicate.judged;
1490
+ // The runner is an injected seam — validate its outcome; a malformed shape
1491
+ // (non-boolean holds, invalid usage) must fail the attempt, never pass it.
1492
+ let outcome;
1493
+ let failureReason;
1494
+ if (!this.judge) {
1495
+ failureReason = "judged predicate requires a configured judge runner";
1496
+ }
1497
+ else {
1498
+ try {
1499
+ const raw = (await this.judge(predicate.judged, { result: output, input: scope.input }));
1500
+ // Snapshot every runner-owned field exactly once, inside the guard —
1501
+ // hostile or unstable getters must not throw past this block or
1502
+ // return different values on a second read.
1503
+ const holds = raw?.holds;
1504
+ const reason = raw?.reason;
1505
+ const stakesValue = raw?.stakes;
1506
+ const modelValue = raw?.model;
1507
+ const usageRaw = raw?.usage; // single read — unstable getters must not diverge across reads
1508
+ const usageValue = typeof usageRaw === "object" && usageRaw !== null ? { ...usageRaw } : usageRaw;
1509
+ if (typeof holds !== "boolean" || typeof reason !== "string" || (usageValue !== undefined && !validUsage(usageValue))) {
1510
+ failureReason = "judge runner returned a malformed outcome";
1511
+ }
1512
+ else {
1513
+ outcome = {
1514
+ holds,
1515
+ reason,
1516
+ ...(typeof stakesValue === "string" ? { stakes: stakesValue } : {}),
1517
+ ...(typeof modelValue === "string" ? { model: modelValue } : {}),
1518
+ ...(usageValue !== undefined ? { usage: usageValue } : {}),
1519
+ };
1520
+ }
1521
+ }
1522
+ catch (error) {
1523
+ failureReason = `judged predicate failed: ${message(error)}`;
1524
+ }
1525
+ }
1526
+ const usage = outcome?.usage ?? {};
1527
+ const budgetFailure = hasBudget(usage) ? this.debit(run, step, state, usage, "settle", scope) : undefined;
1528
+ // A judged debit inside a fanout item must stay visible per item — the
1529
+ // observability contract forbids anonymous ledger movement.
1530
+ if (fanoutItem && hasBudget(usage)) {
1531
+ this.event(run, "fanout_ledger_debit", step.id, { itemIndex: fanoutItem.itemIndex, amount: usage, source: "judged" });
1532
+ }
1533
+ // Fixed audit payload — every judged evaluation events, failures included.
1534
+ this.event(run, "judged", this.scopedId(scope, step.id), {
1535
+ statement,
1536
+ holds: outcome?.holds ?? false,
1537
+ reason: outcome?.reason ?? failureReason ?? "unknown judged failure",
1538
+ // outcome fields are snapshot-normalized above — plain values, no getters.
1539
+ stakes: outcome?.stakes ?? stakes,
1540
+ model: outcome?.model ?? "none",
1541
+ usage: { tokens: usage.tokens ?? 0, usd: usage.usd ?? 0 },
1542
+ ...(fanoutItem ? { itemIndex: fanoutItem.itemIndex, stage: fanoutItem.stage } : {}),
1543
+ });
1544
+ if (budgetFailure === "flow")
1545
+ return { kind: "flow_budget" };
1546
+ if (budgetFailure === "subflow")
1547
+ return { kind: "subflow_budget", reason: "subflow budget exhausted (judged predicate)" };
1548
+ if (budgetFailure === "task")
1549
+ return { kind: "fail", reason: "task budget exhausted (judged predicate)" };
1550
+ if (failureReason !== undefined)
1551
+ return { kind: "fail", reason: failureReason };
1552
+ if (!outcome.holds) {
1553
+ return { kind: "fail", reason: `ensure judged ${JSON.stringify(statement)} failed: ${outcome.reason}` };
1554
+ }
1555
+ continue;
1556
+ }
1557
+ const expression = "expr" in predicate
1558
+ ? predicate.expr
1559
+ : "file_exists" in predicate
1560
+ ? `file_exists(${JSON.stringify(predicate.file_exists)})`
1561
+ : `file_contains(${JSON.stringify(predicate.file_contains.path)}, ${JSON.stringify(predicate.file_contains.text)})`;
1562
+ const verdict = this.ensurePredicate(expression, run, output, scope, fanoutItem);
1563
+ if (!verdict.holds)
1564
+ return { kind: "fail", reason: `ensure ${JSON.stringify(expression)} failed: ${verdict.reason}` };
1565
+ }
1566
+ return undefined;
1567
+ }
1568
+ ensurePredicate(expression, run, output, scope = this.rootScope(run, this.validationFor(run).value), fanoutItem) {
1569
+ // Stage ensures evaluate with their legal item/prev bindings, and file
1570
+ // predicates jail to the ITEM's working directory (the worktree, under
1571
+ // isolation) — its files do not exist in the parent workspace until merge.
1572
+ const workspaceRoot = fanoutItem?.workspaceRoot ?? run.workspaceRoot;
1573
+ const context = {
1574
+ ...this.context(run, scope),
1575
+ result: output,
1576
+ ...(fanoutItem ? { item: fanoutItem.item, prev: fanoutItem.prev } : {}),
1577
+ ...(workspaceRoot !== undefined ? { workspaceRoot } : {}),
1578
+ };
1579
+ // The evaluator is an injected seam: a throw or malformed verdict fails the
1580
+ // predicate with a structured reason — it never escapes stepDone unrecorded.
1581
+ try {
1582
+ if (this.evaluator.evaluatePredicate) {
1583
+ const verdict = this.evaluator.evaluatePredicate(expression, context);
1584
+ if (typeof verdict?.holds !== "boolean" || typeof verdict.reason !== "string") {
1585
+ return { holds: false, reason: "evaluator returned a malformed predicate verdict" };
1586
+ }
1587
+ return { holds: verdict.holds, reason: verdict.reason };
1588
+ }
1589
+ const value = this.evaluator.evaluate(expression, context);
1590
+ return value === true
1591
+ ? { holds: true, reason: "predicate evaluated to true" }
1592
+ : { holds: false, reason: `predicate evaluated to ${JSON.stringify(value) ?? "undefined"}` };
1593
+ }
1594
+ catch (error) {
1595
+ return { holds: false, reason: message(error) };
1596
+ }
1597
+ }
1598
+ /**
1599
+ * "reserve" checks before consuming (pre-dispatch — nothing spent yet, so an
1600
+ * over-limit reservation records nothing). "settle" records actual post-dispatch
1601
+ * usage in BOTH ledgers even when over limit — the resources are already consumed.
1602
+ */
1603
+ debit(run, step, state, usage, mode, scope = this.rootScope(run, this.validationFor(run).value)) {
1604
+ const flowLedger = new BudgetLedger(this.flowFor(run, this.validationFor(run).value).budget, run.flowSpent);
1605
+ const subflowLedger = scope.parent ? new BudgetLedger(scope.parent.step.budget, scope.parent.state.spent) : undefined;
1606
+ const taskLedger = new BudgetLedger(step.budget, state.spent);
1607
+ const flowOk = flowLedger.canDebit(usage);
1608
+ const subflowOk = subflowLedger?.canDebit(usage) ?? true;
1609
+ const taskOk = taskLedger.canDebit(usage);
1610
+ if (mode === "settle" || (flowOk && subflowOk && taskOk)) {
1611
+ flowLedger.debit(usage);
1612
+ subflowLedger?.debit(usage);
1613
+ taskLedger.debit(usage);
1614
+ Object.assign(run.flowSpent, flowLedger.spent);
1615
+ if (subflowLedger && scope.parent)
1616
+ Object.assign(scope.parent.state.spent, subflowLedger.spent);
1617
+ Object.assign(state.spent, taskLedger.spent);
1618
+ }
1619
+ if (!flowOk)
1620
+ return "flow";
1621
+ if (!subflowOk)
1622
+ return "subflow";
1623
+ if (!taskOk)
1624
+ return "task";
1625
+ return undefined;
1626
+ }
1627
+ unreachableOnFailTarget(step, scope) {
1628
+ const routers = scope.flow.steps.filter((candidate) => candidate.on_fail === step.id);
1629
+ return routers.length > 0
1630
+ && scope.steps[step.id].routed === undefined
1631
+ && routers.every((router) => terminal(scope.steps[router.id].status));
1632
+ }
1633
+ isActivated(step, scope) {
1634
+ const routesHere = scope.flow.steps.some((candidate) => candidate.on_fail === step.id
1635
+ || candidate.gate?.on_approve === step.id || candidate.gate?.on_kill === step.id);
1636
+ return !routesHere || scope.steps[step.id].routed !== undefined;
1637
+ }
1638
+ /** Reset a revise target and its ordinary descendants; static validation proved target ancestry. */
1639
+ resetFrom(flow, steps, target) {
1640
+ const descendants = new Set([target]);
1641
+ let changed = true;
1642
+ while (changed) {
1643
+ changed = false;
1644
+ for (const step of flow.steps) {
1645
+ if (descendants.has(step.id))
1646
+ continue;
1647
+ // Descendants close over the SAME forward edges the validator's
1648
+ // ancestry check walks: after/data refs, on_fail routes, and gate
1649
+ // approve/kill routes (revise is a back-edge, never forward) — a
1650
+ // revise into an on_fail/gate-routed region must clear all of it.
1651
+ const viaDependency = this.dependencies(step).some((dependency) => descendants.has(dependency));
1652
+ const viaRoute = flow.steps.some((router) => descendants.has(router.id)
1653
+ && (router.on_fail === step.id || router.gate?.on_approve === step.id || router.gate?.on_kill === step.id));
1654
+ if (viaDependency || viaRoute) {
1655
+ descendants.add(step.id);
1656
+ changed = true;
1657
+ }
1658
+ }
1659
+ }
1660
+ const gateIds = new Set(flow.steps.flatMap((step) => step.gate !== undefined ? [step.id] : []));
1661
+ for (const id of descendants) {
1662
+ const state = steps[id];
1663
+ state.status = "pending";
1664
+ state.attempts = [];
1665
+ state.spent = {};
1666
+ delete state.output;
1667
+ delete state.failure;
1668
+ delete state.routed;
1669
+ delete state.dispatchToken;
1670
+ delete state.gateToken;
1671
+ delete state.acceptedDispatchToken;
1672
+ // A live fanout for this step must be invalidated, not just cleared:
1673
+ // the epoch bump makes in-flight workers/settlement stale (they check
1674
+ // object identity) and lets the re-activated step schedule freshly.
1675
+ state.epoch = (state.epoch ?? 0) + 1;
1676
+ if (state.fanout)
1677
+ state.fanoutEpoch = (state.fanoutEpoch ?? 0) + 1;
1678
+ delete state.fanout;
1679
+ delete state.sub;
1680
+ // On gates, `iterations` is the gate's REVISION counter — its max_rounds
1681
+ // cap counts total revisions for the whole run, so a revise must never
1682
+ // reset it (any gate's, target included). On tasks it is the
1683
+ // iterate-loop counter, which always restarts with the region.
1684
+ if (!gateIds.has(id))
1685
+ delete state.iterations;
1686
+ }
1687
+ }
1688
+ dependenciesDone(step, scope) {
1689
+ // A skipped dependency satisfies the edge (`when` is a LOCAL skip); a data ref
1690
+ // into a skipped step still fails at render time because its output is unavailable.
1691
+ return this.dependencies(step).every((id) => {
1692
+ const status = scope.steps[id]?.status;
1693
+ return status === "succeeded" || status === "skipped";
1694
+ });
1695
+ }
1696
+ dependencies(step) {
1697
+ const output = new Set(step.after ?? []);
1698
+ for (const value of stringLeaves(step)) {
1699
+ for (const extracted of extractReferences(value) ?? [])
1700
+ if (extracted.reference.kind === "step")
1701
+ output.add(extracted.reference.stepId);
1702
+ }
1703
+ return [...output];
1704
+ }
1705
+ readyStep(run, step, state, scope = this.rootScope(run, this.validationFor(run).value)) {
1706
+ if (step.do === undefined)
1707
+ throw new Error("not a do step");
1708
+ if (state.dispatchToken === undefined)
1709
+ throw new Error("ready step is missing its persisted dispatch token");
1710
+ const attempt = state.attempts.length + 1;
1711
+ return {
1712
+ id: this.scopedId(scope, step.id), do: this.render(step.do, scope), agent: step.agent ?? "claude", attempt,
1713
+ epoch: state.epoch ?? 0, dispatchToken: state.dispatchToken,
1714
+ ...(state.failure ? { previousFailure: state.failure } : state.routed ? { previousFailure: state.routed } : {}),
1715
+ };
1716
+ }
1717
+ consumerDescriptor(run, spec, step, state, item) {
1718
+ if (!step.fanout || step.fanout.dispatch !== "consumer")
1719
+ throw new Error("not a consumer fanout");
1720
+ if (item.status !== "ready" || item.dispatchToken === undefined || item.stage === undefined) {
1721
+ throw new Error("consumer fanout item is missing persisted readiness");
1722
+ }
1723
+ if (run.revisionDigest === undefined)
1724
+ throw new Error("consumer descriptor requires a persisted revision digest");
1725
+ const values = this.resolveFanoutOver(step.fanout.over, run);
1726
+ if (!Array.isArray(values))
1727
+ throw new Error("fanout over must resolve to an array");
1728
+ const stage = step.fanout.steps[item.stage];
1729
+ if (!stage)
1730
+ throw new Error("consumer fanout stage is out of range");
1731
+ const closure = stage.out === undefined ? null : this.contractClosure(spec, stage.out);
1732
+ return {
1733
+ id: `${step.id}/${item.index}`,
1734
+ do: this.renderFanout(stage.do, run, values[item.index], item.output),
1735
+ agent: stage.agent ?? "claude",
1736
+ attempt: item.attempts.length + 1,
1737
+ epoch: item.epoch ?? state.epoch ?? 0,
1738
+ dispatchToken: item.dispatchToken,
1739
+ ...(item.failure ? { previousFailure: item.failure } : {}),
1740
+ flow: run.flowName,
1741
+ step: step.id,
1742
+ stage: item.stage,
1743
+ isFinalStage: item.stage === step.fanout.steps.length - 1,
1744
+ itemIndex: item.index,
1745
+ generation: item.generation,
1746
+ contract: closure,
1747
+ contractDigest: closure === null ? null : digest(closure),
1748
+ policy: {
1749
+ isolation: step.fanout.isolation,
1750
+ merge: step.fanout.merge,
1751
+ pre_merge: [...(step.fanout.pre_merge ?? [])],
1752
+ },
1753
+ revisionDigest: run.revisionDigest,
1754
+ };
1755
+ }
1756
+ contractClosure(spec, root) {
1757
+ const raw = spec.contracts;
1758
+ const reachable = new Set();
1759
+ const visit = (name) => {
1760
+ if (reachable.has(name))
1761
+ return;
1762
+ const fields = raw[name];
1763
+ if (!fields)
1764
+ throw new Error(`output contract ${name} is missing`);
1765
+ reachable.add(name);
1766
+ for (const type of Object.values(fields)) {
1767
+ const referenced = contractReference(type, raw);
1768
+ if (referenced !== undefined)
1769
+ visit(referenced);
1770
+ }
1771
+ };
1772
+ visit(root);
1773
+ return {
1774
+ root,
1775
+ contracts: Object.fromEntries([...reachable].sort().map((name) => [name, structuredClone(raw[name])])),
1776
+ };
1777
+ }
1778
+ render(value, scope) {
1779
+ const references = extractReferences(value);
1780
+ if (!references)
1781
+ throw new Error("invalid reference after validation");
1782
+ // Rebuild from match positions in the ORIGINAL template: resolved values that
1783
+ // themselves contain `${...}` or `$&`-style text are inserted verbatim, never re-scanned.
1784
+ const parts = [];
1785
+ let cursor = 0;
1786
+ for (const extracted of references) {
1787
+ const resolved = this.resolve(extracted.reference, scope);
1788
+ if (resolved === undefined)
1789
+ throw new Error("reference output is unavailable (the source may have been skipped)");
1790
+ if (extracted.fullValue) {
1791
+ if (typeof resolved !== "string")
1792
+ throw new Error("do task must render to a string");
1793
+ return resolved;
1794
+ }
1795
+ const at = value.indexOf(extracted.raw, cursor);
1796
+ if (at < 0)
1797
+ throw new Error("reference token missing from template");
1798
+ parts.push(value.slice(cursor, at), interpolate(resolved));
1799
+ cursor = at + extracted.raw.length;
1800
+ }
1801
+ parts.push(value.slice(cursor));
1802
+ return parts.join("");
1803
+ }
1804
+ resolveFanoutOver(value, run, scope = this.rootScope(run, this.validationFor(run).value)) {
1805
+ const references = extractReferences(value);
1806
+ if (!references || references.length !== 1 || !references[0].fullValue)
1807
+ throw new Error("fanout over must be one full reference");
1808
+ return this.resolve(references[0].reference, scope);
1809
+ }
1810
+ renderFanout(value, run, item, previous) {
1811
+ const references = extractReferences(value);
1812
+ if (!references)
1813
+ throw new Error("invalid reference after validation");
1814
+ const scope = this.rootScope(run, this.validationFor(run).value);
1815
+ const resolveReference = (reference) => reference.kind === "item" ? item : reference.kind === "prev" ? previous : this.resolve(reference, scope);
1816
+ const parts = [];
1817
+ let cursor = 0;
1818
+ for (const extracted of references) {
1819
+ const resolved = resolveReference(extracted.reference);
1820
+ if (resolved === undefined)
1821
+ throw new Error("reference output is unavailable (the source may have been skipped)");
1822
+ if (extracted.fullValue) {
1823
+ if (typeof resolved !== "string")
1824
+ throw new Error("do task must render to a string");
1825
+ return resolved;
1826
+ }
1827
+ const at = value.indexOf(extracted.raw, cursor);
1828
+ if (at < 0)
1829
+ throw new Error("reference token missing from template");
1830
+ parts.push(value.slice(cursor, at), interpolate(resolved));
1831
+ cursor = at + extracted.raw.length;
1832
+ }
1833
+ parts.push(value.slice(cursor));
1834
+ return parts.join("");
1835
+ }
1836
+ evaluateFanout(expression, run, item, previous, itemRoot) {
1837
+ // File predicates in a stage `when` see the ITEM's working directory (the
1838
+ // worktree under isolation) — same jail the stage's ensures evaluate in.
1839
+ const workspaceRoot = itemRoot ?? run.workspaceRoot;
1840
+ return this.evaluator.evaluate(expression, { ...this.context(run, this.rootScope(run, this.validationFor(run).value)), item, prev: previous, ...(workspaceRoot !== undefined ? { workspaceRoot } : {}) });
1841
+ }
1842
+ resolveFlowOutput(scope) {
1843
+ const extracted = extractReferences(scope.flow.output.from)?.[0];
1844
+ if (!extracted)
1845
+ throw new Error("invalid flow output reference after validation");
1846
+ return this.resolve(extracted.reference, scope);
1847
+ }
1848
+ flowOutputError(scope, contracts, completedStepId) {
1849
+ const ref = extractReferences(scope.flow.output.from)?.[0]?.reference;
1850
+ if (ref?.kind !== "step" || ref.stepId !== completedStepId)
1851
+ return undefined;
1852
+ const parse = contracts[scope.flow.output.contract]?.safeParse(this.resolve(ref, scope));
1853
+ return parse && !parse.success ? parse.error.message : parse ? undefined : "flow output contract missing";
1854
+ }
1855
+ contractError(step, output, contracts) {
1856
+ if (step.out === undefined)
1857
+ return undefined;
1858
+ const parse = contracts[step.out]?.safeParse(output);
1859
+ return parse && !parse.success ? parse.error.message : parse ? undefined : "output contract missing";
1860
+ }
1861
+ rootScope(run, spec) {
1862
+ return { input: run.input, steps: run.steps, flow: this.flowFor(run, spec) };
1863
+ }
1864
+ childScope(spec, parentStep, parentState) {
1865
+ if (parentStep.run === undefined || parentState.sub === undefined)
1866
+ throw new Error("subflow state missing");
1867
+ const flow = spec.flows[parentStep.run];
1868
+ if (!flow || typeof flow === "string")
1869
+ throw new Error("subflow missing after validation");
1870
+ return {
1871
+ input: parentState.sub.input,
1872
+ steps: parentState.sub.steps,
1873
+ flow,
1874
+ prefix: parentStep.id,
1875
+ parent: { step: parentStep, state: parentState },
1876
+ };
1877
+ }
1878
+ locateStep(run, spec, id) {
1879
+ const root = this.rootScope(run, spec);
1880
+ if (!id.includes("/")) {
1881
+ const step = root.flow.steps.find((candidate) => candidate.id === id);
1882
+ const state = root.steps[id];
1883
+ return step && state ? { scope: root, step, state } : undefined;
1884
+ }
1885
+ const parts = id.split("/");
1886
+ if (parts.length !== 2 || !parts[0] || !parts[1])
1887
+ return undefined;
1888
+ const [parentId, childId] = parts;
1889
+ const parentStep = root.flow.steps.find((candidate) => candidate.id === parentId);
1890
+ const parentState = root.steps[parentId];
1891
+ if (parentStep?.fanout?.dispatch === "consumer" && parentState?.status === "running" && parentState.fanout) {
1892
+ if (!/^(0|[1-9][0-9]*)$/.test(childId))
1893
+ return undefined;
1894
+ const item = parentState.fanout.items[Number(childId)];
1895
+ return item && item.index === Number(childId) ? { scope: root, step: parentStep, state: parentState, item } : undefined;
1896
+ }
1897
+ if (!parentStep || parentStep.run === undefined || !parentState?.sub || parentState.status !== "running")
1898
+ return undefined;
1899
+ const scope = this.childScope(spec, parentStep, parentState);
1900
+ const step = scope.flow.steps.find((candidate) => candidate.id === childId);
1901
+ const state = scope.steps[childId];
1902
+ return step && state ? { scope, step, state } : undefined;
1903
+ }
1904
+ collectReady(run, spec) {
1905
+ const root = this.rootScope(run, spec);
1906
+ const ready = root.flow.steps.flatMap((step) => {
1907
+ const state = root.steps[step.id];
1908
+ return step.do !== undefined && state.status === "ready" ? [this.readyStep(run, step, state, root)] : [];
1909
+ });
1910
+ for (const parentStep of root.flow.steps) {
1911
+ const parentState = root.steps[parentStep.id];
1912
+ if (parentStep.run === undefined || parentState?.status !== "running" || !parentState.sub)
1913
+ continue;
1914
+ const child = this.childScope(spec, parentStep, parentState);
1915
+ for (const step of child.flow.steps) {
1916
+ const state = child.steps[step.id];
1917
+ if (step.do !== undefined && state.status === "ready")
1918
+ ready.push(this.readyStep(run, step, state, child));
1919
+ }
1920
+ }
1921
+ for (const step of root.flow.steps) {
1922
+ const state = root.steps[step.id];
1923
+ if (step.fanout?.dispatch !== "consumer" || state?.status !== "running" || !state.fanout)
1924
+ continue;
1925
+ for (const item of state.fanout.items) {
1926
+ if (item.status === "ready")
1927
+ ready.push(this.consumerDescriptor(run, spec, step, state, item));
1928
+ }
1929
+ }
1930
+ return ready;
1931
+ }
1932
+ /** Root gates first, then child gates in parent/child declaration order. */
1933
+ collectWaitingGates(run, spec) {
1934
+ const root = this.rootScope(run, spec);
1935
+ const waiting = root.flow.steps.flatMap((step) => root.steps[step.id].status === "waiting_gate" ? [step.id] : []);
1936
+ for (const parentStep of root.flow.steps) {
1937
+ const parentState = root.steps[parentStep.id];
1938
+ if (parentStep.run === undefined || parentState?.status !== "running" || !parentState.sub)
1939
+ continue;
1940
+ const child = this.childScope(spec, parentStep, parentState);
1941
+ for (const step of child.flow.steps) {
1942
+ if (child.steps[step.id].status === "waiting_gate")
1943
+ waiting.push(this.scopedId(child, step.id));
1944
+ }
1945
+ }
1946
+ return waiting;
1947
+ }
1948
+ /** Fanout lives only at root (subflow bodies forbid it). A fanout step stays
1949
+ * `running` from dispatch until settleFanout flips it, so this is true exactly
1950
+ * while a settlement is still pending and could advance the run behind the driver. */
1951
+ anyFanoutRunning(run, spec) {
1952
+ const root = this.rootScope(run, spec);
1953
+ return root.flow.steps.some((step) => step.fanout !== undefined && root.steps[step.id].status === "running");
1954
+ }
1955
+ renderValue(value, scope) {
1956
+ if (typeof value === "string")
1957
+ return this.resolveTemplate(value, scope);
1958
+ if (Array.isArray(value))
1959
+ return value.map((item) => this.renderValue(item, scope));
1960
+ if (typeof value === "object" && value !== null) {
1961
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.renderValue(item, scope)]));
1962
+ }
1963
+ return value;
1964
+ }
1965
+ resolveTemplate(value, scope) {
1966
+ const references = extractReferences(value);
1967
+ if (!references)
1968
+ throw new Error("invalid reference after validation");
1969
+ const parts = [];
1970
+ let cursor = 0;
1971
+ for (const extracted of references) {
1972
+ const resolved = this.resolve(extracted.reference, scope);
1973
+ if (resolved === undefined)
1974
+ throw new Error("reference output is unavailable (the source may have been skipped)");
1975
+ if (extracted.fullValue)
1976
+ return resolved;
1977
+ const at = value.indexOf(extracted.raw, cursor);
1978
+ if (at < 0)
1979
+ throw new Error("reference token missing from template");
1980
+ parts.push(value.slice(cursor, at), interpolate(resolved));
1981
+ cursor = at + extracted.raw.length;
1982
+ }
1983
+ parts.push(value.slice(cursor));
1984
+ return parts.join("");
1985
+ }
1986
+ scopedId(scope, stepId) {
1987
+ return scope.prefix ? `${scope.prefix}/${stepId}` : stepId;
1988
+ }
1989
+ async failScope(run, spec, contracts, scope, reason) {
1990
+ const failure = { attempt: 0, reason };
1991
+ return scope.parent ? this.failParentRunStep(run, spec, contracts, scope, failure) : this.terminalFailure(run, failure);
1992
+ }
1993
+ failParentRunStep(run, spec, contracts, childScope, failure) {
1994
+ if (!childScope.parent)
1995
+ return this.terminalFailure(run, failure);
1996
+ const root = this.rootScope(run, spec);
1997
+ const { step, state } = childScope.parent;
1998
+ return this.failAttempt(run, spec, contracts, root, step, state, state.attempts.length + 1, failure.reason, {}, undefined, undefined, true);
1999
+ }
2000
+ async completeSubflow(run, spec, contracts, childScope, output) {
2001
+ if (!childScope.parent)
2002
+ throw new Error("cannot complete the root as a subflow");
2003
+ const root = this.rootScope(run, spec);
2004
+ const { step, state } = childScope.parent;
2005
+ const contractFailure = this.contractError(step, output, contracts);
2006
+ state.output = output;
2007
+ const flowFailure = this.flowOutputError(root, contracts, step.id);
2008
+ if (contractFailure ?? flowFailure) {
2009
+ delete state.output;
2010
+ return this.failParentRunStep(run, spec, contracts, childScope, { attempt: 1, reason: contractFailure ?? flowFailure });
2011
+ }
2012
+ state.status = "succeeded";
2013
+ state.attempts.push({ attempt: state.attempts.length + 1, at: now(), result: output });
2014
+ this.event(run, "result", step.id, { attempt: state.attempts.length, result: output });
2015
+ await this.persist(run);
2016
+ return this.advance(run, spec, contracts, root);
2017
+ }
2018
+ resolve(reference, scope) {
2019
+ if (reference.kind === "input")
2020
+ return access(scope.input, reference.path);
2021
+ if (reference.kind === "step")
2022
+ return access(scope.steps[reference.stepId]?.output, reference.path);
2023
+ throw new Error("fanout references are outside P1 engine scope");
2024
+ }
2025
+ context(_run, scope) {
2026
+ return { input: scope.input, steps: Object.fromEntries(Object.entries(scope.steps).flatMap(([id, state]) => state.output === undefined ? [] : [[id, state.output]])) };
2027
+ }
2028
+ validationFor(run) {
2029
+ const result = validateSpec(run.spec);
2030
+ if (!result.ok)
2031
+ throw new Error("persisted run contains an invalid spec");
2032
+ return result;
2033
+ }
2034
+ assertExternalMutationAllowed(runId, operation) {
2035
+ const bg = this.bgFlows.get(runId);
2036
+ if (bg !== undefined && bg.status !== "completed" && bg.status !== "failed" && bg.status !== "budget_exhausted") {
2037
+ throw new Error(`run ${runId} is background-driven; external ${operation} is not permitted (poll via flow_bg_poll)`);
2038
+ }
2039
+ }
2040
+ async loadCheckpointRun(runId) {
2041
+ // Parity with Python (server.py:3970-4055): commit/revert operate on ANY retained
2042
+ // run regardless of status — reverting a terminal (failed/completed) run to a good
2043
+ // checkpoint is the whole point of the recovery use case. Only an unloadable run is
2044
+ // flow_not_found.
2045
+ try {
2046
+ return await this.loadRun(runId);
2047
+ }
2048
+ catch {
2049
+ throw new CheckpointOperationError("flow_not_found", `No active flow with id '${runId}'`);
2050
+ }
2051
+ }
2052
+ nextGeneration(run) {
2053
+ const next = (run.generationCounter ?? 0) + 1;
2054
+ run.generationCounter = next;
2055
+ return next;
2056
+ }
2057
+ /** Runs persisted before token fencing carry ready/waiting issuances without
2058
+ * tokens; mint them on resume (before the resume persist) so their readiness
2059
+ * can be re-exposed instead of throwing — the state-side half of the
2060
+ * missing-echo migration compat. */
2061
+ backfillIssuanceTokens(run) {
2062
+ const walk = (steps) => {
2063
+ for (const state of Object.values(steps)) {
2064
+ if (state.status === "ready" && state.dispatchToken === undefined)
2065
+ state.dispatchToken = randomUUID();
2066
+ if (state.status === "waiting_gate" && state.gateToken === undefined)
2067
+ state.gateToken = randomUUID();
2068
+ if (state.sub)
2069
+ walk(state.sub.steps);
2070
+ }
2071
+ };
2072
+ walk(run.steps);
2073
+ }
2074
+ /** Checkpoints intentionally restore `steps` but not the run-global counter.
2075
+ * Rotate every restored live issuance and give every restored non-terminal
2076
+ * fanout item a fresh generation before the state can be exposed again. */
2077
+ rotateRestoredIssuances(run) {
2078
+ const rotateSteps = (steps) => {
2079
+ for (const state of Object.values(steps)) {
2080
+ if (state.status === "ready") {
2081
+ state.dispatchToken = randomUUID();
2082
+ delete state.acceptedDispatchToken;
2083
+ }
2084
+ else if (state.status === "waiting_gate") {
2085
+ state.gateToken = randomUUID();
2086
+ }
2087
+ for (const item of state.fanout?.items ?? []) {
2088
+ if (item.status === "succeeded" || item.status === "failed" || item.status === "skipped")
2089
+ continue;
2090
+ item.generation = this.nextGeneration(run);
2091
+ item.epoch = state.epoch ?? item.epoch ?? 0;
2092
+ if (item.dispatchToken !== undefined || item.status === "ready" || item.status === "running") {
2093
+ item.dispatchToken = randomUUID();
2094
+ }
2095
+ delete item.acceptedDispatchToken;
2096
+ }
2097
+ if (state.sub)
2098
+ rotateSteps(state.sub.steps);
2099
+ }
2100
+ };
2101
+ rotateSteps(run.steps);
2102
+ }
2103
+ // A foreground fanout runs its connector work OUTSIDE the run lock, then settles under
2104
+ // it — holding references to the pre-checkpoint step/fanout objects. A commit would
2105
+ // snapshot mid-flight state; a revert reassigns run.steps to a clone, orphaning those
2106
+ // objects so the worker's `state.fanout === fanoutRef` staleness check still passes and
2107
+ // it settles onto the restored state. Refuse both while a fanout is in flight (a fanout
2108
+ // step stays `running` from dispatch through settlement), the same quiescence the
2109
+ // detached driver already requires. bg-driven runs are covered by the ownership guard.
2110
+ assertNoForegroundFanout(run, operation) {
2111
+ if (run.status !== "running")
2112
+ return;
2113
+ const spec = this.validationFor(run).value;
2114
+ const flow = this.flowFor(run, spec);
2115
+ for (const step of flow.steps) {
2116
+ if (!step.fanout)
2117
+ continue;
2118
+ const state = run.steps[step.id];
2119
+ if (state.status === "running") {
2120
+ throw new Error(`run ${run.id} has an in-flight fanout; ${operation} must wait for it to settle`);
2121
+ }
2122
+ if (step.fanout.dispatch !== "consumer" || step.fanout.isolation !== "worktree" || state.fanout === undefined)
2123
+ continue;
2124
+ // Release through the SAME successor notion validation enforces — an
2125
+ // unconditional, normally-activated gate whose dependencies include the
2126
+ // fanout. Array adjacency is not that notion: the validated gate may sit
2127
+ // anywhere in the steps array.
2128
+ const routedTargets = new Set();
2129
+ for (const candidate of flow.steps) {
2130
+ if (candidate.on_fail !== undefined)
2131
+ routedTargets.add(candidate.on_fail);
2132
+ if (candidate.gate?.on_approve)
2133
+ routedTargets.add(candidate.gate.on_approve);
2134
+ if (candidate.gate?.on_kill)
2135
+ routedTargets.add(candidate.gate.on_kill);
2136
+ }
2137
+ const qualifying = flow.steps.filter((candidate) => candidate.gate !== undefined
2138
+ && candidate.when === undefined
2139
+ && !routedTargets.has(candidate.id)
2140
+ && this.dependencies(candidate).includes(step.id));
2141
+ const released = qualifying.length > 0 && qualifying.every((gate) => run.steps[gate.id]?.status === "succeeded");
2142
+ if (!released) {
2143
+ throw new Error(`run ${run.id} has an active consumer fanout lifecycle; ${operation} must wait for its successor gate to resolve`);
2144
+ }
2145
+ }
2146
+ }
2147
+ flowFor(run, spec) {
2148
+ const flow = spec.flows[run.flowName];
2149
+ if (!flow || run.flowName === "entry")
2150
+ throw new Error("persisted run references an unknown flow");
2151
+ return flow;
2152
+ }
2153
+ async terminalBudget(run, failure) {
2154
+ run.status = "budget_exhausted";
2155
+ run.failure = failure;
2156
+ this.event(run, "budget_exhausted", undefined, failure);
2157
+ await this.persist(run);
2158
+ return this.response(run);
2159
+ }
2160
+ async terminalFailure(run, failure) {
2161
+ run.status = "failed";
2162
+ run.failure = failure;
2163
+ this.event(run, "failed", undefined, failure);
2164
+ await this.persist(run);
2165
+ return this.response(run);
2166
+ }
2167
+ response(run) {
2168
+ const ledger = this.ledgerInfo(run);
2169
+ if (run.status === "completed")
2170
+ return { status: "completed", runId: run.id, output: run.output, ledger };
2171
+ if (run.status === "budget_exhausted")
2172
+ return { status: "budget_exhausted", runId: run.id, failure: requiredFailure(run), ledger };
2173
+ return { status: "failed", runId: run.id, failure: requiredFailure(run), ledger };
2174
+ }
2175
+ withRevisionDigest(response, run) {
2176
+ if (run.revisionDigest === undefined)
2177
+ throw new Error("run is missing its persisted revision digest");
2178
+ return { ...response, revisionDigest: run.revisionDigest };
2179
+ }
2180
+ ledgerInfo(run) {
2181
+ const budget = this.flowFor(run, this.validationFor(run).value).budget;
2182
+ return { spent: structuredClone(run.flowSpent), ...(budget ? { budget: structuredClone(budget) } : {}) };
2183
+ }
2184
+ event(run, type, stepId, detail) {
2185
+ run.events.push({ at: now(), type, ...(stepId ? { stepId } : {}), ...(detail !== undefined ? { detail } : {}) });
2186
+ }
2187
+ persist(run) {
2188
+ const previous = this.persistLocks.get(run.id) ?? Promise.resolve();
2189
+ const result = previous.then(() => this.store.save(run));
2190
+ const tail = result.catch(() => undefined);
2191
+ this.persistLocks.set(run.id, tail);
2192
+ void tail.then(() => { if (this.persistLocks.get(run.id) === tail)
2193
+ this.persistLocks.delete(run.id); });
2194
+ return result;
2195
+ }
2196
+ }
2197
+ function access(value, path) {
2198
+ let current = value;
2199
+ for (const part of path) {
2200
+ if (typeof part === "number") {
2201
+ if (!Array.isArray(current))
2202
+ return undefined;
2203
+ current = current[part];
2204
+ }
2205
+ else {
2206
+ if (typeof current !== "object" || current === null || Array.isArray(current))
2207
+ return undefined;
2208
+ current = current[part];
2209
+ }
2210
+ }
2211
+ return current;
2212
+ }
2213
+ function stringLeaves(step) {
2214
+ const values = [];
2215
+ const collect = (value) => {
2216
+ if (typeof value === "string")
2217
+ values.push(value);
2218
+ else if (Array.isArray(value))
2219
+ value.forEach(collect);
2220
+ else if (typeof value === "object" && value !== null)
2221
+ Object.values(value).forEach(collect);
2222
+ };
2223
+ if (step.do !== undefined)
2224
+ collect(step.do);
2225
+ if (step.when !== undefined)
2226
+ collect(step.when);
2227
+ if (step.set !== undefined)
2228
+ collect(step.set);
2229
+ // The engine's dependency edges must mirror the validator's: subflow `with`
2230
+ // templates and fanout over/stage templates reference steps too — a fanout
2231
+ // over "${prep.output.items}" must wait for prep, not fail at resolve time.
2232
+ if (step.with !== undefined)
2233
+ collect(step.with);
2234
+ if (step.fanout !== undefined) {
2235
+ collect(step.fanout.over);
2236
+ for (const stage of step.fanout.steps) {
2237
+ collect(stage.do);
2238
+ if (stage.when !== undefined)
2239
+ collect(stage.when);
2240
+ }
2241
+ }
2242
+ return values;
2243
+ }
2244
+ function deepEqual(left, right) {
2245
+ if (Object.is(left, right))
2246
+ return true;
2247
+ if (typeof left !== "object" || left === null || typeof right !== "object" || right === null)
2248
+ return false;
2249
+ if (Array.isArray(left) || Array.isArray(right)) {
2250
+ return Array.isArray(left) && Array.isArray(right)
2251
+ && left.length === right.length
2252
+ && left.every((value, index) => deepEqual(value, right[index]));
2253
+ }
2254
+ const leftRecord = left;
2255
+ const rightRecord = right;
2256
+ const leftKeys = Object.keys(leftRecord);
2257
+ const rightKeys = Object.keys(rightRecord);
2258
+ return leftKeys.length === rightKeys.length
2259
+ && leftKeys.every((key) => Object.hasOwn(rightRecord, key) && deepEqual(leftRecord[key], rightRecord[key]));
2260
+ }
2261
+ function digest(value) {
2262
+ return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
2263
+ }
2264
+ function canonicalJson(value) {
2265
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
2266
+ const serialized = JSON.stringify(value);
2267
+ if (serialized === undefined)
2268
+ throw new Error("effective specification contains a non-JSON value");
2269
+ return serialized;
2270
+ }
2271
+ if (Array.isArray(value))
2272
+ return `[${value.map(canonicalJson).join(",")}]`;
2273
+ if (typeof value === "object") {
2274
+ const record = value;
2275
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
2276
+ }
2277
+ throw new Error("effective specification contains a non-JSON value");
2278
+ }
2279
+ function terminal(status) { return status === "succeeded" || status === "failed" || status === "skipped"; }
2280
+ function terminalFanoutItem(status) { return status === "succeeded" || status === "failed" || status === "skipped"; }
2281
+ function contractReference(type, contracts) {
2282
+ let raw = type.endsWith("?") ? type.slice(0, -1) : type;
2283
+ while (raw.endsWith("[]"))
2284
+ raw = raw.slice(0, -2);
2285
+ return Object.hasOwn(contracts, raw) ? raw : undefined;
2286
+ }
2287
+ function now() { return new Date().toISOString(); }
2288
+ function delay(ms) { return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); }
2289
+ // Total for arbitrary thrown values: Object.create(null) and hostile getters must
2290
+ // not turn an error-formatting call into a second unhandled throw.
2291
+ function message(error) {
2292
+ try {
2293
+ const text = error instanceof Error ? error.message : String(error);
2294
+ return typeof text === "string" ? text : String(text);
2295
+ }
2296
+ catch {
2297
+ return "unstringifiable thrown value";
2298
+ }
2299
+ }
2300
+ function hasBudget(usage) { return Object.keys(usage).length > 0; }
2301
+ function validConnectorTelemetry(value) {
2302
+ if (value === undefined)
2303
+ return true;
2304
+ if (typeof value !== "object" || value === null || Array.isArray(value))
2305
+ return false;
2306
+ if (Object.keys(value).some((key) => !["durationMs", "model", "effort"].includes(key)))
2307
+ return false;
2308
+ return typeof value.durationMs === "number" && Number.isFinite(value.durationMs) && value.durationMs >= 0
2309
+ && typeof value.model === "string" && value.model.length > 0
2310
+ && (value.effort === undefined || (typeof value.effort === "string" && value.effort.length > 0));
2311
+ }
2312
+ function telemetryFields(value) {
2313
+ return value === undefined ? {} : { durationMs: value.durationMs, model: value.model, ...(value.effort !== undefined ? { effort: value.effort } : {}) };
2314
+ }
2315
+ function requiredFailure(run) { return run.failure ?? { attempt: 0, reason: "run failed without context" }; }
2316
+ function interpolate(value) {
2317
+ if (value === null || value === undefined)
2318
+ return "";
2319
+ if (typeof value === "string")
2320
+ return value;
2321
+ return JSON.stringify(value);
2322
+ }
2323
+ export const defaultConnector = async ({ agent, prompt, cwd, previousFailure, outSchema, sandbox }) => {
2324
+ // The agent is TOLD the output contract and the prior failure — engine-owned
2325
+ // retries are structured feedback loops, never blind re-dispatches.
2326
+ let fullPrompt = prompt;
2327
+ if (outSchema !== undefined) {
2328
+ fullPrompt += `\n\nRespond with ONLY a minified JSON object matching this contract (field: type): ${JSON.stringify(outSchema)}. No prose, no code fences.`;
2329
+ }
2330
+ if (previousFailure !== undefined) {
2331
+ fullPrompt += `\n\nYour previous attempt failed: ${previousFailure.reason}\nCorrect the problem and try again.`;
2332
+ }
2333
+ const result = await runAgent({
2334
+ agent,
2335
+ prompt: fullPrompt,
2336
+ ...(cwd !== undefined ? { cwd } : {}),
2337
+ // Worktree-isolated stages must be able to edit their worktree.
2338
+ ...(agent === "codex" && sandbox !== undefined ? { sandboxMode: sandbox } : {}),
2339
+ });
2340
+ if ("status" in result)
2341
+ return { failure: "background connector response is not valid for synchronous fanout" };
2342
+ if (outSchema === undefined)
2343
+ return { output: result.text, usage: result.usage, telemetry: result.telemetry };
2344
+ try {
2345
+ return { output: JSON.parse(stripJsonFences(result.text)), usage: result.usage, telemetry: result.telemetry };
2346
+ }
2347
+ catch {
2348
+ return { failure: "connector result must be JSON for a contract-enforced fanout stage", usage: result.usage, telemetry: result.telemetry };
2349
+ }
2350
+ };
2351
+ function stripJsonFences(text) {
2352
+ const trimmed = text.trim();
2353
+ const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/.exec(trimmed);
2354
+ return fenced ? fenced[1] : trimmed;
2355
+ }
2356
+ //# sourceMappingURL=engine.js.map