taskflow-core 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +247 -862
  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/build-info.d.ts +48 -0
  7. package/dist/build-info.d.ts.map +1 -0
  8. package/dist/build-info.js +112 -0
  9. package/dist/build-info.js.map +1 -0
  10. package/dist/build-info.json +4 -0
  11. package/dist/cwd-bridge.d.ts +61 -0
  12. package/dist/cwd-bridge.d.ts.map +1 -0
  13. package/dist/cwd-bridge.js +136 -0
  14. package/dist/cwd-bridge.js.map +1 -0
  15. package/dist/detached-runner.js +31 -5
  16. package/dist/detached-runner.js.map +1 -1
  17. package/dist/exec/driver.d.ts +6 -0
  18. package/dist/exec/driver.d.ts.map +1 -1
  19. package/dist/exec/driver.js +40 -18
  20. package/dist/exec/driver.js.map +1 -1
  21. package/dist/exec/kernel-policy.d.ts.map +1 -1
  22. package/dist/exec/kernel-policy.js +10 -0
  23. package/dist/exec/kernel-policy.js.map +1 -1
  24. package/dist/exec/step-kinds.d.ts +14 -1
  25. package/dist/exec/step-kinds.d.ts.map +1 -1
  26. package/dist/exec/step-kinds.js +126 -6
  27. package/dist/exec/step-kinds.js.map +1 -1
  28. package/dist/exec/step.d.ts +22 -0
  29. package/dist/exec/step.d.ts.map +1 -1
  30. package/dist/exec/step.js +40 -5
  31. package/dist/exec/step.js.map +1 -1
  32. package/dist/final-output.d.ts +53 -0
  33. package/dist/final-output.d.ts.map +1 -0
  34. package/dist/final-output.js +65 -0
  35. package/dist/final-output.js.map +1 -0
  36. package/dist/flowir/canonical-hash.d.ts.map +1 -1
  37. package/dist/flowir/canonical-hash.js +2 -0
  38. package/dist/flowir/canonical-hash.js.map +1 -1
  39. package/dist/flowir/compile.d.ts.map +1 -1
  40. package/dist/flowir/compile.js +17 -0
  41. package/dist/flowir/compile.js.map +1 -1
  42. package/dist/flowir/meta.d.ts +3 -0
  43. package/dist/flowir/meta.d.ts.map +1 -1
  44. package/dist/flowir/schema.d.ts +3 -0
  45. package/dist/flowir/schema.d.ts.map +1 -1
  46. package/dist/flowir/schema.js.map +1 -1
  47. package/dist/flowir/translate.d.ts.map +1 -1
  48. package/dist/flowir/translate.js +4 -0
  49. package/dist/flowir/translate.js.map +1 -1
  50. package/dist/host/runner-types.d.ts +16 -0
  51. package/dist/host/runner-types.d.ts.map +1 -1
  52. package/dist/index.d.ts +5 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +9 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/interpolate.d.ts +4 -0
  57. package/dist/interpolate.d.ts.map +1 -1
  58. package/dist/interpolate.js +14 -0
  59. package/dist/interpolate.js.map +1 -1
  60. package/dist/resources/authority.d.ts +35 -0
  61. package/dist/resources/authority.d.ts.map +1 -0
  62. package/dist/resources/authority.js +67 -0
  63. package/dist/resources/authority.js.map +1 -0
  64. package/dist/resources/backend.d.ts +302 -0
  65. package/dist/resources/backend.d.ts.map +1 -0
  66. package/dist/resources/backend.js +16 -0
  67. package/dist/resources/backend.js.map +1 -0
  68. package/dist/resources/baseline.d.ts +116 -0
  69. package/dist/resources/baseline.d.ts.map +1 -0
  70. package/dist/resources/baseline.js +447 -0
  71. package/dist/resources/baseline.js.map +1 -0
  72. package/dist/resources/canonical-json.d.ts +7 -0
  73. package/dist/resources/canonical-json.d.ts.map +1 -0
  74. package/dist/resources/canonical-json.js +75 -0
  75. package/dist/resources/canonical-json.js.map +1 -0
  76. package/dist/resources/errors.d.ts +23 -0
  77. package/dist/resources/errors.d.ts.map +1 -0
  78. package/dist/resources/errors.js +89 -0
  79. package/dist/resources/errors.js.map +1 -0
  80. package/dist/resources/execution.d.ts +90 -0
  81. package/dist/resources/execution.d.ts.map +1 -0
  82. package/dist/resources/execution.js +581 -0
  83. package/dist/resources/execution.js.map +1 -0
  84. package/dist/resources/index.d.ts +15 -0
  85. package/dist/resources/index.d.ts.map +1 -0
  86. package/dist/resources/index.js +15 -0
  87. package/dist/resources/index.js.map +1 -0
  88. package/dist/resources/journal.d.ts +138 -0
  89. package/dist/resources/journal.d.ts.map +1 -0
  90. package/dist/resources/journal.js +438 -0
  91. package/dist/resources/journal.js.map +1 -0
  92. package/dist/resources/leases.d.ts +51 -0
  93. package/dist/resources/leases.d.ts.map +1 -0
  94. package/dist/resources/leases.js +354 -0
  95. package/dist/resources/leases.js.map +1 -0
  96. package/dist/resources/permits.d.ts +52 -0
  97. package/dist/resources/permits.d.ts.map +1 -0
  98. package/dist/resources/permits.js +240 -0
  99. package/dist/resources/permits.js.map +1 -0
  100. package/dist/resources/persistence.d.ts +63 -0
  101. package/dist/resources/persistence.d.ts.map +1 -0
  102. package/dist/resources/persistence.js +522 -0
  103. package/dist/resources/persistence.js.map +1 -0
  104. package/dist/resources/registry.d.ts +56 -0
  105. package/dist/resources/registry.d.ts.map +1 -0
  106. package/dist/resources/registry.js +139 -0
  107. package/dist/resources/registry.js.map +1 -0
  108. package/dist/resources/resolve.d.ts +49 -0
  109. package/dist/resources/resolve.d.ts.map +1 -0
  110. package/dist/resources/resolve.js +309 -0
  111. package/dist/resources/resolve.js.map +1 -0
  112. package/dist/resources/sandbox.d.ts +72 -0
  113. package/dist/resources/sandbox.d.ts.map +1 -0
  114. package/dist/resources/sandbox.js +952 -0
  115. package/dist/resources/sandbox.js.map +1 -0
  116. package/dist/resources/schema.d.ts +195 -0
  117. package/dist/resources/schema.d.ts.map +1 -0
  118. package/dist/resources/schema.js +231 -0
  119. package/dist/resources/schema.js.map +1 -0
  120. package/dist/resources/types.d.ts +36 -0
  121. package/dist/resources/types.d.ts.map +1 -0
  122. package/dist/resources/types.js +75 -0
  123. package/dist/resources/types.js.map +1 -0
  124. package/dist/resume.d.ts +76 -0
  125. package/dist/resume.d.ts.map +1 -0
  126. package/dist/resume.js +190 -0
  127. package/dist/resume.js.map +1 -0
  128. package/dist/runner-core.d.ts +29 -0
  129. package/dist/runner-core.d.ts.map +1 -1
  130. package/dist/runner-core.js +311 -36
  131. package/dist/runner-core.js.map +1 -1
  132. package/dist/runtime/phases/parallel.d.ts +3 -0
  133. package/dist/runtime/phases/parallel.d.ts.map +1 -1
  134. package/dist/runtime/phases/parallel.js.map +1 -1
  135. package/dist/runtime/phases/reduce.d.ts +47 -0
  136. package/dist/runtime/phases/reduce.d.ts.map +1 -0
  137. package/dist/runtime/phases/reduce.js +195 -0
  138. package/dist/runtime/phases/reduce.js.map +1 -0
  139. package/dist/runtime/phases/script.d.ts.map +1 -1
  140. package/dist/runtime/phases/script.js +47 -9
  141. package/dist/runtime/phases/script.js.map +1 -1
  142. package/dist/runtime.d.ts +67 -1
  143. package/dist/runtime.d.ts.map +1 -1
  144. package/dist/runtime.js +904 -79
  145. package/dist/runtime.js.map +1 -1
  146. package/dist/schema.d.ts +135 -5
  147. package/dist/schema.d.ts.map +1 -1
  148. package/dist/schema.js +338 -21
  149. package/dist/schema.js.map +1 -1
  150. package/dist/store.d.ts +56 -0
  151. package/dist/store.d.ts.map +1 -1
  152. package/dist/store.js +5 -1
  153. package/dist/store.js.map +1 -1
  154. package/dist/trace.d.ts +3 -0
  155. package/dist/trace.d.ts.map +1 -1
  156. package/dist/trace.js.map +1 -1
  157. package/package.json +4 -3
package/dist/runtime.js CHANGED
@@ -11,7 +11,7 @@
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
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
@@ -28,7 +28,7 @@ const noRunnerInjected = async (_cwd, _agents, agentName, task) => ({
28
28
  });
29
29
  export { PHASE_TIMEOUT_ABORT_GRACE_MS } from "./runner-core.js";
30
30
  import { aggregateUsage, emptyUsage } from "./usage.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, validateTaskflow } from "./schema.js";
31
+ import { asArray, dependenciesOf, 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";
32
32
  import { verifyTaskflow } from "./verify.js";
33
33
  import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, scoreResultJSON, scorerShapeErrors } from "./scorers.js";
34
34
  import { parseGateVerdict, overBudget as overBudgetCheck, parseTournamentWinner } from "./deterministic.js";
@@ -40,11 +40,19 @@ export { parseGateVerdict };
40
40
  import { runCodeCompilesScorer } from "./scorer-runtime.js";
41
41
  import { buildReflexionSummary, isContractViolation, REFLEXION_SENTINEL } from "./reflexion.js";
42
42
  import { hashInput, newRunId, runsDir } from "./store.js";
43
+ import { resolveFinalOutput } from "./final-output.js";
43
44
  import { CacheStore, resolveFingerprint } from "./cache.js";
44
45
  import { compileTaskflowToIR, phaseFingerprint } from "./flowir/index.js";
45
46
  import { computeStaleFrontier, declaredReadMapOfDef, readMapOf } from "./stale.js";
46
47
  import { ctxDirFor, drainPendingSpawns, initCtxDir, registerNode, setNodeStatus } from "./context-store.js";
47
48
  import { allocateWorkspace, isWorkspaceKeyword } from "./workspace.js";
49
+ import { cwdArgName, directoryIdentity, isPathWithin, resolveCwdArg, } from "./cwd-bridge.js";
50
+ import { createResolveOnlyWorkspaceSession, } from "./resources/execution.js";
51
+ // Re-export the shared final-output helper + types so callers importing from
52
+ // the runtime barrel get them from one place. The event kernel
53
+ // (`exec/driver.ts`) imports these directly from `./final-output.ts` to avoid
54
+ // a static runtime↔driver import cycle.
55
+ export { resolveFinalOutput, } from "./final-output.js";
48
56
  /** Compute the incremental-reuse summary from a run's terminal phase states.
49
57
  * Pure, total, never throws. A phase is "reused" iff it carries a `cacheHit`
50
58
  * marker (set by `cachedPhase` for both within-run resume and cross-run hits). */
@@ -443,7 +451,7 @@ function liveSink(state, phaseId, emitProgress) {
443
451
  */
444
452
  const CONTEXT_MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
445
453
  const MAX_TOTAL_CONTEXT_CHARS = 200_000;
446
- async function resolvePhaseContext(phase, ctx) {
454
+ async function resolvePhaseContext(phase, ctx, cwd, boundary) {
447
455
  const entries = phase.context;
448
456
  if (!entries || entries.length === 0)
449
457
  return "";
@@ -482,20 +490,29 @@ async function resolvePhaseContext(phase, ctx) {
482
490
  const blocks = [];
483
491
  for (const p of filtered) {
484
492
  try {
485
- const abs = path.resolve(p);
486
- const stat = fs.statSync(abs);
493
+ const abs = path.resolve(cwd, p);
494
+ if (boundary && !isPathWithin(boundary, abs)) {
495
+ throw new Error(`TF_CWD_BOUNDARY_ESCAPE: context path '${p}' escapes the inherited cwd boundary`);
496
+ }
497
+ const canonical = fs.realpathSync(abs);
498
+ if (boundary && !isPathWithin(boundary, canonical)) {
499
+ throw new Error(`TF_CWD_BOUNDARY_ESCAPE: context path '${p}' resolves outside the inherited cwd boundary`);
500
+ }
501
+ const stat = fs.statSync(canonical);
487
502
  if (!stat.isFile())
488
503
  continue;
489
504
  if (stat.size > CONTEXT_MAX_FILE_BYTES)
490
505
  continue;
491
- const content = fs.readFileSync(abs, "utf-8");
506
+ const content = fs.readFileSync(canonical, "utf-8");
492
507
  const truncated = content.length > limit
493
508
  ? content.slice(0, limit) + `\n... [truncated ${content.length - limit} chars]`
494
509
  : content;
495
510
  const ext = path.extname(p).slice(1) || "txt";
496
511
  blocks.push(`## File: ${p}\n\n\`\`\`${ext}\n${truncated}\n\`\`\``);
497
512
  }
498
- catch {
513
+ catch (error) {
514
+ if (error instanceof Error && error.message.startsWith("TF_CWD_BOUNDARY_ESCAPE:"))
515
+ throw error;
499
516
  console.warn(`[taskflow] Skipped unreadable context file: ${p}`);
500
517
  }
501
518
  }
@@ -537,19 +554,92 @@ function spawnedOverBudget(state, local) {
537
554
  * two isolation-leak bugs in the 0.0.23 review).
538
555
  */
539
556
  function resolveEffCwd(deps, phase) {
540
- return deps._cwdOverride ?? (isWorkspaceKeyword(phase.cwd) ? deps.cwd : phase.cwd ?? deps.cwd);
557
+ if (deps._cwdOverride)
558
+ return deps._cwdOverride;
559
+ if (!phase.cwd || isWorkspaceKeyword(phase.cwd))
560
+ return deps.cwd;
561
+ // Node resolves a relative spawn cwd against the Taskflow process cwd. That
562
+ // is not necessarily the invocation root, so anchor legacy literals here.
563
+ return path.resolve(deps.cwd, phase.cwd);
564
+ }
565
+ /** Resolve a literal per-branch cwd once for BOTH cache identity and execution.
566
+ * Existing directories are realpath-canonicalized so aliases/symlinks cannot
567
+ * collide with a different invocation root under the same raw cwd string. */
568
+ function resolveBranchCwd(deps, cwd) {
569
+ const resolved = path.resolve(deps.cwd, cwd);
570
+ return directoryIdentity(resolved)?.canonicalPath ?? resolved;
571
+ }
572
+ function flowTreeUsesCwdBridge(def, loadFlow, seenUses = new Set()) {
573
+ if (def.phases.some((phase) => cwdArgName(phase.cwd) !== undefined))
574
+ return true;
575
+ if (!loadFlow)
576
+ return false;
577
+ for (const phase of def.phases) {
578
+ if ((phase.type ?? "agent") !== "flow" || !phase.use)
579
+ continue;
580
+ if (seenUses.has(phase.use))
581
+ continue;
582
+ seenUses.add(phase.use);
583
+ try {
584
+ const child = loadFlow(phase.use);
585
+ if (child && flowTreeUsesCwdBridge(child, loadFlow, seenUses))
586
+ return true;
587
+ }
588
+ catch {
589
+ // Unknown is treated as capability-bearing: disable reuse, then let the
590
+ // normal phase execution path report the loader failure coherently.
591
+ return true;
592
+ }
593
+ }
594
+ return false;
595
+ }
596
+ /**
597
+ * Freeze the saved-flow namespace for one top-level execution. Capability
598
+ * discovery and phase execution must observe the same definition, including
599
+ * the same loader error, or a mutable loader could introduce a bridge only
600
+ * after the root binding and cache policy were decided.
601
+ */
602
+ function snapshotFlowLoader(deps) {
603
+ if (!deps.loadFlow || deps._flowLoaderSnapshot)
604
+ return deps;
605
+ const source = deps.loadFlow;
606
+ const snapshot = new Map();
607
+ const loadFlow = (name) => {
608
+ let entry = snapshot.get(name);
609
+ if (!entry) {
610
+ try {
611
+ const loaded = source(name);
612
+ // Loader-owned objects may be mutated asynchronously. Capability scan
613
+ // and execution operate on a detached structured snapshot, never the
614
+ // loader's live reference.
615
+ entry = { ok: true, value: loaded === undefined ? undefined : structuredClone(loaded) };
616
+ }
617
+ catch (error) {
618
+ entry = { ok: false, error };
619
+ }
620
+ snapshot.set(name, entry);
621
+ }
622
+ if (!entry.ok)
623
+ throw entry.error;
624
+ return entry.value;
625
+ };
626
+ return { ...deps, loadFlow, _flowLoaderSnapshot: snapshot };
627
+ }
628
+ function sameDirectoryIdentity(a, b) {
629
+ return !!a && !!b && a.canonicalPath === b.canonicalPath && a.device === b.device && a.inode === b.inode;
541
630
  }
542
631
  async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, deps, state, localSpawnUsage) {
543
632
  const stack = deps._stack ?? [];
544
633
  const inlineDepth = stack.filter((s) => s.startsWith("def:")).length;
545
634
  if (inlineDepth >= MAX_DYNAMIC_NESTING) {
546
- return { output: `(spawned subflow rejected: nesting exceeded MAX_DYNAMIC_NESTING (${MAX_DYNAMIC_NESTING}))`, usage: emptyUsage() };
635
+ const error = `spawned subflow rejected: nesting exceeded MAX_DYNAMIC_NESTING (${MAX_DYNAMIC_NESTING})`;
636
+ return { output: `(${error})`, usage: emptyUsage(), failed: true, error };
547
637
  }
548
638
  const wrapped = normalizeInlineDef(subflowSpec, childNodeId);
549
639
  if (!wrapped)
550
- return { output: "(spawned subflow is not a Taskflow / phases array)", usage: emptyUsage() };
640
+ return { output: "(spawned subflow is not a Taskflow / phases array)", usage: emptyUsage(), failed: true, error: "spawned subflow is not a Taskflow / phases array" };
551
641
  if (wrapped.phases.length === 0)
552
- return { output: "(spawned subflow had zero phases — no-op)", usage: emptyUsage() };
642
+ return { output: "(spawned subflow had zero phases — no-op)", usage: emptyUsage(), failed: false };
553
643
  // Inner phases without their own agent inherit the assignment's defaultAgent.
554
644
  if (defaultAgent) {
555
645
  for (const p of wrapped.phases)
@@ -559,12 +649,15 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
559
649
  const spawnCwd = resolveEffCwd(deps, phase);
560
650
  const dynCwd = spawnCwd;
561
651
  const v = validateTaskflow(wrapped, { dynamic: true, cwd: dynCwd });
562
- if (!v.ok)
563
- return { output: `(spawned subflow failed validation: ${v.errors.join("; ")})`, usage: emptyUsage() };
652
+ if (!v.ok) {
653
+ const error = `spawned subflow failed validation: ${v.errors.join("; ")}`;
654
+ return { output: `(${error})`, usage: emptyUsage(), failed: true, error };
655
+ }
564
656
  const ver = verifyTaskflow({ name: wrapped.name, phases: wrapped.phases, budget: wrapped.budget, concurrency: wrapped.concurrency });
565
657
  if (!ver.ok) {
566
658
  const errs = ver.issues.filter((i) => i.severity === "error").map((i) => i.message);
567
- return { output: `(spawned subflow failed verification: ${errs.join("; ")})`, usage: emptyUsage() };
659
+ const error = `spawned subflow failed verification: ${errs.join("; ")}`;
660
+ return { output: `(${error})`, usage: emptyUsage(), failed: true, error };
568
661
  }
569
662
  // The generated sub-flow gets only what remains after both already-folded
570
663
  // parent spend and siblings/ancestors in this still-running spawn batch. USD
@@ -591,6 +684,8 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
591
684
  const subResult = await executeTaskflow(subState, {
592
685
  ...deps,
593
686
  cwd: dynCwd,
687
+ _cacheCwdIdentity: phase.cwd !== undefined || deps._cacheCwdIdentity !== undefined ? dynCwd : undefined,
688
+ _dynamic: true,
594
689
  // The parent phase's isolated workspace (if any) applies only to the
595
690
  // parent — each spawned sub-phase resolves its own cwd. Clear the
596
691
  // override so the whole subflow doesn't inherit the parent's dir
@@ -606,19 +701,23 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
606
701
  // Sum every sub-phase's usage so the parent's budget guard sees spawn spend
607
702
  // (verdict Issue 2).
608
703
  const usage = aggregateUsage(Object.values(subResult.state.phases).map((p) => p.usage ?? emptyUsage()));
609
- return { output: subResult.finalOutput ?? "", usage };
704
+ return {
705
+ output: subResult.finalOutput ?? "",
706
+ usage,
707
+ failed: !subResult.ok,
708
+ ...(!subResult.ok ? { error: sanitizeErrorMessage(subResult.finalOutput || "spawned subflow failed") } : {}),
709
+ };
610
710
  }
611
711
  catch (e) {
612
- return { output: `(spawned subflow failed: ${e instanceof Error ? e.message : String(e)})`, usage: emptyUsage() };
712
+ const error = sanitizeErrorMessage(e instanceof Error ? e.message : String(e));
713
+ return { output: `(spawned subflow failed: ${error})`, usage: emptyUsage(), failed: true, error };
613
714
  }
614
715
  }
615
- async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps, state, run, ledger = { usage: emptyUsage() }) {
716
+ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps, state, runChild, ledger = { usage: emptyUsage() }) {
616
717
  const capped = assignments.slice(0, MAX_DYNAMIC_MAP_ITEMS);
617
718
  const lines = [];
618
719
  const usages = [];
619
- // Effective cwd for flat spawned tasks: honour a workspace override and never
620
- // pass a reserved keyword through to the runner.
621
- const spawnCwd = resolveEffCwd(deps, phase);
720
+ const errors = [];
622
721
  let idx = 0;
623
722
  for (const a of capped) {
624
723
  if (deps.signal?.aborted || spawnedOverBudget(state, ledger.usage))
@@ -635,11 +734,24 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
635
734
  out = sub.output;
636
735
  usages.push(sub.usage);
637
736
  ledger.usage = aggregateUsage([ledger.usage, sub.usage]);
638
- setNodeStatus(ctxDir, childNodeId, "done");
737
+ if (sub.failed)
738
+ errors.push(sub.error ?? `spawned subflow ${childNodeId} failed`);
739
+ setNodeStatus(ctxDir, childNodeId, sub.failed ? "failed" : "done");
639
740
  }
640
741
  else {
641
- 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);
742
+ const task = a.task ?? "";
743
+ // Flat ctx_spawn children use the SAME runOne policy as their parent:
744
+ // phase timeout, idle watchdog, retries, budget accounting, tracing,
745
+ // prompt diagnostics, cwd capability, and workspace binding.
746
+ const usageBefore = ledger.usage;
747
+ const r = await runChild(agentName, task, childNodeId, usageBefore);
642
748
  out = r.output ?? "";
749
+ if (isFailed(r)) {
750
+ const detail = sanitizeErrorMessage(r.errorMessage ?? r.stderr ?? "spawned child failed");
751
+ errors.push(detail);
752
+ if (!out)
753
+ out = `(spawned child failed: ${detail})`;
754
+ }
643
755
  if (r.usage) {
644
756
  usages.push(r.usage);
645
757
  ledger.usage = aggregateUsage([ledger.usage, r.usage]);
@@ -648,23 +760,31 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
648
760
  // A child may itself have queued spawns — recurse (depth-capped by the tool).
649
761
  const grand = drainPendingSpawns(ctxDir, childNodeId);
650
762
  if (grand.length > 0 && !deps.signal?.aborted && !spawnedOverBudget(state, ledger.usage)) {
651
- const rec = await runSpawnedChildren(grand, ctxDir, childNodeId, phase, deps, state, run, ledger);
763
+ const rec = await runSpawnedChildren(grand, ctxDir, childNodeId, phase, deps, state, runChild, ledger);
652
764
  if (rec.reports)
653
765
  out += rec.reports;
654
766
  usages.push(rec.usage);
767
+ errors.push(...rec.errors);
655
768
  }
656
769
  }
657
770
  }
658
771
  catch (e) {
659
772
  setNodeStatus(ctxDir, childNodeId, "failed");
660
- out = `(spawned child failed: ${e instanceof Error ? e.message : String(e)})`;
773
+ const detail = sanitizeErrorMessage(e instanceof Error ? e.message : String(e));
774
+ errors.push(detail);
775
+ out = `(spawned child failed: ${detail})`;
661
776
  }
662
777
  lines.push(`### spawned child ${idx} (${agentName})\n${out}`);
663
778
  }
664
779
  const usage = aggregateUsage(usages);
665
780
  if (lines.length === 0)
666
- return { reports: undefined, usage };
667
- return { reports: `\n\n<!-- ctx_spawn: ${lines.length} child report(s) -->\n${lines.join("\n\n")}`, usage };
781
+ return { reports: undefined, usage, failed: errors.length > 0, errors };
782
+ return {
783
+ reports: `\n\n<!-- ctx_spawn: ${lines.length} child report(s) -->\n${lines.join("\n\n")}`,
784
+ usage,
785
+ failed: errors.length > 0,
786
+ errors,
787
+ };
668
788
  }
669
789
  async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
670
790
  // Trace: phase-start (fail-open). Record whether this phase is replayable
@@ -681,8 +801,10 @@ async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth
681
801
  emitUnreplayableMarker(deps, state, phase);
682
802
  let result;
683
803
  let threw = false;
804
+ const promptCalls = [];
805
+ const trackedOpts = { ...opts, promptCalls };
684
806
  try {
685
- result = await executePhaseImpl(phase, state, deps, prior, emitProgress, _retryDepth, opts);
807
+ result = await executePhaseImpl(phase, state, deps, prior, emitProgress, _retryDepth, trackedOpts);
686
808
  }
687
809
  catch (e) {
688
810
  threw = true;
@@ -696,6 +818,9 @@ async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth
696
818
  }
697
819
  if (threw)
698
820
  return result; // unreachable; satisfies TS
821
+ if (promptCalls.length > 0 && !result.cacheHit) {
822
+ setPromptStats(result, promptCalls);
823
+ }
699
824
  // S1: cache-hit decision (within-run or cross-run) for fold/replay.
700
825
  if (result.cacheHit) {
701
826
  traceDecision(deps, state, phase.id, {
@@ -732,9 +857,163 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
732
857
  }
733
858
  return ps;
734
859
  };
860
+ const cwdArg = cwdArgName(phase.cwd);
861
+ let innerOpts = { ...opts, upstreamDeps: deps };
862
+ if ((cwdArg !== undefined || deps._dynamic === true || deps._cwdBoundary !== undefined) && phase.when !== undefined) {
863
+ const whenReadRefs = [];
864
+ const whenCtx = buildInterpolationContext(state, lastCompletedOutput(state, phase), undefined, (ref) => whenReadRefs.push(ref));
865
+ const whenResult = evaluateCondition(phase.when, whenCtx);
866
+ traceDecision(deps, state, phase.id, {
867
+ type: "when-guard",
868
+ expression: phase.when,
869
+ result: whenResult,
870
+ });
871
+ if (!whenResult) {
872
+ return stamp({
873
+ id: phase.id,
874
+ status: "skipped",
875
+ error: `Condition not met: ${phase.when}`,
876
+ endedAt: Date.now(),
877
+ usage: emptyUsage(),
878
+ reads: readRefsToReads(whenReadRefs, state),
879
+ });
880
+ }
881
+ innerOpts = { ...innerOpts, whenPrechecked: true, whenReadRefs };
882
+ }
883
+ if (deps._dynamic === true && ((phase.context?.length ?? 0) > 0 || phase.cwd !== undefined)) {
884
+ return stamp({
885
+ id: phase.id,
886
+ status: "failed",
887
+ error: "TF_DYNAMIC_RESOURCE_FORBIDDEN: generated sub-flows cannot declare cwd or context file pre-reads",
888
+ endedAt: Date.now(),
889
+ usage: emptyUsage(),
890
+ });
891
+ }
892
+ if (cwdArg !== undefined) {
893
+ const spec = state.def.args?.[cwdArg];
894
+ if (spec?.type !== "relative-path") {
895
+ return stamp({
896
+ id: phase.id,
897
+ status: "failed",
898
+ error: `TF_CWD_ARG_INVALID: cwd argument '${cwdArg}' is not declared with type 'relative-path'`,
899
+ endedAt: Date.now(),
900
+ usage: emptyUsage(),
901
+ });
902
+ }
903
+ const bridgeMode = deps.workspaceSession ? "resolve-only" : deps.cwdBridgeMode;
904
+ const bound = resolveCwdArg(deps.cwd, cwdArg, state.args[cwdArg], bridgeMode);
905
+ if (!bound.ok) {
906
+ return stamp({
907
+ id: phase.id,
908
+ status: "failed",
909
+ error: `${bound.code}: ${bound.message}`,
910
+ endedAt: Date.now(),
911
+ usage: emptyUsage(),
912
+ });
913
+ }
914
+ if (deps._cwdBoundary && !isPathWithin(deps._cwdBoundary, bound.value.absolutePath)) {
915
+ return stamp({
916
+ id: phase.id,
917
+ status: "failed",
918
+ error: `TF_CWD_BOUNDARY_ESCAPE: cwd argument '${cwdArg}' resolves outside the inherited cwd boundary`,
919
+ endedAt: Date.now(),
920
+ usage: emptyUsage(),
921
+ });
922
+ }
923
+ let workspaceBinding;
924
+ try {
925
+ workspaceBinding = await deps.workspaceSession?.bindPhase({
926
+ invocationRoot: deps.cwd,
927
+ runId: state.runId,
928
+ phaseId: phase.id,
929
+ argName: cwdArg,
930
+ argDefinitions: state.def.args ?? {},
931
+ argValues: state.args,
932
+ });
933
+ }
934
+ catch (error) {
935
+ return stamp({
936
+ id: phase.id,
937
+ status: "failed",
938
+ error: error instanceof Error ? error.message : String(error),
939
+ endedAt: Date.now(),
940
+ usage: emptyUsage(),
941
+ });
942
+ }
943
+ if (workspaceBinding && workspaceBinding.absolutePath !== bound.value.absolutePath) {
944
+ return stamp({
945
+ id: phase.id,
946
+ status: "failed",
947
+ error: "TFWS_IDENTITY_MISMATCH: compatibility resolver and capability resolver selected different cwd identities",
948
+ endedAt: Date.now(),
949
+ usage: emptyUsage(),
950
+ });
951
+ }
952
+ const innerDeps = {
953
+ ...deps,
954
+ _cwdOverride: bound.value.absolutePath,
955
+ _cwdBoundary: bound.value.absolutePath,
956
+ _cacheCwdIdentity: bound.value.absolutePath,
957
+ _disableCache: true,
958
+ _workspaceBinding: workspaceBinding,
959
+ };
960
+ const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts);
961
+ ps.warnings = [
962
+ ...(ps.warnings ?? []),
963
+ `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`,
964
+ ];
965
+ return stamp(ps);
966
+ }
967
+ if (deps._cwdBoundary && phase.cwd) {
968
+ if (isWorkspaceKeyword(phase.cwd)) {
969
+ return stamp({
970
+ id: phase.id,
971
+ status: "failed",
972
+ error: `TF_CWD_BOUNDARY_ESCAPE: workspace provider '${phase.cwd}' cannot expand an inherited cwd boundary`,
973
+ endedAt: Date.now(),
974
+ usage: emptyUsage(),
975
+ });
976
+ }
977
+ const selected = directoryIdentity(path.resolve(deps.cwd, phase.cwd));
978
+ if (!selected || !isPathWithin(deps._cwdBoundary, selected.canonicalPath)) {
979
+ return stamp({
980
+ id: phase.id,
981
+ status: "failed",
982
+ error: `TF_CWD_BOUNDARY_ESCAPE: cwd '${phase.cwd}' must select an existing directory inside the inherited cwd boundary`,
983
+ endedAt: Date.now(),
984
+ usage: emptyUsage(),
985
+ });
986
+ }
987
+ let narrowedBinding;
988
+ try {
989
+ narrowedBinding = await deps.workspaceSession?.bindPhase({
990
+ invocationRoot: selected.canonicalPath,
991
+ runId: state.runId,
992
+ phaseId: phase.id,
993
+ argDefinitions: state.def.args ?? {},
994
+ argValues: state.args,
995
+ });
996
+ }
997
+ catch (error) {
998
+ return stamp({
999
+ id: phase.id,
1000
+ status: "failed",
1001
+ error: error instanceof Error ? error.message : String(error),
1002
+ endedAt: Date.now(),
1003
+ usage: emptyUsage(),
1004
+ });
1005
+ }
1006
+ return stamp(await executePhaseInner(phase, state, {
1007
+ ...deps,
1008
+ _cwdOverride: selected.canonicalPath,
1009
+ _cwdBoundary: selected.canonicalPath,
1010
+ _cacheCwdIdentity: selected.canonicalPath,
1011
+ _workspaceBinding: narrowedBinding,
1012
+ }, prior, emitProgress, _retryDepth, innerOpts));
1013
+ }
735
1014
  // Non-keyword cwd (or none): no workspace lifecycle — run directly.
736
1015
  if (!isWorkspaceKeyword(phase.cwd)) {
737
- return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, opts));
1016
+ return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, innerOpts));
738
1017
  }
739
1018
  let ws;
740
1019
  try {
@@ -748,9 +1027,9 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
748
1027
  catch {
749
1028
  ws = undefined; // fail-open: run in the base cwd
750
1029
  }
751
- const innerDeps = ws ? { ...deps, _cwdOverride: ws.dir } : deps;
1030
+ const innerDeps = ws ? { ...deps, _cwdOverride: ws.dir, _cacheCwdIdentity: ws.dir } : deps;
752
1031
  try {
753
- const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, opts);
1032
+ const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts);
754
1033
  if (ws && (ws.kind !== "inherited" || ws.note)) {
755
1034
  const tag = ws.kind === "inherited" ? "workspace" : `workspace:${ws.kind}`;
756
1035
  const msg = ws.note ? `${tag} — ${ws.note}` : `${tag} at ${ws.dir}`;
@@ -770,7 +1049,13 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
770
1049
  async function executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
771
1050
  const type = phase.type ?? "agent";
772
1051
  const concurrency = phase.concurrency ?? state.def.concurrency ?? 8;
773
- const previousOutput = lastCompletedOutput(state, phase);
1052
+ // BREAKING (dogfood issue 1): a reduce phase's {previous.output} aggregates
1053
+ // ALL completed `from[]` outputs in from-array order (one → raw, many →
1054
+ // `### <id>\n\n<output>` joined by `\n\n---\n\n`). Other phase types keep the
1055
+ // historical "last completed dependency" behavior. The aggregated from-ids
1056
+ // are recorded as observed reads (below) so staleness tracks them.
1057
+ const reduceAgg = type === "reduce" ? aggregateReduceFrom(state, phase) : undefined;
1058
+ const previousOutput = reduceAgg ? reduceAgg.value : lastCompletedOutput(state, phase);
774
1059
  const run = deps.runTask ?? noRunnerInjected;
775
1060
  // Effective working directory for THIS phase's execution. When an isolated
776
1061
  // workspace was allocated (worktree isolation), `_cwdOverride` is its dir and
@@ -800,16 +1085,24 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
800
1085
  // M3 observed-readSet: collect every upstream ref this phase resolves, so we
801
1086
  // can record what its result ACTUALLY depended on (not just its declared
802
1087
  // dependsOn). Shared by every interpolation in this phase (task / when / …).
803
- const readRefs = [];
1088
+ const readRefs = [...(opts?.whenReadRefs ?? [])];
804
1089
  const onRead = (ref) => {
805
1090
  readRefs.push(ref);
806
1091
  };
1092
+ // Record the reduce-aggregated from-ids as observed reads: the phase consumed
1093
+ // these upstream outputs (folded into {previous.output}) even when the task
1094
+ // references {previous.output} once (a single placeholder read would otherwise
1095
+ // record only "previous.output", not the real upstream ids).
1096
+ if (reduceAgg) {
1097
+ for (const id of reduceAgg.ids)
1098
+ readRefs.push(`steps.${id}.output`);
1099
+ }
807
1100
  const ctx = buildInterpolationContext(state, previousOutput, undefined, onRead);
808
1101
  // M3 observed-readSet: when conditions are part of the phase's real
809
1102
  // dependencies. Evaluate them inside executePhaseInner so every upstream
810
1103
  // interpolation is captured by the shared onRead hook, not silently dropped
811
1104
  // by a separate out-of-band context.
812
- if (phase.when !== undefined) {
1105
+ if (phase.when !== undefined && opts?.whenPrechecked !== true) {
813
1106
  const whenResult = evaluateCondition(phase.when, ctx);
814
1107
  traceDecision(deps, state, phase.id, {
815
1108
  type: "when-guard",
@@ -827,7 +1120,10 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
827
1120
  };
828
1121
  }
829
1122
  }
830
- const preRead = await resolvePhaseContext(phase, ctx);
1123
+ // `context` keeps its historical invocation-root meaning. Phase cwd may be a
1124
+ // temporary/worktree directory; rebasing context there would silently stop
1125
+ // existing flows from reading authored source files.
1126
+ const preRead = await resolvePhaseContext(phase, ctx, deps.cwd, deps._cwdBoundary);
831
1127
  // Resolve this phase's cache policy once. Default scope is "run-only" (the
832
1128
  // historical within-run resume behavior). Only "cross-run" phases resolve a
833
1129
  // fingerprint and consult the persistent store.
@@ -851,6 +1147,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
851
1147
  if (phase.idempotent === false) {
852
1148
  cacheScope = "off";
853
1149
  }
1150
+ if (deps._disableCache) {
1151
+ cacheScope = "off";
1152
+ }
854
1153
  const cc = {
855
1154
  scope: cacheScope,
856
1155
  ttlMs: phase.cache?.ttl ? (parseTtlMs(phase.cache.ttl) ?? undefined) : undefined,
@@ -869,17 +1168,72 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
869
1168
  agentScope: state.def.agentScope,
870
1169
  contextSharing: state.def.contextSharing === true,
871
1170
  agentDefinitions: agentDefinitionsIdentity(deps.agents),
1171
+ executionCwd: directoryIdentity(effCwd)?.canonicalPath ?? path.resolve(effCwd),
1172
+ };
1173
+ // Effective idle watchdog (ms): phase overrides flow; host default (300000)
1174
+ // applies when neither sets it. `0` disables the watchdog (validation already
1175
+ // required a finite wall `timeout` in that case). Threaded into RunOptions so
1176
+ // every host runner that delegates to runSubagentProcess honors it.
1177
+ const effIdleTimeoutMs = resolveIdleTimeoutMs(phase, state.def);
1178
+ const baseRun = (agentName, task, onLive, ctxNodeId, signal, callCwd, onTerminalCommit) => {
1179
+ const invocationCwd = callCwd ? path.resolve(deps.cwd, callCwd) : effCwd;
1180
+ // A per-branch cwd cannot replace a phase-level workspace/cwd-bridge
1181
+ // binding: that would bypass the binding's containment/dirty-state
1182
+ // lifecycle. Validation rejects this shape; keep a runtime fail-closed
1183
+ // guard for direct callers or legacy persisted definitions.
1184
+ if (callCwd && (deps._workspaceBinding || isWorkspaceKeyword(phase.cwd))) {
1185
+ return Promise.resolve({
1186
+ agent: agentName,
1187
+ task,
1188
+ exitCode: 1,
1189
+ output: "",
1190
+ stderr: "TF_CWD_BRANCH_BINDING_CONFLICT: per-branch cwd cannot override a phase workspace binding",
1191
+ usage: emptyUsage(),
1192
+ stopReason: "error",
1193
+ errorMessage: "TF_CWD_BRANCH_BINDING_CONFLICT: per-branch cwd cannot override a phase workspace binding",
1194
+ });
1195
+ }
1196
+ if (callCwd && deps._cwdBoundary) {
1197
+ const selected = directoryIdentity(invocationCwd);
1198
+ if (!selected || !isPathWithin(deps._cwdBoundary, selected.canonicalPath)) {
1199
+ return Promise.resolve({
1200
+ agent: agentName,
1201
+ task,
1202
+ exitCode: 1,
1203
+ output: "",
1204
+ stderr: "TF_CWD_BOUNDARY_ESCAPE: per-branch cwd must select an existing directory inside the inherited cwd boundary",
1205
+ usage: emptyUsage(),
1206
+ stopReason: "error",
1207
+ errorMessage: "TF_CWD_BOUNDARY_ESCAPE: per-branch cwd must select an existing directory inside the inherited cwd boundary",
1208
+ });
1209
+ }
1210
+ }
1211
+ opts?.promptCalls?.push(task);
1212
+ const runOptions = {
1213
+ model: phase.model,
1214
+ thinking: phase.thinking,
1215
+ tools: phase.tools,
1216
+ cwd: invocationCwd,
1217
+ signal: signal ?? deps.signal,
1218
+ onLive,
1219
+ ctxDir: ctxDir,
1220
+ nodeId: ctxDir ? ctxNodeId : undefined,
1221
+ idleTimeoutMs: effIdleTimeoutMs,
1222
+ onTerminalCommit,
1223
+ };
1224
+ const invoke = () => run(invocationCwd, deps.agents, agentName, task, runOptions, deps.globalThinking);
1225
+ return deps._workspaceBinding
1226
+ ? deps._workspaceBinding.runAgent({
1227
+ agents: deps.agents,
1228
+ agentName,
1229
+ task,
1230
+ opts: runOptions,
1231
+ globalThinking: deps.globalThinking,
1232
+ unitId: ctxNodeId ?? phase.id,
1233
+ invoke,
1234
+ })
1235
+ : invoke();
872
1236
  };
873
- const baseRun = (agentName, task, onLive, ctxNodeId, signal) => run(effCwd, deps.agents, agentName, task, {
874
- model: phase.model,
875
- thinking: phase.thinking,
876
- tools: phase.tools,
877
- cwd: effCwd,
878
- signal: signal ?? deps.signal,
879
- onLive,
880
- ctxDir: ctxDir,
881
- nodeId: ctxDir ? ctxNodeId : undefined,
882
- }, deps.globalThinking);
883
1237
  // Wrap each subagent call in the phase's retry policy. Usage is summed across
884
1238
  // attempts; the attempt count rides along on the result for the TUI.
885
1239
  //
@@ -899,7 +1253,18 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
899
1253
  : undefined;
900
1254
  const runOne = async (agentName, task, onLive, ctxNodeId, check,
901
1255
  /** Extra abort (e.g. race branch cancelLosers) — chained with run + phase timeout. */
902
- extraSignal) => {
1256
+ extraSignal,
1257
+ /** Per-call cwd override (e.g. a parallel branch's literal cwd). Falls back
1258
+ * to the phase effective cwd when absent. Workspace keywords are rejected
1259
+ * by validation for branches, so this is always a literal path or undefined. */
1260
+ callCwd,
1261
+ /** Usage already spent by earlier calls inside this same multi-call phase
1262
+ * (tree reduce). Included in live budget accounting, but not returned as
1263
+ * this call's own usage. */
1264
+ usageBefore,
1265
+ /** Optional hard admission guard invoked immediately before EVERY actual
1266
+ * runner attempt. Return an error message to deny that attempt. */
1267
+ beforeAttempt) => {
903
1268
  const explicitMax = Math.max(1, 1 + Math.max(0, Math.floor(retry?.max ?? 0)));
904
1269
  // Allow enough attempts to cover whichever policy applies on a given attempt.
905
1270
  const maxAttempts = Math.max(explicitMax, 1 + DEFAULT_TRANSIENT_RETRIES);
@@ -908,9 +1273,24 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
908
1273
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
909
1274
  if (deps.signal?.aborted || extraSignal?.aborted)
910
1275
  break;
1276
+ const admissionError = beforeAttempt?.();
1277
+ if (admissionError) {
1278
+ last = {
1279
+ agent: agentName,
1280
+ task,
1281
+ exitCode: 1,
1282
+ output: "",
1283
+ stderr: admissionError,
1284
+ usage: emptyUsage(),
1285
+ stopReason: "error",
1286
+ errorMessage: admissionError,
1287
+ };
1288
+ break;
1289
+ }
911
1290
  // AbortController chains: run signal + optional extra (race cancel) + phase timeout.
912
1291
  // Deterministic: a timed-out call is never retried (would double-spend).
913
1292
  let timedOut = false;
1293
+ let terminalCommitted = false;
914
1294
  let timer;
915
1295
  let forceReturnTimer;
916
1296
  const removers = [];
@@ -936,10 +1316,21 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
936
1316
  }
937
1317
  }
938
1318
  try {
939
- const invocation = baseRun(agentName, task, onLive, ctxNodeId, callSignal);
1319
+ const onTerminalCommit = () => {
1320
+ if (timedOut)
1321
+ return;
1322
+ terminalCommitted = true;
1323
+ if (timer) {
1324
+ clearTimeout(timer);
1325
+ timer = undefined;
1326
+ }
1327
+ };
1328
+ const invocation = baseRun(agentName, task, onLive, ctxNodeId, callSignal, callCwd, onTerminalCommit);
940
1329
  if (phaseTimeoutMs && timeoutController) {
941
1330
  const timeoutFallback = new Promise((resolve) => {
942
1331
  timer = setTimeout(() => {
1332
+ if (terminalCommitted)
1333
+ return;
943
1334
  timedOut = true;
944
1335
  timeoutController?.abort();
945
1336
  forceReturnTimer = setTimeout(() => resolve({
@@ -952,6 +1343,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
952
1343
  stopReason: "error",
953
1344
  errorMessage: `Phase runner did not stop within ${PHASE_TIMEOUT_ABORT_GRACE_MS}ms after abort`,
954
1345
  phaseTimeout: true,
1346
+ completionSource: "phase-timeout",
955
1347
  }), PHASE_TIMEOUT_ABORT_GRACE_MS);
956
1348
  }, phaseTimeoutMs);
957
1349
  });
@@ -979,12 +1371,18 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
979
1371
  stopReason: "error",
980
1372
  errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`,
981
1373
  phaseTimeout: true,
1374
+ completionSource: "phase-timeout",
982
1375
  };
983
1376
  usages.push(last.usage);
984
1377
  traceEmit(deps, {
985
1378
  ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "subagent-call",
986
1379
  input: { agent: agentName, model: phase.model, task, preRead, nodePath: ctxNodeId ?? phase.id, attempt },
987
- output: { text: last.output, model: last.model, usage: last.usage, stopReason: last.stopReason },
1380
+ output: {
1381
+ text: last.output, model: last.model, usage: last.usage, stopReason: last.stopReason,
1382
+ completionSource: last.completionSource,
1383
+ reapedAfterTerminal: last.reapedAfterTerminal,
1384
+ terminalGraceMs: last.terminalGraceMs,
1385
+ },
988
1386
  });
989
1387
  traceFlush(deps, phase.id);
990
1388
  break;
@@ -1008,14 +1406,19 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1008
1406
  // so the TUI / budget guard see the in-flight spend on every attempt.
1009
1407
  const liveRetry = state.phases[phase.id];
1010
1408
  if (liveRetry)
1011
- liveRetry.usage = aggregateUsage(usages);
1409
+ liveRetry.usage = aggregateUsage(usageBefore ? [usageBefore, ...usages] : usages);
1012
1410
  // Persist every attempt, not only the final aggregate. This is required
1013
1411
  // for honest replay/cost accounting when a transient or explicit retry
1014
1412
  // succeeds after earlier spend.
1015
1413
  traceEmit(deps, {
1016
1414
  ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "subagent-call",
1017
1415
  input: { agent: agentName, model: phase.model, task, preRead, nodePath: ctxNodeId ?? phase.id, attempt },
1018
- output: { text: last.output, model: last.model, usage: last.usage, stopReason: last.stopReason },
1416
+ output: {
1417
+ text: last.output, model: last.model, usage: last.usage, stopReason: last.stopReason,
1418
+ completionSource: last.completionSource,
1419
+ reapedAfterTerminal: last.reapedAfterTerminal,
1420
+ terminalGraceMs: last.terminalGraceMs,
1421
+ },
1019
1422
  });
1020
1423
  traceFlush(deps, phase.id);
1021
1424
  if (!isFailed(last))
@@ -1023,6 +1426,20 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1023
1426
  // Stop retrying on abort (run-level or race cancel) or once over budget.
1024
1427
  if (deps.signal?.aborted || extraSignal?.aborted || overBudget(state).over)
1025
1428
  break;
1429
+ if (deps._workspaceBinding) {
1430
+ // A failed RW-capability attempt has an unknown filesystem outcome and
1431
+ // is durably marked dirty. Retrying it cannot be proven idempotent until
1432
+ // workspace snapshots/restoration exist, so preserve the first failure
1433
+ // instead of replacing it with a later TFWS_RESOURCE_DIRTY refusal.
1434
+ const requestedRetry = (retry?.max ?? 0) > 0 || isTransientError(last);
1435
+ if (requestedRetry && last.workspaceMutationStarted) {
1436
+ last = {
1437
+ ...last,
1438
+ 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`,
1439
+ };
1440
+ }
1441
+ break;
1442
+ }
1026
1443
  // Decide whether THIS failure warrants another attempt. Explicit retry
1027
1444
  // policy covers all failures up to its cap; the transient fallback covers
1028
1445
  // only retryable provider errors. A non-transient failure with no explicit
@@ -1089,11 +1506,33 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1089
1506
  attempts: 0,
1090
1507
  };
1091
1508
  }
1092
- if (usages.length > 1)
1509
+ if (usages.length > 0)
1093
1510
  last.usage = aggregateUsage(usages);
1094
1511
  last.attempts = usages.length;
1095
1512
  return last;
1096
1513
  };
1514
+ const runSpawnedChild = async (agentName, task, childNodeId, spawnedUsageBefore) => {
1515
+ // runOne updates the live phase usage so retry/budget admission sees current
1516
+ // spend. A spawned child is folded into the parent result later, so restore
1517
+ // the parent's pre-child usage after the call to avoid replacing/doubling it.
1518
+ const livePhase = state.phases[phase.id];
1519
+ const parentUsage = livePhase?.usage ? { ...livePhase.usage } : undefined;
1520
+ const totalUsageBefore = aggregateUsage([
1521
+ parentUsage ?? emptyUsage(),
1522
+ spawnedUsageBefore ?? emptyUsage(),
1523
+ ]);
1524
+ try {
1525
+ return await runOne(agentName, task, undefined, childNodeId, undefined, undefined, undefined, totalUsageBefore);
1526
+ }
1527
+ finally {
1528
+ if (livePhase) {
1529
+ if (parentUsage)
1530
+ livePhase.usage = parentUsage;
1531
+ else
1532
+ livePhase.usage = undefined;
1533
+ }
1534
+ }
1535
+ };
1097
1536
  const parseJson = phase.output === "json";
1098
1537
  // Output contract (`expect`): validates the parsed JSON output of a finished
1099
1538
  // subagent call. Wired into `runOne` so a violation counts as a failed attempt
@@ -1188,12 +1627,16 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1188
1627
  if (l.model)
1189
1628
  latestModel = l.model;
1190
1629
  refresh();
1191
- }, ctxDir ? nodeIdFor(String(idx)) : undefined);
1630
+ }, ctxDir ? nodeIdFor(String(idx)) : undefined, undefined, undefined, it.cwd);
1192
1631
  running--;
1193
1632
  done++;
1194
1633
  if (isFailed(r))
1195
1634
  failed++;
1196
1635
  liveUsages[idx] = r.usage;
1636
+ // Publish the just-finished sibling's spend before considering any
1637
+ // ctx_spawn intents. Budgeted fan-out allows one atomic call to cross the
1638
+ // ceiling, but must not admit descendants after that overshoot.
1639
+ refresh();
1197
1640
  // Per-item cross-run cache record (map only): persist a successful fresh
1198
1641
  // item so a later run with this item unchanged hits per-item instead of
1199
1642
  // re-running. Failed and budget-skipped items are never cached (a stale
@@ -1221,9 +1664,14 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1221
1664
  // post-run drain below only covers single-agent phases).
1222
1665
  const spawned = drainPendingSpawns(ctxDir, itemNid);
1223
1666
  if (spawned.length > 0 && !deps.signal?.aborted && !overBudget(state).over) {
1224
- const child = await runSpawnedChildren(spawned, ctxDir, itemNid, phase, deps, state, run);
1667
+ const child = await runSpawnedChildren(spawned, ctxDir, itemNid, phase, deps, state, runSpawnedChild, { usage: emptyUsage() });
1225
1668
  if (child.reports)
1226
1669
  r.output = `${r.output ?? ""}${child.reports}`;
1670
+ if (child.failed && deps._workspaceBinding) {
1671
+ r.exitCode = r.exitCode === 0 ? 1 : r.exitCode;
1672
+ r.stopReason = "error";
1673
+ r.errorMessage = `Workspace ctx_spawn descendant failed: ${child.errors.join("; ")}`;
1674
+ }
1227
1675
  if (child.usage) {
1228
1676
  r.usage = aggregateUsage([r.usage ?? emptyUsage(), child.usage]);
1229
1677
  liveUsages[idx] = r.usage;
@@ -1239,6 +1687,96 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1239
1687
  // Single-agent phases: agent, gate, and reduce all run one subagent on an
1240
1688
  // interpolated task. gate additionally parses a verdict; reduce simply pulls
1241
1689
  // its inputs from `from` phases (already exposed via interpolation).
1690
+ //
1691
+ // Tree reduce (`reduceStrategy: "tree"`) is handled FIRST: it runs batched
1692
+ // intermediate reducer calls over the aggregated `from[]` inputs, reusing
1693
+ // `runOne` so retry/timeout/budget/idleTimeout behavior is identical to a
1694
+ // one-shot call. It forces the imperative runtime (the event kernel falls
1695
+ // back via kernelUnsupportedReason). The corrected `{previous.output}`
1696
+ // aggregation (all completed `from[]` sources) applies to EVERY round.
1697
+ if (type === "reduce" && phase.reduceStrategy === "tree") {
1698
+ const stratBs = phase.batchSize;
1699
+ const batchSize = typeof stratBs === "number" && Number.isFinite(stratBs) && stratBs >= 2 ? Math.floor(stratBs) : 2;
1700
+ const { collectTreeReduceInputs, executeTreeReduction, treeReduceCacheParts, } = await import("./runtime/phases/reduce.js");
1701
+ const inputs = collectTreeReduceInputs(state, phase);
1702
+ // 0–1 completed inputs: tree reduction is a no-op — fall through to the
1703
+ // one-shot path (which handles the degenerate case correctly).
1704
+ if (inputs.length >= 2) {
1705
+ const agentName = resolveAgent(phase.agent, deps, state);
1706
+ const cacheKey = cacheKeys(cc, treeReduceCacheParts(state, phase, inputs, batchSize));
1707
+ const cached = cachedPhase(cc, cacheKey);
1708
+ if (cached)
1709
+ return cached;
1710
+ const execution = await executeTreeReduction({
1711
+ phase,
1712
+ inputs,
1713
+ batchSize,
1714
+ agentName,
1715
+ inputHash: cacheKey.key,
1716
+ isAborted: () => deps.signal?.aborted === true,
1717
+ isOverBudget: () => overBudget(state).over,
1718
+ resolveTask: (batchValue) => {
1719
+ const batchCtx = buildInterpolationContext(state, batchValue, undefined, onRead);
1720
+ const interp = interpolate(phase.task ?? "", batchCtx);
1721
+ return {
1722
+ task: appendGateFormatSuffix(preRead + interp.text, phase),
1723
+ warning: warnUnresolvedRefs(phase.id, interp.missing),
1724
+ };
1725
+ },
1726
+ runOne: async (task, usageBefore, beforeAttempt, callId) => {
1727
+ const batchNodeId = nodeIdFor(`tree-${callId}`);
1728
+ if (ctxDir) {
1729
+ try {
1730
+ registerNode(ctxDir, batchNodeId, phase.id, undefined, "running");
1731
+ }
1732
+ catch { /* fail-open */ }
1733
+ }
1734
+ const result = await runOne(agentName, task, liveSink(state, phase.id, emitProgress), batchNodeId, contractCheck, undefined, undefined, usageBefore, beforeAttempt);
1735
+ if (ctxDir) {
1736
+ try {
1737
+ const spawned = drainPendingSpawns(ctxDir, batchNodeId);
1738
+ if (spawned.length > 0 && !deps.signal?.aborted && !overBudget(state).over) {
1739
+ const child = await runSpawnedChildren(spawned, ctxDir, batchNodeId, phase, deps, state, runSpawnedChild, { usage: emptyUsage() });
1740
+ if (child.reports)
1741
+ result.output = `${result.output ?? ""}${child.reports}`;
1742
+ if (child.failed && deps._workspaceBinding) {
1743
+ result.exitCode = result.exitCode === 0 ? 1 : result.exitCode;
1744
+ result.stopReason = "error";
1745
+ result.errorMessage = `Workspace ctx_spawn descendant failed: ${child.errors.join("; ")}`;
1746
+ }
1747
+ result.usage = aggregateUsage([result.usage ?? emptyUsage(), child.usage]);
1748
+ }
1749
+ setNodeStatus(ctxDir, batchNodeId, isFailed(result) ? "failed" : "done");
1750
+ }
1751
+ catch { /* fail-open */ }
1752
+ }
1753
+ const livePhase = state.phases[phase.id];
1754
+ if (livePhase) {
1755
+ livePhase.usage = aggregateUsage([
1756
+ usageBefore ?? emptyUsage(),
1757
+ result.usage ?? emptyUsage(),
1758
+ ]);
1759
+ }
1760
+ return result;
1761
+ },
1762
+ });
1763
+ const ps = execution.phaseState;
1764
+ if (parseJson && ps.status === "done")
1765
+ ps.json = safeParse(ps.output ?? "");
1766
+ if (readRefs.length)
1767
+ ps.reads = readRefsToReads(readRefs, state);
1768
+ if (execution.refWarning)
1769
+ ps.warnings = [...(ps.warnings ?? []), execution.refWarning];
1770
+ if (ps.budgetTruncated) {
1771
+ ps.warnings = [...(ps.warnings ?? []), "tree reduction stopped by the run budget; output is partial"];
1772
+ }
1773
+ attachReduceInputStats(ps, state, phase);
1774
+ if (execution.cacheable)
1775
+ recordCache(cc, ps);
1776
+ return ps;
1777
+ }
1778
+ // inputs.length < 2 → fall through to one-shot (below).
1779
+ }
1242
1780
  if (type === "agent" || type === "gate" || type === "reduce") {
1243
1781
  // Eval gate: zero-token machine checks before the LLM gate.
1244
1782
  if (type === "gate" && Array.isArray(phase.eval) && phase.eval.length > 0) {
@@ -1468,7 +2006,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1468
2006
  break;
1469
2007
  attempt++;
1470
2008
  if (_retryDepth < MAX_RETRY_DEPTH) {
1471
- const { _cwdOverride: _dropGateWs, ...depsForUpstream } = deps;
2009
+ const depsForUpstream = opts?.upstreamDeps ?? deps;
1472
2010
  for (const depId of phase.dependsOn ?? []) {
1473
2011
  const d = state.def.phases.find((p) => p.id === depId);
1474
2012
  if (!d)
@@ -1528,6 +2066,11 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1528
2066
  ps.reads = readRefsToReads(readRefs, state);
1529
2067
  if (refWarning)
1530
2068
  ps.warnings = [...(ps.warnings ?? []), refWarning];
2069
+ // Prompt-size diagnostics for the single agent call (durable).
2070
+ attachPromptStats(ps, [fullTask]);
2071
+ // reduce: record aggregate input stats.
2072
+ if (type === "reduce")
2073
+ attachReduceInputStats(ps, state, phase);
1531
2074
  if (type === "gate" && ps.status === "done") {
1532
2075
  ps.gate = parseGateVerdict(r.output);
1533
2076
  // Trace: gate decision (fail-open). Replay re-adjudicates thresholds.
@@ -1544,9 +2087,13 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1544
2087
  registerNode(ctxDir, nid, phase.id, undefined, ps.status === "failed" ? "failed" : "done");
1545
2088
  const spawned = drainPendingSpawns(ctxDir, nid);
1546
2089
  if (spawned.length > 0 && !deps.signal?.aborted && !overBudget(state).over) {
1547
- const child = await runSpawnedChildren(spawned, ctxDir, nid, phase, deps, state, run);
2090
+ const child = await runSpawnedChildren(spawned, ctxDir, nid, phase, deps, state, runSpawnedChild, { usage: emptyUsage() });
1548
2091
  if (child.reports)
1549
2092
  ps.output = `${ps.output ?? ""}${child.reports}`;
2093
+ if (child.failed && deps._workspaceBinding) {
2094
+ ps.status = "failed";
2095
+ ps.error = `Workspace ctx_spawn descendant failed: ${child.errors.join("; ")}`;
2096
+ }
1550
2097
  // Fold spawned spend into this phase's usage so the run-wide budget
1551
2098
  // guard accounts for it (verdict Issue 2).
1552
2099
  ps.usage = aggregateUsage([ps.usage ?? emptyUsage(), child.usage]);
@@ -1575,7 +2122,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1575
2122
  // NOTE: we intentionally pass the gate's `prior` (not the dep's own
1576
2123
  // completed state) so the dep does NOT cache-hit and actually
1577
2124
  // RE-RUNS — re-running upstream is the whole point of onBlock:retry.
1578
- const { _cwdOverride: _dropGateWs, ...depsForUpstream } = deps;
2125
+ const depsForUpstream = opts?.upstreamDeps ?? deps;
1579
2126
  for (const depId of phase.dependsOn ?? []) {
1580
2127
  const d = state.def.phases.find((p) => p.id === depId);
1581
2128
  if (!d)
@@ -1637,7 +2184,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1637
2184
  const SCRIPT_TIMEOUT_MS = phase.timeout ?? 60_000;
1638
2185
  const reads = readRefs.length ? readRefsToReads(readRefs, state) : undefined;
1639
2186
  try {
1640
- const result = await runScriptCommand({
2187
+ const invoke = () => runScriptCommand({
1641
2188
  interpRunText,
1642
2189
  arrayForm: Array.isArray(cmd),
1643
2190
  cwd: effCwd,
@@ -1645,6 +2192,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1645
2192
  stdinInput,
1646
2193
  timeoutMs: SCRIPT_TIMEOUT_MS,
1647
2194
  });
2195
+ const result = deps._workspaceBinding
2196
+ ? await deps._workspaceBinding.runScript({ unitId: phase.id, signal: deps.signal, invoke })
2197
+ : await invoke();
1648
2198
  const ps = scriptResultToPhaseState(phase, result, {
1649
2199
  inputHash,
1650
2200
  timeoutMs: SCRIPT_TIMEOUT_MS,
@@ -1669,6 +2219,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1669
2219
  return {
1670
2220
  agent: resolveAgent(b.agent ?? phase.agent, deps, state),
1671
2221
  task: preRead + r.text,
2222
+ cwd: typeof b.cwd === "string" ? resolveBranchCwd(deps, b.cwd) : undefined,
1672
2223
  };
1673
2224
  });
1674
2225
  const ck = cacheKeys(cc, [phase.id, phase.model ?? "", JSON.stringify(branches)]);
@@ -1961,16 +2512,29 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1961
2512
  // Resolve sub-flow args (interpolate string values), then apply declared defaults.
1962
2513
  const provided = {};
1963
2514
  for (const [k, v] of Object.entries(phase.with ?? {})) {
1964
- provided[k] = typeof v === "string" ? interpolate(v, ctx).text : v;
2515
+ provided[k] = interpolateValue(v, ctx);
1965
2516
  }
1966
2517
  const subArgs = resolveArgs(subDef, provided);
2518
+ if (deps._dynamic === true) {
2519
+ const dynamicChild = validateTaskflow(subDef, { dynamic: true, cwd: effCwd, args: subArgs });
2520
+ if (!dynamicChild.ok) {
2521
+ return failPhase(phase.id, `dynamic nested flow '${subDef.name}' is invalid: ${dynamicChild.errors.join("; ")}`);
2522
+ }
2523
+ }
2524
+ // Re-check the exact loaded definition at the cache boundary. A loader may
2525
+ // change between the root pre-scan and this phase (or return aliases), and
2526
+ // a bridge-bearing child must never be skipped by a cached parent result.
2527
+ const nestedBridgeTree = flowTreeUsesCwdBridge(subDef, deps.loadFlow);
2528
+ if (nestedBridgeTree)
2529
+ deps._disableCache = true;
2530
+ const flowCc = nestedBridgeTree ? { ...cc, scope: "off" } : cc;
1967
2531
  // Every sub-flow cache identity includes the resolved definition. A saved
1968
2532
  // flow's name alone is insufficient: its contents can change without the
1969
2533
  // parent definition moving.
1970
2534
  const flowIdentity = `${hasDef ? "def" : "flow"}:${name}:${JSON.stringify(subDef)}`;
1971
- const ck = cacheKeys(cc, [phase.id, flowIdentity, preRead, JSON.stringify(subArgs)]);
2535
+ const ck = cacheKeys(flowCc, [phase.id, flowIdentity, preRead, JSON.stringify(subArgs)]);
1972
2536
  const inputHash = ck.key;
1973
- const cached = cachedPhase(cc, ck);
2537
+ const cached = cachedPhase(flowCc, ck);
1974
2538
  if (cached) {
1975
2539
  if (type === "expand" && expandMode === "graft" && cached.promotedPhases) {
1976
2540
  const promo = promoteGraftPhases(state, cached.promotedPhases);
@@ -2012,6 +2576,8 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2012
2576
  // phases without an explicit cwd derive their subagents from the
2013
2577
  // flow's cwd (not the caller's cwd).
2014
2578
  cwd: effCwd,
2579
+ _cacheCwdIdentity: phase.cwd !== undefined || deps._cacheCwdIdentity !== undefined ? effCwd : undefined,
2580
+ _dynamic: hasDef || deps._dynamic === true ? true : undefined,
2015
2581
  // The workspace override applies only to THIS flow phase, not to the
2016
2582
  // nested sub-phases (each resolves its own cwd). Clear it so the child
2017
2583
  // phases don't all inherit this phase's isolated dir as an override.
@@ -2040,6 +2606,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2040
2606
  },
2041
2607
  });
2042
2608
  const sp = Object.values(subState.phases);
2609
+ const nestedFailure = sp.find((nested) => nested.status === "failed")?.error;
2043
2610
  // expand graft promote — pure helper (see runtime/phases/expand.ts)
2044
2611
  const warnings = [];
2045
2612
  let graftPromotedIds = [];
@@ -2073,7 +2640,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2073
2640
  running: 0,
2074
2641
  failed: sp.filter((p) => p.status === "failed").length,
2075
2642
  },
2076
- error: subResult.ok ? undefined : `sub-flow '${name}' ${subResult.state.status}`,
2643
+ error: subResult.ok
2644
+ ? undefined
2645
+ : `sub-flow '${name}' ${subResult.state.status}${nestedFailure ? `: ${nestedFailure}` : ""}`,
2077
2646
  inputHash,
2078
2647
  reads: readRefsToReads(readRefs, state),
2079
2648
  endedAt: Date.now(),
@@ -2082,7 +2651,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2082
2651
  ? { promotedPhases: Object.fromEntries(graftPromotedIds.map((id) => [id, { ...subState.phases[id] }])) }
2083
2652
  : {}),
2084
2653
  };
2085
- recordCache(cc, flowPs);
2654
+ recordCache(flowCc, flowPs);
2086
2655
  return flowPs;
2087
2656
  }
2088
2657
  // loop-until-done: run the body repeatedly until `until` is truthy, the output
@@ -2462,6 +3031,147 @@ function lastCompletedOutput(state, phase) {
2462
3031
  }
2463
3032
  return undefined;
2464
3033
  }
3034
+ /** A conservative prompt-size warning threshold in estimated tokens. Crossed
3035
+ * → the phase records a `warnings` entry so an author notices they may be
3036
+ * approaching a model's context limit. Approximate (ceil(chars/4)), not a
3037
+ * real tokenizer count. Chosen conservatively well below typical 128K context
3038
+ * windows so the warning fires with ample headroom. */
3039
+ export const PROMPT_SIZE_WARN_TOKENS = 32_000;
3040
+ /** Compute durable prompt-size diagnostics for a resolved prompt string:
3041
+ * exact UTF-8 byte count, character count, and a documented approximate token
3042
+ * estimate (ceil(chars/4)). Never throws. */
3043
+ export function promptSizeStats(text) {
3044
+ let chars = 0;
3045
+ for (const _char of text)
3046
+ chars++; // Unicode code points, not UTF-16 code units
3047
+ const bytes = Buffer.byteLength(text, "utf8");
3048
+ const estTokens = Math.ceil(chars / 4);
3049
+ return { bytes, chars, estTokens };
3050
+ }
3051
+ /** Replace a phase's call diagnostics with the exact prompts captured by the
3052
+ * shared runner boundary. This covers single calls, retries, fan-out items,
3053
+ * loop/tournament/judge calls, and tree-reduce rounds without per-branch drift. */
3054
+ function setPromptStats(ps, prompts) {
3055
+ try {
3056
+ const calls = prompts.map((text) => promptSizeStats(text));
3057
+ const reduceInputs = ps.promptStats?.reduceInputs;
3058
+ ps.promptStats = { calls, ...(reduceInputs ? { reduceInputs } : {}) };
3059
+ ps.warnings = (ps.warnings ?? []).filter((warning) => !warning.startsWith("Prompt size ≈"));
3060
+ for (const call of calls) {
3061
+ if (call.estTokens >= PROMPT_SIZE_WARN_TOKENS) {
3062
+ ps.warnings.push(`Prompt size ≈${call.estTokens} tokens (${call.chars} chars, ${call.bytes} bytes) exceeds the conservative ${PROMPT_SIZE_WARN_TOKENS}-token warning threshold — the prompt may be approaching a model's context limit.`);
3063
+ }
3064
+ }
3065
+ if (ps.warnings.length === 0)
3066
+ delete ps.warnings;
3067
+ }
3068
+ catch {
3069
+ /* diagnostics must never sink the phase */
3070
+ }
3071
+ }
3072
+ /** Attach durable prompt-size diagnostics to a PhaseState. `prompts` is the list
3073
+ * of resolved prompt strings actually sent to a subagent (one entry per call —
3074
+ * one for an agent phase, multiple for a tree reduce). Each becomes a
3075
+ * `{bytes, chars, estTokens}` record on `ps.promptStats.calls`. When any call
3076
+ * crosses {@link PROMPT_SIZE_WARN_TOKENS}, a `warnings` entry is appended so an
3077
+ * author notices they may be approaching a model's context limit (the estimate
3078
+ * is conservative: ceil(chars/4), not a real tokenizer count). Never throws. */
3079
+ function attachPromptStats(ps, prompts) {
3080
+ try {
3081
+ const calls = prompts.map((t) => promptSizeStats(t));
3082
+ const existing = ps.promptStats;
3083
+ ps.promptStats = existing
3084
+ ? { ...existing, calls: [...existing.calls, ...calls] }
3085
+ : { calls };
3086
+ for (const c of calls) {
3087
+ if (c.estTokens >= PROMPT_SIZE_WARN_TOKENS) {
3088
+ ps.warnings = [...(ps.warnings ?? []), `Prompt size ≈${c.estTokens} tokens (${c.chars} chars, ${c.bytes} bytes) exceeds the conservative ${PROMPT_SIZE_WARN_TOKENS}-token warning threshold — the prompt may be approaching a model's context limit.`];
3089
+ }
3090
+ }
3091
+ }
3092
+ catch {
3093
+ /* diagnostics must never sink the phase */
3094
+ }
3095
+ }
3096
+ /** Attach aggregate input stats for a reduce phase: count + total bytes/chars/
3097
+ * estTokens over the completed `from[]` inputs being reduced. Stored on
3098
+ * `ps.promptStats.reduceInputs` so post-hoc inspection can account for input
3099
+ * size across reduce rounds. Never throws. */
3100
+ function attachReduceInputStats(ps, state, phase) {
3101
+ try {
3102
+ const s = reduceInputStats(state, phase);
3103
+ const existing = ps.promptStats;
3104
+ ps.promptStats = existing
3105
+ ? { ...existing, reduceInputs: s }
3106
+ : { calls: [], reduceInputs: s };
3107
+ }
3108
+ catch {
3109
+ /* diagnostics must never sink the phase */
3110
+ }
3111
+ }
3112
+ /** Format a single reduce-from input as a labeled section. */
3113
+ function formatReduceInput(id, output) {
3114
+ return `### ${id}\n\n${output}`;
3115
+ }
3116
+ /** Aggregate completed `from[]` outputs for a reduce phase's {previous.output}.
3117
+ *
3118
+ * BREAKING correction (dogfood issue 1): a reduce phase's {previous.output}
3119
+ * resolves to ALL completed `from[]` outputs in from-array order — not just
3120
+ * the last completed dependency. One completed input → its raw output.
3121
+ * Multiple → `### <id>\n\n<output>` sections joined by `\n\n---\n\n`.
3122
+ * `join:"any"` includes only completed branches (skipped/failed are omitted).
3123
+ *
3124
+ * Returns `{ value, ids }` where `ids` are the from-ids actually aggregated
3125
+ * (so the runtime can record them as observed reads). `value` is undefined
3126
+ * when no `from[]` phase completed (e.g. all skipped under join:any). */
3127
+ function aggregateReduceFrom(state, phase) {
3128
+ const fromIds = asArray(phase.from);
3129
+ const completed = [];
3130
+ for (const id of fromIds) {
3131
+ const ps = state.phases[id];
3132
+ if (ps?.status === "done" && ps.output !== undefined) {
3133
+ completed.push({ id, output: ps.output });
3134
+ }
3135
+ }
3136
+ if (completed.length === 0)
3137
+ return { value: undefined, ids: [] };
3138
+ if (completed.length === 1)
3139
+ return { value: completed[0].output, ids: [completed[0].id] };
3140
+ const value = completed.map((c) => formatReduceInput(c.id, c.output)).join("\n\n---\n\n");
3141
+ return { value, ids: completed.map((c) => c.id) };
3142
+ }
3143
+ /** Aggregate stats over the completed `from[]` inputs for reduce diagnostics. */
3144
+ function reduceInputStats(state, phase) {
3145
+ const fromIds = asArray(phase.from);
3146
+ let count = 0;
3147
+ let totalBytes = 0;
3148
+ let totalChars = 0;
3149
+ let totalEstTokens = 0;
3150
+ for (const id of fromIds) {
3151
+ const ps = state.phases[id];
3152
+ if (ps?.status === "done" && ps.output !== undefined) {
3153
+ count++;
3154
+ const s = promptSizeStats(ps.output);
3155
+ totalBytes += s.bytes;
3156
+ totalChars += s.chars;
3157
+ totalEstTokens += s.estTokens;
3158
+ }
3159
+ }
3160
+ return { count, totalBytes, totalChars, totalEstTokens };
3161
+ }
3162
+ /** Resolve the effective idle-watchdog ms for a phase: phase overrides flow;
3163
+ * returns `undefined` when neither sets it (host default 300000 applies).
3164
+ * `0` is passed through (disables the watchdog — validation already ensured a
3165
+ * finite wall `timeout` exists in that case). */
3166
+ export function resolveIdleTimeoutMs(phase, def) {
3167
+ const p = phase.idleTimeout;
3168
+ if (typeof p === "number" && Number.isFinite(p))
3169
+ return p;
3170
+ const f = def.idleTimeout;
3171
+ if (typeof f === "number" && Number.isFinite(f))
3172
+ return f;
3173
+ return undefined;
3174
+ }
2465
3175
  /** Stable cache identity for the fully resolved agent pool. File paths are
2466
3176
  * excluded: content/config, not installation location, determines output. */
2467
3177
  export function agentDefinitionsIdentity(agents) {
@@ -2503,6 +3213,7 @@ export function cacheKeys(cc, baseParts) {
2503
3213
  `agent-scope:${cc.agentScope ?? "user"}`,
2504
3214
  `context-sharing:${cc.contextSharing === true ? "1" : "0"}`,
2505
3215
  `agents:${cc.agentDefinitions ?? ""}`,
3216
+ ...(cc.executionCwd ? [`cwd:${cc.executionCwd}`] : []),
2506
3217
  ];
2507
3218
  const fold = (parts) => cc.fingerprint ? hashInput(...parts, cc.fingerprint) : hashInput(...parts);
2508
3219
  // Per-phase sub-fingerprint; falls back to the whole-flow hash when absent
@@ -2738,9 +3449,27 @@ export async function recomputeTaskflow(state, deps, seeds,
2738
3449
  // Fail-safe default: a real recompute overwrites the run and spends tokens.
2739
3450
  // The tool/command wrappers can explicitly opt into dryRun:false.
2740
3451
  opts = { dryRun: true }) {
3452
+ deps = snapshotFlowLoader(deps);
2741
3453
  // Never mutate the caller's RunState in-place. Recompute is a speculative
2742
3454
  // replay; only the caller decides whether to persist the new state.
2743
3455
  const newState = structuredClone(state);
3456
+ newState.args = resolveArgs(newState.def, newState.args);
3457
+ const invocationErrors = validateInvocationArgs(newState.def, newState.args);
3458
+ if (invocationErrors.length > 0) {
3459
+ throw new Error(`Taskflow '${newState.def.name}' invocation is invalid: ${invocationErrors.join("; ")}`);
3460
+ }
3461
+ const bridgeTree = flowTreeUsesCwdBridge(newState.def, deps.loadFlow);
3462
+ // Once a run has exercised the compatibility bridge, its persisted root
3463
+ // binding is permanent provenance. A later definition downgrade must not
3464
+ // silently turn cache/recompute back on for state produced with filesystem
3465
+ // authority.
3466
+ const bridgeTainted = bridgeTree || newState.cwdRootBinding !== undefined;
3467
+ if (bridgeTainted && opts.dryRun === false) {
3468
+ throw new Error("recompute dryRun:false is unavailable for cwd-bridge flows until workspace state restoration exists; run the whole flow instead");
3469
+ }
3470
+ if (!deps._disableCache && bridgeTainted) {
3471
+ deps = { ...deps, _disableCache: true };
3472
+ }
2744
3473
  const reads = readMapOf(newState.phases);
2745
3474
  // M2: derive the declared read-map fresh from the def so the frontier uses
2746
3475
  // the UNION (observed ∪ declared). Derived here (not read from the persisted
@@ -2868,7 +3597,7 @@ opts = { dryRun: true }) {
2868
3597
  newState.phases[id] = ps;
2869
3598
  // A phase counts as "rerun" if it was a forced seed OR its result moved;
2870
3599
  // otherwise it hit its cache (inputHash unchanged) → early cutoff.
2871
- if (isSeed || ps.inputHash !== before) {
3600
+ if (isSeed || !ps.cacheHit || ps.inputHash !== before) {
2872
3601
  rerun.push(id);
2873
3602
  outputMoved.add(id);
2874
3603
  decisions.push(isSeed
@@ -2924,7 +3653,106 @@ opts = { dryRun: true }) {
2924
3653
  };
2925
3654
  }
2926
3655
  export async function executeTaskflow(state, deps) {
3656
+ deps = snapshotFlowLoader(deps);
2927
3657
  const def = state.def;
3658
+ // Normalize defaults at the engine boundary too. Adapters already do this,
3659
+ // but direct Core callers, resume, and detached execution must behave the same.
3660
+ state.args = resolveArgs(def, state.args);
3661
+ const invocationErrors = validateInvocationArgs(def, state.args);
3662
+ if (invocationErrors.length > 0) {
3663
+ state.status = "failed";
3664
+ safeEmit(deps, state);
3665
+ return {
3666
+ state,
3667
+ finalOutput: `Taskflow '${def.name}' invocation is invalid: ${invocationErrors.join("; ")}`,
3668
+ ok: false,
3669
+ totalUsage: emptyUsage(),
3670
+ };
3671
+ }
3672
+ if (deps._dynamic === true) {
3673
+ const dynamicValidation = validateTaskflow(def, { dynamic: true, cwd: deps.cwd, args: state.args });
3674
+ if (!dynamicValidation.ok) {
3675
+ state.status = "failed";
3676
+ safeEmit(deps, state);
3677
+ return {
3678
+ state,
3679
+ finalOutput: `Dynamic taskflow '${def.name}' is invalid: ${dynamicValidation.errors.join("; ")}`,
3680
+ ok: false,
3681
+ totalUsage: emptyUsage(),
3682
+ };
3683
+ }
3684
+ }
3685
+ // A cwd bridge carries compatibility read-write authority. Until workspace
3686
+ // state restoration exists, output-only cache hits could skip required file
3687
+ // mutations or let downstream phases observe stale files. Disable cache and
3688
+ // within-run resume reuse across the complete reachable flow tree.
3689
+ const bridgeTree = flowTreeUsesCwdBridge(def, deps.loadFlow);
3690
+ // Persisted binding is a permanent taint bit: saved-flow definitions can
3691
+ // change between resumes, but prior outputs may already depend on filesystem
3692
+ // mutations. Never regain cache/rebind privileges merely because the current
3693
+ // snapshot no longer declares the bridge.
3694
+ const bridgeTainted = bridgeTree || state.cwdRootBinding !== undefined;
3695
+ if (bridgeTainted) {
3696
+ const invocationRoot = directoryIdentity(deps.cwd);
3697
+ const statePathRoot = directoryIdentity(state.cwd);
3698
+ const launchRoot = state.invocationRootSnapshot;
3699
+ const recordedRoot = state.cwdRootBinding;
3700
+ const executablePhaseIds = new Set(def.phases.map((phase) => phase.id));
3701
+ const hasExecutablePriorState = Object.keys(state.phases).some((id) => executablePhaseIds.has(id));
3702
+ // Pre-seeded external dependencies are inputs, not evidence that a bridge
3703
+ // phase previously executed without a persisted root binding. Conversely,
3704
+ // a host's launch snapshot proves root continuity, not prior bridge
3705
+ // authorization: adding a bridge after ordinary phases ran still fails.
3706
+ const isLegacyResume = bridgeTree && recordedRoot === undefined && hasExecutablePriorState;
3707
+ if (isLegacyResume ||
3708
+ !sameDirectoryIdentity(statePathRoot, invocationRoot) ||
3709
+ (launchRoot !== undefined && !sameDirectoryIdentity(launchRoot, invocationRoot)) ||
3710
+ (recordedRoot !== undefined && !sameDirectoryIdentity(recordedRoot, invocationRoot))) {
3711
+ state.status = "failed";
3712
+ safeEmit(deps, state);
3713
+ return {
3714
+ state,
3715
+ 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`,
3716
+ ok: false,
3717
+ totalUsage: emptyUsage(),
3718
+ };
3719
+ }
3720
+ state.cwdRootBinding ??= invocationRoot;
3721
+ // Freeze the invocation root to the canonical identity we just bound. In
3722
+ // particular, do not resolve phase cwd through a caller-provided symlink a
3723
+ // second time after the root-binding check.
3724
+ if (invocationRoot)
3725
+ deps = { ...deps, cwd: invocationRoot.canonicalPath };
3726
+ }
3727
+ if (!deps._disableCache && bridgeTainted) {
3728
+ deps = { ...deps, _disableCache: true };
3729
+ }
3730
+ // The explicit 0.2.1 resolve-only opt-in uses a W1a-compatible partial
3731
+ // control/durability scaffold. This does not upgrade its assurance: the
3732
+ // session is deliberately labelled resolve-only and no OS sandbox claim is
3733
+ // made. A native session must come from an exact approved host baseline cell.
3734
+ if (bridgeTree && deps.cwdBridgeMode === "resolve-only" && !deps.workspaceSession) {
3735
+ try {
3736
+ deps = {
3737
+ ...deps,
3738
+ workspaceSession: await createResolveOnlyWorkspaceSession({
3739
+ invocationRoot: deps.cwd,
3740
+ controlDirectory: deps.workspaceControlDirectory,
3741
+ signal: deps.signal,
3742
+ }),
3743
+ };
3744
+ }
3745
+ catch (error) {
3746
+ state.status = "failed";
3747
+ safeEmit(deps, state);
3748
+ return {
3749
+ state,
3750
+ finalOutput: `Taskflow '${def.name}' workspace capability initialization failed: ${error instanceof Error ? error.message : String(error)}`,
3751
+ ok: false,
3752
+ totalUsage: emptyUsage(),
3753
+ };
3754
+ }
3755
+ }
2928
3756
  const runnerUsageAccounting = deps.runTask
2929
3757
  ?.usageAccounting;
2930
3758
  if (!deps.usageAccounting && runnerUsageAccounting) {
@@ -2948,7 +3776,7 @@ export async function executeTaskflow(state, deps) {
2948
3776
  // not yet persist compatible input hashes, so it must never blindly trust a
2949
3777
  // prior `done` row.
2950
3778
  const hasPriorState = Object.keys(state.phases).length > 0;
2951
- if (eventKernelEnabled(deps) && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) {
3779
+ if (eventKernelEnabled(deps) && deps._cwdBoundary === undefined && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) {
2952
3780
  if (!deps.runTask) {
2953
3781
  throw new Error("event kernel requires RuntimeDeps.runTask");
2954
3782
  }
@@ -2966,6 +3794,7 @@ export async function executeTaskflow(state, deps) {
2966
3794
  requestApproval: deps.requestApproval,
2967
3795
  loadFlow: deps.loadFlow,
2968
3796
  _stack: deps._stack,
3797
+ _dynamic: deps._dynamic,
2969
3798
  });
2970
3799
  }
2971
3800
  return await runTaskflowLayers(state, deps);
@@ -3075,6 +3904,8 @@ async function runTaskflowLayers(state, deps) {
3075
3904
  let gateBlocked = false;
3076
3905
  let gateReason = "";
3077
3906
  let gateOutput = "";
3907
+ /** Id of the blocking gate/approval phase (source of `gateOutput`). */
3908
+ let gatePhaseId;
3078
3909
  // `budgetBlocked` gates the skipping of remaining phases once the cap is hit
3079
3910
  // and also drives the terminal "blocked" status — a maxUSD ceiling must never
3080
3911
  // silently do nothing.
@@ -3182,6 +4013,7 @@ async function runTaskflowLayers(state, deps) {
3182
4013
  gateBlocked = true;
3183
4014
  gateReason = ps.gate.reason ?? "";
3184
4015
  gateOutput = ps.output ?? "";
4016
+ gatePhaseId = phase.id;
3185
4017
  }
3186
4018
  // A fan-out cut short by the cap is itself a budget skip.
3187
4019
  if (ps.budgetTruncated) {
@@ -3220,15 +4052,6 @@ async function runTaskflowLayers(state, deps) {
3220
4052
  break;
3221
4053
  }
3222
4054
  }
3223
- const fp = finalPhase(def.phases);
3224
- let finalState = state.phases[fp.id];
3225
- // If the designated final phase produced no output (skipped/blocked), fall
3226
- // back to the last phase (in definition order) that actually completed.
3227
- if (!finalState || finalState.status !== "done") {
3228
- const doneInOrder = def.phases.map((p) => state.phases[p.id]).filter((p) => p?.status === "done");
3229
- if (doneInOrder.length)
3230
- finalState = doneInOrder[doneInOrder.length - 1];
3231
- }
3232
4055
  // A failed non-optional phase fails the run; optional failures are tolerated.
3233
4056
  const anyFailed = Object.entries(state.phases).some(([id, p]) => p.status === "failed" && !byId.get(id)?.optional && !p.optional);
3234
4057
  state.status = aborted
@@ -3239,13 +4062,14 @@ async function runTaskflowLayers(state, deps) {
3239
4062
  ? "failed"
3240
4063
  : "completed";
3241
4064
  safeEmit(deps, state);
3242
- let finalOutput = finalState?.output ?? "(no output)";
3243
- if (gateBlocked) {
3244
- finalOutput = `Gate blocked the workflow.${gateReason ? `\nReason: ${gateReason}` : ""}${gateOutput ? `\n\n${gateOutput}` : ""}`;
3245
- }
3246
- else if (budgetBlocked) {
3247
- finalOutput = `Budget exceeded — run halted.${budgetReason ? `\nReason: ${budgetReason}` : ""}${finalState?.output ? `\n\n${finalState.output}` : ""}`;
3248
- }
4065
+ const { finalOutput, outputSourcePhaseId } = resolveFinalOutput(def.phases, state, {
4066
+ gate: gateBlocked,
4067
+ gateReason,
4068
+ gateOutput,
4069
+ gatePhaseId,
4070
+ budget: budgetBlocked,
4071
+ budgetReason,
4072
+ });
3249
4073
  const totalUsage = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage()));
3250
4074
  return {
3251
4075
  state,
@@ -3253,6 +4077,7 @@ async function runTaskflowLayers(state, deps) {
3253
4077
  ok: state.status === "completed",
3254
4078
  totalUsage,
3255
4079
  reuse: summarizeReuse(state),
4080
+ outputSourcePhaseId,
3256
4081
  };
3257
4082
  }
3258
4083
  //# sourceMappingURL=runtime.js.map