taskflow-core 0.1.8 → 0.2.1

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 (193) hide show
  1. package/README.md +248 -808
  2. package/dist/agents.d.ts +12 -0
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +29 -1
  5. package/dist/agents.js.map +1 -1
  6. package/dist/cwd-bridge.d.ts +61 -0
  7. package/dist/cwd-bridge.d.ts.map +1 -0
  8. package/dist/cwd-bridge.js +136 -0
  9. package/dist/cwd-bridge.js.map +1 -0
  10. package/dist/detached-runner.js +31 -5
  11. package/dist/detached-runner.js.map +1 -1
  12. package/dist/deterministic.d.ts +8 -0
  13. package/dist/deterministic.d.ts.map +1 -1
  14. package/dist/deterministic.js +23 -1
  15. package/dist/deterministic.js.map +1 -1
  16. package/dist/exec/driver.d.ts +48 -0
  17. package/dist/exec/driver.d.ts.map +1 -0
  18. package/dist/exec/driver.js +410 -0
  19. package/dist/exec/driver.js.map +1 -0
  20. package/dist/exec/events.d.ts +76 -0
  21. package/dist/exec/events.d.ts.map +1 -0
  22. package/dist/exec/events.js +108 -0
  23. package/dist/exec/events.js.map +1 -0
  24. package/dist/exec/fold.d.ts +45 -0
  25. package/dist/exec/fold.d.ts.map +1 -0
  26. package/dist/exec/fold.js +122 -0
  27. package/dist/exec/fold.js.map +1 -0
  28. package/dist/exec/index.d.ts +17 -0
  29. package/dist/exec/index.d.ts.map +1 -0
  30. package/dist/exec/index.js +17 -0
  31. package/dist/exec/index.js.map +1 -0
  32. package/dist/exec/kernel-policy.d.ts +35 -0
  33. package/dist/exec/kernel-policy.d.ts.map +1 -0
  34. package/dist/exec/kernel-policy.js +184 -0
  35. package/dist/exec/kernel-policy.js.map +1 -0
  36. package/dist/exec/step-kinds.d.ts +41 -0
  37. package/dist/exec/step-kinds.d.ts.map +1 -0
  38. package/dist/exec/step-kinds.js +599 -0
  39. package/dist/exec/step-kinds.js.map +1 -0
  40. package/dist/exec/step.d.ts +94 -0
  41. package/dist/exec/step.d.ts.map +1 -0
  42. package/dist/exec/step.js +454 -0
  43. package/dist/exec/step.js.map +1 -0
  44. package/dist/flowir/canonical-hash.d.ts +101 -0
  45. package/dist/flowir/canonical-hash.d.ts.map +1 -0
  46. package/dist/flowir/canonical-hash.js +219 -0
  47. package/dist/flowir/canonical-hash.js.map +1 -0
  48. package/dist/flowir/compile.d.ts +49 -0
  49. package/dist/flowir/compile.d.ts.map +1 -0
  50. package/dist/flowir/compile.js +232 -0
  51. package/dist/flowir/compile.js.map +1 -0
  52. package/dist/flowir/cond.d.ts +76 -0
  53. package/dist/flowir/cond.d.ts.map +1 -0
  54. package/dist/flowir/cond.js +214 -0
  55. package/dist/flowir/cond.js.map +1 -0
  56. package/dist/flowir/index.d.ts +20 -23
  57. package/dist/flowir/index.d.ts.map +1 -1
  58. package/dist/flowir/index.js +47 -35
  59. package/dist/flowir/index.js.map +1 -1
  60. package/dist/flowir/meta.d.ts +4 -4
  61. package/dist/flowir/meta.d.ts.map +1 -1
  62. package/dist/flowir/phasefp.d.ts.map +1 -1
  63. package/dist/flowir/phasefp.js +13 -1
  64. package/dist/flowir/phasefp.js.map +1 -1
  65. package/dist/flowir/schema.d.ts +260 -0
  66. package/dist/flowir/schema.d.ts.map +1 -0
  67. package/dist/flowir/schema.js +234 -0
  68. package/dist/flowir/schema.js.map +1 -0
  69. package/dist/flowir/translate.d.ts.map +1 -1
  70. package/dist/flowir/translate.js +3 -0
  71. package/dist/flowir/translate.js.map +1 -1
  72. package/dist/host/runner-types.d.ts +19 -0
  73. package/dist/host/runner-types.d.ts.map +1 -1
  74. package/dist/index.d.ts +4 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +8 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/interpolate.d.ts +4 -0
  79. package/dist/interpolate.d.ts.map +1 -1
  80. package/dist/interpolate.js +14 -0
  81. package/dist/interpolate.js.map +1 -1
  82. package/dist/rates.d.ts +121 -0
  83. package/dist/rates.d.ts.map +1 -0
  84. package/dist/rates.js +187 -0
  85. package/dist/rates.js.map +1 -0
  86. package/dist/replay.d.ts +43 -50
  87. package/dist/replay.d.ts.map +1 -1
  88. package/dist/replay.js +629 -19
  89. package/dist/replay.js.map +1 -1
  90. package/dist/resources/authority.d.ts +35 -0
  91. package/dist/resources/authority.d.ts.map +1 -0
  92. package/dist/resources/authority.js +67 -0
  93. package/dist/resources/authority.js.map +1 -0
  94. package/dist/resources/backend.d.ts +302 -0
  95. package/dist/resources/backend.d.ts.map +1 -0
  96. package/dist/resources/backend.js +16 -0
  97. package/dist/resources/backend.js.map +1 -0
  98. package/dist/resources/baseline.d.ts +116 -0
  99. package/dist/resources/baseline.d.ts.map +1 -0
  100. package/dist/resources/baseline.js +447 -0
  101. package/dist/resources/baseline.js.map +1 -0
  102. package/dist/resources/canonical-json.d.ts +7 -0
  103. package/dist/resources/canonical-json.d.ts.map +1 -0
  104. package/dist/resources/canonical-json.js +75 -0
  105. package/dist/resources/canonical-json.js.map +1 -0
  106. package/dist/resources/errors.d.ts +23 -0
  107. package/dist/resources/errors.d.ts.map +1 -0
  108. package/dist/resources/errors.js +89 -0
  109. package/dist/resources/errors.js.map +1 -0
  110. package/dist/resources/execution.d.ts +90 -0
  111. package/dist/resources/execution.d.ts.map +1 -0
  112. package/dist/resources/execution.js +581 -0
  113. package/dist/resources/execution.js.map +1 -0
  114. package/dist/resources/index.d.ts +15 -0
  115. package/dist/resources/index.d.ts.map +1 -0
  116. package/dist/resources/index.js +15 -0
  117. package/dist/resources/index.js.map +1 -0
  118. package/dist/resources/journal.d.ts +138 -0
  119. package/dist/resources/journal.d.ts.map +1 -0
  120. package/dist/resources/journal.js +438 -0
  121. package/dist/resources/journal.js.map +1 -0
  122. package/dist/resources/leases.d.ts +51 -0
  123. package/dist/resources/leases.d.ts.map +1 -0
  124. package/dist/resources/leases.js +354 -0
  125. package/dist/resources/leases.js.map +1 -0
  126. package/dist/resources/permits.d.ts +52 -0
  127. package/dist/resources/permits.d.ts.map +1 -0
  128. package/dist/resources/permits.js +240 -0
  129. package/dist/resources/permits.js.map +1 -0
  130. package/dist/resources/persistence.d.ts +63 -0
  131. package/dist/resources/persistence.d.ts.map +1 -0
  132. package/dist/resources/persistence.js +522 -0
  133. package/dist/resources/persistence.js.map +1 -0
  134. package/dist/resources/registry.d.ts +56 -0
  135. package/dist/resources/registry.d.ts.map +1 -0
  136. package/dist/resources/registry.js +139 -0
  137. package/dist/resources/registry.js.map +1 -0
  138. package/dist/resources/resolve.d.ts +49 -0
  139. package/dist/resources/resolve.d.ts.map +1 -0
  140. package/dist/resources/resolve.js +309 -0
  141. package/dist/resources/resolve.js.map +1 -0
  142. package/dist/resources/sandbox.d.ts +72 -0
  143. package/dist/resources/sandbox.d.ts.map +1 -0
  144. package/dist/resources/sandbox.js +952 -0
  145. package/dist/resources/sandbox.js.map +1 -0
  146. package/dist/resources/schema.d.ts +195 -0
  147. package/dist/resources/schema.d.ts.map +1 -0
  148. package/dist/resources/schema.js +231 -0
  149. package/dist/resources/schema.js.map +1 -0
  150. package/dist/resources/types.d.ts +36 -0
  151. package/dist/resources/types.d.ts.map +1 -0
  152. package/dist/resources/types.js +75 -0
  153. package/dist/resources/types.js.map +1 -0
  154. package/dist/runner-core.d.ts +53 -0
  155. package/dist/runner-core.d.ts.map +1 -1
  156. package/dist/runner-core.js +521 -53
  157. package/dist/runner-core.js.map +1 -1
  158. package/dist/runtime/phases/approval.d.ts +23 -0
  159. package/dist/runtime/phases/approval.d.ts.map +1 -0
  160. package/dist/runtime/phases/approval.js +38 -0
  161. package/dist/runtime/phases/approval.js.map +1 -0
  162. package/dist/runtime/phases/expand.d.ts +29 -0
  163. package/dist/runtime/phases/expand.d.ts.map +1 -0
  164. package/dist/runtime/phases/expand.js +126 -0
  165. package/dist/runtime/phases/expand.js.map +1 -0
  166. package/dist/runtime/phases/parallel.d.ts +26 -0
  167. package/dist/runtime/phases/parallel.d.ts.map +1 -0
  168. package/dist/runtime/phases/parallel.js +15 -0
  169. package/dist/runtime/phases/parallel.js.map +1 -0
  170. package/dist/runtime/phases/race.d.ts +31 -0
  171. package/dist/runtime/phases/race.d.ts.map +1 -0
  172. package/dist/runtime/phases/race.js +220 -0
  173. package/dist/runtime/phases/race.js.map +1 -0
  174. package/dist/runtime/phases/script.d.ts +38 -0
  175. package/dist/runtime/phases/script.d.ts.map +1 -0
  176. package/dist/runtime/phases/script.js +177 -0
  177. package/dist/runtime/phases/script.js.map +1 -0
  178. package/dist/runtime.d.ts +68 -16
  179. package/dist/runtime.d.ts.map +1 -1
  180. package/dist/runtime.js +1091 -337
  181. package/dist/runtime.js.map +1 -1
  182. package/dist/schema.d.ts +104 -9
  183. package/dist/schema.d.ts.map +1 -1
  184. package/dist/schema.js +249 -32
  185. package/dist/schema.js.map +1 -1
  186. package/dist/store.d.ts +13 -0
  187. package/dist/store.d.ts.map +1 -1
  188. package/dist/store.js.map +1 -1
  189. package/dist/trace.d.ts +7 -1
  190. package/dist/trace.d.ts.map +1 -1
  191. package/dist/trace.js +33 -26
  192. package/dist/trace.js.map +1 -1
  193. package/package.json +7 -5
package/dist/runtime.js CHANGED
@@ -11,9 +11,9 @@
11
11
  */
12
12
  import * as path from "node:path";
13
13
  import * as fs from "node:fs";
14
- import { coerceArray, evaluateCondition, interpolate, safeParse, tryEvaluateCondition } from "./interpolate.js";
14
+ import { coerceArray, evaluateCondition, interpolate, interpolateValue, safeParse, tryEvaluateCondition } from "./interpolate.js";
15
15
  import { contractViolations } from "./contract.js";
16
- import { isFailed, isTransientError, mapWithConcurrencyLimit, sanitizeErrorMessage } from "./runner-core.js";
16
+ import { isFailed, isTransientError, mapWithConcurrencyLimit, PHASE_TIMEOUT_ABORT_GRACE_MS, sanitizeErrorMessage } from "./runner-core.js";
17
17
  /** Default runner used when no host injected one: fail loudly rather than
18
18
  * silently spawn anything (core is host-neutral and cannot spawn pi/codex). */
19
19
  const noRunnerInjected = async (_cwd, _agents, agentName, task) => ({
@@ -26,11 +26,13 @@ const noRunnerInjected = async (_cwd, _agents, agentName, task) => ({
26
26
  errorMessage: "No subagent runner injected",
27
27
  stopReason: "error",
28
28
  });
29
+ export { PHASE_TIMEOUT_ABORT_GRACE_MS } from "./runner-core.js";
29
30
  import { aggregateUsage, emptyUsage } from "./usage.js";
30
- import { dependenciesOf, finalPhase, LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, MAX_DYNAMIC_MAP_ITEMS, MAX_DYNAMIC_NESTING, parseTtlMs, resolveArgs, topoLayers, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, validateTaskflow } from "./schema.js";
31
+ import { dependenciesOf, finalPhase, LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, MAX_DYNAMIC_MAP_ITEMS, MAX_DYNAMIC_NESTING, MAX_DYNAMIC_PHASES, parseTtlMs, resolveArgs, topoLayers, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, validateInvocationArgs, validateTaskflow } from "./schema.js";
31
32
  import { verifyTaskflow } from "./verify.js";
32
- import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, scoreResultJSON, scorerShapeErrors, WINNER_TOKEN_RE } from "./scorers.js";
33
- import { parseGateVerdict, overBudget as overBudgetCheck } from "./deterministic.js";
33
+ import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, scoreResultJSON, scorerShapeErrors } from "./scorers.js";
34
+ import { parseGateVerdict, overBudget as overBudgetCheck, parseTournamentWinner } from "./deterministic.js";
35
+ export { parseTournamentWinner } from "./deterministic.js";
34
36
  import {} from "./trace.js";
35
37
  // Re-export so existing `import { parseGateVerdict } from "./runtime.ts"` callers
36
38
  // (and tests) keep working; the implementation now lives in deterministic.ts.
@@ -43,6 +45,8 @@ import { compileTaskflowToIR, phaseFingerprint } from "./flowir/index.js";
43
45
  import { computeStaleFrontier, declaredReadMapOfDef, readMapOf } from "./stale.js";
44
46
  import { ctxDirFor, drainPendingSpawns, initCtxDir, registerNode, setNodeStatus } from "./context-store.js";
45
47
  import { allocateWorkspace, isWorkspaceKeyword } from "./workspace.js";
48
+ import { cwdArgName, directoryIdentity, isPathWithin, resolveCwdArg, } from "./cwd-bridge.js";
49
+ import { createResolveOnlyWorkspaceSession, } from "./resources/execution.js";
46
50
  /** Compute the incremental-reuse summary from a run's terminal phase states.
47
51
  * Pure, total, never throws. A phase is "reused" iff it carries a `cacheHit`
48
52
  * marker (set by `cachedPhase` for both within-run resume and cross-run hits). */
@@ -252,13 +256,17 @@ function normalizeInlineDef(parsed, phaseId) {
252
256
  * than the parent's, never looser. A generated def cannot raise the spend cap by
253
257
  * declaring its own large budget. Each dimension becomes min(child, parent).
254
258
  */
255
- function clampSubFlowBudget(sub, parentBudget) {
259
+ function clampSubFlowBudget(sub, parentBudget, spent = emptyUsage()) {
256
260
  if (!parentBudget)
257
261
  return sub;
258
262
  const child = sub.budget;
263
+ const remainingUSD = parentBudget.maxUSD === undefined ? Infinity : Math.max(0, parentBudget.maxUSD - spent.cost);
264
+ const remainingTokens = parentBudget.maxTokens === undefined
265
+ ? Infinity
266
+ : Math.max(0, parentBudget.maxTokens - (spent.input + spent.output));
259
267
  const clamped = {
260
- maxUSD: Math.min(child?.maxUSD ?? Infinity, parentBudget.maxUSD ?? Infinity),
261
- maxTokens: Math.min(child?.maxTokens ?? Infinity, parentBudget.maxTokens ?? Infinity),
268
+ maxUSD: Math.min(child?.maxUSD ?? Infinity, remainingUSD),
269
+ maxTokens: Math.min(child?.maxTokens ?? Infinity, remainingTokens),
262
270
  };
263
271
  // Drop Infinity dimensions (no cap on that axis).
264
272
  const budget = {};
@@ -344,6 +352,37 @@ function traceEmit(deps, event) {
344
352
  /* trace is best-effort; never run-breaking */
345
353
  }
346
354
  }
355
+ /** Emit a `decision` event (S1: full decision coverage for fold/replay). Fail-open. */
356
+ function traceDecision(deps, state, phaseId, decision) {
357
+ traceEmit(deps, {
358
+ ts: Date.now(),
359
+ runId: state.runId,
360
+ phaseId,
361
+ kind: "decision",
362
+ decision,
363
+ });
364
+ }
365
+ /** Emit gate decision as gate-score when scores present, else gate-verdict. */
366
+ function traceGateDecision(deps, state, phaseId, gate, judgeOutput) {
367
+ if (gate.scores) {
368
+ traceDecision(deps, state, phaseId, {
369
+ type: "gate-score",
370
+ target: "",
371
+ results: gate.scores.results,
372
+ combined: gate.scores.combined,
373
+ threshold: gate.scores.threshold,
374
+ verdict: gate.verdict,
375
+ judgeOutput,
376
+ });
377
+ }
378
+ else {
379
+ traceDecision(deps, state, phaseId, {
380
+ type: "gate-verdict",
381
+ value: gate.verdict,
382
+ reason: gate.reason,
383
+ });
384
+ }
385
+ }
347
386
  /** Fail-open trace flush at phase-end. */
348
387
  function traceFlush(deps, phaseId) {
349
388
  try {
@@ -355,7 +394,7 @@ function traceFlush(deps, phaseId) {
355
394
  }
356
395
  /** Emit a `decision: unreplayable` marker for a phase whose inputs the trace
357
396
  * cannot fully capture (Shared Context Tree, inner sub-flows, context files,
358
- * unobservable interpolation deps). A future replay marks such phases
397
+ * unobservable interpolation deps). Offline replay marks such phases
359
398
  * `needs-live-rerun` instead of silently reusing a recorded output.
360
399
  * Single-phase analog of `hasUnobservedDependencies`. Fail-open. */
361
400
  function emitUnreplayableMarker(deps, state, phase) {
@@ -371,7 +410,7 @@ function emitUnreplayableMarker(deps, state, phase) {
371
410
  function unreplayableReason(state, phase) {
372
411
  if (phase.shareContext === true || state.def.contextSharing === true)
373
412
  return "context-sharing";
374
- if (phase.type === "flow")
413
+ if (phase.type === "flow" || phase.type === "expand")
375
414
  return "inner-flow";
376
415
  if (phase.context && phase.context.length > 0)
377
416
  return "context-files";
@@ -406,7 +445,7 @@ function liveSink(state, phaseId, emitProgress) {
406
445
  */
407
446
  const CONTEXT_MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
408
447
  const MAX_TOTAL_CONTEXT_CHARS = 200_000;
409
- async function resolvePhaseContext(phase, ctx) {
448
+ async function resolvePhaseContext(phase, ctx, cwd, boundary) {
410
449
  const entries = phase.context;
411
450
  if (!entries || entries.length === 0)
412
451
  return "";
@@ -445,20 +484,29 @@ async function resolvePhaseContext(phase, ctx) {
445
484
  const blocks = [];
446
485
  for (const p of filtered) {
447
486
  try {
448
- const abs = path.resolve(p);
449
- const stat = fs.statSync(abs);
487
+ const abs = path.resolve(cwd, p);
488
+ if (boundary && !isPathWithin(boundary, abs)) {
489
+ throw new Error(`TF_CWD_BOUNDARY_ESCAPE: context path '${p}' escapes the inherited cwd boundary`);
490
+ }
491
+ const canonical = fs.realpathSync(abs);
492
+ if (boundary && !isPathWithin(boundary, canonical)) {
493
+ throw new Error(`TF_CWD_BOUNDARY_ESCAPE: context path '${p}' resolves outside the inherited cwd boundary`);
494
+ }
495
+ const stat = fs.statSync(canonical);
450
496
  if (!stat.isFile())
451
497
  continue;
452
498
  if (stat.size > CONTEXT_MAX_FILE_BYTES)
453
499
  continue;
454
- const content = fs.readFileSync(abs, "utf-8");
500
+ const content = fs.readFileSync(canonical, "utf-8");
455
501
  const truncated = content.length > limit
456
502
  ? content.slice(0, limit) + `\n... [truncated ${content.length - limit} chars]`
457
503
  : content;
458
504
  const ext = path.extname(p).slice(1) || "txt";
459
505
  blocks.push(`## File: ${p}\n\n\`\`\`${ext}\n${truncated}\n\`\`\``);
460
506
  }
461
- catch {
507
+ catch (error) {
508
+ if (error instanceof Error && error.message.startsWith("TF_CWD_BOUNDARY_ESCAPE:"))
509
+ throw error;
462
510
  console.warn(`[taskflow] Skipped unreadable context file: ${p}`);
463
511
  }
464
512
  }
@@ -469,6 +517,16 @@ async function resolvePhaseContext(phase, ctx) {
469
517
  }
470
518
  return result;
471
519
  }
520
+ function spawnedOverBudget(state, local) {
521
+ const budget = state.def.budget;
522
+ if (!budget)
523
+ return false;
524
+ return overBudgetCheck({
525
+ maxUSD: budget.maxUSD,
526
+ maxTokens: budget.maxTokens,
527
+ usages: [...Object.values(state.phases).map((p) => p.usage ?? emptyUsage()), local],
528
+ }).over;
529
+ }
472
530
  /**
473
531
  * Run an inline sub-flow queued via `ctx_spawn({subflow})`. Reuses the SAME
474
532
  * validation + execution machinery as a `flow{def}` phase (normalizeInlineDef →
@@ -490,19 +548,85 @@ async function resolvePhaseContext(phase, ctx) {
490
548
  * two isolation-leak bugs in the 0.0.23 review).
491
549
  */
492
550
  function resolveEffCwd(deps, phase) {
493
- return deps._cwdOverride ?? (isWorkspaceKeyword(phase.cwd) ? deps.cwd : phase.cwd ?? deps.cwd);
551
+ if (deps._cwdOverride)
552
+ return deps._cwdOverride;
553
+ if (!phase.cwd || isWorkspaceKeyword(phase.cwd))
554
+ return deps.cwd;
555
+ // Node resolves a relative spawn cwd against the Taskflow process cwd. That
556
+ // is not necessarily the invocation root, so anchor legacy literals here.
557
+ return path.resolve(deps.cwd, phase.cwd);
494
558
  }
495
- async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, deps, state) {
559
+ function flowTreeUsesCwdBridge(def, loadFlow, seenUses = new Set()) {
560
+ if (def.phases.some((phase) => cwdArgName(phase.cwd) !== undefined))
561
+ return true;
562
+ if (!loadFlow)
563
+ return false;
564
+ for (const phase of def.phases) {
565
+ if ((phase.type ?? "agent") !== "flow" || !phase.use)
566
+ continue;
567
+ if (seenUses.has(phase.use))
568
+ continue;
569
+ seenUses.add(phase.use);
570
+ try {
571
+ const child = loadFlow(phase.use);
572
+ if (child && flowTreeUsesCwdBridge(child, loadFlow, seenUses))
573
+ return true;
574
+ }
575
+ catch {
576
+ // Unknown is treated as capability-bearing: disable reuse, then let the
577
+ // normal phase execution path report the loader failure coherently.
578
+ return true;
579
+ }
580
+ }
581
+ return false;
582
+ }
583
+ /**
584
+ * Freeze the saved-flow namespace for one top-level execution. Capability
585
+ * discovery and phase execution must observe the same definition, including
586
+ * the same loader error, or a mutable loader could introduce a bridge only
587
+ * after the root binding and cache policy were decided.
588
+ */
589
+ function snapshotFlowLoader(deps) {
590
+ if (!deps.loadFlow || deps._flowLoaderSnapshot)
591
+ return deps;
592
+ const source = deps.loadFlow;
593
+ const snapshot = new Map();
594
+ const loadFlow = (name) => {
595
+ let entry = snapshot.get(name);
596
+ if (!entry) {
597
+ try {
598
+ const loaded = source(name);
599
+ // Loader-owned objects may be mutated asynchronously. Capability scan
600
+ // and execution operate on a detached structured snapshot, never the
601
+ // loader's live reference.
602
+ entry = { ok: true, value: loaded === undefined ? undefined : structuredClone(loaded) };
603
+ }
604
+ catch (error) {
605
+ entry = { ok: false, error };
606
+ }
607
+ snapshot.set(name, entry);
608
+ }
609
+ if (!entry.ok)
610
+ throw entry.error;
611
+ return entry.value;
612
+ };
613
+ return { ...deps, loadFlow, _flowLoaderSnapshot: snapshot };
614
+ }
615
+ function sameDirectoryIdentity(a, b) {
616
+ return !!a && !!b && a.canonicalPath === b.canonicalPath && a.device === b.device && a.inode === b.inode;
617
+ }
618
+ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, deps, state, localSpawnUsage) {
496
619
  const stack = deps._stack ?? [];
497
620
  const inlineDepth = stack.filter((s) => s.startsWith("def:")).length;
498
621
  if (inlineDepth >= MAX_DYNAMIC_NESTING) {
499
- return { output: `(spawned subflow rejected: nesting exceeded MAX_DYNAMIC_NESTING (${MAX_DYNAMIC_NESTING}))`, usage: emptyUsage() };
622
+ const error = `spawned subflow rejected: nesting exceeded MAX_DYNAMIC_NESTING (${MAX_DYNAMIC_NESTING})`;
623
+ return { output: `(${error})`, usage: emptyUsage(), failed: true, error };
500
624
  }
501
625
  const wrapped = normalizeInlineDef(subflowSpec, childNodeId);
502
626
  if (!wrapped)
503
- return { output: "(spawned subflow is not a Taskflow / phases array)", usage: emptyUsage() };
627
+ return { output: "(spawned subflow is not a Taskflow / phases array)", usage: emptyUsage(), failed: true, error: "spawned subflow is not a Taskflow / phases array" };
504
628
  if (wrapped.phases.length === 0)
505
- return { output: "(spawned subflow had zero phases — no-op)", usage: emptyUsage() };
629
+ return { output: "(spawned subflow had zero phases — no-op)", usage: emptyUsage(), failed: false };
506
630
  // Inner phases without their own agent inherit the assignment's defaultAgent.
507
631
  if (defaultAgent) {
508
632
  for (const p of wrapped.phases)
@@ -512,14 +636,26 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
512
636
  const spawnCwd = resolveEffCwd(deps, phase);
513
637
  const dynCwd = spawnCwd;
514
638
  const v = validateTaskflow(wrapped, { dynamic: true, cwd: dynCwd });
515
- if (!v.ok)
516
- return { output: `(spawned subflow failed validation: ${v.errors.join("; ")})`, usage: emptyUsage() };
639
+ if (!v.ok) {
640
+ const error = `spawned subflow failed validation: ${v.errors.join("; ")}`;
641
+ return { output: `(${error})`, usage: emptyUsage(), failed: true, error };
642
+ }
517
643
  const ver = verifyTaskflow({ name: wrapped.name, phases: wrapped.phases, budget: wrapped.budget, concurrency: wrapped.concurrency });
518
644
  if (!ver.ok) {
519
645
  const errs = ver.issues.filter((i) => i.severity === "error").map((i) => i.message);
520
- return { output: `(spawned subflow failed verification: ${errs.join("; ")})`, usage: emptyUsage() };
521
- }
522
- const subDef = clampSubFlowBudget(wrapped, state.def.budget);
646
+ const error = `spawned subflow failed verification: ${errs.join("; ")}`;
647
+ return { output: `(${error})`, usage: emptyUsage(), failed: true, error };
648
+ }
649
+ // The generated sub-flow gets only what remains after both already-folded
650
+ // parent spend and siblings/ancestors in this still-running spawn batch. USD
651
+ // and tokens are clamped independently by clampSubFlowBudget. Like the main
652
+ // runtime, this is an atomic-call ceiling: one call may cross the cap, then no
653
+ // subsequent call is admitted.
654
+ const parentAndBatchSpent = aggregateUsage([
655
+ ...Object.values(state.phases).map((p) => p.usage ?? emptyUsage()),
656
+ localSpawnUsage,
657
+ ]);
658
+ const subDef = clampSubFlowBudget(wrapped, state.def.budget, parentAndBatchSpent);
523
659
  const subState = {
524
660
  runId: newRunId(subDef.name),
525
661
  flowName: subDef.name,
@@ -535,6 +671,8 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
535
671
  const subResult = await executeTaskflow(subState, {
536
672
  ...deps,
537
673
  cwd: dynCwd,
674
+ _cacheCwdIdentity: phase.cwd !== undefined || deps._cacheCwdIdentity !== undefined ? dynCwd : undefined,
675
+ _dynamic: true,
538
676
  // The parent phase's isolated workspace (if any) applies only to the
539
677
  // parent — each spawned sub-phase resolves its own cwd. Clear the
540
678
  // override so the whole subflow doesn't inherit the parent's dir
@@ -550,22 +688,29 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
550
688
  // Sum every sub-phase's usage so the parent's budget guard sees spawn spend
551
689
  // (verdict Issue 2).
552
690
  const usage = aggregateUsage(Object.values(subResult.state.phases).map((p) => p.usage ?? emptyUsage()));
553
- return { output: subResult.finalOutput ?? "", usage };
691
+ return {
692
+ output: subResult.finalOutput ?? "",
693
+ usage,
694
+ failed: !subResult.ok,
695
+ ...(!subResult.ok ? { error: sanitizeErrorMessage(subResult.finalOutput || "spawned subflow failed") } : {}),
696
+ };
554
697
  }
555
698
  catch (e) {
556
- return { output: `(spawned subflow failed: ${e instanceof Error ? e.message : String(e)})`, usage: emptyUsage() };
699
+ const error = sanitizeErrorMessage(e instanceof Error ? e.message : String(e));
700
+ return { output: `(spawned subflow failed: ${error})`, usage: emptyUsage(), failed: true, error };
557
701
  }
558
702
  }
559
- async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps, state, run) {
703
+ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps, state, run, ledger = { usage: emptyUsage() }) {
560
704
  const capped = assignments.slice(0, MAX_DYNAMIC_MAP_ITEMS);
561
705
  const lines = [];
562
706
  const usages = [];
707
+ const errors = [];
563
708
  // Effective cwd for flat spawned tasks: honour a workspace override and never
564
709
  // pass a reserved keyword through to the runner.
565
710
  const spawnCwd = resolveEffCwd(deps, phase);
566
711
  let idx = 0;
567
712
  for (const a of capped) {
568
- if (deps.signal?.aborted || overBudget(state).over)
713
+ if (deps.signal?.aborted || spawnedOverBudget(state, ledger.usage))
569
714
  break;
570
715
  idx++;
571
716
  const childNodeId = `${parentNodeId}--c${idx}`.replace(/[^A-Za-z0-9._-]+/g, "_");
@@ -575,42 +720,92 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
575
720
  let out = "";
576
721
  try {
577
722
  if (isSubflow) {
578
- const sub = await runInlineSubflow(a.subflow, a.defaultAgent ?? phase.agent, childNodeId, phase, deps, state);
723
+ const sub = await runInlineSubflow(a.subflow, a.defaultAgent ?? phase.agent, childNodeId, phase, deps, state, ledger.usage);
579
724
  out = sub.output;
580
725
  usages.push(sub.usage);
581
- setNodeStatus(ctxDir, childNodeId, "done");
726
+ ledger.usage = aggregateUsage([ledger.usage, sub.usage]);
727
+ if (sub.failed)
728
+ errors.push(sub.error ?? `spawned subflow ${childNodeId} failed`);
729
+ setNodeStatus(ctxDir, childNodeId, sub.failed ? "failed" : "done");
582
730
  }
583
731
  else {
584
- const r = await run(spawnCwd, deps.agents, agentName, a.task ?? "", { model: phase.model, thinking: phase.thinking, tools: phase.tools, cwd: spawnCwd, signal: deps.signal, ctxDir, nodeId: childNodeId }, deps.globalThinking);
732
+ const task = a.task ?? "";
733
+ const runOptions = {
734
+ model: phase.model,
735
+ thinking: phase.thinking,
736
+ tools: phase.tools,
737
+ cwd: spawnCwd,
738
+ signal: deps.signal,
739
+ ctxDir,
740
+ nodeId: childNodeId,
741
+ };
742
+ const invoke = () => run(spawnCwd, deps.agents, agentName, task, runOptions, deps.globalThinking);
743
+ // ctx_spawn is part of the phase's execution tree, not bookkeeping.
744
+ // Inherit the exact cwd capability and give every descendant a unique
745
+ // unit owner so no child can bypass lease/WAL/permit coordination.
746
+ const r = deps._workspaceBinding
747
+ ? await deps._workspaceBinding.runAgent({
748
+ agents: deps.agents,
749
+ agentName,
750
+ task,
751
+ opts: runOptions,
752
+ globalThinking: deps.globalThinking,
753
+ unitId: childNodeId,
754
+ invoke,
755
+ })
756
+ : await invoke();
585
757
  out = r.output ?? "";
586
- if (r.usage)
758
+ if (isFailed(r)) {
759
+ const detail = sanitizeErrorMessage(r.errorMessage ?? r.stderr ?? "spawned child failed");
760
+ errors.push(detail);
761
+ if (!out)
762
+ out = `(spawned child failed: ${detail})`;
763
+ }
764
+ if (r.usage) {
587
765
  usages.push(r.usage);
766
+ ledger.usage = aggregateUsage([ledger.usage, r.usage]);
767
+ }
588
768
  setNodeStatus(ctxDir, childNodeId, isFailed(r) ? "failed" : "done");
589
769
  // A child may itself have queued spawns — recurse (depth-capped by the tool).
590
770
  const grand = drainPendingSpawns(ctxDir, childNodeId);
591
- if (grand.length > 0 && !deps.signal?.aborted && !overBudget(state).over) {
592
- const rec = await runSpawnedChildren(grand, ctxDir, childNodeId, phase, deps, state, run);
771
+ if (grand.length > 0 && !deps.signal?.aborted && !spawnedOverBudget(state, ledger.usage)) {
772
+ const rec = await runSpawnedChildren(grand, ctxDir, childNodeId, phase, deps, state, run, ledger);
593
773
  if (rec.reports)
594
774
  out += rec.reports;
595
775
  usages.push(rec.usage);
776
+ errors.push(...rec.errors);
596
777
  }
597
778
  }
598
779
  }
599
780
  catch (e) {
600
781
  setNodeStatus(ctxDir, childNodeId, "failed");
601
- out = `(spawned child failed: ${e instanceof Error ? e.message : String(e)})`;
782
+ const detail = sanitizeErrorMessage(e instanceof Error ? e.message : String(e));
783
+ errors.push(detail);
784
+ out = `(spawned child failed: ${detail})`;
602
785
  }
603
786
  lines.push(`### spawned child ${idx} (${agentName})\n${out}`);
604
787
  }
605
788
  const usage = aggregateUsage(usages);
606
789
  if (lines.length === 0)
607
- return { reports: undefined, usage };
608
- return { reports: `\n\n<!-- ctx_spawn: ${lines.length} child report(s) -->\n${lines.join("\n\n")}`, usage };
790
+ return { reports: undefined, usage, failed: errors.length > 0, errors };
791
+ return {
792
+ reports: `\n\n<!-- ctx_spawn: ${lines.length} child report(s) -->\n${lines.join("\n\n")}`,
793
+ usage,
794
+ failed: errors.length > 0,
795
+ errors,
796
+ };
609
797
  }
610
798
  async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
611
799
  // Trace: phase-start (fail-open). Record whether this phase is replayable
612
- // up front so a future replay can short-circuit it without walking events.
613
- traceEmit(deps, { ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "phase-start" });
800
+ // up front so replay can short-circuit it without guessing from output.
801
+ traceEmit(deps, {
802
+ ts: Date.now(),
803
+ runId: state.runId,
804
+ phaseId: phase.id,
805
+ kind: "phase-start",
806
+ dependencies: dependenciesOf(phase),
807
+ optional: phase.optional === true,
808
+ });
614
809
  if (deps.trace)
615
810
  emitUnreplayableMarker(deps, state, phase);
616
811
  let result;
@@ -630,6 +825,13 @@ async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth
630
825
  }
631
826
  if (threw)
632
827
  return result; // unreachable; satisfies TS
828
+ // S1: cache-hit decision (within-run or cross-run) for fold/replay.
829
+ if (result.cacheHit) {
830
+ traceDecision(deps, state, phase.id, {
831
+ type: "cache-hit",
832
+ scope: result.cacheHit === "cross-run" ? "cross-run" : "run-only",
833
+ });
834
+ }
633
835
  // Trace: phase-end with the real status, then flush buffered events.
634
836
  traceEmit(deps, {
635
837
  ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "phase-end",
@@ -644,6 +846,8 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
644
846
  // every type branch inside executePhaseInner is covered. A skipped phase ran
645
847
  // nothing — no side effect to record.
646
848
  const stamp = (ps) => {
849
+ if (phase.optional === true)
850
+ ps.optional = true;
647
851
  if (phase.idempotent === false && ps.status !== "skipped") {
648
852
  ps.sideEffect = true;
649
853
  // Resume double-fire warning (issue #20): a non-idempotent phase is never
@@ -657,9 +861,163 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
657
861
  }
658
862
  return ps;
659
863
  };
864
+ const cwdArg = cwdArgName(phase.cwd);
865
+ let innerOpts = { ...opts, upstreamDeps: deps };
866
+ if ((cwdArg !== undefined || deps._dynamic === true || deps._cwdBoundary !== undefined) && phase.when !== undefined) {
867
+ const whenReadRefs = [];
868
+ const whenCtx = buildInterpolationContext(state, lastCompletedOutput(state, phase), undefined, (ref) => whenReadRefs.push(ref));
869
+ const whenResult = evaluateCondition(phase.when, whenCtx);
870
+ traceDecision(deps, state, phase.id, {
871
+ type: "when-guard",
872
+ expression: phase.when,
873
+ result: whenResult,
874
+ });
875
+ if (!whenResult) {
876
+ return stamp({
877
+ id: phase.id,
878
+ status: "skipped",
879
+ error: `Condition not met: ${phase.when}`,
880
+ endedAt: Date.now(),
881
+ usage: emptyUsage(),
882
+ reads: readRefsToReads(whenReadRefs, state),
883
+ });
884
+ }
885
+ innerOpts = { ...innerOpts, whenPrechecked: true, whenReadRefs };
886
+ }
887
+ if (deps._dynamic === true && ((phase.context?.length ?? 0) > 0 || phase.cwd !== undefined)) {
888
+ return stamp({
889
+ id: phase.id,
890
+ status: "failed",
891
+ error: "TF_DYNAMIC_RESOURCE_FORBIDDEN: generated sub-flows cannot declare cwd or context file pre-reads",
892
+ endedAt: Date.now(),
893
+ usage: emptyUsage(),
894
+ });
895
+ }
896
+ if (cwdArg !== undefined) {
897
+ const spec = state.def.args?.[cwdArg];
898
+ if (spec?.type !== "relative-path") {
899
+ return stamp({
900
+ id: phase.id,
901
+ status: "failed",
902
+ error: `TF_CWD_ARG_INVALID: cwd argument '${cwdArg}' is not declared with type 'relative-path'`,
903
+ endedAt: Date.now(),
904
+ usage: emptyUsage(),
905
+ });
906
+ }
907
+ const bridgeMode = deps.workspaceSession ? "resolve-only" : deps.cwdBridgeMode;
908
+ const bound = resolveCwdArg(deps.cwd, cwdArg, state.args[cwdArg], bridgeMode);
909
+ if (!bound.ok) {
910
+ return stamp({
911
+ id: phase.id,
912
+ status: "failed",
913
+ error: `${bound.code}: ${bound.message}`,
914
+ endedAt: Date.now(),
915
+ usage: emptyUsage(),
916
+ });
917
+ }
918
+ if (deps._cwdBoundary && !isPathWithin(deps._cwdBoundary, bound.value.absolutePath)) {
919
+ return stamp({
920
+ id: phase.id,
921
+ status: "failed",
922
+ error: `TF_CWD_BOUNDARY_ESCAPE: cwd argument '${cwdArg}' resolves outside the inherited cwd boundary`,
923
+ endedAt: Date.now(),
924
+ usage: emptyUsage(),
925
+ });
926
+ }
927
+ let workspaceBinding;
928
+ try {
929
+ workspaceBinding = await deps.workspaceSession?.bindPhase({
930
+ invocationRoot: deps.cwd,
931
+ runId: state.runId,
932
+ phaseId: phase.id,
933
+ argName: cwdArg,
934
+ argDefinitions: state.def.args ?? {},
935
+ argValues: state.args,
936
+ });
937
+ }
938
+ catch (error) {
939
+ return stamp({
940
+ id: phase.id,
941
+ status: "failed",
942
+ error: error instanceof Error ? error.message : String(error),
943
+ endedAt: Date.now(),
944
+ usage: emptyUsage(),
945
+ });
946
+ }
947
+ if (workspaceBinding && workspaceBinding.absolutePath !== bound.value.absolutePath) {
948
+ return stamp({
949
+ id: phase.id,
950
+ status: "failed",
951
+ error: "TFWS_IDENTITY_MISMATCH: compatibility resolver and capability resolver selected different cwd identities",
952
+ endedAt: Date.now(),
953
+ usage: emptyUsage(),
954
+ });
955
+ }
956
+ const innerDeps = {
957
+ ...deps,
958
+ _cwdOverride: bound.value.absolutePath,
959
+ _cwdBoundary: bound.value.absolutePath,
960
+ _cacheCwdIdentity: bound.value.absolutePath,
961
+ _disableCache: true,
962
+ _workspaceBinding: workspaceBinding,
963
+ };
964
+ const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts);
965
+ ps.warnings = [
966
+ ...(ps.warnings ?? []),
967
+ `cwd bridge: resolve-only {args.${cwdArg}} -> ${bound.value.logicalPath}; principal/root authorization, cross-process lease, and write journal are active, but filesystem access outside this directory is not sandbox-enforced`,
968
+ ];
969
+ return stamp(ps);
970
+ }
971
+ if (deps._cwdBoundary && phase.cwd) {
972
+ if (isWorkspaceKeyword(phase.cwd)) {
973
+ return stamp({
974
+ id: phase.id,
975
+ status: "failed",
976
+ error: `TF_CWD_BOUNDARY_ESCAPE: workspace provider '${phase.cwd}' cannot expand an inherited cwd boundary`,
977
+ endedAt: Date.now(),
978
+ usage: emptyUsage(),
979
+ });
980
+ }
981
+ const selected = directoryIdentity(path.resolve(deps.cwd, phase.cwd));
982
+ if (!selected || !isPathWithin(deps._cwdBoundary, selected.canonicalPath)) {
983
+ return stamp({
984
+ id: phase.id,
985
+ status: "failed",
986
+ error: `TF_CWD_BOUNDARY_ESCAPE: cwd '${phase.cwd}' must select an existing directory inside the inherited cwd boundary`,
987
+ endedAt: Date.now(),
988
+ usage: emptyUsage(),
989
+ });
990
+ }
991
+ let narrowedBinding;
992
+ try {
993
+ narrowedBinding = await deps.workspaceSession?.bindPhase({
994
+ invocationRoot: selected.canonicalPath,
995
+ runId: state.runId,
996
+ phaseId: phase.id,
997
+ argDefinitions: state.def.args ?? {},
998
+ argValues: state.args,
999
+ });
1000
+ }
1001
+ catch (error) {
1002
+ return stamp({
1003
+ id: phase.id,
1004
+ status: "failed",
1005
+ error: error instanceof Error ? error.message : String(error),
1006
+ endedAt: Date.now(),
1007
+ usage: emptyUsage(),
1008
+ });
1009
+ }
1010
+ return stamp(await executePhaseInner(phase, state, {
1011
+ ...deps,
1012
+ _cwdOverride: selected.canonicalPath,
1013
+ _cwdBoundary: selected.canonicalPath,
1014
+ _cacheCwdIdentity: selected.canonicalPath,
1015
+ _workspaceBinding: narrowedBinding,
1016
+ }, prior, emitProgress, _retryDepth, innerOpts));
1017
+ }
660
1018
  // Non-keyword cwd (or none): no workspace lifecycle — run directly.
661
1019
  if (!isWorkspaceKeyword(phase.cwd)) {
662
- return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, opts));
1020
+ return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, innerOpts));
663
1021
  }
664
1022
  let ws;
665
1023
  try {
@@ -673,9 +1031,9 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
673
1031
  catch {
674
1032
  ws = undefined; // fail-open: run in the base cwd
675
1033
  }
676
- const innerDeps = ws ? { ...deps, _cwdOverride: ws.dir } : deps;
1034
+ const innerDeps = ws ? { ...deps, _cwdOverride: ws.dir, _cacheCwdIdentity: ws.dir } : deps;
677
1035
  try {
678
- const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, opts);
1036
+ const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts);
679
1037
  if (ws && (ws.kind !== "inherited" || ws.note)) {
680
1038
  const tag = ws.kind === "inherited" ? "workspace" : `workspace:${ws.kind}`;
681
1039
  const msg = ws.note ? `${tag} — ${ws.note}` : `${tag} at ${ws.dir}`;
@@ -725,7 +1083,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
725
1083
  // M3 observed-readSet: collect every upstream ref this phase resolves, so we
726
1084
  // can record what its result ACTUALLY depended on (not just its declared
727
1085
  // dependsOn). Shared by every interpolation in this phase (task / when / …).
728
- const readRefs = [];
1086
+ const readRefs = [...(opts?.whenReadRefs ?? [])];
729
1087
  const onRead = (ref) => {
730
1088
  readRefs.push(ref);
731
1089
  };
@@ -734,8 +1092,14 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
734
1092
  // dependencies. Evaluate them inside executePhaseInner so every upstream
735
1093
  // interpolation is captured by the shared onRead hook, not silently dropped
736
1094
  // by a separate out-of-band context.
737
- if (phase.when !== undefined) {
738
- if (!evaluateCondition(phase.when, ctx)) {
1095
+ if (phase.when !== undefined && opts?.whenPrechecked !== true) {
1096
+ const whenResult = evaluateCondition(phase.when, ctx);
1097
+ traceDecision(deps, state, phase.id, {
1098
+ type: "when-guard",
1099
+ expression: phase.when,
1100
+ result: whenResult,
1101
+ });
1102
+ if (!whenResult) {
739
1103
  return {
740
1104
  id: phase.id,
741
1105
  status: "skipped",
@@ -746,7 +1110,10 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
746
1110
  };
747
1111
  }
748
1112
  }
749
- const preRead = await resolvePhaseContext(phase, ctx);
1113
+ // `context` keeps its historical invocation-root meaning. Phase cwd may be a
1114
+ // temporary/worktree directory; rebasing context there would silently stop
1115
+ // existing flows from reading authored source files.
1116
+ const preRead = await resolvePhaseContext(phase, ctx, deps.cwd, deps._cwdBoundary);
750
1117
  // Resolve this phase's cache policy once. Default scope is "run-only" (the
751
1118
  // historical within-run resume behavior). Only "cross-run" phases resolve a
752
1119
  // fingerprint and consult the persistent store.
@@ -755,7 +1122,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
755
1122
  // each run (schema already rejects explicit cross-run, but the default-scope
756
1123
  // path must also be blocked). If flowDefHash failed, cross-run is unsafe
757
1124
  // because the key degrades to flowName-only and reopens cross-flow collisions.
758
- const CROSS_RUN_BLOCKED_TYPES = new Set(["gate", "approval", "loop", "tournament", "script"]);
1125
+ const CROSS_RUN_BLOCKED_TYPES = new Set(["gate", "approval", "loop", "tournament", "script", "race", "expand"]);
759
1126
  if (cacheScope === "cross-run" && CROSS_RUN_BLOCKED_TYPES.has(type)) {
760
1127
  cacheScope = "run-only";
761
1128
  }
@@ -770,6 +1137,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
770
1137
  if (phase.idempotent === false) {
771
1138
  cacheScope = "off";
772
1139
  }
1140
+ if (deps._disableCache) {
1141
+ cacheScope = "off";
1142
+ }
773
1143
  const cc = {
774
1144
  scope: cacheScope,
775
1145
  ttlMs: phase.cache?.ttl ? (parseTtlMs(phase.cache.ttl) ?? undefined) : undefined,
@@ -785,17 +1155,36 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
785
1155
  thinking: phase.thinking,
786
1156
  tools: phase.tools,
787
1157
  preRead,
1158
+ agentScope: state.def.agentScope,
1159
+ contextSharing: state.def.contextSharing === true,
1160
+ agentDefinitions: agentDefinitionsIdentity(deps.agents),
1161
+ executionCwd: phase.cwd !== undefined || deps._cacheCwdIdentity !== undefined ? effCwd : undefined,
1162
+ };
1163
+ const baseRun = (agentName, task, onLive, ctxNodeId, signal, onTerminalCommit) => {
1164
+ const runOptions = {
1165
+ model: phase.model,
1166
+ thinking: phase.thinking,
1167
+ tools: phase.tools,
1168
+ cwd: effCwd,
1169
+ signal: signal ?? deps.signal,
1170
+ onLive,
1171
+ ctxDir: ctxDir,
1172
+ nodeId: ctxDir ? ctxNodeId : undefined,
1173
+ onTerminalCommit,
1174
+ };
1175
+ const invoke = () => run(effCwd, deps.agents, agentName, task, runOptions, deps.globalThinking);
1176
+ return deps._workspaceBinding
1177
+ ? deps._workspaceBinding.runAgent({
1178
+ agents: deps.agents,
1179
+ agentName,
1180
+ task,
1181
+ opts: runOptions,
1182
+ globalThinking: deps.globalThinking,
1183
+ unitId: ctxNodeId ?? phase.id,
1184
+ invoke,
1185
+ })
1186
+ : invoke();
788
1187
  };
789
- const baseRun = (agentName, task, onLive, ctxNodeId, signal) => run(effCwd, deps.agents, agentName, task, {
790
- model: phase.model,
791
- thinking: phase.thinking,
792
- tools: phase.tools,
793
- cwd: effCwd,
794
- signal: signal ?? deps.signal,
795
- onLive,
796
- ctxDir: ctxDir,
797
- nodeId: ctxDir ? ctxNodeId : undefined,
798
- }, deps.globalThinking);
799
1188
  // Wrap each subagent call in the phase's retry policy. Usage is summed across
800
1189
  // attempts; the attempt count rides along on the result for the TUI.
801
1190
  //
@@ -813,44 +1202,90 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
813
1202
  const phaseTimeoutMs = type !== "script" && typeof phase.timeout === "number" && Number.isFinite(phase.timeout) && phase.timeout >= 1000
814
1203
  ? phase.timeout
815
1204
  : undefined;
816
- const runOne = async (agentName, task, onLive, ctxNodeId, check) => {
1205
+ const runOne = async (agentName, task, onLive, ctxNodeId, check,
1206
+ /** Extra abort (e.g. race branch cancelLosers) — chained with run + phase timeout. */
1207
+ extraSignal) => {
817
1208
  const explicitMax = Math.max(1, 1 + Math.max(0, Math.floor(retry?.max ?? 0)));
818
1209
  // Allow enough attempts to cover whichever policy applies on a given attempt.
819
1210
  const maxAttempts = Math.max(explicitMax, 1 + DEFAULT_TRANSIENT_RETRIES);
820
1211
  const usages = [];
821
1212
  let last;
822
1213
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
823
- if (deps.signal?.aborted)
1214
+ if (deps.signal?.aborted || extraSignal?.aborted)
824
1215
  break;
825
- // Phase timeout — an AbortController chained to the run's signal that also
826
- // fires after `phase.timeout` ms. Deterministic: a timed-out call is never
827
- // retried (retrying a call that just burned its full cap would double-spend).
1216
+ // AbortController chains: run signal + optional extra (race cancel) + phase timeout.
1217
+ // Deterministic: a timed-out call is never retried (would double-spend).
828
1218
  let timedOut = false;
1219
+ let terminalCommitted = false;
829
1220
  let timer;
830
- let onParentAbort;
1221
+ let forceReturnTimer;
1222
+ const removers = [];
831
1223
  let callSignal;
832
- if (phaseTimeoutMs) {
1224
+ let timeoutController;
1225
+ if (phaseTimeoutMs || extraSignal) {
833
1226
  const ac = new AbortController();
1227
+ timeoutController = ac;
834
1228
  callSignal = ac.signal;
835
- if (deps.signal?.aborted)
1229
+ if (deps.signal?.aborted || extraSignal?.aborted)
836
1230
  ac.abort();
837
- else if (deps.signal) {
838
- onParentAbort = () => ac.abort();
839
- deps.signal.addEventListener("abort", onParentAbort, { once: true });
1231
+ else {
1232
+ if (deps.signal) {
1233
+ const fn = () => ac.abort();
1234
+ deps.signal.addEventListener("abort", fn, { once: true });
1235
+ removers.push(() => deps.signal?.removeEventListener("abort", fn));
1236
+ }
1237
+ if (extraSignal) {
1238
+ const fn = () => ac.abort();
1239
+ extraSignal.addEventListener("abort", fn, { once: true });
1240
+ removers.push(() => extraSignal.removeEventListener("abort", fn));
1241
+ }
840
1242
  }
841
- timer = setTimeout(() => {
842
- timedOut = true;
843
- ac.abort();
844
- }, phaseTimeoutMs);
845
1243
  }
846
1244
  try {
847
- last = await baseRun(agentName, task, onLive, ctxNodeId, callSignal);
1245
+ const onTerminalCommit = () => {
1246
+ if (timedOut)
1247
+ return;
1248
+ terminalCommitted = true;
1249
+ if (timer) {
1250
+ clearTimeout(timer);
1251
+ timer = undefined;
1252
+ }
1253
+ };
1254
+ const invocation = baseRun(agentName, task, onLive, ctxNodeId, callSignal, onTerminalCommit);
1255
+ if (phaseTimeoutMs && timeoutController) {
1256
+ const timeoutFallback = new Promise((resolve) => {
1257
+ timer = setTimeout(() => {
1258
+ if (terminalCommitted)
1259
+ return;
1260
+ timedOut = true;
1261
+ timeoutController?.abort();
1262
+ forceReturnTimer = setTimeout(() => resolve({
1263
+ agent: agentName,
1264
+ task,
1265
+ exitCode: 1,
1266
+ output: "",
1267
+ stderr: "",
1268
+ usage: emptyUsage(),
1269
+ stopReason: "error",
1270
+ errorMessage: `Phase runner did not stop within ${PHASE_TIMEOUT_ABORT_GRACE_MS}ms after abort`,
1271
+ phaseTimeout: true,
1272
+ completionSource: "phase-timeout",
1273
+ }), PHASE_TIMEOUT_ABORT_GRACE_MS);
1274
+ }, phaseTimeoutMs);
1275
+ });
1276
+ last = await Promise.race([invocation, timeoutFallback]);
1277
+ }
1278
+ else {
1279
+ last = await invocation;
1280
+ }
848
1281
  }
849
1282
  finally {
850
1283
  if (timer)
851
1284
  clearTimeout(timer);
852
- if (onParentAbort)
853
- deps.signal?.removeEventListener("abort", onParentAbort);
1285
+ if (forceReturnTimer)
1286
+ clearTimeout(forceReturnTimer);
1287
+ for (const r of removers)
1288
+ r();
854
1289
  }
855
1290
  if (timedOut) {
856
1291
  // Reclassify the abort as a phase timeout: a distinct, deterministic
@@ -862,8 +1297,20 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
862
1297
  stopReason: "error",
863
1298
  errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`,
864
1299
  phaseTimeout: true,
1300
+ completionSource: "phase-timeout",
865
1301
  };
866
1302
  usages.push(last.usage);
1303
+ traceEmit(deps, {
1304
+ ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "subagent-call",
1305
+ input: { agent: agentName, model: phase.model, task, preRead, nodePath: ctxNodeId ?? phase.id, attempt },
1306
+ output: {
1307
+ text: last.output, model: last.model, usage: last.usage, stopReason: last.stopReason,
1308
+ completionSource: last.completionSource,
1309
+ reapedAfterTerminal: last.reapedAfterTerminal,
1310
+ terminalGraceMs: last.terminalGraceMs,
1311
+ },
1312
+ });
1313
+ traceFlush(deps, phase.id);
867
1314
  break;
868
1315
  }
869
1316
  usages.push(last.usage);
@@ -886,11 +1333,39 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
886
1333
  const liveRetry = state.phases[phase.id];
887
1334
  if (liveRetry)
888
1335
  liveRetry.usage = aggregateUsage(usages);
1336
+ // Persist every attempt, not only the final aggregate. This is required
1337
+ // for honest replay/cost accounting when a transient or explicit retry
1338
+ // succeeds after earlier spend.
1339
+ traceEmit(deps, {
1340
+ ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "subagent-call",
1341
+ input: { agent: agentName, model: phase.model, task, preRead, nodePath: ctxNodeId ?? phase.id, attempt },
1342
+ output: {
1343
+ text: last.output, model: last.model, usage: last.usage, stopReason: last.stopReason,
1344
+ completionSource: last.completionSource,
1345
+ reapedAfterTerminal: last.reapedAfterTerminal,
1346
+ terminalGraceMs: last.terminalGraceMs,
1347
+ },
1348
+ });
1349
+ traceFlush(deps, phase.id);
889
1350
  if (!isFailed(last))
890
1351
  break;
891
- // Stop retrying on abort or once the run is over budget.
892
- if (deps.signal?.aborted || overBudget(state).over)
1352
+ // Stop retrying on abort (run-level or race cancel) or once over budget.
1353
+ if (deps.signal?.aborted || extraSignal?.aborted || overBudget(state).over)
1354
+ break;
1355
+ if (deps._workspaceBinding) {
1356
+ // A failed RW-capability attempt has an unknown filesystem outcome and
1357
+ // is durably marked dirty. Retrying it cannot be proven idempotent until
1358
+ // workspace snapshots/restoration exist, so preserve the first failure
1359
+ // instead of replacing it with a later TFWS_RESOURCE_DIRTY refusal.
1360
+ const requestedRetry = (retry?.max ?? 0) > 0 || isTransientError(last);
1361
+ if (requestedRetry && last.workspaceMutationStarted) {
1362
+ last = {
1363
+ ...last,
1364
+ errorMessage: `${last.errorMessage ?? last.stderr ?? "workspace execution failed"}\nTFWS_RETRY_UNSAFE: retry suppressed because the prior read-write attempt may have mutated the workspace; reconcile before a new attempt`,
1365
+ };
1366
+ }
893
1367
  break;
1368
+ }
894
1369
  // Decide whether THIS failure warrants another attempt. Explicit retry
895
1370
  // policy covers all failures up to its cap; the transient fallback covers
896
1371
  // only retryable provider errors. A non-transient failure with no explicit
@@ -919,8 +1394,29 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
919
1394
  // backoff.
920
1395
  const factor = retry ? (retry.factor ?? 1) : DEFAULT_TRANSIENT_FACTOR;
921
1396
  const wait = Math.min(60000, Math.round(baseMs * factor ** attempt));
922
- if (wait > 0)
923
- await delay(wait, deps.signal);
1397
+ if (wait > 0) {
1398
+ // Honor run abort and/or race-branch cancel during backoff.
1399
+ if (deps.signal && extraSignal) {
1400
+ const ac = new AbortController();
1401
+ const ab = () => ac.abort();
1402
+ if (deps.signal.aborted || extraSignal.aborted)
1403
+ ac.abort();
1404
+ else {
1405
+ deps.signal.addEventListener("abort", ab, { once: true });
1406
+ extraSignal.addEventListener("abort", ab, { once: true });
1407
+ }
1408
+ try {
1409
+ await delay(wait, ac.signal);
1410
+ }
1411
+ finally {
1412
+ deps.signal.removeEventListener("abort", ab);
1413
+ extraSignal.removeEventListener("abort", ab);
1414
+ }
1415
+ }
1416
+ else {
1417
+ await delay(wait, extraSignal ?? deps.signal);
1418
+ }
1419
+ }
924
1420
  }
925
1421
  // Aborted before any attempt ran → return a clean aborted result (no crash).
926
1422
  if (!last) {
@@ -939,29 +1435,6 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
939
1435
  if (usages.length > 1)
940
1436
  last.usage = aggregateUsage(usages);
941
1437
  last.attempts = usages.length;
942
- // Trace: record this subagent call (the load-bearing record a future
943
- // replay consumes). Fail-open. nodePath carries the item/variant
944
- // discriminator (e.g. "review", "review-item-3", "gate-judge").
945
- traceEmit(deps, {
946
- ts: Date.now(),
947
- runId: state.runId,
948
- phaseId: phase.id,
949
- kind: "subagent-call",
950
- input: {
951
- agent: agentName,
952
- model: phase.model,
953
- task,
954
- preRead,
955
- nodePath: ctxNodeId ?? phase.id,
956
- attempt: usages.length > 0 ? usages.length - 1 : undefined,
957
- },
958
- output: {
959
- text: last.output,
960
- model: last.model,
961
- usage: last.usage,
962
- stopReason: last.stopReason,
963
- },
964
- });
965
1438
  return last;
966
1439
  };
967
1440
  const parseJson = phase.output === "json";
@@ -1000,7 +1473,11 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1000
1473
  emitProgress();
1001
1474
  };
1002
1475
  refresh();
1003
- return mapWithConcurrencyLimit(items, concurrency, async (it, idx) => {
1476
+ // Usage is only authoritative after a call reports it. Serial admission for
1477
+ // budgeted fan-out prevents N siblings from all observing the same remaining
1478
+ // allowance and overshooting it concurrently.
1479
+ const admissionConcurrency = state.def.budget ? 1 : concurrency;
1480
+ return mapWithConcurrencyLimit(items, admissionConcurrency, async (it, idx) => {
1004
1481
  // Budget guard: stop spawning new fan-out items once the run is over budget.
1005
1482
  if (overBudget(state).over) {
1006
1483
  done++;
@@ -1090,6 +1567,11 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1090
1567
  const child = await runSpawnedChildren(spawned, ctxDir, itemNid, phase, deps, state, run);
1091
1568
  if (child.reports)
1092
1569
  r.output = `${r.output ?? ""}${child.reports}`;
1570
+ if (child.failed && deps._workspaceBinding) {
1571
+ r.exitCode = r.exitCode === 0 ? 1 : r.exitCode;
1572
+ r.stopReason = "error";
1573
+ r.errorMessage = `Workspace ctx_spawn descendant failed: ${child.errors.join("; ")}`;
1574
+ }
1093
1575
  if (child.usage) {
1094
1576
  r.usage = aggregateUsage([r.usage ?? emptyUsage(), child.usage]);
1095
1577
  liveUsages[idx] = r.usage;
@@ -1227,6 +1709,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1227
1709
  };
1228
1710
  if (readRefs.length)
1229
1711
  ps.reads = readRefsToReads(readRefs, state);
1712
+ traceGateDecision(deps, state, phase.id, ps.gate, undefined);
1230
1713
  return ps;
1231
1714
  }
1232
1715
  const report = targetResolved
@@ -1259,6 +1742,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1259
1742
  const verdict = final.passed ? "pass" : "block";
1260
1743
  ps.gate = { verdict, reason: judged.reason, scores: { results, combined: final.combined, threshold } };
1261
1744
  ps.json = scoreResultJSON(results, final.combined, verdict, threshold, { score: judged.score, reason: judged.reason });
1745
+ traceGateDecision(deps, state, phase.id, ps.gate, r.output);
1262
1746
  }
1263
1747
  if (readRefs.length)
1264
1748
  ps.reads = readRefsToReads(readRefs, state);
@@ -1332,7 +1816,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1332
1816
  break;
1333
1817
  attempt++;
1334
1818
  if (_retryDepth < MAX_RETRY_DEPTH) {
1335
- const { _cwdOverride: _dropGateWs, ...depsForUpstream } = deps;
1819
+ const depsForUpstream = opts?.upstreamDeps ?? deps;
1336
1820
  for (const depId of phase.dependsOn ?? []) {
1337
1821
  const d = state.def.phases.find((p) => p.id === depId);
1338
1822
  if (!d)
@@ -1394,13 +1878,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1394
1878
  ps.warnings = [...(ps.warnings ?? []), refWarning];
1395
1879
  if (type === "gate" && ps.status === "done") {
1396
1880
  ps.gate = parseGateVerdict(r.output);
1397
- // Trace: record the gate verdict decision (fail-open). A future replay
1398
- // re-adjudicates this against a changed threshold.
1881
+ // Trace: gate decision (fail-open). Replay re-adjudicates thresholds.
1399
1882
  if (ps.gate)
1400
- traceEmit(deps, {
1401
- ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "decision",
1402
- decision: { type: "gate-verdict", value: ps.gate.verdict, reason: ps.gate.reason },
1403
- });
1883
+ traceGateDecision(deps, state, phase.id, ps.gate);
1404
1884
  }
1405
1885
  // Shared Context Tree: register this node, mark its terminal status, and
1406
1886
  // pick up any ctx_spawn intents the subagent queued. The spawned child
@@ -1415,6 +1895,10 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1415
1895
  const child = await runSpawnedChildren(spawned, ctxDir, nid, phase, deps, state, run);
1416
1896
  if (child.reports)
1417
1897
  ps.output = `${ps.output ?? ""}${child.reports}`;
1898
+ if (child.failed && deps._workspaceBinding) {
1899
+ ps.status = "failed";
1900
+ ps.error = `Workspace ctx_spawn descendant failed: ${child.errors.join("; ")}`;
1901
+ }
1418
1902
  // Fold spawned spend into this phase's usage so the run-wide budget
1419
1903
  // guard accounts for it (verdict Issue 2).
1420
1904
  ps.usage = aggregateUsage([ps.usage ?? emptyUsage(), child.usage]);
@@ -1443,7 +1927,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1443
1927
  // NOTE: we intentionally pass the gate's `prior` (not the dep's own
1444
1928
  // completed state) so the dep does NOT cache-hit and actually
1445
1929
  // RE-RUNS — re-running upstream is the whole point of onBlock:retry.
1446
- const { _cwdOverride: _dropGateWs, ...depsForUpstream } = deps;
1930
+ const depsForUpstream = opts?.upstreamDeps ?? deps;
1447
1931
  for (const depId of phase.dependsOn ?? []) {
1448
1932
  const d = state.def.phases.find((p) => p.id === depId);
1449
1933
  if (!d)
@@ -1470,9 +1954,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1470
1954
  recordCache(cc, ps);
1471
1955
  return ps;
1472
1956
  }
1473
- // script — zero-token shell command. Spawns a child process, pipes
1474
- // interpolated `input` to stdin, captures stdout as the phase output.
1957
+ // script — zero-token shell (spawn/timeout/size caps in runtime/phases/script.ts)
1475
1958
  if (type === "script") {
1959
+ const { runScriptCommand, scriptResultToPhaseState, scriptSpawnErrorToPhaseState } = await import("./runtime/phases/script.js");
1476
1960
  const cmd = phase.run;
1477
1961
  if (!cmd) {
1478
1962
  return {
@@ -1483,19 +1967,16 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1483
1967
  usage: emptyUsage(),
1484
1968
  };
1485
1969
  }
1486
- // Interpolate the command.
1487
1970
  // Array form: interpolate each element (safe — schema rejects placeholders in string form).
1488
1971
  // String form: skip interpolation — schema already guarantees no {placeholders}.
1489
1972
  const interpRun = Array.isArray(cmd)
1490
1973
  ? cmd.map((s) => interpolate(s, ctx))
1491
1974
  : [{ text: cmd, missing: [] }];
1492
- // Warn unresolved references.
1493
1975
  for (const r of interpRun) {
1494
1976
  if (r.missing.length)
1495
1977
  warnUnresolvedRefs(phase.id, r.missing);
1496
1978
  }
1497
1979
  const interpRunText = interpRun.map((r) => r.text);
1498
- // Interpolate stdin input if provided.
1499
1980
  const stdinInterp = phase.input !== undefined ? interpolate(phase.input, ctx) : undefined;
1500
1981
  if (stdinInterp?.missing.length)
1501
1982
  warnUnresolvedRefs(phase.id, stdinInterp.missing);
@@ -1505,123 +1986,39 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1505
1986
  const cached = cachedPhase(cc, ck);
1506
1987
  if (cached)
1507
1988
  return cached;
1508
- const MAX_STDOUT = 1_048_576; // 1 MB cap
1509
- const SCRIPT_TIMEOUT_MS = (phase.timeout ?? 60_000); // default 60 s, configurable via phase.timeout
1510
- const SIGKILL_GRACE_MS = 5_000; // grace period after SIGTERM before SIGKILL
1989
+ const SCRIPT_TIMEOUT_MS = phase.timeout ?? 60_000;
1990
+ const reads = readRefs.length ? readRefsToReads(readRefs, state) : undefined;
1511
1991
  try {
1512
- const { spawn } = await import("node:child_process");
1513
- const result = await new Promise((resolve, reject) => {
1514
- // Array command → direct spawn (safe); string → shell (rejected at validation if it has placeholders).
1515
- const child = Array.isArray(cmd)
1516
- ? spawn(interpRunText[0], interpRunText.slice(1), {
1517
- cwd: effCwd,
1518
- env: process.env,
1519
- shell: false,
1520
- signal: deps.signal,
1521
- })
1522
- : spawn(interpRunText[0], [], {
1523
- cwd: effCwd,
1524
- env: process.env,
1525
- shell: true,
1526
- signal: deps.signal,
1527
- });
1528
- let stdout = "";
1529
- let stderr = "";
1530
- let stdoutOversize = false;
1531
- let timedOut = false;
1532
- child.stdout?.on("data", (d) => {
1533
- if (stdout.length < MAX_STDOUT) {
1534
- const need = MAX_STDOUT - stdout.length;
1535
- stdout += d.toString().slice(0, need);
1536
- if (stdout.length >= MAX_STDOUT)
1537
- stdoutOversize = true;
1538
- }
1539
- });
1540
- child.stderr?.on("data", (d) => {
1541
- if (stderr.length < 500) {
1542
- stderr += d.toString().slice(0, 500 - stderr.length);
1543
- }
1544
- });
1545
- // Timeout guard: SIGTERM first, then SIGKILL after grace period.
1546
- let sigkillTimer;
1547
- const timer = setTimeout(() => {
1548
- timedOut = true;
1549
- child.kill("SIGTERM");
1550
- // SIGKILL fallback if process ignores SIGTERM.
1551
- sigkillTimer = setTimeout(() => {
1552
- try {
1553
- child.kill("SIGKILL");
1554
- }
1555
- catch { /* already dead */ }
1556
- }, SIGKILL_GRACE_MS);
1557
- }, SCRIPT_TIMEOUT_MS);
1558
- child.on("error", (err) => {
1559
- clearTimeout(timer);
1560
- clearTimeout(sigkillTimer);
1561
- reject(err);
1562
- });
1563
- child.on("close", (code) => {
1564
- clearTimeout(timer);
1565
- clearTimeout(sigkillTimer);
1566
- resolve({ stdout, stderr, code, stdoutOversize, timedOut });
1567
- });
1568
- if (stdinInput !== undefined) {
1569
- child.stdin?.on("error", () => { }); // swallow EPIPE when child closes stdin early
1570
- child.stdin?.write(stdinInput);
1571
- child.stdin?.end();
1572
- }
1992
+ const invoke = () => runScriptCommand({
1993
+ interpRunText,
1994
+ arrayForm: Array.isArray(cmd),
1995
+ cwd: effCwd,
1996
+ signal: deps.signal,
1997
+ stdinInput,
1998
+ timeoutMs: SCRIPT_TIMEOUT_MS,
1573
1999
  });
1574
- if (result.code !== 0 || result.timedOut) {
1575
- const ps = {
1576
- id: phase.id,
1577
- status: "failed",
1578
- output: result.stdout,
1579
- error: result.timedOut
1580
- ? `Script timed out after ${SCRIPT_TIMEOUT_MS}ms`
1581
- : `Script exited with code ${result.code}${result.stderr ? ": " + result.stderr.slice(0, 500) : ""}${result.stdoutOversize ? " [stdout truncated at 1 MB]" : ""}`,
1582
- timedOut: result.timedOut || undefined,
1583
- usage: emptyUsage(),
1584
- inputHash,
1585
- endedAt: Date.now(),
1586
- };
1587
- if (readRefs.length)
1588
- ps.reads = readRefsToReads(readRefs, state);
1589
- // Non-zero exit: cache (deterministic failure). Timeout: don't cache (transient, like spawn errors).
1590
- if (!result.timedOut)
1591
- recordCache(cc, ps);
1592
- return ps;
1593
- }
1594
- const ps = {
1595
- id: phase.id,
1596
- status: "done",
1597
- output: result.stdout.trimEnd() + (result.stdoutOversize ? "\n[stdout truncated at 1 MB]" : ""),
1598
- usage: emptyUsage(),
2000
+ const result = deps._workspaceBinding
2001
+ ? await deps._workspaceBinding.runScript({ unitId: phase.id, signal: deps.signal, invoke })
2002
+ : await invoke();
2003
+ const ps = scriptResultToPhaseState(phase, result, {
1599
2004
  inputHash,
1600
- endedAt: Date.now(),
1601
- };
1602
- if (readRefs.length)
1603
- ps.reads = readRefsToReads(readRefs, state);
1604
- recordCache(cc, ps);
2005
+ timeoutMs: SCRIPT_TIMEOUT_MS,
2006
+ reads,
2007
+ });
2008
+ // Non-zero exit: cache (deterministic). Timeout/spawn: don't cache (transient).
2009
+ if (ps.status === "done" || (ps.status === "failed" && !ps.timedOut)) {
2010
+ recordCache(cc, ps);
2011
+ }
1605
2012
  return ps;
1606
2013
  }
1607
2014
  catch (err) {
1608
- const msg = err instanceof Error ? err.message : String(err);
1609
- // Note: intentionally NOT cached — spawn errors (ENOENT, permission denied, etc.)
1610
- // are treated as transient. The phase will re-execute on resume/retry.
1611
- const ps = {
1612
- id: phase.id,
1613
- status: "failed",
1614
- error: `Script error: ${msg}`,
1615
- usage: emptyUsage(),
1616
- inputHash,
1617
- endedAt: Date.now(),
1618
- };
1619
- if (readRefs.length)
1620
- ps.reads = readRefsToReads(readRefs, state);
1621
- return ps;
2015
+ // Spawn errors intentionally NOT cached — re-execute on resume/retry.
2016
+ return scriptSpawnErrorToPhaseState(phase.id, err, { inputHash, reads });
1622
2017
  }
1623
2018
  }
2019
+ // parallel — all branches; merge via shared mergePhaseState (phases/parallel.ts)
1624
2020
  if (type === "parallel") {
2021
+ const { executeParallelBranches } = await import("./runtime/phases/parallel.js");
1625
2022
  const branches = (phase.branches ?? []).map((b) => {
1626
2023
  const r = interpolate(b.task, ctx);
1627
2024
  return {
@@ -1634,10 +2031,36 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1634
2031
  const cached = cachedPhase(cc, ck);
1635
2032
  if (cached)
1636
2033
  return cached;
1637
- const results = await runFanout(branches);
1638
- const ps = mergePhaseState(phase.id, results, inputHash, parseJson);
1639
- if (readRefs.length)
1640
- ps.reads = readRefsToReads(readRefs, state);
2034
+ const ps = await executeParallelBranches(phase, branches, runFanout, mergePhaseState, {
2035
+ inputHash,
2036
+ parseJson,
2037
+ reads: readRefs.length ? readRefsToReads(readRefs, state) : undefined,
2038
+ });
2039
+ recordCache(cc, ps);
2040
+ return ps;
2041
+ }
2042
+ // Horizon B: race — implementation lives in runtime/phases/race.ts
2043
+ if (type === "race") {
2044
+ const { executeRaceBranches } = await import("./runtime/phases/race.js");
2045
+ const branches = (phase.branches ?? []).map((b) => {
2046
+ const r = interpolate(b.task, ctx);
2047
+ return {
2048
+ agent: resolveAgent(b.agent ?? phase.agent, deps, state),
2049
+ task: preRead + r.text,
2050
+ };
2051
+ });
2052
+ const ck = cacheKeys(cc, [phase.id, "race", phase.model ?? "", JSON.stringify(branches)]);
2053
+ const inputHash = ck.key;
2054
+ const cached = cachedPhase(cc, ck);
2055
+ if (cached)
2056
+ return cached;
2057
+ const raceRunOne = (agent, task, branchSignal) => runOne(agent, task, undefined, undefined, undefined, branchSignal);
2058
+ const ps = await executeRaceBranches(phase, branches, raceRunOne, isFailed, {
2059
+ inputHash,
2060
+ parseJson,
2061
+ readRefs: readRefs.length ? readRefsToReads(readRefs, state) : undefined,
2062
+ parentSignal: deps.signal,
2063
+ });
1641
2064
  recordCache(cc, ps);
1642
2065
  return ps;
1643
2066
  }
@@ -1735,7 +2158,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1735
2158
  recordCache(cc, ps);
1736
2159
  return ps;
1737
2160
  }
2161
+ // approval — HITL pause (decision → PhaseState in runtime/phases/approval.ts)
1738
2162
  if (type === "approval") {
2163
+ const { approvalDecisionToPhaseState } = await import("./runtime/phases/approval.js");
1739
2164
  const readRefs = [];
1740
2165
  const ctx = buildInterpolationContext(state, previousOutput, undefined, (ref) => readRefs.push(ref));
1741
2166
  const message = interpolate(phase.task ?? "Approve to continue?", ctx).text;
@@ -1744,48 +2169,51 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1744
2169
  const cached = cachedPhase(cc, ck);
1745
2170
  if (cached)
1746
2171
  return cached;
1747
- // Non-interactive (headless/CI/detached): auto-REJECT, fail-open, but record it.
1748
- // Approval gates are safety boundaries — bypassing them silently in CI would
1749
- // let unreviewed work ship. Detached/CI runs must not bypass approval gates.
2172
+ const reads = readRefsToReads(readRefs, state);
2173
+ // Non-interactive (headless/CI/detached): auto-REJECT — safety boundary, never bypass.
1750
2174
  if (!deps.requestApproval) {
1751
- return {
1752
- id: phase.id,
1753
- status: "done",
1754
- output: "(auto-rejected: no interactive approver available)",
1755
- approval: { decision: "reject", auto: true },
1756
- gate: { verdict: "block", reason: "(auto-rejected: no interactive approver available)" },
1757
- usage: emptyUsage(),
2175
+ return approvalDecisionToPhaseState(phase.id, { decision: "reject" }, {
1758
2176
  inputHash,
1759
- reads: readRefsToReads(readRefs, state),
1760
- endedAt: Date.now(),
1761
- };
1762
- }
1763
- const decision = await deps.requestApproval({ phaseId: phase.id, message, upstream: previousOutput });
1764
- const note = decision.note?.trim();
1765
- const ps = {
1766
- id: phase.id,
1767
- status: "done",
1768
- output: note || `(${decision.decision})`,
1769
- approval: { decision: decision.decision, note },
1770
- usage: emptyUsage(),
1771
- inputHash,
1772
- reads: readRefsToReads(readRefs, state),
1773
- endedAt: Date.now(),
1774
- };
1775
- // A rejection halts the flow via the same mechanism as a blocking gate.
1776
- if (decision.decision === "reject") {
1777
- ps.gate = { verdict: "block", reason: note || "Rejected by user" };
2177
+ reads,
2178
+ auto: true,
2179
+ });
1778
2180
  }
1779
- return ps;
2181
+ const decision = await deps.requestApproval({
2182
+ phaseId: phase.id,
2183
+ message,
2184
+ upstream: previousOutput,
2185
+ });
2186
+ return approvalDecisionToPhaseState(phase.id, decision, { inputHash, reads });
1780
2187
  }
1781
- if (type === "flow") {
2188
+ if (type === "flow" || type === "expand") {
1782
2189
  const readRefs = [];
1783
2190
  const ctx = buildInterpolationContext(state, previousOutput, undefined, (ref) => readRefs.push(ref));
1784
- const hasDef = phase.def !== undefined;
2191
+ // expand always requires `def`; flow may use `use` or `def`.
2192
+ const hasDef = type === "expand" ? phase.def !== undefined : phase.def !== undefined;
1785
2193
  const stack = deps._stack ?? [];
2194
+ const { resolveExpandMode, resolveMaxNodes, prefixGraftFragment, promoteGraftPhases } = await import("./runtime/phases/expand.js");
2195
+ const expandMode = type === "expand" ? resolveExpandMode(phase) : "nested";
2196
+ const maxNodes = type === "expand" ? resolveMaxNodes(phase, MAX_DYNAMIC_PHASES) : 50;
2197
+ if (type === "expand" && expandMode === "graft") {
2198
+ // Rerun replacement semantics: prior promoted children belong to the old
2199
+ // fragment and must disappear before ANY new resolution path. This is
2200
+ // intentionally before parse/validation/empty/sub-flow failure returns so
2201
+ // stale children and their usage cannot survive a failed or empty v2 plan.
2202
+ const declaredIds = new Set(state.def.phases.map((p) => p.id));
2203
+ for (const oldId of Object.keys(prior?.promotedPhases ?? {})) {
2204
+ // Definition evolution may promote an old dynamic id into a real
2205
+ // authored parent phase. That declared phase owns its state now and must
2206
+ // never be deleted by stale graft metadata.
2207
+ if (!declaredIds.has(oldId))
2208
+ delete state.phases[oldId];
2209
+ }
2210
+ }
1786
2211
  let subDef;
1787
2212
  let name;
1788
2213
  let recursionKey; // identity used for cache key + recursion guard
2214
+ if (type === "expand" && !hasDef) {
2215
+ return failPhase(phase.id, `expand phase '${phase.id}' requires 'def'`);
2216
+ }
1789
2217
  if (hasDef) {
1790
2218
  // --- Inline `def`: resolve at runtime, validate, fail-OPEN on any error. ---
1791
2219
  // Fail-open contract: a bad def NEVER aborts the run. The phase resolves
@@ -1822,7 +2250,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1822
2250
  parsed = rawDef;
1823
2251
  }
1824
2252
  // Accept a full Taskflow, a bare phases array, or {phases:[...]}; wrap the latter two.
1825
- const wrapped = normalizeInlineDef(parsed, phase.id);
2253
+ let wrapped = normalizeInlineDef(parsed, phase.id);
1826
2254
  if (!wrapped) {
1827
2255
  return defFailOpen("inline def is not a Taskflow, phases array, or {phases:[...]}");
1828
2256
  }
@@ -1835,11 +2263,20 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1835
2263
  output: "",
1836
2264
  json: parseJson ? safeParse("") : undefined,
1837
2265
  usage: emptyUsage(),
1838
- inputHash: hashInput(phase.id, "flow-def-empty"),
2266
+ inputHash: hashInput(phase.id, type === "expand" ? "expand-def-empty" : "flow-def-empty"),
1839
2267
  reads: readRefsToReads(readRefs, state),
1840
2268
  endedAt: Date.now(),
1841
2269
  };
1842
2270
  }
2271
+ // expand: cap fragment size + prefix ids for graft (helpers in phases/expand.ts).
2272
+ if (type === "expand") {
2273
+ if (wrapped.phases.length > maxNodes) {
2274
+ return defFailOpen(`expand fragment has ${wrapped.phases.length} phases (maxNodes=${maxNodes})`);
2275
+ }
2276
+ if (expandMode === "graft") {
2277
+ wrapped = prefixGraftFragment(wrapped, phase.id);
2278
+ }
2279
+ }
1843
2280
  // Validate with `dynamic` hardening (breadth caps + cwd containment) since
1844
2281
  // this content is LLM-authored / untrusted. cwd anchors containment checks.
1845
2282
  const dynCwd = effCwd;
@@ -1879,23 +2316,46 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1879
2316
  // Resolve sub-flow args (interpolate string values), then apply declared defaults.
1880
2317
  const provided = {};
1881
2318
  for (const [k, v] of Object.entries(phase.with ?? {})) {
1882
- provided[k] = typeof v === "string" ? interpolate(v, ctx).text : v;
2319
+ provided[k] = interpolateValue(v, ctx);
1883
2320
  }
1884
2321
  const subArgs = resolveArgs(subDef, provided);
1885
- // For inline defs the cache identity must include the resolved def content so
1886
- // that a different generated plan yields a different key (and an identical plan
1887
- // hits cache). For saved flows the name is the identity (historical behavior).
1888
- const flowIdentity = hasDef ? `def:${JSON.stringify(subDef)}` : `flow:${name}`;
1889
- const ck = cacheKeys(cc, [phase.id, flowIdentity, preRead, JSON.stringify(subArgs)]);
2322
+ if (deps._dynamic === true) {
2323
+ const dynamicChild = validateTaskflow(subDef, { dynamic: true, cwd: effCwd, args: subArgs });
2324
+ if (!dynamicChild.ok) {
2325
+ return failPhase(phase.id, `dynamic nested flow '${subDef.name}' is invalid: ${dynamicChild.errors.join("; ")}`);
2326
+ }
2327
+ }
2328
+ // Re-check the exact loaded definition at the cache boundary. A loader may
2329
+ // change between the root pre-scan and this phase (or return aliases), and
2330
+ // a bridge-bearing child must never be skipped by a cached parent result.
2331
+ const nestedBridgeTree = flowTreeUsesCwdBridge(subDef, deps.loadFlow);
2332
+ if (nestedBridgeTree)
2333
+ deps._disableCache = true;
2334
+ const flowCc = nestedBridgeTree ? { ...cc, scope: "off" } : cc;
2335
+ // Every sub-flow cache identity includes the resolved definition. A saved
2336
+ // flow's name alone is insufficient: its contents can change without the
2337
+ // parent definition moving.
2338
+ const flowIdentity = `${hasDef ? "def" : "flow"}:${name}:${JSON.stringify(subDef)}`;
2339
+ const ck = cacheKeys(flowCc, [phase.id, flowIdentity, preRead, JSON.stringify(subArgs)]);
1890
2340
  const inputHash = ck.key;
1891
- const cached = cachedPhase(cc, ck);
1892
- if (cached)
2341
+ const cached = cachedPhase(flowCc, ck);
2342
+ if (cached) {
2343
+ if (type === "expand" && expandMode === "graft" && cached.promotedPhases) {
2344
+ const promo = promoteGraftPhases(state, cached.promotedPhases);
2345
+ if (promo.promotedIds.length > 0) {
2346
+ cached.promotedPhases = Object.fromEntries(promo.promotedIds.map((id) => [id, { ...cached.promotedPhases[id] }]));
2347
+ }
2348
+ else {
2349
+ delete cached.promotedPhases;
2350
+ }
2351
+ }
1893
2352
  return cached;
2353
+ }
1894
2354
  const live = state.phases[phase.id];
1895
- // Sub-flows enforce their own budget; if they declare none, inherit the
1896
- // parent cap as a soft per-flow ceiling (best-effort — spend does not cross
1897
- // flow boundaries, so the parent's already-spent total is not subtracted).
1898
- const subDefEffective = subDef.budget || !state.def.budget ? subDef : { ...subDef, budget: state.def.budget };
2355
+ // A nested flow receives only the parent's remaining allowance, then its
2356
+ // own cap (if any) may tighten each dimension independently.
2357
+ const parentSpent = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage()));
2358
+ const subDefEffective = clampSubFlowBudget(subDef, state.def.budget, parentSpent);
1899
2359
  const subState = {
1900
2360
  runId: newRunId(subDef.name),
1901
2361
  flowName: subDef.name,
@@ -1913,10 +2373,15 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1913
2373
  const subRunTask = (cwd, agents, agentName, subTask, opts, globalThinking) => baseRunTask(cwd, agents, agentName, preRead + subTask, opts, globalThinking);
1914
2374
  const subResult = await executeTaskflow(subState, {
1915
2375
  ...deps,
2376
+ // A trace file is scoped to one runId. The parent flow phase carries an
2377
+ // unreplayable marker; nested events must not be mixed into that file.
2378
+ trace: undefined,
1916
2379
  // Override deps.cwd with the flow phase's own cwd so that sub-flow
1917
2380
  // phases without an explicit cwd derive their subagents from the
1918
2381
  // flow's cwd (not the caller's cwd).
1919
2382
  cwd: effCwd,
2383
+ _cacheCwdIdentity: phase.cwd !== undefined || deps._cacheCwdIdentity !== undefined ? effCwd : undefined,
2384
+ _dynamic: hasDef || deps._dynamic === true ? true : undefined,
1920
2385
  // The workspace override applies only to THIS flow phase, not to the
1921
2386
  // nested sub-phases (each resolves its own cwd). Clear it so the child
1922
2387
  // phases don't all inherit this phase's isolated dir as an override.
@@ -1945,12 +2410,31 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1945
2410
  },
1946
2411
  });
1947
2412
  const sp = Object.values(subState.phases);
2413
+ const nestedFailure = sp.find((nested) => nested.status === "failed")?.error;
2414
+ // expand graft promote — pure helper (see runtime/phases/expand.ts)
2415
+ const warnings = [];
2416
+ let graftPromotedIds = [];
2417
+ if (type === "expand" && expandMode === "graft" && subResult.ok) {
2418
+ const promo = promoteGraftPhases(state, subState.phases);
2419
+ warnings.push(...promo.warnings);
2420
+ graftPromotedIds = promo.promotedIds;
2421
+ }
2422
+ // Graft accounting is ownership-based. Successfully promoted children carry
2423
+ // their own usage in parent state; children skipped on id collision remain
2424
+ // owned by the expand phase and their residual usage must stay here. This
2425
+ // yields: all promoted => 0, all collision => all child usage, mixed => only
2426
+ // collision residual (no loss and no double count).
2427
+ const phaseUsage = type === "expand" && expandMode === "graft" && subResult.ok
2428
+ ? aggregateUsage(Object.entries(subState.phases)
2429
+ .filter(([id]) => !graftPromotedIds.includes(id))
2430
+ .map(([, ps]) => ps.usage ?? emptyUsage()))
2431
+ : subResult.totalUsage;
1948
2432
  const flowPs = {
1949
2433
  id: phase.id,
1950
2434
  status: subResult.ok ? "done" : "failed",
1951
2435
  output: subResult.finalOutput,
1952
2436
  json: parseJson ? safeParse(subResult.finalOutput) : undefined,
1953
- usage: subResult.totalUsage,
2437
+ usage: phaseUsage,
1954
2438
  // B-F015: include failed in `done` so the renderer's
1955
2439
  // `done - failed` formula gives the success count (matches the
1956
2440
  // map/parallel runner's overlapping-counter convention).
@@ -1960,12 +2444,18 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1960
2444
  running: 0,
1961
2445
  failed: sp.filter((p) => p.status === "failed").length,
1962
2446
  },
1963
- error: subResult.ok ? undefined : `sub-flow '${name}' ${subResult.state.status}`,
2447
+ error: subResult.ok
2448
+ ? undefined
2449
+ : `sub-flow '${name}' ${subResult.state.status}${nestedFailure ? `: ${nestedFailure}` : ""}`,
1964
2450
  inputHash,
1965
2451
  reads: readRefsToReads(readRefs, state),
1966
2452
  endedAt: Date.now(),
2453
+ ...(warnings.length ? { warnings } : {}),
2454
+ ...(type === "expand" && expandMode === "graft" && subResult.ok && graftPromotedIds.length > 0
2455
+ ? { promotedPhases: Object.fromEntries(graftPromotedIds.map((id) => [id, { ...subState.phases[id] }])) }
2456
+ : {}),
1967
2457
  };
1968
- recordCache(cc, flowPs);
2458
+ recordCache(flowCc, flowPs);
1969
2459
  return flowPs;
1970
2460
  }
1971
2461
  // loop-until-done: run the body repeatedly until `until` is truthy, the output
@@ -2208,6 +2698,12 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2208
2698
  }
2209
2699
  // Only one competitor survived → no contest; it wins by default (skip judge).
2210
2700
  if (ok.length === 1) {
2701
+ const w = ranIdx(ok[0]);
2702
+ traceDecision(deps, state, phase.id, {
2703
+ type: "tournament-winner",
2704
+ value: w,
2705
+ reason: "only surviving variant",
2706
+ });
2211
2707
  return {
2212
2708
  id: phase.id,
2213
2709
  status: "done",
@@ -2216,7 +2712,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2216
2712
  usage: variantUsage,
2217
2713
  model: ok[0].model,
2218
2714
  budgetTruncated: budgetSkipCount > 0 || undefined,
2219
- tournament: { variants: competitors.length, winner: ranIdx(ok[0]), mode, reason: "only surviving variant" },
2715
+ tournament: { variants: competitors.length, winner: w, mode, reason: "only surviving variant" },
2220
2716
  inputHash,
2221
2717
  reads: readRefsToReads(readRefs, state),
2222
2718
  endedAt: Date.now(),
@@ -2278,6 +2774,11 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2278
2774
  const chosen = winnerIneligible ? ok[0] : winnerResult;
2279
2775
  const winnerIdx = ranIdx(chosen);
2280
2776
  const output = mode === "aggregate" ? judgeRes.output : chosen.output;
2777
+ traceDecision(deps, state, phase.id, {
2778
+ type: "tournament-winner",
2779
+ value: winnerIdx,
2780
+ reason,
2781
+ });
2281
2782
  return {
2282
2783
  id: phase.id,
2283
2784
  status: "done",
@@ -2334,9 +2835,24 @@ function lastCompletedOutput(state, phase) {
2334
2835
  }
2335
2836
  return undefined;
2336
2837
  }
2838
+ /** Stable cache identity for the fully resolved agent pool. File paths are
2839
+ * excluded: content/config, not installation location, determines output. */
2840
+ export function agentDefinitionsIdentity(agents) {
2841
+ return JSON.stringify(agents
2842
+ .map((a) => ({
2843
+ name: a.name,
2844
+ description: a.description,
2845
+ systemPrompt: a.systemPrompt,
2846
+ model: a.model ?? "",
2847
+ thinking: a.thinking ?? "",
2848
+ tools: [...(a.tools ?? [])].sort(),
2849
+ source: a.source,
2850
+ }))
2851
+ .sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)));
2852
+ }
2337
2853
  /** Fold the phase fingerprint into the base hash parts to form the cache keys.
2338
2854
  *
2339
- * Four keys are produced for backward compatibility (see
2855
+ * Four keys are derived for tooling/backward compatibility (see
2340
2856
  * docs/internal/cache-migration.md):
2341
2857
  * - `key` : `v3:phasefp:<subfp>` — the current write key (per-phase
2342
2858
  * structural sub-fingerprint; falls back to the whole-flow hash when
@@ -2344,9 +2860,9 @@ function lastCompletedOutput(state, phase) {
2344
2860
  * - `v2Key` : `v2:flowdef:<flowDefHash>` — pre-M6 whole-flow key.
2345
2861
  * - `bareKey` : bare `flowdef:<flowDefHash>` (unversioned) — pre-H1 entries.
2346
2862
  * - `legacyKey`: the flowdef line omitted — pre-flowDefHash entries.
2347
- * `cachedPhase` consults all four READ-ONLY on a miss; `recordCache` writes
2348
- * only `key`. This means an upgrade never produces a miss-storm: existing
2349
- * entries (whichever shape) still hit, and new writes converge on `key`. */
2863
+ * `cachedPhase` consults the first three safe tiers READ-ONLY on a miss;
2864
+ * `legacyKey` is never read because it has no structural identity.
2865
+ * `recordCache` writes only `key`. */
2350
2866
  export function cacheKeys(cc, baseParts) {
2351
2867
  // Fold the full cache identity into the hash: flow name (prevents collisions
2352
2868
  // across different flows that share a phase.id + task + model), the per-phase
@@ -2357,6 +2873,10 @@ export function cacheKeys(cc, baseParts) {
2357
2873
  `think:${cc.thinking ?? ""}`,
2358
2874
  `tools:${JSON.stringify(cc.tools ?? [])}`,
2359
2875
  `ctx:${cc.preRead ?? ""}`,
2876
+ `agent-scope:${cc.agentScope ?? "user"}`,
2877
+ `context-sharing:${cc.contextSharing === true ? "1" : "0"}`,
2878
+ `agents:${cc.agentDefinitions ?? ""}`,
2879
+ ...(cc.executionCwd ? [`cwd:${cc.executionCwd}`] : []),
2360
2880
  ];
2361
2881
  const fold = (parts) => cc.fingerprint ? hashInput(...parts, cc.fingerprint) : hashInput(...parts);
2362
2882
  // Per-phase sub-fingerprint; falls back to the whole-flow hash when absent
@@ -2375,13 +2895,13 @@ export function cacheKeys(cc, baseParts) {
2375
2895
  * - "off": never reuse (even within-run).
2376
2896
  * - "run-only": within-run resume only (historical behavior).
2377
2897
  * - "cross-run": within-run first, then the persistent cross-run store.
2378
- * On a cross-run hit, usage is zeroed and `cacheHit` records the source.
2379
- *
2380
- * The cross-run read is FOUR-TIER and READ-ONLY for fallback keys: it tries
2898
+ * On a cross-run hit, usage is zeroed and `cacheHit` records the source.
2899
+ *
2900
+ * The cross-run read is three-tier and READ-ONLY for fallback keys: it tries
2381
2901
  * `keys.key` (current `v3:phasefp:` shape) first, then `keys.v2Key` (pre-M6
2382
- * `v2:flowdef:`), then `keys.bareKey` (pre-H1 bare `flowdef:`), then
2383
- * `keys.legacyKey` (pre-flowDefHash, no flowdef line).
2384
- * A hit on ANY tier is restored as a cache hit; we do NOT write-through (no
2902
+ * `v2:flowdef:`), then `keys.bareKey` (pre-H1 bare `flowdef:`). The older
2903
+ * no-flowdef key is deliberately unsafe and ignored.
2904
+ * A hit on any safe tier is restored as a cache hit; we do NOT write-through (no
2385
2905
  * re-store under the new key) so the cache size stays stable and the legacy
2386
2906
  * entry ages out naturally. See docs/internal/cache-migration.md.
2387
2907
  */
@@ -2397,9 +2917,13 @@ function cachedPhase(cc, keys) {
2397
2917
  if (cc.prior && cc.prior.status === "done" && cc.prior.inputHash === keys.key) {
2398
2918
  return { ...cc.prior, status: "done", cacheHit: "run-only" };
2399
2919
  }
2400
- // 2. cross-run memoization (opt-in) — four-tier read-only fallback.
2920
+ // 2. cross-run memoization (opt-in) — three safe read-only tiers.
2401
2921
  if (cc.scope === "cross-run") {
2402
- for (const k of [keys.key, keys.v2Key, keys.bareKey, keys.legacyKey]) {
2922
+ // The pre-flow-definition legacy key is intentionally NOT read: it omits
2923
+ // all structural identity and can return stale output after any semantic
2924
+ // flow change. v2/bare remain safe because they include today's definition
2925
+ // hash; old entries whose historical hash omitted new fields simply miss.
2926
+ for (const k of [keys.key, keys.v2Key, keys.bareKey]) {
2403
2927
  const e = cc.store.get(k, cc.ttlMs);
2404
2928
  if (!e)
2405
2929
  continue;
@@ -2484,12 +3008,8 @@ function defaultAgent(deps) {
2484
3008
  * JSON verdict object whose value is non-blocking (e.g. `{"verdict":"No issues
2485
3009
  * found"}`) is an *explicit* pass, not ambiguity, and still resolves to pass.
2486
3010
  */
2487
- // `parseGateVerdict` is implemented in `deterministic.ts` (a pure seam a
2488
- // future replay can import without dragging in the process-spawning runner).
2489
- // It is imported at the top of this file and re-exported from the barrel.
2490
- function asReason(v) {
2491
- return typeof v === "string" && v.trim() ? v.trim() : undefined;
2492
- }
3011
+ // `parseGateVerdict` / `parseTournamentWinner` live in `deterministic.ts`
3012
+ // (pure seam for replay + event kernel). Re-exported via the barrel.
2493
3013
  /**
2494
3014
  * If a gate phase relies on free-text verdict parsing (no `output:"json"` +
2495
3015
  * `expect` contract) and its task does not already demand a `VERDICT:` marker,
@@ -2513,30 +3033,7 @@ function appendGateFormatSuffix(task, phase) {
2513
3033
  `End your response with exactly one line in this exact form (no Markdown, no bold, no extra words):\n` +
2514
3034
  `VERDICT: PASS\nor\nVERDICT: BLOCK`);
2515
3035
  }
2516
- /**
2517
- * Parse a judge's pick of the winning variant. Accepts JSON ({"winner":n} or
2518
- * {"best":n}) or a `WINNER: n` line (last match wins). Clamps to [1, count].
2519
- * Fail-open: an unreadable verdict defaults to variant 1 so the work is never
2520
- * lost. Returns the 1-based index plus an optional reason.
2521
- */
2522
- export function parseTournamentWinner(output, count) {
2523
- const clamp = (n) => Math.min(Math.max(1, Math.floor(n)), Math.max(1, count));
2524
- const json = safeParse(output);
2525
- if (json && typeof json === "object") {
2526
- const o = json;
2527
- const raw = o.winner ?? o.best ?? o.choice;
2528
- const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
2529
- if (Number.isFinite(n))
2530
- return { winner: clamp(n), reason: asReason(o.reason) };
2531
- }
2532
- const matches = [...output.matchAll(WINNER_TOKEN_RE)];
2533
- if (matches.length) {
2534
- const n = Number(matches[matches.length - 1][1]);
2535
- if (Number.isFinite(n))
2536
- return { winner: clamp(n) };
2537
- }
2538
- return { winner: 1, reason: "no parseable winner; defaulted to variant 1" };
2539
- }
3036
+ /* parseTournamentWinner lives in deterministic.ts (shared with event kernel). */
2540
3037
  /**
2541
3038
  * Best-effort invocation of the user-provided `persist` + `onProgress` callbacks.
2542
3039
  *
@@ -2615,9 +3112,27 @@ export async function recomputeTaskflow(state, deps, seeds,
2615
3112
  // Fail-safe default: a real recompute overwrites the run and spends tokens.
2616
3113
  // The tool/command wrappers can explicitly opt into dryRun:false.
2617
3114
  opts = { dryRun: true }) {
3115
+ deps = snapshotFlowLoader(deps);
2618
3116
  // Never mutate the caller's RunState in-place. Recompute is a speculative
2619
3117
  // replay; only the caller decides whether to persist the new state.
2620
3118
  const newState = structuredClone(state);
3119
+ newState.args = resolveArgs(newState.def, newState.args);
3120
+ const invocationErrors = validateInvocationArgs(newState.def, newState.args);
3121
+ if (invocationErrors.length > 0) {
3122
+ throw new Error(`Taskflow '${newState.def.name}' invocation is invalid: ${invocationErrors.join("; ")}`);
3123
+ }
3124
+ const bridgeTree = flowTreeUsesCwdBridge(newState.def, deps.loadFlow);
3125
+ // Once a run has exercised the compatibility bridge, its persisted root
3126
+ // binding is permanent provenance. A later definition downgrade must not
3127
+ // silently turn cache/recompute back on for state produced with filesystem
3128
+ // authority.
3129
+ const bridgeTainted = bridgeTree || newState.cwdRootBinding !== undefined;
3130
+ if (bridgeTainted && opts.dryRun === false) {
3131
+ throw new Error("recompute dryRun:false is unavailable for cwd-bridge flows until workspace state restoration exists; run the whole flow instead");
3132
+ }
3133
+ if (!deps._disableCache && bridgeTainted) {
3134
+ deps = { ...deps, _disableCache: true };
3135
+ }
2621
3136
  const reads = readMapOf(newState.phases);
2622
3137
  // M2: derive the declared read-map fresh from the def so the frontier uses
2623
3138
  // the UNION (observed ∪ declared). Derived here (not read from the persisted
@@ -2729,10 +3244,23 @@ opts = { dryRun: true }) {
2729
3244
  const changedUpstreams = depsFor(id).filter((u) => outputMoved.has(u));
2730
3245
  try {
2731
3246
  const ps = await executePhase(phase, newState, deps, newState.phases[id], noop, 0, execOpts);
3247
+ if (ps.status === "failed") {
3248
+ // Recompute is speculative. Preserve the last known-good row for the
3249
+ // failed phase and every downstream phase, then stop. Mark the report
3250
+ // aborted so callers cannot persist a partially refreshed graph.
3251
+ rerun.push(id);
3252
+ decisions.push({
3253
+ phaseId: id,
3254
+ outcome: "failed",
3255
+ reason: ps.error ?? "re-execution returned a failed phase",
3256
+ });
3257
+ aborted = true;
3258
+ break;
3259
+ }
2732
3260
  newState.phases[id] = ps;
2733
3261
  // A phase counts as "rerun" if it was a forced seed OR its result moved;
2734
3262
  // otherwise it hit its cache (inputHash unchanged) → early cutoff.
2735
- if (isSeed || ps.inputHash !== before) {
3263
+ if (isSeed || !ps.cacheHit || ps.inputHash !== before) {
2736
3264
  rerun.push(id);
2737
3265
  outputMoved.add(id);
2738
3266
  decisions.push(isSeed
@@ -2756,11 +3284,16 @@ opts = { dryRun: true }) {
2756
3284
  });
2757
3285
  }
2758
3286
  }
2759
- catch {
3287
+ catch (error) {
2760
3288
  // A failing recompute phase is recorded as rerun (it was attempted).
2761
3289
  rerun.push(id);
2762
- outputMoved.add(id);
2763
- decisions.push({ phaseId: id, outcome: "failed", reason: "re-execution attempted but the phase failed" });
3290
+ decisions.push({
3291
+ phaseId: id,
3292
+ outcome: "failed",
3293
+ reason: `re-execution threw: ${error instanceof Error ? error.message : String(error)}`,
3294
+ });
3295
+ aborted = true;
3296
+ break;
2764
3297
  }
2765
3298
  }
2766
3299
  // Frontier-external phases were never touched — record them as reused.
@@ -2783,8 +3316,150 @@ opts = { dryRun: true }) {
2783
3316
  };
2784
3317
  }
2785
3318
  export async function executeTaskflow(state, deps) {
3319
+ deps = snapshotFlowLoader(deps);
2786
3320
  const def = state.def;
3321
+ // Normalize defaults at the engine boundary too. Adapters already do this,
3322
+ // but direct Core callers, resume, and detached execution must behave the same.
3323
+ state.args = resolveArgs(def, state.args);
3324
+ const invocationErrors = validateInvocationArgs(def, state.args);
3325
+ if (invocationErrors.length > 0) {
3326
+ state.status = "failed";
3327
+ safeEmit(deps, state);
3328
+ return {
3329
+ state,
3330
+ finalOutput: `Taskflow '${def.name}' invocation is invalid: ${invocationErrors.join("; ")}`,
3331
+ ok: false,
3332
+ totalUsage: emptyUsage(),
3333
+ };
3334
+ }
3335
+ if (deps._dynamic === true) {
3336
+ const dynamicValidation = validateTaskflow(def, { dynamic: true, cwd: deps.cwd, args: state.args });
3337
+ if (!dynamicValidation.ok) {
3338
+ state.status = "failed";
3339
+ safeEmit(deps, state);
3340
+ return {
3341
+ state,
3342
+ finalOutput: `Dynamic taskflow '${def.name}' is invalid: ${dynamicValidation.errors.join("; ")}`,
3343
+ ok: false,
3344
+ totalUsage: emptyUsage(),
3345
+ };
3346
+ }
3347
+ }
3348
+ // A cwd bridge carries compatibility read-write authority. Until workspace
3349
+ // state restoration exists, output-only cache hits could skip required file
3350
+ // mutations or let downstream phases observe stale files. Disable cache and
3351
+ // within-run resume reuse across the complete reachable flow tree.
3352
+ const bridgeTree = flowTreeUsesCwdBridge(def, deps.loadFlow);
3353
+ // Persisted binding is a permanent taint bit: saved-flow definitions can
3354
+ // change between resumes, but prior outputs may already depend on filesystem
3355
+ // mutations. Never regain cache/rebind privileges merely because the current
3356
+ // snapshot no longer declares the bridge.
3357
+ const bridgeTainted = bridgeTree || state.cwdRootBinding !== undefined;
3358
+ if (bridgeTainted) {
3359
+ const invocationRoot = directoryIdentity(deps.cwd);
3360
+ const statePathRoot = directoryIdentity(state.cwd);
3361
+ const launchRoot = state.invocationRootSnapshot;
3362
+ const recordedRoot = state.cwdRootBinding;
3363
+ const executablePhaseIds = new Set(def.phases.map((phase) => phase.id));
3364
+ const hasExecutablePriorState = Object.keys(state.phases).some((id) => executablePhaseIds.has(id));
3365
+ // Pre-seeded external dependencies are inputs, not evidence that a bridge
3366
+ // phase previously executed without a persisted root binding. Conversely,
3367
+ // a host's launch snapshot proves root continuity, not prior bridge
3368
+ // authorization: adding a bridge after ordinary phases ran still fails.
3369
+ const isLegacyResume = bridgeTree && recordedRoot === undefined && hasExecutablePriorState;
3370
+ if (isLegacyResume ||
3371
+ !sameDirectoryIdentity(statePathRoot, invocationRoot) ||
3372
+ (launchRoot !== undefined && !sameDirectoryIdentity(launchRoot, invocationRoot)) ||
3373
+ (recordedRoot !== undefined && !sameDirectoryIdentity(recordedRoot, invocationRoot))) {
3374
+ state.status = "failed";
3375
+ safeEmit(deps, state);
3376
+ return {
3377
+ state,
3378
+ finalOutput: `Taskflow '${def.name}' cwd-bridge invocation root does not match the run's persisted root; start a new run instead of rebinding on resume`,
3379
+ ok: false,
3380
+ totalUsage: emptyUsage(),
3381
+ };
3382
+ }
3383
+ state.cwdRootBinding ??= invocationRoot;
3384
+ // Freeze the invocation root to the canonical identity we just bound. In
3385
+ // particular, do not resolve phase cwd through a caller-provided symlink a
3386
+ // second time after the root-binding check.
3387
+ if (invocationRoot)
3388
+ deps = { ...deps, cwd: invocationRoot.canonicalPath };
3389
+ }
3390
+ if (!deps._disableCache && bridgeTainted) {
3391
+ deps = { ...deps, _disableCache: true };
3392
+ }
3393
+ // The explicit 0.2.1 resolve-only opt-in uses a W1a-compatible partial
3394
+ // control/durability scaffold. This does not upgrade its assurance: the
3395
+ // session is deliberately labelled resolve-only and no OS sandbox claim is
3396
+ // made. A native session must come from an exact approved host baseline cell.
3397
+ if (bridgeTree && deps.cwdBridgeMode === "resolve-only" && !deps.workspaceSession) {
3398
+ try {
3399
+ deps = {
3400
+ ...deps,
3401
+ workspaceSession: await createResolveOnlyWorkspaceSession({
3402
+ invocationRoot: deps.cwd,
3403
+ controlDirectory: deps.workspaceControlDirectory,
3404
+ signal: deps.signal,
3405
+ }),
3406
+ };
3407
+ }
3408
+ catch (error) {
3409
+ state.status = "failed";
3410
+ safeEmit(deps, state);
3411
+ return {
3412
+ state,
3413
+ finalOutput: `Taskflow '${def.name}' workspace capability initialization failed: ${error instanceof Error ? error.message : String(error)}`,
3414
+ ok: false,
3415
+ totalUsage: emptyUsage(),
3416
+ };
3417
+ }
3418
+ }
3419
+ const runnerUsageAccounting = deps.runTask
3420
+ ?.usageAccounting;
3421
+ if (!deps.usageAccounting && runnerUsageAccounting) {
3422
+ // Preserve a runner-advertised capability across the wrapper functions used
3423
+ // by nested flow/context execution; those wrappers would otherwise erase a
3424
+ // property attached to the original runTask function.
3425
+ deps = { ...deps, usageAccounting: runnerUsageAccounting };
3426
+ }
2787
3427
  try {
3428
+ if (deps.usageAccounting === "unavailable" && def.budget) {
3429
+ throw new Error(`Usage accounting is unavailable for this host; refusing budgeted flow '${def.name}' because its token/USD ceiling cannot be enforced`);
3430
+ }
3431
+ if (deps.usageAccounting === "tokens-only" && def.budget?.maxUSD !== undefined) {
3432
+ throw new Error("This host reports tokens but not cost, so budget.maxUSD cannot be enforced. " +
3433
+ "Use budget.maxTokens or a host with cost accounting.");
3434
+ }
3435
+ // S2 strangler (default OFF): all phase kinds may use the event kernel when enabled.
3436
+ const { eventKernelEnabled, canUseEventKernel, runEventKernel } = await import("./exec/driver.js");
3437
+ // Existing phase state requires the imperative cache/inputHash machinery to
3438
+ // validate definition and idempotency before reuse. The event kernel does
3439
+ // not yet persist compatible input hashes, so it must never blindly trust a
3440
+ // prior `done` row.
3441
+ const hasPriorState = Object.keys(state.phases).length > 0;
3442
+ if (eventKernelEnabled(deps) && deps._cwdBoundary === undefined && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) {
3443
+ if (!deps.runTask) {
3444
+ throw new Error("event kernel requires RuntimeDeps.runTask");
3445
+ }
3446
+ return await runEventKernel(state, {
3447
+ cwd: deps.cwd,
3448
+ agents: deps.agents,
3449
+ runTask: deps.runTask,
3450
+ signal: deps.signal,
3451
+ globalThinking: deps.globalThinking,
3452
+ usageAccounting: deps.usageAccounting,
3453
+ trace: deps.trace,
3454
+ persist: deps.persist,
3455
+ onProgress: deps.onProgress,
3456
+ eventKernel: deps.eventKernel,
3457
+ requestApproval: deps.requestApproval,
3458
+ loadFlow: deps.loadFlow,
3459
+ _stack: deps._stack,
3460
+ _dynamic: deps._dynamic,
3461
+ });
3462
+ }
2788
3463
  return await runTaskflowLayers(state, deps);
2789
3464
  }
2790
3465
  catch (e) {
@@ -2806,6 +3481,31 @@ export async function executeTaskflow(state, deps) {
2806
3481
  }
2807
3482
  async function runTaskflowLayers(state, deps) {
2808
3483
  const def = state.def;
3484
+ // Ownership migration must happen before ANY phase in the new definition is
3485
+ // scheduled. Definition evolution may remove/rename ordinary phases as well
3486
+ // as graft children; neither their terminal failure nor their usage may leak
3487
+ // into the new run. Dynamic promoted state is intentionally cleared here too
3488
+ // and is restored only by its owning expand phase (from its current result or
3489
+ // cache). An id newly promoted to a real authored phase is preserved because
3490
+ // it is present in `declaredPhaseIds` and the scheduler will validate/rerun it.
3491
+ // Cleaning inside the expand phase would be too late: an unrelated authored
3492
+ // phase may already have been scheduled and stale usage already counted.
3493
+ const declaredPhaseIds = new Set(def.phases.map((p) => p.id));
3494
+ // A pre-seeded state may intentionally supply an external dependency (for
3495
+ // example an embedding host injects `src` and the definition starts at a map
3496
+ // that depends on it). Those ids are part of the new definition's dependency
3497
+ // contract even though they have no executable Phase row, so preserve them.
3498
+ const externalDependencyIds = new Set(def.phases.flatMap((phase) => dependenciesOf(phase)).filter((id) => !declaredPhaseIds.has(id)));
3499
+ for (const oldId of Object.keys(state.phases)) {
3500
+ if (!declaredPhaseIds.has(oldId) && !externalDependencyIds.has(oldId))
3501
+ delete state.phases[oldId];
3502
+ }
3503
+ for (const previous of Object.values(state.phases)) {
3504
+ for (const oldId of Object.keys(previous.promotedPhases ?? {})) {
3505
+ if (!declaredPhaseIds.has(oldId))
3506
+ delete state.phases[oldId];
3507
+ }
3508
+ }
2809
3509
  const layers = topoLayers(def.phases);
2810
3510
  // Content-fingerprint the desugared definition ONCE per run and fold it into
2811
3511
  // every phase's cache key (overstory hash algorithm; see ./flowir/hash.ts).
@@ -2819,16 +3519,20 @@ async function runTaskflowLayers(state, deps) {
2819
3519
  // plane) is persisted for audit/provenance. The declared plane is also
2820
3520
  // derived fresh from `def` in recompute (so old runs get union semantics
2821
3521
  // too); the persisted copy is for display.
2822
- if (state.flowDefHash === undefined) {
2823
- try {
2824
- const ir = await compileTaskflowToIR(def);
2825
- state.flowDefHash = ir.hash ?? "failed";
2826
- state.declaredDeps = ir.meta.declaredDeps;
2827
- if (ir.errors.length) {
2828
- console.warn(`[taskflow] IR compile errors for '${def.name}': ${ir.errors.map((e) => e.message).join("; ")}`);
2829
- }
3522
+ try {
3523
+ const ir = await compileTaskflowToIR(def);
3524
+ const nextHash = ir.hash ?? "failed";
3525
+ if (state.flowDefHash !== nextHash) {
3526
+ state.flowDefHash = nextHash;
3527
+ state.phaseFingerprints = undefined;
2830
3528
  }
2831
- catch (e) {
3529
+ state.declaredDeps = ir.meta.declaredDeps;
3530
+ if (ir.errors.length) {
3531
+ console.warn(`[taskflow] IR compile errors for '${def.name}': ${ir.errors.map((e) => e.message).join("; ")}`);
3532
+ }
3533
+ }
3534
+ catch (e) {
3535
+ if (state.flowDefHash === undefined) {
2832
3536
  // Fail-safe: warn loudly rather than silently degrading to the legacy
2833
3537
  // flowName-only key, which would reopen the cross-flow collision hole.
2834
3538
  console.warn(`[taskflow] flowDefHash failed for '${def.name}': ${e instanceof Error ? e.message : String(e)}. ` +
@@ -2875,7 +3579,11 @@ async function runTaskflowLayers(state, deps) {
2875
3579
  break;
2876
3580
  }
2877
3581
  // Phases within a layer have no inter-dependencies → run concurrently.
2878
- const layerConcurrency = Math.max(1, def.concurrency ?? 8);
3582
+ // A usage report arrives only after a subagent call. With a declared hard
3583
+ // budget, concurrent layer admission would let every sibling observe the
3584
+ // same remaining allowance. Serialize admission so no additional call can
3585
+ // begin after a previous call has exhausted the cap.
3586
+ const layerConcurrency = def.budget ? 1 : Math.max(1, def.concurrency ?? 8);
2879
3587
  await mapWithConcurrencyLimit(layer, layerConcurrency, async (phase) => {
2880
3588
  // Snapshot prior state BEFORE marking running, so resume cache checks work.
2881
3589
  const prior = state.phases[phase.id];
@@ -2900,8 +3608,27 @@ async function runTaskflowLayers(state, deps) {
2900
3608
  else if (!depsSatisfied)
2901
3609
  skipReason = join === "any" ? "All dependencies failed or were skipped" : "Upstream dependency not satisfied";
2902
3610
  if (skipReason) {
2903
- if (skipReason.startsWith("Budget exceeded"))
3611
+ if (skipReason.startsWith("Budget exceeded")) {
2904
3612
  budgetBlocked = true;
3613
+ // S1: budget-hit decision so fold/replay can re-tally under new caps.
3614
+ traceDecision(deps, state, phase.id, {
3615
+ type: "budget-hit",
3616
+ value: budgetReason || "budget",
3617
+ reason: skipReason,
3618
+ });
3619
+ // executePhase already flushed its phase-end batch. Flush this
3620
+ // post-completion decision too so FileTraceSink cannot strand it.
3621
+ traceFlush(deps, phase.id);
3622
+ }
3623
+ // Synthetic phase-start/end so fold sees a complete phase lifecycle.
3624
+ traceEmit(deps, {
3625
+ ts: Date.now(),
3626
+ runId: state.runId,
3627
+ phaseId: phase.id,
3628
+ kind: "phase-start",
3629
+ dependencies: dependenciesOf(phase),
3630
+ optional: phase.optional === true,
3631
+ });
2905
3632
  state.phases[phase.id] = {
2906
3633
  id: phase.id,
2907
3634
  status: "skipped",
@@ -2909,6 +3636,15 @@ async function runTaskflowLayers(state, deps) {
2909
3636
  endedAt: Date.now(),
2910
3637
  usage: emptyUsage(),
2911
3638
  };
3639
+ traceEmit(deps, {
3640
+ ts: Date.now(),
3641
+ runId: state.runId,
3642
+ phaseId: phase.id,
3643
+ kind: "phase-end",
3644
+ status: "skipped",
3645
+ error: skipReason,
3646
+ });
3647
+ traceFlush(deps, phase.id);
2912
3648
  safeEmit(deps, state);
2913
3649
  return;
2914
3650
  }
@@ -2952,11 +3688,29 @@ async function runTaskflowLayers(state, deps) {
2952
3688
  // acceptable: budgetBlocked prevents cascading into subsequent layers.
2953
3689
  const ob = overBudget(state);
2954
3690
  if (ob.over) {
3691
+ if (!budgetBlocked) {
3692
+ // First time we detect the ceiling after a phase completes.
3693
+ traceDecision(deps, state, phase.id, {
3694
+ type: "budget-hit",
3695
+ value: "budget",
3696
+ reason: ob.reason,
3697
+ });
3698
+ traceFlush(deps, phase.id);
3699
+ }
2955
3700
  budgetBlocked = true;
2956
3701
  budgetReason = ob.reason;
2957
3702
  }
2958
3703
  safeEmit(deps, state);
2959
3704
  });
3705
+ // The signal can flip while a layer is in flight. Checking only at the
3706
+ // beginning of the next layer lets an abort during the final layer fall
3707
+ // through to `completed` (notably when a non-cooperative race branch later
3708
+ // reports success). Cancellation is terminal for this invocation: preserve
3709
+ // the phase evidence gathered above, but classify the run as resumable.
3710
+ if (deps.signal?.aborted) {
3711
+ aborted = true;
3712
+ break;
3713
+ }
2960
3714
  }
2961
3715
  const fp = finalPhase(def.phases);
2962
3716
  let finalState = state.phases[fp.id];
@@ -2968,7 +3722,7 @@ async function runTaskflowLayers(state, deps) {
2968
3722
  finalState = doneInOrder[doneInOrder.length - 1];
2969
3723
  }
2970
3724
  // A failed non-optional phase fails the run; optional failures are tolerated.
2971
- const anyFailed = Object.entries(state.phases).some(([id, p]) => p.status === "failed" && !byId.get(id)?.optional);
3725
+ const anyFailed = Object.entries(state.phases).some(([id, p]) => p.status === "failed" && !byId.get(id)?.optional && !p.optional);
2972
3726
  state.status = aborted
2973
3727
  ? "paused"
2974
3728
  : gateBlocked || budgetBlocked