@dzhechkov/harness-core 0.5.0 → 0.5.2

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 (96) hide show
  1. package/.dz-manifest.json +339 -75
  2. package/README.md +47 -4
  3. package/dist/codex-hooks-assets.d.ts.map +1 -1
  4. package/dist/codex-hooks-assets.js +39 -2
  5. package/dist/codex-hooks-assets.js.map +1 -1
  6. package/dist/codex-hooks-verify.d.ts +23 -2
  7. package/dist/codex-hooks-verify.d.ts.map +1 -1
  8. package/dist/codex-hooks-verify.js +29 -0
  9. package/dist/codex-hooks-verify.js.map +1 -1
  10. package/dist/codex-hooks.d.ts +90 -7
  11. package/dist/codex-hooks.d.ts.map +1 -1
  12. package/dist/codex-hooks.js +171 -21
  13. package/dist/codex-hooks.js.map +1 -1
  14. package/dist/feature-adr-routing.d.ts +22 -0
  15. package/dist/feature-adr-routing.d.ts.map +1 -1
  16. package/dist/feature-adr-routing.js +45 -0
  17. package/dist/feature-adr-routing.js.map +1 -1
  18. package/dist/index.d.ts +11 -4
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +17 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/loop-blobs.generated.d.ts +1 -1
  23. package/dist/loop-blobs.generated.d.ts.map +1 -1
  24. package/dist/loop-blobs.generated.js +12 -3
  25. package/dist/loop-blobs.generated.js.map +1 -1
  26. package/dist/loop-plan.d.ts +70 -0
  27. package/dist/loop-plan.d.ts.map +1 -1
  28. package/dist/loop-plan.js +103 -0
  29. package/dist/loop-plan.js.map +1 -1
  30. package/dist/loop-render.d.ts.map +1 -1
  31. package/dist/loop-render.js +38 -130
  32. package/dist/loop-render.js.map +1 -1
  33. package/dist/loop-run-semantics.d.ts +130 -0
  34. package/dist/loop-run-semantics.d.ts.map +1 -0
  35. package/dist/loop-run-semantics.js +257 -0
  36. package/dist/loop-run-semantics.js.map +1 -0
  37. package/dist/loop-trace.d.ts +106 -5
  38. package/dist/loop-trace.d.ts.map +1 -1
  39. package/dist/loop-trace.js +151 -18
  40. package/dist/loop-trace.js.map +1 -1
  41. package/dist/managed-hooks.d.ts +10 -0
  42. package/dist/managed-hooks.d.ts.map +1 -1
  43. package/dist/managed-hooks.js +17 -5
  44. package/dist/managed-hooks.js.map +1 -1
  45. package/dist/named-lock.d.ts +57 -0
  46. package/dist/named-lock.d.ts.map +1 -0
  47. package/dist/named-lock.js +247 -0
  48. package/dist/named-lock.js.map +1 -0
  49. package/dist/operations.d.ts +81 -5
  50. package/dist/operations.d.ts.map +1 -1
  51. package/dist/operations.js +356 -38
  52. package/dist/operations.js.map +1 -1
  53. package/dist/parity.d.ts +70 -2
  54. package/dist/parity.d.ts.map +1 -1
  55. package/dist/parity.js +133 -2
  56. package/dist/parity.js.map +1 -1
  57. package/dist/qe-bridge.d.ts +291 -0
  58. package/dist/qe-bridge.d.ts.map +1 -0
  59. package/dist/qe-bridge.js +538 -0
  60. package/dist/qe-bridge.js.map +1 -0
  61. package/dist/score.d.ts.map +1 -1
  62. package/dist/score.js +43 -9
  63. package/dist/score.js.map +1 -1
  64. package/dist/trace-corroborate.d.ts +48 -0
  65. package/dist/trace-corroborate.d.ts.map +1 -0
  66. package/dist/trace-corroborate.js +172 -0
  67. package/dist/trace-corroborate.js.map +1 -0
  68. package/dist/workflow-run-dispatch.d.ts +230 -0
  69. package/dist/workflow-run-dispatch.d.ts.map +1 -0
  70. package/dist/workflow-run-dispatch.js +363 -0
  71. package/dist/workflow-run-dispatch.js.map +1 -0
  72. package/dist/workflow-run.d.ts +513 -0
  73. package/dist/workflow-run.d.ts.map +1 -0
  74. package/dist/workflow-run.js +1377 -0
  75. package/dist/workflow-run.js.map +1 -0
  76. package/package.json +2 -2
  77. package/sbom.json +740 -80
  78. package/src/codex-hooks-assets.ts +39 -2
  79. package/src/codex-hooks-verify.ts +55 -2
  80. package/src/codex-hooks.ts +172 -20
  81. package/src/feature-adr-routing.ts +55 -0
  82. package/src/index.ts +46 -1
  83. package/src/loop-blobs.generated.ts +12 -3
  84. package/src/loop-plan.ts +185 -0
  85. package/src/loop-render.ts +38 -128
  86. package/src/loop-run-semantics.ts +278 -0
  87. package/src/loop-trace.ts +207 -16
  88. package/src/managed-hooks.ts +26 -5
  89. package/src/named-lock.ts +277 -0
  90. package/src/operations.ts +441 -40
  91. package/src/parity.ts +177 -2
  92. package/src/qe-bridge.ts +737 -0
  93. package/src/score.ts +50 -9
  94. package/src/trace-corroborate.ts +205 -0
  95. package/src/workflow-run-dispatch.ts +459 -0
  96. package/src/workflow-run.ts +1773 -0
@@ -0,0 +1,1773 @@
1
+ /**
2
+ * `workflow-run` — the PURE scheduler half of `dz workflow run` (feature dz-workflow-run).
3
+ *
4
+ * `dz workflow run` INTERPRETS a `loop-plan/1` plan; it never executes the rendered Claude-host
5
+ * script (ADR-001). That is the whole design in one sentence, and everything in this file follows
6
+ * from it: the plan is read through a PROJECTION (`toRunProjection` — the AM-3 doctrine, now with a
7
+ * second enactor), the enactment DECISIONS come from the one shared module the rendered script also
8
+ * carries as a blob (`loop-run-semantics`), and the trace is emitted through the SAME validated
9
+ * grammar the Claude host uses (`loop-trace`), so a divergent event cannot even be buffered.
10
+ *
11
+ * PURITY (NFR-3): everything here is deterministic under injected seams — a `Dispatcher` per family,
12
+ * a `RunStore` for every byte that touches a disk, an injected clock and an injected runId. No
13
+ * `spawn`, no `fs`, no `Date`, no randomness. That is what buys the determinism test, the
14
+ * replay-derived discrimination leg, the landed-barrier mutant and all 22 taxonomy producers
15
+ * WITHOUT spawning a single child.
16
+ *
17
+ * The three refusal planes, in the order a run meets them:
18
+ * 1. PREFLIGHT — decidable before anything is spawned (no trace plane, unroutable model, a write
19
+ * that escapes the root, same-family QE, an operator override that cannot fit a boundary).
20
+ * 2. RESUME — decidable from state + checkpoints + artifact probes, never from a step list.
21
+ * 3. RUNTIME — dispatch failures, gate verdicts, deliverables that did not land, budget.
22
+ * Every refusal names itself from ONE closed list (`WF_RUN_REASONS`), and the list is the count
23
+ * authority a reachability suite walks.
24
+ */
25
+
26
+ import type { FailureClass, LoopPlan, RunBoundary, RunProjection, RunStepSpec } from './loop-plan.js';
27
+ import { toRunProjection, planDigest } from './loop-plan.js';
28
+ import { computeExecAxisInputs, computeExecFingerprint } from './loop-render.js';
29
+ import {
30
+ parseTrace,
31
+ traceClose,
32
+ traceDrain,
33
+ traceInit,
34
+ traceLedgerLine,
35
+ traceOnDispatch,
36
+ traceOnSettle,
37
+ traceShellQuote,
38
+ TRACE_KEY_RE,
39
+ type TraceState,
40
+ } from './loop-trace.js';
41
+ import { classifyFailure, errSnap, gateVerdict, joinRegion, stepContractLines, type JoinOutcome } from './loop-run-semantics.js';
42
+ import { checkpointInputHash, decideCheckpointResume, parseCheckpointRead, serializeCheckpoint } from './feature-adr-checkpoints.js';
43
+ import { modelFamily, type BridgeFamily } from './qe-bridge.js';
44
+ import { CODEX_EXEC_XHIGH_TIMEOUT_MS, defangGateEchoes, type DispatchResult, type Dispatcher } from './workflow-run-dispatch.js';
45
+
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+ // Schemas / constants
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+
50
+ export const WF_RUN_STATE_SCHEMA = 'wf-run-state/1';
51
+ export const WF_BUDGET_ROW_SCHEMA = 'wf-budget-1';
52
+ export const WF_PAUSE_ENVELOPE_SCHEMA = 'wf-pause-envelope/1';
53
+ export const WF_RUN_RESULT_SCHEMA = 'wf-run-result/1';
54
+ export const WF_RUN_OWNER_HOST = 'dz-workflow-run';
55
+
56
+ /** ADR-004 W9 — a DECLARED GUESS, deliberately ONE exported constant so calibration is a one-line
57
+ * change with a name, not a number sprinkled through the scheduler. */
58
+ export const WALLCLOCK_CEILING_MULTIPLIER = 1.5;
59
+
60
+ /** 75 = sysexits EX_TEMPFAIL ("try again later"). NOT 3: that collides with workflow-lint's
61
+ * inconclusive and reads ignorable, while a pause strands resumable progress (AM-11). */
62
+ export const WF_EXIT = { completed: 0, failed: 1, usage: 2, pause: 75 } as const;
63
+
64
+ /**
65
+ * The CLOSED reason set (AM-1 + AM-15 + AM-19) as DATA — the single count authority. The
66
+ * reachability suite WALKS this array: a member no in-suite scenario can produce fails the suite, so
67
+ * the list can never quietly grow a decorative member or lose a real one. Every count written in
68
+ * prose anywhere is a DESCRIPTION of this array, never a second authority.
69
+ */
70
+ export const WF_RUN_REASONS = [
71
+ 'plan-invalid', 'trace-emit-required', 'plan-model-unroutable', 'artifact-path-escapes-root',
72
+ 'probe-failed', 'dispatch-timeout', 'dispatch-dead', 'gate-verdict-unparseable',
73
+ 'deliverable-not-landed', 'same-family-qe-refused', 'prompt-over-ceiling',
74
+ 'stale-input-refused', 'resume-model-unavailable', 'foreign-run-refused', 'run-exists',
75
+ 'run-locked', 'budget-exhausted', 'budget-extension-exhausted', 'budget-invariant-violated',
76
+ 'resume-already-completed', 'wall-extension-exhausted', 'reservation-unsatisfiable',
77
+ // AM-19: a PARSED `GATE: FAIL` with its routing exhausted is not an unparseable verdict. The
78
+ // model answered clearly; the plan declared nowhere for the answer to go. Two producers, two
79
+ // members — collapsing them would tell an operator the model produced garbage when it did not.
80
+ 'gate-failed',
81
+ // AM-20 (Step-8 HIGH-7): a plan-declared pause is a first-class RESULT of a run, and AM-16
82
+ // requires the envelope's `reason` to be a member of THIS list. It was emitting the free string
83
+ // `typed-pause`, which is exactly the "closed set with an escape hatch" shape the taxonomy exists
84
+ // to forbid — a wrapper switching on the list would have fallen through.
85
+ 'plan-pause',
86
+ ] as const;
87
+ export type WfRunReason = (typeof WF_RUN_REASONS)[number];
88
+
89
+ /**
90
+ * The members whose PRODUCER lives in the impure half (`dz workflow run`'s CLI): a run DIRECTORY
91
+ * that already holds a run, and a LIVE owner marker. Neither is decidable without a filesystem, so
92
+ * neither can be produced by the core suite.
93
+ *
94
+ * Exported as DATA so the reachability proof spans both packages WITHOUT either side restating the
95
+ * other's list: the core suite asserts `produced-in-core ∪ this === WF_RUN_REASONS`, and the CLI
96
+ * suite asserts it produces every member of exactly this array. Together that is a real 23/23 walk;
97
+ * a member that fell out of both halves would fail the core union check, and a member added here
98
+ * without a CLI producer would fail the CLI check.
99
+ */
100
+ export const WF_RUN_REASONS_CLI_PRODUCED = ['run-exists', 'run-locked'] as const;
101
+
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+ // State shapes (ADR-003 `wf-run-state/1`)
104
+ // ─────────────────────────────────────────────────────────────────────────────
105
+
106
+ export interface WfRunOwner {
107
+ host: typeof WF_RUN_OWNER_HOST;
108
+ runnerVersion: string;
109
+ pid: number;
110
+ startedMarker: string;
111
+ }
112
+
113
+ export interface WfRunState {
114
+ schema: typeof WF_RUN_STATE_SCHEMA;
115
+ /**
116
+ * CONTENT binding for the attestation (feature honest-trace-provenance, ADR-001 round 3).
117
+ * Identifiers alone were not enough: a fabricated trace dropped beside a genuine run-state with
118
+ * matching ids satisfied the binding, which is exactly the counterexample that failed round 2.
119
+ * Optional because a state written before this feature has none — and a state with NO binding can
120
+ * never mint `instrument`, which is the fail-closed direction.
121
+ */
122
+ traceSha256?: string;
123
+ traceLines?: number;
124
+ owner: WfRunOwner;
125
+ status: 'running' | 'paused' | 'completed' | 'failed';
126
+ runId: string;
127
+ /** BARE hex everywhere it is stored or compared (K7) — the rendered header's `sha256:` prefix is
128
+ * display-only. One domain, so no comparison has to remember to strip a prefix. */
129
+ planDigest: string;
130
+ execFp: string;
131
+ argsHash: string;
132
+ pause?: {
133
+ state: string;
134
+ resumeArg: string;
135
+ payloadSchema: Record<string, unknown> | null;
136
+ remainingSteps: string[];
137
+ reservationNote: string;
138
+ };
139
+ failure?: { reason: WfRunReason; detail: string };
140
+ /**
141
+ * DIAGNOSTIC-ONLY (W10). Read by NOTHING — the resume cursor comes from checkpoint lines plus
142
+ * artifact probes, never from a step list a crashed writer may have half-updated. A test corrupts
143
+ * this field arbitrarily and asserts every resume decision is byte-identical.
144
+ */
145
+ completedSteps: string[];
146
+ /** stepId → PROBED model id (joins resume identity, AM-8). */
147
+ resolvedModels: Record<string, string>;
148
+ budget: { total: number; spent: number; extensions: { ts: string; extra: number; newTotal: number }[] };
149
+ wallClock: { ceilingMs: number; spentMs: number; extensions: { ts: string; extraMs: number; newCeilingMs: number }[] };
150
+ waivers: { kind: 'same-family-qe'; step: string; recordedDebt: string }[];
151
+ /**
152
+ * Every resume arg supplied so far, across ALL legs (Step-8 re-QE NEW-B2). Which pauses are
153
+ * SATISFIED is run state, not an argument of the current invocation: a three-pause plan is
154
+ * resumed three times, and leg 3 supplies only its own key.
155
+ */
156
+ resumeArgs?: Record<string, string>;
157
+ coderFamily: BridgeFamily;
158
+ startedAt: string;
159
+ updatedAt: string;
160
+ /** True when a scripted-dispatcher TEST SEAM supplied the dispatchers — recorded LOUDLY, because
161
+ * a test seam that leaves no trace in the artifact is indistinguishable from a real run. */
162
+ dispatcherOverride?: boolean;
163
+ }
164
+
165
+ export interface WfBudgetRow {
166
+ schema: typeof WF_BUDGET_ROW_SCHEMA;
167
+ kind: 'stage' | 'probe';
168
+ runId: string;
169
+ /** Joins 1:1 to the trace's dispatch events; null on a probe row (a probe is not a dispatch). */
170
+ dispatchSeq: number | null;
171
+ stepId: string | null;
172
+ itemKey: string | null;
173
+ attempt: number | null;
174
+ family: BridgeFamily;
175
+ model: string | null;
176
+ wallMs: number;
177
+ tokensIn: number | null;
178
+ tokensOut: number | null;
179
+ tokensSource: 'claude-envelope' | 'codex-stderr' | null;
180
+ outcome: 'ok' | 'null' | 'error' | null;
181
+ timeoutMs: number | null;
182
+ }
183
+
184
+ export interface WfPauseEnvelope {
185
+ schema: typeof WF_PAUSE_ENVELOPE_SCHEMA;
186
+ runId: string;
187
+ exitCode: 75;
188
+ pauseState: string;
189
+ /** CLOSED (Step-8 HIGH-7): a member of `WF_RUN_REASONS`, never a free string. */
190
+ reason: WfRunReason;
191
+ /** The REAL path of this run's state file — a custom `--run-dir` must not be told to look in the
192
+ * default one. */
193
+ runStatePath: string;
194
+ /** A command that actually works for this run, `--run-dir` included. */
195
+ resumeCmd: string;
196
+ }
197
+
198
+ export interface WfRunResult {
199
+ schema: typeof WF_RUN_RESULT_SCHEMA;
200
+ runId: string;
201
+ status: 'completed' | 'failed';
202
+ reason?: WfRunReason;
203
+ exitCode: 0 | 1;
204
+ /**
205
+ * The gate `terminal:` route this run ended on, when it ended on one (Step-8 MEDIUM-13).
206
+ *
207
+ * A terminal route is a PLAN-DECLARED ending, so the exit code stays 0 — parity with the rendered
208
+ * script, whose top-level terminal `return` also completes the Workflow. But "completed" and
209
+ * "completed because a gate rejected it" are different facts, and a wrapper could not tell them
210
+ * apart from the result line alone; the route was visible only in the ledger. It is a field
211
+ * rather than a new exit code because changing the exit semantics would silently reclassify every
212
+ * existing terminal-route run.
213
+ */
214
+ terminalRoute?: string;
215
+ /** True when the SCRIPTED dispatcher seam supplied the models (Step-8 MEDIUM-11) — a run that
216
+ * dispatched to no real model must say so in its own result, not only in a state file. */
217
+ dispatcherOverride?: boolean;
218
+ }
219
+
220
+ // ─────────────────────────────────────────────────────────────────────────────
221
+ // Runner inputs + identity
222
+ // ─────────────────────────────────────────────────────────────────────────────
223
+
224
+ export interface RunnerInputs {
225
+ plan: LoopPlan;
226
+ runId: string;
227
+ coderFamily: BridgeFamily;
228
+ allowSameFamilyQe: boolean;
229
+ defaultFamily: BridgeFamily | null;
230
+ budgetOverride: number | null;
231
+ maxWallClockMsOverride: number | null;
232
+ stageTimeoutMsOverride: number | null;
233
+ /** runId to resume, or null for a fresh run. */
234
+ resume: string | null;
235
+ resumeArgs: Record<string, string>;
236
+ budgetExtra: number | null;
237
+ wallClockExtraMs: number | null;
238
+ runnerVersion: string;
239
+ cwdRoot: string;
240
+ }
241
+
242
+ /** Small, dependency-free 64-bit FNV — the same shape the checkpoint plane uses, kept local so this
243
+ * module has no reason to reach for a crypto import in a file that must stay trivially pure. */
244
+ function fnv64(text: string): string {
245
+ let h1 = 0x811c9dc5;
246
+ let h2 = 0x01000193;
247
+ for (let i = 0; i < text.length; i++) {
248
+ const c = text.charCodeAt(i);
249
+ h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
250
+ h2 = Math.imul(h2 ^ (c + i), 0x85ebca6b) >>> 0;
251
+ }
252
+ return (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0');
253
+ }
254
+
255
+ /**
256
+ * The run-args identity hash, over the inputs MINUS an ENUMERATED exclusion list (AM-13 / W12).
257
+ *
258
+ * The exclusions are the whole point and they are returned as DATA so a test can pin them rather
259
+ * than restate them: the plan-declared `resumeArg` keys (supplying one is what a resume IS),
260
+ * `budgetExtra` and `wallClockExtra` (extending a ceiling is a resume-plane input, not a different
261
+ * run). Everything else — including a resume arg the plan never declared — is IDENTITY, so changing
262
+ * it makes the resume stale rather than silently continuing a different run.
263
+ */
264
+ export function computeRunArgsHash(inputs: RunnerInputs, proj: RunProjection): { hash: string; excluded: string[] } {
265
+ const excluded = [...proj.resumeArgKeys, 'budgetExtra', 'wallClockExtra'].sort();
266
+ const declared = new Set(proj.resumeArgKeys);
267
+ const identityArgs = Object.keys(inputs.resumeArgs)
268
+ .filter((k) => !declared.has(k))
269
+ .sort()
270
+ .map((k) => [k, inputs.resumeArgs[k] ?? null] as const);
271
+ const tuple = [
272
+ 'wf-run-args/1',
273
+ inputs.runId,
274
+ inputs.coderFamily,
275
+ inputs.allowSameFamilyQe,
276
+ inputs.defaultFamily,
277
+ inputs.budgetOverride,
278
+ inputs.maxWallClockMsOverride,
279
+ inputs.stageTimeoutMsOverride,
280
+ inputs.cwdRoot,
281
+ identityArgs,
282
+ ];
283
+ return { hash: fnv64(JSON.stringify(tuple)), excluded };
284
+ }
285
+
286
+ // ─────────────────────────────────────────────────────────────────────────────
287
+ // Reservation arithmetic (ADR-004 W11 / W19 / W9)
288
+ // ─────────────────────────────────────────────────────────────────────────────
289
+
290
+ export interface BoundaryReservation {
291
+ boundaryId: string;
292
+ kind: 'stage' | 'region' | 'gate';
293
+ steps: string[];
294
+ /** Worst-case agent invocations this boundary can consume, retries and redos included. */
295
+ invocations: number;
296
+ /** Worst-case wall clock, before the ceiling multiplier. */
297
+ wallMs: number;
298
+ }
299
+
300
+ /**
301
+ * Worst-case reservation PER BOUNDARY (AM-4: a region is reserved as a whole and never interrupted,
302
+ * which is what makes "pause BEFORE the region" expressible at all).
303
+ *
304
+ * • stage — the step's declared `maxAgents`;
305
+ * • gate — the gate's own allowance PLUS the declared redo allowance, reserved AT the gate
306
+ * boundary (a plan-declared redo must be affordable where it is spent, not somewhere
307
+ * upstream, or a short budget pauses in the middle of a redo loop);
308
+ * • region — every activated member × the whole chain. This is the number that can legitimately
309
+ * exceed the plan's total: the render's ceiling counts a member step ONCE, the runtime
310
+ * dispatches it per item. Naming the gap here is what makes the extension cap
311
+ * satisfiable instead of a surprise mid-run.
312
+ */
313
+ export function computeBoundaryReservations(
314
+ proj: RunProjection,
315
+ timeoutMsFor: (s: RunStepSpec) => number,
316
+ ): BoundaryReservation[] {
317
+ const gateAllowanceOf = (b: RunBoundary): number => {
318
+ const g = b.stage?.gate;
319
+ if (b.stage === undefined || g === null || g === undefined) return 0;
320
+ if (g.maxRedos <= 0 || g.failRoute === null || g.failRoute.startsWith('terminal:')) return 0;
321
+ const route = proj.boundaries.find((x) => x.boundaryId === g.failRoute)?.stage;
322
+ return g.maxRedos * ((route === undefined ? 1 : worstCaseInvocations(route)) + worstCaseInvocations(b.stage));
323
+ };
324
+ const gateWallOf = (b: RunBoundary): number => {
325
+ const g = b.stage?.gate;
326
+ if (b.stage === undefined || g === null || g === undefined) return 0;
327
+ if (g.maxRedos <= 0 || g.failRoute === null || g.failRoute.startsWith('terminal:')) return 0;
328
+ const route = proj.boundaries.find((x) => x.boundaryId === g.failRoute)?.stage;
329
+ const routeWall = route === undefined ? 0 : worstCaseInvocations(route) * timeoutMsFor(route);
330
+ return g.maxRedos * (routeWall + worstCaseInvocations(b.stage) * timeoutMsFor(b.stage));
331
+ };
332
+
333
+ const out: BoundaryReservation[] = [];
334
+ for (const b of proj.boundaries) {
335
+ if (b.kind === 'pause') continue; // a pause dispatches nothing — it reserves nothing
336
+ if (b.kind === 'region') {
337
+ const r = b.region;
338
+ if (r === undefined) continue;
339
+ const members = activatedMembers(r.registry, r.dedup, r.maxFanout).length;
340
+ const perItem = r.chain.reduce((n, s) => n + worstCaseInvocations(s), 0);
341
+ const perItemWall = r.chain.reduce((n, s) => n + worstCaseInvocations(s) * timeoutMsFor(s), 0);
342
+ out.push({
343
+ boundaryId: b.boundaryId,
344
+ kind: 'region',
345
+ steps: r.chain.map((s) => s.stepId),
346
+ invocations: members * perItem,
347
+ // a `barrier`/`pipeline` region runs at most maxFanout branches at once, so its worst-case
348
+ // WALL is the serialized work divided by the concurrency bound (never below one full item)
349
+ wallMs: r.maxFanout > 0 ? Math.ceil((members * perItemWall) / r.maxFanout) : members * perItemWall,
350
+ });
351
+ continue;
352
+ }
353
+ const s = b.stage;
354
+ if (s === undefined) continue;
355
+ out.push({
356
+ boundaryId: b.boundaryId,
357
+ kind: b.kind === 'gate' ? 'gate' : 'stage',
358
+ steps: [s.stepId],
359
+ invocations: worstCaseInvocations(s) + gateAllowanceOf(b),
360
+ wallMs: worstCaseInvocations(s) * timeoutMsFor(s) + gateWallOf(b),
361
+ });
362
+ }
363
+ return out;
364
+ }
365
+
366
+ /**
367
+ * The members a fanout actually ACTIVATES.
368
+ *
369
+ * `maxFanout` caps ACTIVATION, not merely concurrency — the rendered script emits
370
+ * `registry.slice(0, maxFanout)` ("bounded fanout (INV-2): never args-derived, never uncapped").
371
+ * Getting this wrong is a STRUCTURAL-EQUIVALENCE break, not a performance detail: a runner that
372
+ * treated the bound as a concurrency limit would dispatch all six entries of a six-item registry
373
+ * where the Claude host dispatched three, and the two hosts' traces would disagree on dispatch
374
+ * multiplicity for the same plan. (MEASURED: that is exactly what the F5 equivalence leg caught
375
+ * against the committed `pkg-audit-1` run — 6 lanes here, 3 there.)
376
+ */
377
+ function activatedMembers(registry: string[], dedup: boolean, maxFanout: number): string[] {
378
+ const base = dedup ? [...new Set(registry)] : [...registry];
379
+ return maxFanout > 0 ? base.slice(0, maxFanout) : base;
380
+ }
381
+
382
+ /**
383
+ * The worst-case invocations ONE occurrence of a step can consume: it cannot attempt more times
384
+ * than its retry profile allows, and the budget guard caps it at its declared allowance. Reserving
385
+ * the raw `maxAgents` would over-reserve by the whole retry headroom a step never asked for — and
386
+ * an over-reservation is not "safe", it is a run that pauses before work it could have afforded.
387
+ */
388
+ function worstCaseInvocations(s: RunStepSpec): number {
389
+ return Math.max(1, Math.min(s.maxAgents, s.retryMaxAttempts));
390
+ }
391
+
392
+ /**
393
+ * The extension cap (AM-14, correcting AM-5's doubling): `max(2 × originalTotal, Σ reservations)`.
394
+ *
395
+ * The doubling bound ALONE left a valid plan permanently unresumable — counterexample from the
396
+ * amendment: T=10 with a last boundary needing 25 can never be finished, because the achievable cap
397
+ * is 20 and the prefix already spent some of it. Σ reservations is PREFIX-CLOSED (actual spend per
398
+ * construct never exceeds its own reservation), so default caps are satisfiable BY CONSTRUCTION.
399
+ * The named cost: for attempt-heavy plans the cap is larger than 2×T, so the doubling guarantee is
400
+ * weakened exactly there — the extension records are what keep that audited.
401
+ */
402
+ export function computeAchievableMax(originalTotal: number, reservations: BoundaryReservation[]): number {
403
+ const sigma = reservations.reduce((n, r) => n + r.invocations, 0);
404
+ return Math.max(2 * originalTotal, sigma);
405
+ }
406
+
407
+ /** ADR-004 W9: Σ worst-case wall × the declared multiplier. The multiplier is a GUESS with a name. */
408
+ export function computeWallClockCeilingMs(reservations: BoundaryReservation[]): number {
409
+ const sigma = reservations.reduce((n, r) => n + r.wallMs, 0);
410
+ return Math.ceil(sigma * WALLCLOCK_CEILING_MULTIPLIER);
411
+ }
412
+
413
+ // ─────────────────────────────────────────────────────────────────────────────
414
+ // Preflight
415
+ // ─────────────────────────────────────────────────────────────────────────────
416
+
417
+ export interface PreflightDeps {
418
+ /** Resolve a path to its real location, or null when it cannot be resolved. */
419
+ realpath: (p: string) => string | null;
420
+ exists: (p: string) => boolean;
421
+ }
422
+
423
+ export interface PreflightOk {
424
+ ok: true;
425
+ projection: RunProjection;
426
+ planDigest: string;
427
+ execFp: string;
428
+ argsHash: string;
429
+ /** stepId → family, TOTAL over every dispatching step (AM-8): resolution happens ONCE, here. */
430
+ families: Record<string, BridgeFamily>;
431
+ reservations: BoundaryReservation[];
432
+ budgetTotal: number;
433
+ wallCeilingMs: number;
434
+ achievableMax: number;
435
+ /** The wall twin of `achievableMax` (AM-14 arithmetic): `max(2 × the COMPUTED default ceiling,
436
+ * Σ boundary wall reservations)`. Plan-derived, so an operator override cannot inflate its own
437
+ * cap (Step-8 HIGH-5). */
438
+ achievableWallMs: number;
439
+ /** same-family QE steps proceeding under the LOUD waiver (each one owes a re-QE debt). */
440
+ qeWaivers: { step: string }[];
441
+ }
442
+
443
+ export interface PreflightRefusal {
444
+ ok: false;
445
+ reason: WfRunReason;
446
+ detail: string;
447
+ }
448
+
449
+ /** The per-stage timeout the run uses: the operator's override, else the measured codex xhigh
450
+ * ceiling (the far end of the distribution — a shorter default would turn slow models into
451
+ * `dispatch-timeout` noise). */
452
+ export function stageTimeoutMs(inputs: RunnerInputs): number {
453
+ const o = inputs.stageTimeoutMsOverride;
454
+ return typeof o === 'number' && Number.isFinite(o) && o > 0 ? Math.floor(o) : CODEX_EXEC_XHIGH_TIMEOUT_MS;
455
+ }
456
+
457
+ /**
458
+ * A declared artifact path — READ or WRITE — must resolve INSIDE the run root.
459
+ *
460
+ * Step-8 CRITICAL-4 closed two holes at once:
461
+ * - only `writes` were checked, while AM-9 covers reads and writes. A read is a path the runner
462
+ * hands a model and asks it to open; `../../.ssh/id_rsa` is not less dangerous for being a read.
463
+ * - containment was decided by `realpath(fullPath)`, which is NULL for a not-yet-created leaf —
464
+ * the normal case for a write. A nonexistent leaf under a SYMLINKED ANCESTOR therefore passed:
465
+ * nothing resolved, so nothing was compared.
466
+ *
467
+ * The fix is the nearest-existing-ancestor walk the CLI already uses for `--out`: every EXISTING
468
+ * component on the way down is resolved and required to stay under the real root, so a symlink
469
+ * anywhere on the path is caught whether or not the leaf exists yet.
470
+ *
471
+ * (The control-character class is spelled with escapes now. It was written with literal control
472
+ * BYTES, which made the guard invisible to `grep` and one careless editor save away from silently
473
+ * becoming `/[ -]/`.)
474
+ */
475
+ function escapesRoot(rel: string, root: string, deps: PreflightDeps): string | null {
476
+ if (rel === '') return 'an empty path';
477
+ // eslint-disable-next-line no-control-regex
478
+ if (/[\x00-\x1f\x7f]/.test(rel)) return 'a control character';
479
+ if (rel.startsWith('/')) return 'an absolute path';
480
+ const segments = rel.split('/').filter((seg) => seg !== '' && seg !== '.');
481
+ if (segments.some((seg) => seg === '..')) return 'a ".." segment';
482
+ if (segments.length === 0) return 'an empty path';
483
+ const base = root.replace(/\/+$/, '');
484
+ const realRoot = deps.realpath(base) ?? base;
485
+ const under = (candidate: string): boolean => candidate === realRoot || candidate.startsWith(realRoot + '/');
486
+ let walked = base;
487
+ for (const seg of segments) {
488
+ walked = walked + '/' + seg;
489
+ if (!deps.exists(walked)) continue; // nothing to resolve yet — deeper components cannot exist either
490
+ const real = deps.realpath(walked);
491
+ if (real === null) return 'a path component that cannot be resolved';
492
+ if (!under(real)) return 'a component whose real location is outside the run root (a symlinked ancestor)';
493
+ }
494
+ return null;
495
+ }
496
+
497
+ /** Every dispatching step of a projection, top-level and fanout members alike. */
498
+ function allSpecs(proj: RunProjection): RunStepSpec[] {
499
+ const out: RunStepSpec[] = [];
500
+ for (const b of proj.boundaries) {
501
+ if (b.stage !== undefined) out.push(b.stage);
502
+ if (b.region !== undefined) out.push(...b.region.chain);
503
+ }
504
+ return out;
505
+ }
506
+
507
+ /**
508
+ * Refusals in a FIXED order, each one named:
509
+ * trace-emit-required → plan-model-unroutable → artifact-path-escapes-root →
510
+ * same-family-qe-refused (unless waived) → reservation-unsatisfiable.
511
+ *
512
+ * The order is not cosmetic. `trace-emit-required` comes first because a run with no trace plane is
513
+ * unverifiable — refusing it later would mean deciding routing for a run nobody could ever check.
514
+ * `reservation-unsatisfiable` comes last because it is the only refusal that depends on every
515
+ * preceding resolution, and it fires ONLY for an EXPLICIT operator override: the defaults are
516
+ * satisfiable by construction (AM-14), so a default-budget run that cannot fit a boundary pauses and
517
+ * resumes rather than refusing to start.
518
+ *
519
+ * `plan-invalid` never reaches here — the CLI parses and validates first and exits 2.
520
+ */
521
+ export function preflight(inputs: RunnerInputs, deps: PreflightDeps): PreflightOk | PreflightRefusal {
522
+ const proj = toRunProjection(inputs.plan);
523
+
524
+ if (!proj.traceEmit) {
525
+ return {
526
+ ok: false,
527
+ reason: 'trace-emit-required',
528
+ detail: 'the plan does not set `trace.emit: true` — a dz run with no trace plane produces nothing the reader, the invariants or the discrimination criterion can check, so it is refused rather than run blind (AM-6)',
529
+ };
530
+ }
531
+
532
+ const specs = allSpecs(proj);
533
+ const families: Record<string, BridgeFamily> = {};
534
+ for (const s of specs) {
535
+ const f = modelFamily(s.model) ?? inputs.defaultFamily;
536
+ if (f === null) {
537
+ return {
538
+ ok: false,
539
+ reason: 'plan-model-unroutable',
540
+ detail: `step ${s.stepId} declares model ${JSON.stringify(s.model)}, which maps to no model family, and no --default-family was given — guessing a family here would silently decide who is allowed to review this run's code`,
541
+ };
542
+ }
543
+ families[s.stepId] = f;
544
+ }
545
+
546
+ for (const s of specs) {
547
+ // READS AND WRITES (AM-9). A read is a path the runner hands a model and asks it to open —
548
+ // containment is not a property of the direction of the I/O.
549
+ for (const [kind, list] of [['read', s.reads], ['write', s.writes]] as const) {
550
+ for (const rel of list) {
551
+ const why = escapesRoot(rel, inputs.cwdRoot, deps);
552
+ if (why !== null) {
553
+ return {
554
+ ok: false,
555
+ reason: 'artifact-path-escapes-root',
556
+ detail: `step ${s.stepId} declares ${kind} ${JSON.stringify(rel)}, which is ${why} — a declared artifact path is one the runner PROBES and a model is asked to open or create; it may not leave the run root`,
557
+ };
558
+ }
559
+ }
560
+ }
561
+ }
562
+
563
+ const qeWaivers: { step: string }[] = [];
564
+ for (const s of specs) {
565
+ if (!s.qeRole) continue;
566
+ if (families[s.stepId] !== inputs.coderFamily) continue;
567
+ if (!inputs.allowSameFamilyQe) {
568
+ return {
569
+ ok: false,
570
+ reason: 'same-family-qe-refused',
571
+ detail: `step ${s.stepId} is marked \`x-role: qe\` and resolves to family ${families[s.stepId]}, which is the family that WROTE the code (--coder-family ${inputs.coderFamily}). Independent cross-model review catches what self-review misses; pass --allow-same-family-qe to proceed under a recorded re-QE debt`,
572
+ };
573
+ }
574
+ qeWaivers.push({ step: s.stepId });
575
+ }
576
+
577
+ const reservations = computeBoundaryReservations(proj, () => stageTimeoutMs(inputs));
578
+ const budgetTotal = inputs.budgetOverride ?? proj.budgetTotal;
579
+ const wallCeilingMs = inputs.maxWallClockMsOverride ?? computeWallClockCeilingMs(reservations);
580
+
581
+ if (inputs.budgetOverride !== null) {
582
+ const worst = reservations.reduce<BoundaryReservation | null>((a, r) => (a === null || r.invocations > a.invocations ? r : a), null);
583
+ if (worst !== null && worst.invocations > budgetTotal) {
584
+ return {
585
+ ok: false,
586
+ reason: 'reservation-unsatisfiable',
587
+ detail: `--budget ${budgetTotal} is below the worst-case reservation of boundary ${worst.boundaryId} (${worst.invocations} invocations over step(s) ${worst.steps.join(', ')}) — that boundary could never run, so the run would pause forever instead of finishing`,
588
+ };
589
+ }
590
+ }
591
+ if (inputs.maxWallClockMsOverride !== null) {
592
+ const worst = reservations.reduce<BoundaryReservation | null>((a, r) => (a === null || r.wallMs > a.wallMs ? r : a), null);
593
+ if (worst !== null && worst.wallMs > wallCeilingMs) {
594
+ return {
595
+ ok: false,
596
+ reason: 'reservation-unsatisfiable',
597
+ detail: `--max-wall-clock ${wallCeilingMs}ms is below the worst-case wall reservation of boundary ${worst.boundaryId} (${worst.wallMs}ms over step(s) ${worst.steps.join(', ')}) — that boundary could never run`,
598
+ };
599
+ }
600
+ }
601
+
602
+ const { hash: argsHash } = computeRunArgsHash(inputs, proj);
603
+ return {
604
+ ok: true,
605
+ projection: proj,
606
+ planDigest: planDigest(inputs.plan),
607
+ execFp: computeExecFingerprint(computeExecAxisInputs(inputs.plan)),
608
+ argsHash,
609
+ families,
610
+ reservations,
611
+ budgetTotal,
612
+ wallCeilingMs,
613
+ achievableMax: computeAchievableMax(proj.budgetTotal, reservations),
614
+ achievableWallMs: Math.max(2 * computeWallClockCeilingMs(reservations), reservations.reduce((n, r) => n + r.wallMs, 0)),
615
+ qeWaivers,
616
+ };
617
+ }
618
+
619
+ // ─────────────────────────────────────────────────────────────────────────────
620
+ // Resume
621
+ // ─────────────────────────────────────────────────────────────────────────────
622
+
623
+ export interface RunResumeDecision {
624
+ ok: boolean;
625
+ reason?: WfRunReason;
626
+ detail?: string;
627
+ /** Completed top-level boundaryIds. Built from checkpoint lines + artifact probes ONLY. */
628
+ cursor: Set<string>;
629
+ }
630
+
631
+ /**
632
+ * The runner's checkpoint key: `checkpointInputHash` (the `fa-ckpt-2` line shape the timeline reader
633
+ * already merges), SALTED with the exec fingerprint exactly like the rendered script's
634
+ * `__ckptInputHash`. Salting with the fingerprint is what makes a semantics change invalidate every
635
+ * prior checkpoint instead of resuming into a plan that no longer means the same thing.
636
+ */
637
+ export function runnerCheckpointHash(boundaryId: string, execFp: string, promptText: string, depResults: unknown[]): string {
638
+ return checkpointInputHash(boundaryId, [execFp, promptText, ...depResults]);
639
+ }
640
+
641
+ /**
642
+ * Refusals in a FIXED order: foreign-run-refused → resume-already-completed → stale-input-refused →
643
+ * resume-model-unavailable. (`run-locked` is the CALLER's — it belongs to the lock, not to the
644
+ * decision.)
645
+ *
646
+ * Two properties this function exists to guarantee:
647
+ * • A STALE-INPUT mismatch never resumes, in ANY mode — there is no override, because the hash
648
+ * proves run-INPUT identity and an input change means the remaining work is not the work the
649
+ * checkpoints describe.
650
+ * • The cursor is built from CHECKPOINT LINES plus ARTIFACT PROBES and from nothing else.
651
+ * `completedSteps` in run-state is diagnostic: a crashed writer can leave it optimistic, and a
652
+ * resume that believed it would skip work that never happened.
653
+ */
654
+ /**
655
+ * The OWNERSHIP + IDENTITY half of the resume decision — everything decidable WITHOUT dispatching
656
+ * anything (Step-8 re-QE NEW-B1).
657
+ *
658
+ * Round 1 hoisted the model probe above the whole decision so `resume-model-unavailable` could be
659
+ * produced at all. That was right, and it had a cost nobody priced: a foreign, completed or
660
+ * stale-input run PROBED first and therefore wrote a `wf-budget-1` probe row before being refused.
661
+ * A refused run must spend NOTHING — a budget row is a record of work, and there was none.
662
+ *
663
+ * So the decision is in two phases now. This one needs no dispatcher, runs first, and refuses on
664
+ * ownership or identity. The model-availability half stays in `decideRunResume`, after the probe.
665
+ * Returns null when there is nothing to refuse.
666
+ */
667
+ export function decideResumeIdentity(opts: {
668
+ runState: WfRunState | null;
669
+ hasTrace: boolean;
670
+ identity: { planDigest: string; execFp: string; argsHash: string };
671
+ }): { reason: WfRunReason; detail: string } | null {
672
+ const st = opts.runState;
673
+ if (st === null) {
674
+ if (opts.hasTrace) {
675
+ return {
676
+ reason: 'foreign-run-refused',
677
+ detail: 'the run directory holds a trace but no `wf-run-state/1` — it was written by another host (a Claude-host generated loop writes traces here too). Cross-host merge of one runId is refused, never reconciled',
678
+ };
679
+ }
680
+ return { reason: 'foreign-run-refused', detail: 'no run state to resume' };
681
+ }
682
+ if (st.schema !== WF_RUN_STATE_SCHEMA || st.owner?.host !== WF_RUN_OWNER_HOST) {
683
+ return {
684
+ reason: 'foreign-run-refused',
685
+ detail: `run state is owned by ${JSON.stringify(st.owner?.host ?? null)} under schema ${JSON.stringify(st.schema ?? null)}, not ${WF_RUN_OWNER_HOST}/${WF_RUN_STATE_SCHEMA}`,
686
+ };
687
+ }
688
+ if (st.status === 'completed') {
689
+ return { reason: 'resume-already-completed', detail: `run ${st.runId} already completed — a completed run is not resumable` };
690
+ }
691
+ const axes: [string, string, string][] = [
692
+ ['planDigest', st.planDigest, opts.identity.planDigest],
693
+ ['execFp', st.execFp, opts.identity.execFp],
694
+ ['argsHash', st.argsHash, opts.identity.argsHash],
695
+ ];
696
+ for (const [name, was, now] of axes) {
697
+ if (was !== now) {
698
+ return {
699
+ reason: 'stale-input-refused',
700
+ detail: `${name} changed since the run was written (${String(was).slice(0, 16)}… → ${String(now).slice(0, 16)}…) — the checkpoints describe a different run, and there is no override for that`,
701
+ };
702
+ }
703
+ }
704
+ return null;
705
+ }
706
+
707
+ /**
708
+ * Accumulate the resume args of the WHOLE pause chain (Step-8 re-QE NEW-B2).
709
+ *
710
+ * A pause is satisfied by an arg supplied in SOME leg, not necessarily the current one. Reading only
711
+ * `inputs.resumeArgs` meant leg 3 (supplying `go2`) forgot that leg 2 had supplied `go1`, so the run
712
+ * fell back to the already-satisfied first pause and could never advance past two pauses at all
713
+ * (MEASURED: P1 → P2 → P1 → P1). The satisfied set is run STATE, so it lives in run-state.
714
+ *
715
+ * FIRST WINS for a key that has already been consumed: the boundary it satisfied has already run,
716
+ * and its result is in the checkpoints and the trace. Re-supplying the SAME value is a harmless
717
+ * repeat; re-supplying a DIFFERENT one is refused rather than silently re-deciding history.
718
+ */
719
+ export function accumulateResumeArgs(
720
+ persisted: Record<string, string> | undefined,
721
+ supplied: Record<string, string>,
722
+ ): { args: Record<string, string>; conflict: { key: string; was: string; now: string } | null } {
723
+ const args: Record<string, string> = { ...(persisted ?? {}) };
724
+ for (const [k, v] of Object.entries(supplied)) {
725
+ const prior = args[k];
726
+ if (prior !== undefined && prior !== v) return { args, conflict: { key: k, was: prior, now: v } };
727
+ args[k] = v;
728
+ }
729
+ return { args, conflict: null };
730
+ }
731
+
732
+ export function decideRunResume(opts: {
733
+ runState: WfRunState | null;
734
+ hasTrace: boolean;
735
+ identity: { planDigest: string; execFp: string; argsHash: string };
736
+ checkpointsText: string | null;
737
+ expectedHashFor: (boundaryId: string) => string;
738
+ probeArtifact: (rel: string) => boolean;
739
+ /** The declared writes of a boundary — its checkpoint is only resumable while they still EXIST.
740
+ * (Step-8 BLOCKER-2: this was missing, so `probeArtifact` was never called and a checkpointed
741
+ * file stage whose deliverable had been deleted was skipped on resume.) */
742
+ declaredWritesFor?: (boundaryId: string) => string[];
743
+ reprobedModels: Record<string, string | null>;
744
+ }): RunResumeDecision {
745
+ const empty = new Set<string>();
746
+ // the ownership/identity half — SHARED with the pre-probe phase, so the two can never disagree
747
+ const refusal = decideResumeIdentity({ runState: opts.runState, hasTrace: opts.hasTrace, identity: opts.identity });
748
+ if (refusal !== null) return { ok: false, reason: refusal.reason, detail: refusal.detail, cursor: empty };
749
+ const st = opts.runState as WfRunState;
750
+
751
+ for (const [stepId, persisted] of Object.entries(st.resolvedModels ?? {})) {
752
+ if (!(stepId in opts.reprobedModels)) continue;
753
+ if (opts.reprobedModels[stepId] !== persisted) {
754
+ return {
755
+ ok: false,
756
+ reason: 'resume-model-unavailable',
757
+ detail: `step ${stepId} ran on model ${persisted}, which no longer answers a probe (re-probe said ${JSON.stringify(opts.reprobedModels[stepId])}) — finishing a run on a different model would silently mix two models' work`,
758
+ cursor: empty,
759
+ };
760
+ }
761
+ }
762
+
763
+ const parsed = parseCheckpointRead(opts.checkpointsText ?? '');
764
+ const cursor = new Set<string>();
765
+ for (const [boundaryId, entry] of Object.entries(parsed.entries)) {
766
+ // ARTIFACT PROBES, not just hashes (the `STAGE_ARTIFACTS` semantics ADR-003 Confirmation-5
767
+ // names): a checkpoint says "this stage ran", and the declared write says "and here is what it
768
+ // produced". If the write is gone, the stage did not leave what the run depends on, so the
769
+ // checkpoint alone must not skip it — a resume that trusts the hash and not the disk silently
770
+ // continues without the deliverable.
771
+ const writes = opts.declaredWritesFor?.(boundaryId) ?? [];
772
+ const listing = new Set<string>(writes.filter((rel) => opts.probeArtifact(rel)));
773
+ const d = decideCheckpointResume({
774
+ mode: 'auto',
775
+ entry,
776
+ inputHash: opts.expectedHashFor(boundaryId),
777
+ artifactRel: writes.length === 0 ? null : writes,
778
+ listing,
779
+ });
780
+ if (d.resume) cursor.add(boundaryId);
781
+ }
782
+ return { ok: true, cursor };
783
+ }
784
+
785
+ /**
786
+ * The results map a RESUME must recompute checkpoint hashes against.
787
+ *
788
+ * Step-8 BLOCKER-2: the hashes were recomputed with `{}`, while the PERSISTED hashes were built from
789
+ * the live `ctx.results`. Any boundary with a dep therefore hashed differently on resume and re-ran
790
+ * — work already paid for, done twice. The reconstruction has two sources, because the run had two:
791
+ * checkpointed stage results, and the resume ARGS that satisfied a pause boundary (a pause is never
792
+ * checkpointed; its "result" is the value the operator supplied).
793
+ */
794
+ function resumeResults(pre: PreflightOk, effectiveArgs: Record<string, string>, checkpointsText: string | null): Record<string, unknown> {
795
+ const out = checkpointResults(checkpointsText);
796
+ for (const b of pre.projection.boundaries) {
797
+ if (b.kind !== 'pause') continue;
798
+ const key = b.pause?.resumeArg ?? '';
799
+ if (key !== '' && Object.prototype.hasOwnProperty.call(effectiveArgs, key)) out[b.boundaryId] = effectiveArgs[key];
800
+ }
801
+ return out;
802
+ }
803
+
804
+ /** The writes a boundary DECLARES — a stage's own, or every chain step's for a region. */
805
+ function declaredWritesOf(pre: PreflightOk, boundaryId: string): string[] {
806
+ const b = pre.projection.boundaries.find((x) => x.boundaryId === boundaryId);
807
+ if (b === undefined) return [];
808
+ if (b.stage !== undefined) return b.stage.deliverable === 'file' ? [...b.stage.writes] : [];
809
+ if (b.region !== undefined) return b.region.chain.filter((c) => c.deliverable === 'file').flatMap((c) => c.writes);
810
+ return [];
811
+ }
812
+
813
+ /** The checkpointed RESULT for a resumed boundary (so downstream steps see what it produced). */
814
+ function checkpointResults(checkpointsText: string | null): Record<string, unknown> {
815
+ const parsed = parseCheckpointRead(checkpointsText ?? '');
816
+ const out: Record<string, unknown> = {};
817
+ for (const [stage, entry] of Object.entries(parsed.entries)) out[stage] = entry.result;
818
+ return out;
819
+ }
820
+
821
+ // ─────────────────────────────────────────────────────────────────────────────
822
+ // Small shared readers / envelopes
823
+ // ─────────────────────────────────────────────────────────────────────────────
824
+
825
+ /**
826
+ * Torn-tail-tolerant JSONL reader (ADR-004 Confirmation-4b). A malformed LAST line is the signature
827
+ * of a process that died mid-append — it is skipped with a NAMED warning. A malformed INTERIOR line
828
+ * is not: something rewrote history, and silently continuing would hide it.
829
+ */
830
+ export function readJsonlTolerant(text: string): { rows: unknown[]; tornTail: boolean; warnings: string[] } {
831
+ const lines = String(text ?? '').split('\n');
832
+ // trailing empties are the normal shape of an append-per-line file, not a torn tail
833
+ while (lines.length > 0 && (lines[lines.length - 1] ?? '').trim() === '') lines.pop();
834
+ const rows: unknown[] = [];
835
+ const warnings: string[] = [];
836
+ let tornTail = false;
837
+ for (let i = 0; i < lines.length; i++) {
838
+ const t = (lines[i] ?? '').trim();
839
+ if (t === '') continue;
840
+ try {
841
+ rows.push(JSON.parse(t));
842
+ } catch {
843
+ if (i === lines.length - 1) {
844
+ tornTail = true;
845
+ warnings.push(`torn tail: the LAST line is unparseable (${t.length} chars) — skipped as an interrupted append; every earlier row was read`);
846
+ } else {
847
+ warnings.push(`unparseable INTERIOR line ${i + 1} — NOT skipped silently: an interior tear means the file was rewritten, not merely interrupted`);
848
+ }
849
+ }
850
+ }
851
+ return { rows, tornTail, warnings };
852
+ }
853
+
854
+ /** The machine-readable pause envelope (AM-16): a wrapper distinguishes pause from failure using
855
+ * ONLY stdout + the exit code, never prose. */
856
+ export function buildPauseEnvelope(
857
+ runId: string,
858
+ pauseState: string,
859
+ reason: WfRunReason,
860
+ planPath: string,
861
+ resumeArg: string | null,
862
+ where?: { runStatePath?: string | undefined; runDirArg?: string | null | undefined },
863
+ ): WfPauseEnvelope {
864
+ // Step-8 HIGH-7: both of these were hard-coded to the DEFAULT run home, so a run under
865
+ // `--run-dir features/<slug>` was handed a state path that does not exist and a resume command
866
+ // that would start a different run. An envelope whose own pointer is wrong is worse than none —
867
+ // it is a machine-readable wrong answer.
868
+ //
869
+ // re-QE R3-B: every INTERPOLATED value is shell-quoted with `traceShellQuote` (the repo's existing
870
+ // single-quote-escape helper, shared with the trace flush), so a run dir / plan path / runId /
871
+ // arg key containing a space or a metachar is safe for an operator to PASTE. A resume command that
872
+ // word-splits or injects when pasted is a command the pause envelope should never emit.
873
+ const q = traceShellQuote;
874
+ const runDirFlag = where?.runDirArg === undefined || where.runDirArg === null || where.runDirArg === '' ? '' : ` --run-dir ${q(where.runDirArg)}`;
875
+ const resumeCmd =
876
+ `dz workflow run ${q(planPath)} --resume ${q(runId)}${runDirFlag}`
877
+ + (resumeArg === null || resumeArg === '' ? '' : ` --arg ${q(resumeArg + '=<value>')}`);
878
+ return {
879
+ schema: WF_PAUSE_ENVELOPE_SCHEMA,
880
+ runId,
881
+ exitCode: 75,
882
+ pauseState,
883
+ reason,
884
+ runStatePath: where?.runStatePath ?? `.dz/loop-trace/${runId}/run-state.json`,
885
+ resumeCmd,
886
+ };
887
+ }
888
+
889
+ // ─────────────────────────────────────────────────────────────────────────────
890
+ // The effects seams
891
+ // ─────────────────────────────────────────────────────────────────────────────
892
+
893
+ export interface RunStore {
894
+ runDirExists(): boolean;
895
+ hasTrace(): boolean;
896
+ /**
897
+ * The trace text written so far, or null when there is none.
898
+ *
899
+ * ADDED to the architecture's verbatim `RunStore` (flagged, not smuggled): a RESUMED leg must
900
+ * CONTINUE the seq counter, not restart it at 1. Without this the two legs of one run collide on
901
+ * seq 1..n and the CONCATENATED trace fails `seq-monotonic` and `join-coverage` — which is
902
+ * exactly the property ADR-004 Confirmation-3 requires to hold across a resume. Reading the file
903
+ * is the only way the pure half can know the high-water mark, and putting the mark in run-state
904
+ * instead would make a diagnostic field load-bearing (the W10 mistake).
905
+ */
906
+ readTraceText(): string | null;
907
+ /** sha256 + non-empty line count of trace.jsonl AS IT NOW STANDS ON DISK, or null when absent.
908
+ * Impure by nature (hashing a file), so it lives behind the store seam like every other fs read. */
909
+ measureTrace(): { sha256: string; lines: number } | null;
910
+ readRunState(): WfRunState | null;
911
+ /** Atomic in the fs impl (temp + rename): a half-written state is a foreign run forever. */
912
+ writeRunState(s: WfRunState): void;
913
+ appendTraceLines(lines: string[]): void;
914
+ readCheckpointsText(): string | null;
915
+ appendCheckpointLine(line: string): void;
916
+ appendBudgetRow(row: WfBudgetRow): void;
917
+ appendLedgerLine(line: string): void;
918
+ probeArtifact(rel: string): boolean;
919
+ /**
920
+ * Does this declared artifact path STILL resolve inside the run root, RIGHT NOW? (re-QE R3-A /
921
+ * round-1 C4's "repeat containment immediately before filesystem access".) The fs impl applies
922
+ * the realpath + symlinked-ancestor walk; a symlink planted between preflight and dispatch is
923
+ * caught here for reads AND writes, not only inferred from a landed-barrier miss on writes.
924
+ */
925
+ pathContainmentOk(rel: string): boolean;
926
+ /** Pre-dispatch content fingerprints of the declared writes (null = absent). */
927
+ snapshotWrites(rels: string[]): Record<string, string | null>;
928
+ writeReqeDebt(record: object): void;
929
+ }
930
+
931
+ export interface SchedulerDeps {
932
+ store: RunStore;
933
+ dispatchers: Record<BridgeFamily, Dispatcher>;
934
+ /** Short state transactions ONLY — never held across a dispatch. */
935
+ lock<T>(fn: () => T): T;
936
+ /** Injected ISO clock (determinism, NFR-2). */
937
+ now(): string;
938
+ monotonicMs(): number;
939
+ /**
940
+ * TEST SEAM (named, never a casual flag): disables the landed barrier so the F5 mutant can show
941
+ * the lying file-step passing. Default false; a run that sets it records `dispatcherOverride`.
942
+ */
943
+ disableLandedBarrier?: boolean;
944
+ /** TEST SEAM: corrupts reservation arithmetic so the per-spawn guard fires inside a reserved
945
+ * region — the ONLY way to reach `budget-invariant-violated` without a real arithmetic bug. */
946
+ corruptReservations?: boolean;
947
+ /** Recorded loudly in run-state when the dispatchers came from the scripted test seam. */
948
+ dispatcherOverride?: boolean;
949
+ planPath?: string;
950
+ slug?: string;
951
+ /** The REAL path of this run's `run-state.json` (Step-8 HIGH-7 — the envelope may not guess it). */
952
+ runStatePath?: string;
953
+ /** The `--run-dir` value the caller used, so the resume command reproduces THIS run. */
954
+ runDirArg?: string | null;
955
+ /** The pid that actually HOLDS this run's owner claim, and when it took it (Step-8 HIGH-6).
956
+ * `run-state.owner.pid` used to be initialised to 0 — a durable record of a process that never
957
+ * existed, which no liveness check could ever mean anything against. */
958
+ ownerPid?: number;
959
+ ownerStartedMarker?: string;
960
+ }
961
+
962
+ export type RunOutcome =
963
+ | { kind: 'completed'; exitCode: 0; result: WfRunResult }
964
+ | { kind: 'failed'; exitCode: 1; reason: WfRunReason; detail: string; result: WfRunResult }
965
+ | { kind: 'paused'; exitCode: 75; envelope: WfPauseEnvelope };
966
+
967
+ /** The item-key domain is the SHARED one (`TRACE_KEY_RE`): a registry value that the trace plane
968
+ * would refuse must never reach a dispatch, or a trace-on run refuses what a trace-off run completes. */
969
+ function safeItemKey(k: string): boolean {
970
+ return TRACE_KEY_RE.test(k);
971
+ }
972
+
973
+ /** Map a dispatch outcome onto the CLOSED failure enum, through the SAME classifier the rendered
974
+ * script uses. A timeout is the one case the adapter knows better than any message heuristic. */
975
+ function failureClassOf(res: DispatchResult): FailureClass | null {
976
+ if (res.failure?.reason === 'dispatch-timeout') return 'timeout';
977
+ if (res.outcome === 'null') return classifyFailure('null', []);
978
+ return classifyFailure('error', errSnap(new Error(res.failure?.detail ?? res.text ?? '')));
979
+ }
980
+
981
+ interface RunCtx {
982
+ inputs: RunnerInputs;
983
+ pre: PreflightOk;
984
+ deps: SchedulerDeps;
985
+ trace: TraceState;
986
+ state: WfRunState;
987
+ results: Record<string, unknown>;
988
+ settleSeq: Record<string, number>;
989
+ invocationN: number;
990
+ dispatchCount: number;
991
+ agentCalls: number;
992
+ /** The reservation currently being spent inside, for the per-spawn invariant guard. */
993
+ activeReservation: { boundaryId: string; remaining: number } | null;
994
+ }
995
+
996
+ class BudgetInvariantError extends Error {
997
+ constructor(readonly boundaryId: string, readonly detail: string) {
998
+ super(detail);
999
+ this.name = 'BudgetInvariantError';
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * THE interpreter loop.
1005
+ *
1006
+ * Per boundary, in plan order: RESERVE (and pause BEFORE the boundary if the worst case does not
1007
+ * fit) → dispatch through the family `Dispatcher` with immediate retries over the closed classes →
1008
+ * settle through the shared trace emitter → verify a file deliverable actually landed → checkpoint →
1009
+ * append the budget row. A per-spawn guard fires only if the reservation arithmetic was WRONG, and
1010
+ * that is a FAILURE (`budget-invariant-violated`), never a pause: a pause promises the remaining
1011
+ * work fits after an extension, and a broken invariant promises nothing.
1012
+ */
1013
+ export async function runWorkflow(inputs: RunnerInputs, pre: PreflightOk, deps: SchedulerDeps): Promise<RunOutcome> {
1014
+ const store = deps.store;
1015
+ const startedAt = deps.now();
1016
+
1017
+ // ── PROBE FIRST (Step-8 BLOCKER-1) ──
1018
+ //
1019
+ // The probe has to happen BEFORE the resume decision, not after it. Probing afterwards meant
1020
+ // `decideRunResume` was handed an empty `reprobedModels`, skipped every persisted step, and
1021
+ // `resume-model-unavailable` could not be produced by the real runner at all — a resumed run
1022
+ // silently switched model ids and finished green. A probe answer is the ONLY thing that makes the
1023
+ // persisted id checkable, so it is the first thing the runner establishes.
1024
+ //
1025
+ // A null answer is NOT decided here: on a resume it means `resume-model-unavailable` (the id this
1026
+ // run committed to is gone), on a fresh run it means `probe-failed`. Deciding it after the resume
1027
+ // check keeps each refusal the precise one.
1028
+ // ── PHASE 0: ownership + identity, BEFORE anything is spent (re-QE NEW-B1) ──
1029
+ //
1030
+ // A refused run must write ZERO budget rows. The probe below is real work with a real record, so
1031
+ // everything decidable without it is decided first.
1032
+ const priorStateEarly = inputs.resume !== null ? store.readRunState() : null;
1033
+ if (inputs.resume !== null) {
1034
+ const refusal = decideResumeIdentity({
1035
+ runState: priorStateEarly,
1036
+ hasTrace: store.hasTrace(),
1037
+ identity: { planDigest: pre.planDigest, execFp: pre.execFp, argsHash: pre.argsHash },
1038
+ });
1039
+ if (refusal !== null) return failOutcome(inputs.runId, refusal.reason, refusal.detail, null);
1040
+ }
1041
+
1042
+ // The pause chain's args, accumulated across every leg (re-QE NEW-B2).
1043
+ const accumulated = accumulateResumeArgs(priorStateEarly?.resumeArgs, inputs.resumeArgs);
1044
+ if (accumulated.conflict !== null) {
1045
+ const c = accumulated.conflict;
1046
+ return failOutcome(
1047
+ inputs.runId,
1048
+ 'stale-input-refused',
1049
+ `--arg ${c.key} was already supplied as ${JSON.stringify(c.was)} on an earlier leg and is now ${JSON.stringify(c.now)} — the pause it satisfied has already run, and its result is in the checkpoints and the trace. Re-deciding it now would make this run's history disagree with itself`,
1050
+ null,
1051
+ );
1052
+ }
1053
+ const effectiveArgs = accumulated.args;
1054
+
1055
+ const usedFamilies = [...new Set(Object.values(pre.families))];
1056
+ const probedIds: Partial<Record<BridgeFamily, string | null>> = {};
1057
+ let probeAgentCalls = 0;
1058
+ const probeDetail: Partial<Record<BridgeFamily, string>> = {};
1059
+ for (const family of usedFamilies) {
1060
+ const candidates = [...new Set(allSpecs(pre.projection).filter((s) => pre.families[s.stepId] === family).map((s) => s.model).filter((m): m is string => typeof m === 'string'))];
1061
+ const probe = await deps.dispatchers[family].probe(candidates);
1062
+ probeAgentCalls++;
1063
+ store.appendBudgetRow({
1064
+ schema: WF_BUDGET_ROW_SCHEMA,
1065
+ kind: 'probe',
1066
+ runId: inputs.runId,
1067
+ dispatchSeq: null,
1068
+ stepId: null,
1069
+ itemKey: null,
1070
+ attempt: null,
1071
+ family,
1072
+ model: probe.id,
1073
+ wallMs: probe.wallMs,
1074
+ tokensIn: null,
1075
+ tokensOut: null,
1076
+ tokensSource: null,
1077
+ outcome: null,
1078
+ timeoutMs: null,
1079
+ });
1080
+ probedIds[family] = probe.id;
1081
+ probeDetail[family] = `candidates: ${candidates.join(', ') || 'defaults'} — ${probe.detail}`;
1082
+ }
1083
+ /** stepId → the id that answers NOW (null when nothing did). */
1084
+ const reprobedModels: Record<string, string | null> = {};
1085
+ for (const spec of allSpecs(pre.projection)) {
1086
+ reprobedModels[spec.stepId] = probedIds[pre.families[spec.stepId] as BridgeFamily] ?? null;
1087
+ }
1088
+
1089
+ // ── resume decision (authority: checkpoints + artifact probes; NEVER completedSteps) ──
1090
+ let cursor = new Set<string>();
1091
+ const priorState: WfRunState | null = priorStateEarly;
1092
+ if (inputs.resume !== null) {
1093
+ const checkpointsText = store.readCheckpointsText();
1094
+ const priorResults = resumeResults(pre, effectiveArgs, checkpointsText);
1095
+ const decision = decideRunResume({
1096
+ runState: priorState,
1097
+ hasTrace: store.hasTrace(),
1098
+ identity: { planDigest: pre.planDigest, execFp: pre.execFp, argsHash: pre.argsHash },
1099
+ checkpointsText,
1100
+ // the SAME inputs the persisted hashes were built from — see `resumeResults`
1101
+ expectedHashFor: (id) => expectedHashFor(pre, id, priorResults),
1102
+ probeArtifact: (rel) => store.probeArtifact(rel),
1103
+ declaredWritesFor: (id) => declaredWritesOf(pre, id),
1104
+ reprobedModels,
1105
+ });
1106
+ if (!decision.ok) {
1107
+ const reason = decision.reason ?? 'foreign-run-refused';
1108
+ return failOutcome(inputs.runId, reason, decision.detail ?? '', null);
1109
+ }
1110
+ cursor = decision.cursor;
1111
+ }
1112
+
1113
+ // a family with NO answering model cannot run anything — decided after the resume check so a
1114
+ // resumed run reports the precise `resume-model-unavailable` instead of a generic probe failure
1115
+ for (const family of usedFamilies) {
1116
+ if (probedIds[family] == null) {
1117
+ return failOutcome(inputs.runId, 'probe-failed', `no ${family} model answered a probe (${probeDetail[family] ?? ''})`, null);
1118
+ }
1119
+ }
1120
+
1121
+ const state: WfRunState = {
1122
+ schema: WF_RUN_STATE_SCHEMA,
1123
+ owner: {
1124
+ host: WF_RUN_OWNER_HOST,
1125
+ runnerVersion: inputs.runnerVersion,
1126
+ // the CURRENT holder of the claim, not the previous leg's (a resumed run is owned by the
1127
+ // process running it) — and never the placeholder 0
1128
+ pid: deps.ownerPid ?? priorState?.owner.pid ?? 0,
1129
+ startedMarker: deps.ownerStartedMarker ?? priorState?.owner.startedMarker ?? startedAt,
1130
+ },
1131
+ status: 'running',
1132
+ runId: inputs.runId,
1133
+ planDigest: pre.planDigest,
1134
+ execFp: pre.execFp,
1135
+ argsHash: pre.argsHash,
1136
+ completedSteps: [...cursor],
1137
+ resolvedModels: { ...(priorState?.resolvedModels ?? {}) },
1138
+ // CUMULATIVE (Step-8 HIGH-5): each leg adds its delta to the total the PREVIOUS leg reached,
1139
+ // never to the plan default. Resetting to `default + this leg's extra` while keeping the old
1140
+ // extension rows made the ledger and the ceiling disagree — two resumes of +1 each left a
1141
+ // total of default+1 with two rows claiming otherwise, so extensions were laundered away (and,
1142
+ // with a bigger first extra, could be re-granted for free).
1143
+ budget: {
1144
+ total: (priorState?.budget.total ?? pre.budgetTotal) + (inputs.budgetExtra ?? 0),
1145
+ spent: priorState?.budget.spent ?? 0,
1146
+ extensions: [...(priorState?.budget.extensions ?? [])],
1147
+ },
1148
+ wallClock: {
1149
+ ceilingMs: (priorState?.wallClock.ceilingMs ?? pre.wallCeilingMs) + (inputs.wallClockExtraMs ?? 0),
1150
+ spentMs: priorState?.wallClock.spentMs ?? 0,
1151
+ extensions: [...(priorState?.wallClock.extensions ?? [])],
1152
+ },
1153
+ waivers: pre.qeWaivers.map((w) => ({ kind: 'same-family-qe' as const, step: w.step, recordedDebt: `.fa-state/reqe-due.json` })),
1154
+ resumeArgs: effectiveArgs,
1155
+ coderFamily: inputs.coderFamily,
1156
+ startedAt: priorState?.startedAt ?? startedAt,
1157
+ updatedAt: startedAt,
1158
+ ...(deps.dispatcherOverride === true ? { dispatcherOverride: true } : {}),
1159
+ };
1160
+
1161
+ // ── extension caps (AM-13/AM-14): recorded, and refused beyond the achievable maximum ──
1162
+ if (inputs.budgetExtra !== null && inputs.budgetExtra > 0) {
1163
+ const newTotal = state.budget.total;
1164
+ if (newTotal > pre.achievableMax) {
1165
+ return failOutcome(
1166
+ inputs.runId,
1167
+ 'budget-extension-exhausted',
1168
+ `--budget-extra ${inputs.budgetExtra} takes the total to ${newTotal}, past the achievable maximum ${pre.achievableMax} (= max(2 × ${pre.projection.budgetTotal}, Σ reservations ${pre.reservations.reduce((n, r) => n + r.invocations, 0)})) — the cap is finite and plan-derived on purpose`,
1169
+ null,
1170
+ );
1171
+ }
1172
+ state.budget.extensions.push({ ts: startedAt, extra: inputs.budgetExtra, newTotal });
1173
+ }
1174
+ if (inputs.wallClockExtraMs !== null && inputs.wallClockExtraMs > 0) {
1175
+ const newCeilingMs = state.wallClock.ceilingMs;
1176
+ // AM-14's reservation-derived form, not `2 × whatever this invocation's ceiling happens to be`
1177
+ // (Step-8 HIGH-5): the cap must be a property of the PLAN, or `--max-wall-clock` would inflate
1178
+ // its own cap and `--wall-clock-extra` could then walk it anywhere.
1179
+ const wallCap = pre.achievableWallMs;
1180
+ if (newCeilingMs > wallCap) {
1181
+ return failOutcome(
1182
+ inputs.runId,
1183
+ 'wall-extension-exhausted',
1184
+ `--wall-clock-extra ${inputs.wallClockExtraMs}ms takes the ceiling to ${newCeilingMs}ms, past the cap ${wallCap}ms`,
1185
+ null,
1186
+ );
1187
+ }
1188
+ state.wallClock.extensions.push({ ts: startedAt, extraMs: inputs.wallClockExtraMs, newCeilingMs });
1189
+ }
1190
+
1191
+ const opened = openTrace(inputs, pre, store, inputs.resume !== null);
1192
+ const ctx: RunCtx = {
1193
+ inputs,
1194
+ pre,
1195
+ deps,
1196
+ trace: opened.trace,
1197
+ state,
1198
+ results: resumeResults(pre, effectiveArgs, store.readCheckpointsText()),
1199
+ settleSeq: opened.settleSeq,
1200
+ invocationN: 0,
1201
+ dispatchCount: 0,
1202
+ agentCalls: probeAgentCalls,
1203
+ activeReservation: null,
1204
+ };
1205
+
1206
+ // a waived same-family QE step owes a machine debt, not a doc instruction (the FR-2.9 precedent)
1207
+ for (const w of pre.qeWaivers) {
1208
+ // The FR-2.9 `reqe-due-1` shape VERBATIM (`reqe.ts` parseReqeDebt), because the debt is only
1209
+ // real if `dz reqe` can read it — a record in a shape the reader rejects is a promise, not a
1210
+ // debt. `qeFamily` equals `coderFamily` here: that identity IS the waiver.
1211
+ store.writeReqeDebt({
1212
+ schema: 'reqe-due-1',
1213
+ slug: deps.slug ?? inputs.runId,
1214
+ coderFamily: inputs.coderFamily,
1215
+ qeFamily: inputs.coderFamily,
1216
+ qeGrade: null,
1217
+ reason: `dz workflow run ${inputs.runId}: step ${w.step} (x-role: qe) resolved to the CODER family ${inputs.coderFamily} and ran under --allow-same-family-qe — the cross-family guard was consciously suspended, so an independent review is OWED`,
1218
+ emittedAt: startedAt,
1219
+ runStamp: `${inputs.runId}:${pre.execFp.slice(0, 16)}`,
1220
+ });
1221
+ }
1222
+
1223
+ // the PROBED ids (established above, before the resume decision) become this run's resolution
1224
+ for (const s of allSpecs(pre.projection)) {
1225
+ const id = probedIds[pre.families[s.stepId] as BridgeFamily];
1226
+ if (id != null) state.resolvedModels[s.stepId] = id;
1227
+ }
1228
+
1229
+ // ── the boundary walk ──
1230
+ const reservationOf = new Map(pre.reservations.map((r) => [r.boundaryId, r]));
1231
+ try {
1232
+ for (const b of pre.projection.boundaries) {
1233
+ if (cursor.has(b.boundaryId)) continue; // resumed — its result already lives in ctx.results
1234
+
1235
+ const res = reservationOf.get(b.boundaryId) ?? null;
1236
+ const remainingBudget = state.budget.total - state.budget.spent;
1237
+ const remainingWall = state.wallClock.ceilingMs - state.wallClock.spentMs;
1238
+
1239
+ if (res !== null && res.invocations > remainingBudget) {
1240
+ return pauseOutcome(ctx, b, 'AWAITING_BUDGET', 'budget-exhausted',
1241
+ `boundary ${b.boundaryId} reserves ${res.invocations} invocation(s) over step(s) ${res.steps.join(', ')}, and only ${remainingBudget} of ${state.budget.total} remain — pausing BEFORE the boundary so it is never interrupted mid-region (AM-4)`);
1242
+ }
1243
+ if (res !== null && res.wallMs > remainingWall) {
1244
+ return pauseOutcome(ctx, b, 'AWAITING_WALL_CLOCK', 'budget-exhausted',
1245
+ `boundary ${b.boundaryId} reserves ${res.wallMs}ms of wall clock and only ${remainingWall}ms of ${state.wallClock.ceilingMs}ms remain — pausing BEFORE the boundary`);
1246
+ }
1247
+
1248
+ if (b.kind === 'pause') {
1249
+ const key = b.pause?.resumeArg ?? '';
1250
+ // satisfied by ANY leg, not just this one (re-QE NEW-B2)
1251
+ const supplied = key !== '' && Object.prototype.hasOwnProperty.call(effectiveArgs, key);
1252
+ if (!supplied) {
1253
+ return pauseOutcome(ctx, b, b.pause?.state ?? 'AWAITING_INPUT', 'plan-pause',
1254
+ `the plan declares pause state ${JSON.stringify(b.pause?.state ?? '')} here; re-invoke with --arg ${key}=<value> to continue`);
1255
+ }
1256
+ ctx.results[b.boundaryId] = effectiveArgs[key];
1257
+ continue;
1258
+ }
1259
+
1260
+ ctx.activeReservation = res === null ? null : { boundaryId: b.boundaryId, remaining: deps.corruptReservations === true ? 0 : res.invocations };
1261
+
1262
+ if (b.kind === 'region') {
1263
+ await runRegion(ctx, b);
1264
+ ctx.activeReservation = null;
1265
+ continue;
1266
+ }
1267
+
1268
+ const terminal = await runStage(ctx, b);
1269
+ ctx.activeReservation = null;
1270
+ if (terminal !== null) {
1271
+ // A gate `terminal:` route ends the run BY PLAN DESIGN. Parity with the rendered script:
1272
+ // the top-level terminal return skips the epilogue, so `run.closed` is NOT written and the
1273
+ // trace parses as incomplete. See the FLAG in the change manifest: `RunOutcome` has no
1274
+ // terminal member, so this reports as `completed` with the route named in the ledger row.
1275
+ return terminalOutcome(ctx, terminal, startedAt);
1276
+ }
1277
+ }
1278
+ } catch (e) {
1279
+ if (e instanceof BudgetInvariantError) {
1280
+ return failOutcome(inputs.runId, 'budget-invariant-violated', e.detail, ctx);
1281
+ }
1282
+ if (e instanceof RunFailure) {
1283
+ return failOutcome(inputs.runId, e.reason, e.detail, ctx);
1284
+ }
1285
+ throw e;
1286
+ }
1287
+
1288
+ // ── completed epilogue: the ONLY path that closes the trace ──
1289
+ traceClose(ctx.trace);
1290
+ flush(ctx);
1291
+ ctx.state.status = 'completed';
1292
+ ctx.state.completedSteps = pre.projection.boundaries.map((b) => b.boundaryId);
1293
+ ctx.state.updatedAt = deps.now();
1294
+ stampTraceBinding(ctx.state, store);
1295
+ deps.lock(() => store.writeRunState(ctx.state));
1296
+ appendLedger(ctx, 'completed');
1297
+ return {
1298
+ kind: 'completed',
1299
+ exitCode: 0,
1300
+ result: {
1301
+ schema: WF_RUN_RESULT_SCHEMA,
1302
+ runId: inputs.runId,
1303
+ status: 'completed',
1304
+ exitCode: 0,
1305
+ ...(deps.dispatcherOverride === true ? { dispatcherOverride: true } : {}),
1306
+ },
1307
+ };
1308
+ }
1309
+
1310
+ /**
1311
+ * Open the run's trace state. A FRESH run buffers its own `run.opened` frame; a RESUMED leg picks
1312
+ * the seq counter up where the previous leg left it and emits NO second `run.opened` — one run has
1313
+ * ONE opened frame and ONE monotonically allocated seq space, whatever the process boundaries.
1314
+ * The dispatched/settled counters are recovered too, so the `run.closed` counts describe the whole
1315
+ * run rather than its last leg.
1316
+ */
1317
+ function openTrace(inputs: RunnerInputs, pre: PreflightOk, store: RunStore, resuming: boolean): { trace: TraceState; settleSeq: Record<string, number> } {
1318
+ // 'dz-process': this runner IS the dz process, and it appends trace.jsonl itself. The value is a
1319
+ // fact about which code path is running, not a claim about trustworthiness — see ADR-001.
1320
+ const st = traceInit(inputs.runId, pre.planDigest, pre.execFp, 'dz-process');
1321
+ if (!resuming) return { trace: st, settleSeq: {} };
1322
+ const priorText = store.readTraceText();
1323
+ if (priorText === null || priorText.trim() === '') return { trace: st, settleSeq: {} };
1324
+ const prior = parseTrace(priorText);
1325
+ const maxSeq = prior.events.reduce((m, e) => Math.max(m, e.seq), 0);
1326
+ if (maxSeq <= 0) return { trace: st, settleSeq: {} };
1327
+ st.buffer.length = 0;
1328
+ st.seq = maxSeq;
1329
+ st.dispatched = prior.events.filter((e) => e.event === 'dispatched').length;
1330
+ st.settled = prior.events.filter((e) => e.event === 'settled').length;
1331
+ // Recover the previous leg's settle seqs so a resumed step's `causedBy` still points at the
1332
+ // upstream settle that actually happened. Without this a resumed leg emits `causedBy: []` for
1333
+ // every dep it did not re-run — technically honest, but it drops the causal edge a reader draws.
1334
+ const stepOf = new Map<string, { stepId: string; itemKey: string | null }>();
1335
+ for (const e of prior.events) {
1336
+ if (e.event === 'dispatched') stepOf.set(e.invocationId, { stepId: e.stepId, itemKey: e.itemKey });
1337
+ }
1338
+ const settleSeq: Record<string, number> = {};
1339
+ for (const e of prior.events) {
1340
+ if (e.event !== 'settled') continue;
1341
+ const who = stepOf.get(e.invocationId);
1342
+ if (who !== undefined) settleSeq[settleKey(who.stepId, who.itemKey)] = e.seq;
1343
+ }
1344
+ return { trace: st, settleSeq };
1345
+ }
1346
+
1347
+ /** A named, non-retryable runtime refusal raised from deep inside the walk. */
1348
+ class RunFailure extends Error {
1349
+ constructor(readonly reason: WfRunReason, readonly detail: string) {
1350
+ super(detail);
1351
+ this.name = 'RunFailure';
1352
+ }
1353
+ }
1354
+
1355
+ function expectedHashFor(pre: PreflightOk, boundaryId: string, results: Record<string, unknown>): string {
1356
+ const b = pre.projection.boundaries.find((x) => x.boundaryId === boundaryId);
1357
+ const spec = b?.stage ?? null;
1358
+ const prompt = spec === null ? '' : assemblePrompt(spec, null, null);
1359
+ const deps = (b?.deps ?? []).map((d) => results[d] ?? null);
1360
+ return runnerCheckpointHash(boundaryId, pre.execFp, prompt, deps);
1361
+ }
1362
+
1363
+ /**
1364
+ * THE prompt assembly, both enactors' version: the USER prompt seed, then the SHARED contract lines
1365
+ * (`stepContractLines` — the same function `loop-render` splices), then the per-item binding, then
1366
+ * the upstream value.
1367
+ *
1368
+ * The upstream value is spliced through `defangGateEchoes` (ADR-002 Confirmation-3). This is the
1369
+ * INGRESS half of the gate defence and it is separate from the LAST-anchored parser for a reason:
1370
+ * the parser stops one reply from smuggling a verdict past its own terminal line, while this stops
1371
+ * an upstream gate's LEGITIMATE verdict from becoming a downstream step's terminal line the moment
1372
+ * that step quotes its input back. "Summarize the previous review" would otherwise be a working
1373
+ * exploit against a plan that did nothing wrong. Neutralization, not deletion — the downstream
1374
+ * model still reads every upstream word.
1375
+ */
1376
+ function assemblePrompt(spec: RunStepSpec, itemKey: string | null, upstream: unknown): string {
1377
+ const parts: string[] = [spec.prompt ?? `TODO: prompt for step ${spec.stepId} (phase ${spec.phase})`]; // no-stubs: the SAME default prompt sentinel the render emits (loop-render.ts:186) — an authoring cue both enactors must show identically, not unfinished code
1378
+ parts.push(...stepContractLines({
1379
+ reads: spec.reads,
1380
+ writes: spec.writes,
1381
+ deliverable: spec.deliverable,
1382
+ tools: spec.tools,
1383
+ gate: spec.gate === null ? null : { kind: spec.gate.kind },
1384
+ }));
1385
+ if (itemKey !== null) parts.push('this branch handles registry item: ' + itemKey);
1386
+ if (upstream !== null && upstream !== undefined) {
1387
+ // a STRING travels as text (that is what the model produced and what it must read back); any
1388
+ // other shape is serialized. Both go through the defang — a JSON string can carry a newline too.
1389
+ const raw = typeof upstream === 'string' ? upstream : JSON.stringify(upstream);
1390
+ parts.push('upstream value: ' + defangGateEchoes(raw));
1391
+ }
1392
+ return parts.join('\n');
1393
+ }
1394
+
1395
+ function flush(ctx: RunCtx): void {
1396
+ const lines = traceDrain(ctx.trace);
1397
+ if (lines.length > 0) ctx.deps.store.appendTraceLines(lines);
1398
+ }
1399
+
1400
+ function spendBudget(ctx: RunCtx, stepId: string): void {
1401
+ if (ctx.activeReservation !== null) {
1402
+ if (ctx.activeReservation.remaining <= 0) {
1403
+ throw new BudgetInvariantError(
1404
+ ctx.activeReservation.boundaryId,
1405
+ `boundary ${ctx.activeReservation.boundaryId} tried to dispatch ${stepId} beyond its own reservation — the reservation arithmetic is WRONG. This is a FAILURE, never a pause: a pause promises the remaining work fits after an extension, and a broken invariant promises nothing`,
1406
+ );
1407
+ }
1408
+ ctx.activeReservation.remaining--;
1409
+ }
1410
+ ctx.state.budget.spent++;
1411
+ ctx.agentCalls++;
1412
+ }
1413
+
1414
+ /** Dispatch ONE invocation with the closed-class immediate retry policy. Returns the settled result. */
1415
+ async function dispatchOnce(
1416
+ ctx: RunCtx,
1417
+ spec: RunStepSpec,
1418
+ itemKey: string | null,
1419
+ upstream: unknown,
1420
+ causedBy: number[],
1421
+ ): Promise<{ ok: boolean; value: unknown; res: DispatchResult | null; reason: WfRunReason | null; detail: string }> {
1422
+ const family = ctx.pre.families[spec.stepId] as BridgeFamily;
1423
+ const dispatcher = ctx.deps.dispatchers[family];
1424
+ const model = ctx.state.resolvedModels[spec.stepId] ?? null;
1425
+ const timeoutMs = stageTimeoutMs(ctx.inputs);
1426
+ const prompt = assemblePrompt(spec, itemKey, upstream);
1427
+
1428
+ let lastRes: DispatchResult | null = null;
1429
+ for (let attempt = 1; attempt <= spec.retryMaxAttempts; attempt++) {
1430
+ // CONTAINMENT re-check FIRST — before ANY invocation accounting (R4: refuse before you
1431
+ // account, the NEW-B1 discipline). Round-3 R3-A put this after spendBudget + traceOnDispatch,
1432
+ // so a refused step still burned a budget unit and emitted a phantom `dispatched` event with no
1433
+ // settle. Preflight validated these paths against a tree the models this run has since written
1434
+ // to; a symlink planted since is caught HERE, for READS and writes alike, before the adapter is
1435
+ // handed the target cwd and before the run charges itself for a dispatch that never happens.
1436
+ for (const [kind, rel] of [...spec.reads.map((r) => ['read', r] as const), ...spec.writes.map((w) => ['write', w] as const)]) {
1437
+ if (!ctx.deps.store.pathContainmentOk(rel)) {
1438
+ return {
1439
+ ok: false,
1440
+ value: null,
1441
+ res: null,
1442
+ reason: 'artifact-path-escapes-root',
1443
+ detail: `step ${spec.stepId} declared ${kind} ${JSON.stringify(rel)} no longer resolves inside the run root at dispatch time — a symlink was planted after preflight, and a declared artifact path may not leave the root whichever direction the I/O goes`,
1444
+ };
1445
+ }
1446
+ }
1447
+ spendBudget(ctx, spec.stepId);
1448
+ const invocationId = spec.stepId + (itemKey === null ? '' : ':' + itemKey) + '#' + String(++ctx.invocationN);
1449
+ const dispatchSeq = traceOnDispatch(ctx.trace, {
1450
+ invocationId,
1451
+ stepId: spec.stepId,
1452
+ itemKey,
1453
+ attempt,
1454
+ phase: spec.phase,
1455
+ model,
1456
+ causedBy: causedBy.filter((n) => typeof n === 'number' && n > 0),
1457
+ });
1458
+ ctx.dispatchCount++;
1459
+
1460
+ const baseline = spec.deliverable === 'file' && spec.writes.length > 0 ? ctx.deps.store.snapshotWrites(spec.writes) : null;
1461
+ const t0 = ctx.deps.monotonicMs();
1462
+ const res = await dispatcher.dispatch({
1463
+ stepId: spec.stepId,
1464
+ itemKey,
1465
+ attempt,
1466
+ prompt,
1467
+ family,
1468
+ resolvedModelId: model ?? '',
1469
+ deliverable: spec.deliverable,
1470
+ expectedReads: spec.reads,
1471
+ expectedWrites: spec.writes,
1472
+ timeoutMs,
1473
+ cwd: ctx.inputs.cwdRoot,
1474
+ });
1475
+ const wallMs = res.wallMs > 0 ? res.wallMs : Math.max(0, ctx.deps.monotonicMs() - t0);
1476
+ ctx.state.wallClock.spentMs += wallMs;
1477
+ lastRes = res;
1478
+
1479
+ // LANDED BARRIER (scheduler-owned, dispatcher-independent): a settled dispatch is not a
1480
+ // delivered file. Declared writes must EXIST and be NEWLY CHANGED against the pre-dispatch
1481
+ // snapshot — an untouched leftover from a previous run is not this step's deliverable.
1482
+ let outcome = res.outcome;
1483
+ let landedFailure: string | null = null;
1484
+ if (outcome === 'ok' && baseline !== null && ctx.deps.disableLandedBarrier !== true) {
1485
+ const notLanded: string[] = [];
1486
+ for (const rel of spec.writes) {
1487
+ if (!ctx.deps.store.probeArtifact(rel)) {
1488
+ notLanded.push(`${rel} (absent)`);
1489
+ continue;
1490
+ }
1491
+ const after = ctx.deps.store.snapshotWrites([rel])[rel] ?? null;
1492
+ if (after !== null && after === baseline[rel]) notLanded.push(`${rel} (unchanged since before the dispatch)`);
1493
+ }
1494
+ if (notLanded.length > 0) {
1495
+ outcome = 'error';
1496
+ landedFailure = `step ${spec.stepId} settled ok but its declared write(s) did not land: ${notLanded.join(', ')}`;
1497
+ }
1498
+ }
1499
+
1500
+ const settleSeq = traceOnSettle(ctx.trace, { invocationId, outcome });
1501
+ ctx.settleSeq[settleKey(spec.stepId, itemKey)] = settleSeq;
1502
+ ctx.deps.store.appendBudgetRow({
1503
+ schema: WF_BUDGET_ROW_SCHEMA,
1504
+ kind: 'stage',
1505
+ runId: ctx.inputs.runId,
1506
+ dispatchSeq,
1507
+ stepId: spec.stepId,
1508
+ itemKey,
1509
+ attempt,
1510
+ family,
1511
+ model: res.modelUsed ?? model,
1512
+ wallMs,
1513
+ tokensIn: res.tokensIn,
1514
+ tokensOut: res.tokensOut,
1515
+ tokensSource: res.tokensSource,
1516
+ outcome,
1517
+ timeoutMs,
1518
+ });
1519
+ flush(ctx);
1520
+
1521
+ if (outcome === 'ok') return { ok: true, value: res.text, res, reason: null, detail: '' };
1522
+ if (landedFailure !== null) {
1523
+ return { ok: false, value: null, res, reason: 'deliverable-not-landed', detail: landedFailure };
1524
+ }
1525
+
1526
+ const cls = failureClassOf(res);
1527
+ const retryable = attempt < spec.retryMaxAttempts && cls !== null && spec.retryOn.includes(cls);
1528
+ if (!retryable) {
1529
+ return {
1530
+ ok: false,
1531
+ value: null,
1532
+ res,
1533
+ reason: res.failure?.reason ?? 'dispatch-dead',
1534
+ detail: res.failure?.detail ?? `step ${spec.stepId} settled ${outcome} (class ${String(cls)}) and this step's retryOn does not cover it`,
1535
+ };
1536
+ }
1537
+ }
1538
+ return { ok: false, value: null, res: lastRes, reason: lastRes?.failure?.reason ?? 'dispatch-dead', detail: 'retries exhausted' };
1539
+ }
1540
+
1541
+ function settleKey(stepId: string, itemKey: string | null): string {
1542
+ return stepId + '' + (itemKey ?? '');
1543
+ }
1544
+
1545
+ function causedByOf(ctx: RunCtx, deps: string[]): number[] {
1546
+ return deps.map((d) => ctx.settleSeq[settleKey(d, null)] ?? -1).filter((n) => n > 0);
1547
+ }
1548
+
1549
+ /** A top-level agent/gate boundary. Returns a terminal-route label, or null to continue. */
1550
+ async function runStage(ctx: RunCtx, b: RunBoundary): Promise<string | null> {
1551
+ const spec = b.stage;
1552
+ if (spec === undefined) return null;
1553
+ const upstream = b.deps.length === 1 ? ctx.results[b.deps[0] as string] ?? null : null;
1554
+ const causedBy = causedByOf(ctx, b.deps);
1555
+
1556
+ let redosLeft = spec.gate?.maxRedos ?? 0;
1557
+ for (;;) {
1558
+ const r = await dispatchOnce(ctx, spec, null, upstream, causedBy);
1559
+ if (!r.ok) throw new RunFailure(r.reason ?? 'dispatch-dead', r.detail);
1560
+
1561
+ if (spec.kind !== 'gate' || spec.gate === null) {
1562
+ ctx.results[b.boundaryId] = r.value;
1563
+ checkpoint(ctx, b, r.value);
1564
+ return null;
1565
+ }
1566
+
1567
+ // GATE — the verdict is PARSED by the shared grammar and never synthesized.
1568
+ const verdict = gateVerdict(r.value);
1569
+ if (verdict === 'pass') {
1570
+ ctx.results[b.boundaryId] = r.value;
1571
+ checkpoint(ctx, b, r.value);
1572
+ return null;
1573
+ }
1574
+ const route = spec.gate.failRoute;
1575
+ if (route !== null && route.startsWith('terminal:')) return route;
1576
+ if (redosLeft <= 0) {
1577
+ // Parity with the rendered script (`loop-render.ts` gate routing): a non-pass verdict with
1578
+ // its declared routing exhausted is a LOUD run failure — never a silent pass, never a retry.
1579
+ // TWO members, because there are two different facts to report (AM-19).
1580
+ throw verdict === 'invalid'
1581
+ ? new RunFailure(
1582
+ 'gate-verdict-unparseable',
1583
+ `gate ${spec.stepId} produced no single anchored terminal "GATE: PASS|FAIL" line — an unparseable verdict is a loud failure, never a synthesized pass, and no redos remain`,
1584
+ )
1585
+ : new RunFailure(
1586
+ 'gate-failed',
1587
+ `gate ${spec.stepId} returned a PARSED "GATE: FAIL" verdict with no redos left and no terminal route declared (failRoute ${JSON.stringify(route)}) — the grammar was satisfied and the model answered clearly; the run fails because the plan declares nowhere for a failing gate to go`,
1588
+ );
1589
+ }
1590
+ redosLeft--;
1591
+ if (route !== null) {
1592
+ const routeBoundary = ctx.pre.projection.boundaries.find((x) => x.boundaryId === route);
1593
+ const routeSpec = routeBoundary?.stage;
1594
+ if (routeSpec !== undefined) {
1595
+ const rr = await dispatchOnce(ctx, routeSpec, null, ctx.results[route] ?? null, causedByOf(ctx, routeBoundary?.deps ?? []));
1596
+ if (!rr.ok) throw new RunFailure(rr.reason ?? 'dispatch-dead', rr.detail);
1597
+ ctx.results[route] = rr.value;
1598
+ }
1599
+ }
1600
+ }
1601
+ }
1602
+
1603
+ /** A parallel region: every registry member activated, at most `maxFanout` in flight, joined by the
1604
+ * SHARED `joinRegion` decision. */
1605
+ async function runRegion(ctx: RunCtx, b: RunBoundary): Promise<void> {
1606
+ const r = b.region;
1607
+ if (r === undefined) return;
1608
+ const members = activatedMembers(r.registry, r.dedup, r.maxFanout);
1609
+ for (const k of members) {
1610
+ if (!safeItemKey(k)) {
1611
+ throw new RunFailure(
1612
+ 'plan-invalid',
1613
+ `fanout ${r.fanout} registry item ${JSON.stringify(k)} is outside the shared ItemKey domain ${String(TRACE_KEY_RE)} — the trace plane would refuse the event, so a trace-on run must refuse the dispatch rather than complete where a trace-off run would`,
1614
+ );
1615
+ }
1616
+ }
1617
+ const causedBy = causedByOf(ctx, b.deps);
1618
+ const bound = r.maxFanout > 0 ? r.maxFanout : 1;
1619
+ const results: unknown[] = new Array(members.length).fill(null);
1620
+ /** The FIRST branch failure, kept so a join refusal reports what actually killed the region
1621
+ * rather than inventing a reason of its own. */
1622
+ const branchFailures: { reason: WfRunReason; detail: string }[] = [];
1623
+
1624
+ let next = 0;
1625
+ const worker = async (): Promise<void> => {
1626
+ for (;;) {
1627
+ const i = next++;
1628
+ if (i >= members.length) return;
1629
+ const item = members[i] as string;
1630
+ let value: unknown = ctx.results[b.boundaryId] ?? null;
1631
+ let failed = false;
1632
+ for (const spec of r.chain) {
1633
+ const rr = await dispatchOnce(ctx, spec, item, value, causedBy);
1634
+ if (!rr.ok) {
1635
+ // a failing branch is a NULL branch — the JOIN decides what that means, not the loop
1636
+ failed = true;
1637
+ branchFailures.push({ reason: rr.reason ?? 'dispatch-dead', detail: rr.detail });
1638
+ break;
1639
+ }
1640
+ value = rr.value;
1641
+ }
1642
+ results[i] = failed ? null : value;
1643
+ }
1644
+ };
1645
+ await Promise.all(Array.from({ length: Math.min(bound, Math.max(members.length, 1)) }, () => worker()));
1646
+
1647
+ // `joinRegion` THROWS a named message when the policy is not met — parity with the rendered
1648
+ // script, which routes that throw through its single terminal exit. Here it becomes a typed run
1649
+ // failure carrying the FIRST branch's reason: the join did not invent the failure, it refused to
1650
+ // paper over one.
1651
+ let join: JoinOutcome;
1652
+ try {
1653
+ join = joinRegion(results, { policy: r.joinPolicy, onInvalid: r.onInvalid, region: r.fanout });
1654
+ } catch (e) {
1655
+ const failure = branchFailures[0];
1656
+ throw new RunFailure(
1657
+ failure?.reason ?? 'dispatch-dead',
1658
+ `${e instanceof Error ? e.message : String(e)} (onInvalid: ${r.onInvalid})` + (failure === undefined ? '' : ` — first failing branch: ${failure.detail}`),
1659
+ );
1660
+ }
1661
+ ctx.results[b.boundaryId] = join.values;
1662
+ if (r.join !== '') ctx.results[r.join] = join.values;
1663
+ checkpoint(ctx, b, join.values);
1664
+ }
1665
+
1666
+ /** K6: the runner checkpoints EVERY top-level agent/gate/region boundary UNCONDITIONALLY — its
1667
+ * resume cursor is built from these lines, so making them optional would make resume optional.
1668
+ * `plan.checkpointing` stays what it always was: the CLAUDE-host opt-in. */
1669
+ function checkpoint(ctx: RunCtx, b: RunBoundary, value: unknown): void {
1670
+ const line = serializeCheckpoint(b.boundaryId, expectedHashFor(ctx.pre, b.boundaryId, ctx.results), value);
1671
+ if (line !== null) ctx.deps.store.appendCheckpointLine(line);
1672
+ }
1673
+
1674
+ /**
1675
+ * THE ONE PLACE the content binding is refreshed. A helper rather than three inline copies, because
1676
+ * three call sites mean three chances to forget — and a forgotten one degrades silently to
1677
+ * `unknown` (fail-closed, but a real dz run would then read as un-attested).
1678
+ * Called AFTER the final trace flush, so it describes the bytes a reader will actually see.
1679
+ */
1680
+ function stampTraceBinding(state: WfRunState, store: RunStore): void {
1681
+ const m = store.measureTrace();
1682
+ if (m === null) return; // no trace ⇒ no binding to make; the reader will say `unknown`
1683
+ state.traceSha256 = m.sha256;
1684
+ state.traceLines = m.lines;
1685
+ }
1686
+
1687
+ function appendLedger(ctx: RunCtx, outcome: string): void {
1688
+ const line = traceLedgerLine({
1689
+ slug: ctx.deps.slug ?? ctx.inputs.runId,
1690
+ runId: ctx.inputs.runId,
1691
+ planDigest: ctx.pre.planDigest,
1692
+ agents: ctx.agentCalls,
1693
+ outcome,
1694
+ date: null,
1695
+ });
1696
+ if (line !== null) ctx.deps.store.appendLedgerLine(line);
1697
+ }
1698
+
1699
+ /** PAUSE — flush WITHOUT `run.closed` (parity with the render's top-level terminal return, which
1700
+ * skips the epilogue). The trace legitimately parses as incomplete, and window-truncated invariants
1701
+ * report inconclusive rather than pass. */
1702
+ function pauseOutcome(ctx: RunCtx, b: RunBoundary, pauseState: string, reason: WfRunReason, detail: string): RunOutcome {
1703
+ flush(ctx);
1704
+ const remaining = ctx.pre.projection.boundaries.map((x) => x.boundaryId).slice(ctx.pre.projection.boundaries.findIndex((x) => x.boundaryId === b.boundaryId));
1705
+ ctx.state.status = 'paused';
1706
+ ctx.state.pause = {
1707
+ state: pauseState,
1708
+ resumeArg: b.pause?.resumeArg ?? '',
1709
+ payloadSchema: b.pause?.payloadSchema ?? null,
1710
+ remainingSteps: remaining,
1711
+ reservationNote: detail,
1712
+ };
1713
+ ctx.state.updatedAt = ctx.deps.now();
1714
+ stampTraceBinding(ctx.state, ctx.deps.store);
1715
+ ctx.deps.lock(() => ctx.deps.store.writeRunState(ctx.state));
1716
+ appendLedger(ctx, 'paused');
1717
+ return {
1718
+ kind: 'paused',
1719
+ exitCode: 75,
1720
+ envelope: buildPauseEnvelope(ctx.inputs.runId, pauseState, reason, ctx.deps.planPath ?? '<plan.json>', b.pause?.resumeArg ?? null, {
1721
+ runStatePath: ctx.deps.runStatePath,
1722
+ runDirArg: ctx.deps.runDirArg ?? null,
1723
+ }),
1724
+ };
1725
+ }
1726
+
1727
+ function terminalOutcome(ctx: RunCtx, route: string, _startedAt: string): RunOutcome {
1728
+ flush(ctx); // NO traceClose: parity with the render's terminal return skipping the epilogue
1729
+ ctx.state.status = 'completed';
1730
+ ctx.state.updatedAt = ctx.deps.now();
1731
+ stampTraceBinding(ctx.state, ctx.deps.store);
1732
+ ctx.deps.lock(() => ctx.deps.store.writeRunState(ctx.state));
1733
+ appendLedger(ctx, route);
1734
+ return {
1735
+ kind: 'completed',
1736
+ exitCode: 0,
1737
+ result: {
1738
+ schema: WF_RUN_RESULT_SCHEMA,
1739
+ runId: ctx.inputs.runId,
1740
+ status: 'completed',
1741
+ exitCode: 0,
1742
+ terminalRoute: route, // MEDIUM-13: a wrapper can now tell this from an ordinary completion
1743
+ ...(ctx.deps.dispatcherOverride === true ? { dispatcherOverride: true } : {}),
1744
+ },
1745
+ };
1746
+ }
1747
+
1748
+ /** FAIL — flush WITHOUT `run.closed`, name the reason, and emit NO pause envelope (AM-16: that is
1749
+ * exactly what lets a wrapper tell a pause from a failure using stdout and the exit code alone). */
1750
+ function failOutcome(runId: string, reason: WfRunReason, detail: string, ctx: RunCtx | null): RunOutcome {
1751
+ if (ctx !== null) {
1752
+ flush(ctx);
1753
+ ctx.state.status = 'failed';
1754
+ ctx.state.failure = { reason, detail };
1755
+ ctx.state.updatedAt = ctx.deps.now();
1756
+ ctx.deps.lock(() => ctx.deps.store.writeRunState(ctx.state));
1757
+ appendLedger(ctx, 'failed');
1758
+ }
1759
+ return {
1760
+ kind: 'failed',
1761
+ exitCode: 1,
1762
+ reason,
1763
+ detail,
1764
+ result: {
1765
+ schema: WF_RUN_RESULT_SCHEMA,
1766
+ runId,
1767
+ status: 'failed',
1768
+ reason,
1769
+ exitCode: 1,
1770
+ ...(ctx?.deps.dispatcherOverride === true ? { dispatcherOverride: true } : {}),
1771
+ },
1772
+ };
1773
+ }