taskflow-core 0.2.0 → 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 (121) 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/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/exec/driver.d.ts +1 -0
  13. package/dist/exec/driver.d.ts.map +1 -1
  14. package/dist/exec/driver.js +15 -2
  15. package/dist/exec/driver.js.map +1 -1
  16. package/dist/exec/step-kinds.d.ts.map +1 -1
  17. package/dist/exec/step-kinds.js +28 -3
  18. package/dist/exec/step-kinds.js.map +1 -1
  19. package/dist/exec/step.d.ts +1 -0
  20. package/dist/exec/step.d.ts.map +1 -1
  21. package/dist/exec/step.js +18 -0
  22. package/dist/exec/step.js.map +1 -1
  23. package/dist/flowir/compile.d.ts.map +1 -1
  24. package/dist/flowir/compile.js +13 -0
  25. package/dist/flowir/compile.js.map +1 -1
  26. package/dist/host/runner-types.d.ts +16 -0
  27. package/dist/host/runner-types.d.ts.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +6 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/interpolate.d.ts +4 -0
  33. package/dist/interpolate.d.ts.map +1 -1
  34. package/dist/interpolate.js +14 -0
  35. package/dist/interpolate.js.map +1 -1
  36. package/dist/resources/authority.d.ts +35 -0
  37. package/dist/resources/authority.d.ts.map +1 -0
  38. package/dist/resources/authority.js +67 -0
  39. package/dist/resources/authority.js.map +1 -0
  40. package/dist/resources/backend.d.ts +302 -0
  41. package/dist/resources/backend.d.ts.map +1 -0
  42. package/dist/resources/backend.js +16 -0
  43. package/dist/resources/backend.js.map +1 -0
  44. package/dist/resources/baseline.d.ts +116 -0
  45. package/dist/resources/baseline.d.ts.map +1 -0
  46. package/dist/resources/baseline.js +447 -0
  47. package/dist/resources/baseline.js.map +1 -0
  48. package/dist/resources/canonical-json.d.ts +7 -0
  49. package/dist/resources/canonical-json.d.ts.map +1 -0
  50. package/dist/resources/canonical-json.js +75 -0
  51. package/dist/resources/canonical-json.js.map +1 -0
  52. package/dist/resources/errors.d.ts +23 -0
  53. package/dist/resources/errors.d.ts.map +1 -0
  54. package/dist/resources/errors.js +89 -0
  55. package/dist/resources/errors.js.map +1 -0
  56. package/dist/resources/execution.d.ts +90 -0
  57. package/dist/resources/execution.d.ts.map +1 -0
  58. package/dist/resources/execution.js +581 -0
  59. package/dist/resources/execution.js.map +1 -0
  60. package/dist/resources/index.d.ts +15 -0
  61. package/dist/resources/index.d.ts.map +1 -0
  62. package/dist/resources/index.js +15 -0
  63. package/dist/resources/index.js.map +1 -0
  64. package/dist/resources/journal.d.ts +138 -0
  65. package/dist/resources/journal.d.ts.map +1 -0
  66. package/dist/resources/journal.js +438 -0
  67. package/dist/resources/journal.js.map +1 -0
  68. package/dist/resources/leases.d.ts +51 -0
  69. package/dist/resources/leases.d.ts.map +1 -0
  70. package/dist/resources/leases.js +354 -0
  71. package/dist/resources/leases.js.map +1 -0
  72. package/dist/resources/permits.d.ts +52 -0
  73. package/dist/resources/permits.d.ts.map +1 -0
  74. package/dist/resources/permits.js +240 -0
  75. package/dist/resources/permits.js.map +1 -0
  76. package/dist/resources/persistence.d.ts +63 -0
  77. package/dist/resources/persistence.d.ts.map +1 -0
  78. package/dist/resources/persistence.js +522 -0
  79. package/dist/resources/persistence.js.map +1 -0
  80. package/dist/resources/registry.d.ts +56 -0
  81. package/dist/resources/registry.d.ts.map +1 -0
  82. package/dist/resources/registry.js +139 -0
  83. package/dist/resources/registry.js.map +1 -0
  84. package/dist/resources/resolve.d.ts +49 -0
  85. package/dist/resources/resolve.d.ts.map +1 -0
  86. package/dist/resources/resolve.js +309 -0
  87. package/dist/resources/resolve.js.map +1 -0
  88. package/dist/resources/sandbox.d.ts +72 -0
  89. package/dist/resources/sandbox.d.ts.map +1 -0
  90. package/dist/resources/sandbox.js +952 -0
  91. package/dist/resources/sandbox.js.map +1 -0
  92. package/dist/resources/schema.d.ts +195 -0
  93. package/dist/resources/schema.d.ts.map +1 -0
  94. package/dist/resources/schema.js +231 -0
  95. package/dist/resources/schema.js.map +1 -0
  96. package/dist/resources/types.d.ts +36 -0
  97. package/dist/resources/types.d.ts.map +1 -0
  98. package/dist/resources/types.js +75 -0
  99. package/dist/resources/types.js.map +1 -0
  100. package/dist/runner-core.d.ts +29 -0
  101. package/dist/runner-core.d.ts.map +1 -1
  102. package/dist/runner-core.js +311 -36
  103. package/dist/runner-core.js.map +1 -1
  104. package/dist/runtime/phases/script.d.ts.map +1 -1
  105. package/dist/runtime/phases/script.js +47 -9
  106. package/dist/runtime/phases/script.js.map +1 -1
  107. package/dist/runtime.d.ts +39 -0
  108. package/dist/runtime.d.ts.map +1 -1
  109. package/dist/runtime.js +542 -50
  110. package/dist/runtime.js.map +1 -1
  111. package/dist/schema.d.ts +68 -5
  112. package/dist/schema.d.ts.map +1 -1
  113. package/dist/schema.js +163 -22
  114. package/dist/schema.js.map +1 -1
  115. package/dist/store.d.ts +7 -0
  116. package/dist/store.d.ts.map +1 -1
  117. package/dist/store.js.map +1 -1
  118. package/dist/trace.d.ts +3 -0
  119. package/dist/trace.d.ts.map +1 -1
  120. package/dist/trace.js.map +1 -1
  121. package/package.json +3 -2
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 { 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";
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";
@@ -45,6 +45,8 @@ import { compileTaskflowToIR, phaseFingerprint } from "./flowir/index.js";
45
45
  import { computeStaleFrontier, declaredReadMapOfDef, readMapOf } from "./stale.js";
46
46
  import { ctxDirFor, drainPendingSpawns, initCtxDir, registerNode, setNodeStatus } from "./context-store.js";
47
47
  import { allocateWorkspace, isWorkspaceKeyword } from "./workspace.js";
48
+ import { cwdArgName, directoryIdentity, isPathWithin, resolveCwdArg, } from "./cwd-bridge.js";
49
+ import { createResolveOnlyWorkspaceSession, } from "./resources/execution.js";
48
50
  /** Compute the incremental-reuse summary from a run's terminal phase states.
49
51
  * Pure, total, never throws. A phase is "reused" iff it carries a `cacheHit`
50
52
  * marker (set by `cachedPhase` for both within-run resume and cross-run hits). */
@@ -443,7 +445,7 @@ function liveSink(state, phaseId, emitProgress) {
443
445
  */
444
446
  const CONTEXT_MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
445
447
  const MAX_TOTAL_CONTEXT_CHARS = 200_000;
446
- async function resolvePhaseContext(phase, ctx) {
448
+ async function resolvePhaseContext(phase, ctx, cwd, boundary) {
447
449
  const entries = phase.context;
448
450
  if (!entries || entries.length === 0)
449
451
  return "";
@@ -482,20 +484,29 @@ async function resolvePhaseContext(phase, ctx) {
482
484
  const blocks = [];
483
485
  for (const p of filtered) {
484
486
  try {
485
- const abs = path.resolve(p);
486
- 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);
487
496
  if (!stat.isFile())
488
497
  continue;
489
498
  if (stat.size > CONTEXT_MAX_FILE_BYTES)
490
499
  continue;
491
- const content = fs.readFileSync(abs, "utf-8");
500
+ const content = fs.readFileSync(canonical, "utf-8");
492
501
  const truncated = content.length > limit
493
502
  ? content.slice(0, limit) + `\n... [truncated ${content.length - limit} chars]`
494
503
  : content;
495
504
  const ext = path.extname(p).slice(1) || "txt";
496
505
  blocks.push(`## File: ${p}\n\n\`\`\`${ext}\n${truncated}\n\`\`\``);
497
506
  }
498
- catch {
507
+ catch (error) {
508
+ if (error instanceof Error && error.message.startsWith("TF_CWD_BOUNDARY_ESCAPE:"))
509
+ throw error;
499
510
  console.warn(`[taskflow] Skipped unreadable context file: ${p}`);
500
511
  }
501
512
  }
@@ -537,19 +548,85 @@ function spawnedOverBudget(state, local) {
537
548
  * two isolation-leak bugs in the 0.0.23 review).
538
549
  */
539
550
  function resolveEffCwd(deps, phase) {
540
- 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);
558
+ }
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;
541
617
  }
542
618
  async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, deps, state, localSpawnUsage) {
543
619
  const stack = deps._stack ?? [];
544
620
  const inlineDepth = stack.filter((s) => s.startsWith("def:")).length;
545
621
  if (inlineDepth >= MAX_DYNAMIC_NESTING) {
546
- 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 };
547
624
  }
548
625
  const wrapped = normalizeInlineDef(subflowSpec, childNodeId);
549
626
  if (!wrapped)
550
- 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" };
551
628
  if (wrapped.phases.length === 0)
552
- 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 };
553
630
  // Inner phases without their own agent inherit the assignment's defaultAgent.
554
631
  if (defaultAgent) {
555
632
  for (const p of wrapped.phases)
@@ -559,12 +636,15 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
559
636
  const spawnCwd = resolveEffCwd(deps, phase);
560
637
  const dynCwd = spawnCwd;
561
638
  const v = validateTaskflow(wrapped, { dynamic: true, cwd: dynCwd });
562
- if (!v.ok)
563
- 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
+ }
564
643
  const ver = verifyTaskflow({ name: wrapped.name, phases: wrapped.phases, budget: wrapped.budget, concurrency: wrapped.concurrency });
565
644
  if (!ver.ok) {
566
645
  const errs = ver.issues.filter((i) => i.severity === "error").map((i) => i.message);
567
- return { output: `(spawned subflow failed verification: ${errs.join("; ")})`, usage: emptyUsage() };
646
+ const error = `spawned subflow failed verification: ${errs.join("; ")}`;
647
+ return { output: `(${error})`, usage: emptyUsage(), failed: true, error };
568
648
  }
569
649
  // The generated sub-flow gets only what remains after both already-folded
570
650
  // parent spend and siblings/ancestors in this still-running spawn batch. USD
@@ -591,6 +671,8 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
591
671
  const subResult = await executeTaskflow(subState, {
592
672
  ...deps,
593
673
  cwd: dynCwd,
674
+ _cacheCwdIdentity: phase.cwd !== undefined || deps._cacheCwdIdentity !== undefined ? dynCwd : undefined,
675
+ _dynamic: true,
594
676
  // The parent phase's isolated workspace (if any) applies only to the
595
677
  // parent — each spawned sub-phase resolves its own cwd. Clear the
596
678
  // override so the whole subflow doesn't inherit the parent's dir
@@ -606,16 +688,23 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
606
688
  // Sum every sub-phase's usage so the parent's budget guard sees spawn spend
607
689
  // (verdict Issue 2).
608
690
  const usage = aggregateUsage(Object.values(subResult.state.phases).map((p) => p.usage ?? emptyUsage()));
609
- 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
+ };
610
697
  }
611
698
  catch (e) {
612
- 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 };
613
701
  }
614
702
  }
615
703
  async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps, state, run, ledger = { usage: emptyUsage() }) {
616
704
  const capped = assignments.slice(0, MAX_DYNAMIC_MAP_ITEMS);
617
705
  const lines = [];
618
706
  const usages = [];
707
+ const errors = [];
619
708
  // Effective cwd for flat spawned tasks: honour a workspace override and never
620
709
  // pass a reserved keyword through to the runner.
621
710
  const spawnCwd = resolveEffCwd(deps, phase);
@@ -635,11 +724,43 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
635
724
  out = sub.output;
636
725
  usages.push(sub.usage);
637
726
  ledger.usage = aggregateUsage([ledger.usage, sub.usage]);
638
- setNodeStatus(ctxDir, childNodeId, "done");
727
+ if (sub.failed)
728
+ errors.push(sub.error ?? `spawned subflow ${childNodeId} failed`);
729
+ setNodeStatus(ctxDir, childNodeId, sub.failed ? "failed" : "done");
639
730
  }
640
731
  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);
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();
642
757
  out = r.output ?? "";
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
+ }
643
764
  if (r.usage) {
644
765
  usages.push(r.usage);
645
766
  ledger.usage = aggregateUsage([ledger.usage, r.usage]);
@@ -652,19 +773,27 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
652
773
  if (rec.reports)
653
774
  out += rec.reports;
654
775
  usages.push(rec.usage);
776
+ errors.push(...rec.errors);
655
777
  }
656
778
  }
657
779
  }
658
780
  catch (e) {
659
781
  setNodeStatus(ctxDir, childNodeId, "failed");
660
- 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})`;
661
785
  }
662
786
  lines.push(`### spawned child ${idx} (${agentName})\n${out}`);
663
787
  }
664
788
  const usage = aggregateUsage(usages);
665
789
  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 };
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
+ };
668
797
  }
669
798
  async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
670
799
  // Trace: phase-start (fail-open). Record whether this phase is replayable
@@ -732,9 +861,163 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
732
861
  }
733
862
  return ps;
734
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
+ }
735
1018
  // Non-keyword cwd (or none): no workspace lifecycle — run directly.
736
1019
  if (!isWorkspaceKeyword(phase.cwd)) {
737
- return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, opts));
1020
+ return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, innerOpts));
738
1021
  }
739
1022
  let ws;
740
1023
  try {
@@ -748,9 +1031,9 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
748
1031
  catch {
749
1032
  ws = undefined; // fail-open: run in the base cwd
750
1033
  }
751
- const innerDeps = ws ? { ...deps, _cwdOverride: ws.dir } : deps;
1034
+ const innerDeps = ws ? { ...deps, _cwdOverride: ws.dir, _cacheCwdIdentity: ws.dir } : deps;
752
1035
  try {
753
- const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, opts);
1036
+ const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts);
754
1037
  if (ws && (ws.kind !== "inherited" || ws.note)) {
755
1038
  const tag = ws.kind === "inherited" ? "workspace" : `workspace:${ws.kind}`;
756
1039
  const msg = ws.note ? `${tag} — ${ws.note}` : `${tag} at ${ws.dir}`;
@@ -800,7 +1083,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
800
1083
  // M3 observed-readSet: collect every upstream ref this phase resolves, so we
801
1084
  // can record what its result ACTUALLY depended on (not just its declared
802
1085
  // dependsOn). Shared by every interpolation in this phase (task / when / …).
803
- const readRefs = [];
1086
+ const readRefs = [...(opts?.whenReadRefs ?? [])];
804
1087
  const onRead = (ref) => {
805
1088
  readRefs.push(ref);
806
1089
  };
@@ -809,7 +1092,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
809
1092
  // dependencies. Evaluate them inside executePhaseInner so every upstream
810
1093
  // interpolation is captured by the shared onRead hook, not silently dropped
811
1094
  // by a separate out-of-band context.
812
- if (phase.when !== undefined) {
1095
+ if (phase.when !== undefined && opts?.whenPrechecked !== true) {
813
1096
  const whenResult = evaluateCondition(phase.when, ctx);
814
1097
  traceDecision(deps, state, phase.id, {
815
1098
  type: "when-guard",
@@ -827,7 +1110,10 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
827
1110
  };
828
1111
  }
829
1112
  }
830
- 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);
831
1117
  // Resolve this phase's cache policy once. Default scope is "run-only" (the
832
1118
  // historical within-run resume behavior). Only "cross-run" phases resolve a
833
1119
  // fingerprint and consult the persistent store.
@@ -851,6 +1137,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
851
1137
  if (phase.idempotent === false) {
852
1138
  cacheScope = "off";
853
1139
  }
1140
+ if (deps._disableCache) {
1141
+ cacheScope = "off";
1142
+ }
854
1143
  const cc = {
855
1144
  scope: cacheScope,
856
1145
  ttlMs: phase.cache?.ttl ? (parseTtlMs(phase.cache.ttl) ?? undefined) : undefined,
@@ -869,17 +1158,33 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
869
1158
  agentScope: state.def.agentScope,
870
1159
  contextSharing: state.def.contextSharing === true,
871
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();
872
1187
  };
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
1188
  // Wrap each subagent call in the phase's retry policy. Usage is summed across
884
1189
  // attempts; the attempt count rides along on the result for the TUI.
885
1190
  //
@@ -911,6 +1216,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
911
1216
  // AbortController chains: run signal + optional extra (race cancel) + phase timeout.
912
1217
  // Deterministic: a timed-out call is never retried (would double-spend).
913
1218
  let timedOut = false;
1219
+ let terminalCommitted = false;
914
1220
  let timer;
915
1221
  let forceReturnTimer;
916
1222
  const removers = [];
@@ -936,10 +1242,21 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
936
1242
  }
937
1243
  }
938
1244
  try {
939
- const invocation = 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);
940
1255
  if (phaseTimeoutMs && timeoutController) {
941
1256
  const timeoutFallback = new Promise((resolve) => {
942
1257
  timer = setTimeout(() => {
1258
+ if (terminalCommitted)
1259
+ return;
943
1260
  timedOut = true;
944
1261
  timeoutController?.abort();
945
1262
  forceReturnTimer = setTimeout(() => resolve({
@@ -952,6 +1269,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
952
1269
  stopReason: "error",
953
1270
  errorMessage: `Phase runner did not stop within ${PHASE_TIMEOUT_ABORT_GRACE_MS}ms after abort`,
954
1271
  phaseTimeout: true,
1272
+ completionSource: "phase-timeout",
955
1273
  }), PHASE_TIMEOUT_ABORT_GRACE_MS);
956
1274
  }, phaseTimeoutMs);
957
1275
  });
@@ -979,12 +1297,18 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
979
1297
  stopReason: "error",
980
1298
  errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`,
981
1299
  phaseTimeout: true,
1300
+ completionSource: "phase-timeout",
982
1301
  };
983
1302
  usages.push(last.usage);
984
1303
  traceEmit(deps, {
985
1304
  ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "subagent-call",
986
1305
  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 },
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
+ },
988
1312
  });
989
1313
  traceFlush(deps, phase.id);
990
1314
  break;
@@ -1015,7 +1339,12 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1015
1339
  traceEmit(deps, {
1016
1340
  ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "subagent-call",
1017
1341
  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 },
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
+ },
1019
1348
  });
1020
1349
  traceFlush(deps, phase.id);
1021
1350
  if (!isFailed(last))
@@ -1023,6 +1352,20 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1023
1352
  // Stop retrying on abort (run-level or race cancel) or once over budget.
1024
1353
  if (deps.signal?.aborted || extraSignal?.aborted || overBudget(state).over)
1025
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
+ }
1367
+ break;
1368
+ }
1026
1369
  // Decide whether THIS failure warrants another attempt. Explicit retry
1027
1370
  // policy covers all failures up to its cap; the transient fallback covers
1028
1371
  // only retryable provider errors. A non-transient failure with no explicit
@@ -1224,6 +1567,11 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1224
1567
  const child = await runSpawnedChildren(spawned, ctxDir, itemNid, phase, deps, state, run);
1225
1568
  if (child.reports)
1226
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
+ }
1227
1575
  if (child.usage) {
1228
1576
  r.usage = aggregateUsage([r.usage ?? emptyUsage(), child.usage]);
1229
1577
  liveUsages[idx] = r.usage;
@@ -1468,7 +1816,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1468
1816
  break;
1469
1817
  attempt++;
1470
1818
  if (_retryDepth < MAX_RETRY_DEPTH) {
1471
- const { _cwdOverride: _dropGateWs, ...depsForUpstream } = deps;
1819
+ const depsForUpstream = opts?.upstreamDeps ?? deps;
1472
1820
  for (const depId of phase.dependsOn ?? []) {
1473
1821
  const d = state.def.phases.find((p) => p.id === depId);
1474
1822
  if (!d)
@@ -1547,6 +1895,10 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1547
1895
  const child = await runSpawnedChildren(spawned, ctxDir, nid, phase, deps, state, run);
1548
1896
  if (child.reports)
1549
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
+ }
1550
1902
  // Fold spawned spend into this phase's usage so the run-wide budget
1551
1903
  // guard accounts for it (verdict Issue 2).
1552
1904
  ps.usage = aggregateUsage([ps.usage ?? emptyUsage(), child.usage]);
@@ -1575,7 +1927,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1575
1927
  // NOTE: we intentionally pass the gate's `prior` (not the dep's own
1576
1928
  // completed state) so the dep does NOT cache-hit and actually
1577
1929
  // RE-RUNS — re-running upstream is the whole point of onBlock:retry.
1578
- const { _cwdOverride: _dropGateWs, ...depsForUpstream } = deps;
1930
+ const depsForUpstream = opts?.upstreamDeps ?? deps;
1579
1931
  for (const depId of phase.dependsOn ?? []) {
1580
1932
  const d = state.def.phases.find((p) => p.id === depId);
1581
1933
  if (!d)
@@ -1637,7 +1989,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1637
1989
  const SCRIPT_TIMEOUT_MS = phase.timeout ?? 60_000;
1638
1990
  const reads = readRefs.length ? readRefsToReads(readRefs, state) : undefined;
1639
1991
  try {
1640
- const result = await runScriptCommand({
1992
+ const invoke = () => runScriptCommand({
1641
1993
  interpRunText,
1642
1994
  arrayForm: Array.isArray(cmd),
1643
1995
  cwd: effCwd,
@@ -1645,6 +1997,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1645
1997
  stdinInput,
1646
1998
  timeoutMs: SCRIPT_TIMEOUT_MS,
1647
1999
  });
2000
+ const result = deps._workspaceBinding
2001
+ ? await deps._workspaceBinding.runScript({ unitId: phase.id, signal: deps.signal, invoke })
2002
+ : await invoke();
1648
2003
  const ps = scriptResultToPhaseState(phase, result, {
1649
2004
  inputHash,
1650
2005
  timeoutMs: SCRIPT_TIMEOUT_MS,
@@ -1961,16 +2316,29 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1961
2316
  // Resolve sub-flow args (interpolate string values), then apply declared defaults.
1962
2317
  const provided = {};
1963
2318
  for (const [k, v] of Object.entries(phase.with ?? {})) {
1964
- provided[k] = typeof v === "string" ? interpolate(v, ctx).text : v;
2319
+ provided[k] = interpolateValue(v, ctx);
1965
2320
  }
1966
2321
  const subArgs = resolveArgs(subDef, provided);
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;
1967
2335
  // Every sub-flow cache identity includes the resolved definition. A saved
1968
2336
  // flow's name alone is insufficient: its contents can change without the
1969
2337
  // parent definition moving.
1970
2338
  const flowIdentity = `${hasDef ? "def" : "flow"}:${name}:${JSON.stringify(subDef)}`;
1971
- const ck = cacheKeys(cc, [phase.id, flowIdentity, preRead, JSON.stringify(subArgs)]);
2339
+ const ck = cacheKeys(flowCc, [phase.id, flowIdentity, preRead, JSON.stringify(subArgs)]);
1972
2340
  const inputHash = ck.key;
1973
- const cached = cachedPhase(cc, ck);
2341
+ const cached = cachedPhase(flowCc, ck);
1974
2342
  if (cached) {
1975
2343
  if (type === "expand" && expandMode === "graft" && cached.promotedPhases) {
1976
2344
  const promo = promoteGraftPhases(state, cached.promotedPhases);
@@ -2012,6 +2380,8 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2012
2380
  // phases without an explicit cwd derive their subagents from the
2013
2381
  // flow's cwd (not the caller's cwd).
2014
2382
  cwd: effCwd,
2383
+ _cacheCwdIdentity: phase.cwd !== undefined || deps._cacheCwdIdentity !== undefined ? effCwd : undefined,
2384
+ _dynamic: hasDef || deps._dynamic === true ? true : undefined,
2015
2385
  // The workspace override applies only to THIS flow phase, not to the
2016
2386
  // nested sub-phases (each resolves its own cwd). Clear it so the child
2017
2387
  // phases don't all inherit this phase's isolated dir as an override.
@@ -2040,6 +2410,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2040
2410
  },
2041
2411
  });
2042
2412
  const sp = Object.values(subState.phases);
2413
+ const nestedFailure = sp.find((nested) => nested.status === "failed")?.error;
2043
2414
  // expand graft promote — pure helper (see runtime/phases/expand.ts)
2044
2415
  const warnings = [];
2045
2416
  let graftPromotedIds = [];
@@ -2073,7 +2444,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2073
2444
  running: 0,
2074
2445
  failed: sp.filter((p) => p.status === "failed").length,
2075
2446
  },
2076
- 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}` : ""}`,
2077
2450
  inputHash,
2078
2451
  reads: readRefsToReads(readRefs, state),
2079
2452
  endedAt: Date.now(),
@@ -2082,7 +2455,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2082
2455
  ? { promotedPhases: Object.fromEntries(graftPromotedIds.map((id) => [id, { ...subState.phases[id] }])) }
2083
2456
  : {}),
2084
2457
  };
2085
- recordCache(cc, flowPs);
2458
+ recordCache(flowCc, flowPs);
2086
2459
  return flowPs;
2087
2460
  }
2088
2461
  // loop-until-done: run the body repeatedly until `until` is truthy, the output
@@ -2503,6 +2876,7 @@ export function cacheKeys(cc, baseParts) {
2503
2876
  `agent-scope:${cc.agentScope ?? "user"}`,
2504
2877
  `context-sharing:${cc.contextSharing === true ? "1" : "0"}`,
2505
2878
  `agents:${cc.agentDefinitions ?? ""}`,
2879
+ ...(cc.executionCwd ? [`cwd:${cc.executionCwd}`] : []),
2506
2880
  ];
2507
2881
  const fold = (parts) => cc.fingerprint ? hashInput(...parts, cc.fingerprint) : hashInput(...parts);
2508
2882
  // Per-phase sub-fingerprint; falls back to the whole-flow hash when absent
@@ -2738,9 +3112,27 @@ export async function recomputeTaskflow(state, deps, seeds,
2738
3112
  // Fail-safe default: a real recompute overwrites the run and spends tokens.
2739
3113
  // The tool/command wrappers can explicitly opt into dryRun:false.
2740
3114
  opts = { dryRun: true }) {
3115
+ deps = snapshotFlowLoader(deps);
2741
3116
  // Never mutate the caller's RunState in-place. Recompute is a speculative
2742
3117
  // replay; only the caller decides whether to persist the new state.
2743
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
+ }
2744
3136
  const reads = readMapOf(newState.phases);
2745
3137
  // M2: derive the declared read-map fresh from the def so the frontier uses
2746
3138
  // the UNION (observed ∪ declared). Derived here (not read from the persisted
@@ -2868,7 +3260,7 @@ opts = { dryRun: true }) {
2868
3260
  newState.phases[id] = ps;
2869
3261
  // A phase counts as "rerun" if it was a forced seed OR its result moved;
2870
3262
  // otherwise it hit its cache (inputHash unchanged) → early cutoff.
2871
- if (isSeed || ps.inputHash !== before) {
3263
+ if (isSeed || !ps.cacheHit || ps.inputHash !== before) {
2872
3264
  rerun.push(id);
2873
3265
  outputMoved.add(id);
2874
3266
  decisions.push(isSeed
@@ -2924,7 +3316,106 @@ opts = { dryRun: true }) {
2924
3316
  };
2925
3317
  }
2926
3318
  export async function executeTaskflow(state, deps) {
3319
+ deps = snapshotFlowLoader(deps);
2927
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
+ }
2928
3419
  const runnerUsageAccounting = deps.runTask
2929
3420
  ?.usageAccounting;
2930
3421
  if (!deps.usageAccounting && runnerUsageAccounting) {
@@ -2948,7 +3439,7 @@ export async function executeTaskflow(state, deps) {
2948
3439
  // not yet persist compatible input hashes, so it must never blindly trust a
2949
3440
  // prior `done` row.
2950
3441
  const hasPriorState = Object.keys(state.phases).length > 0;
2951
- if (eventKernelEnabled(deps) && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) {
3442
+ if (eventKernelEnabled(deps) && deps._cwdBoundary === undefined && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) {
2952
3443
  if (!deps.runTask) {
2953
3444
  throw new Error("event kernel requires RuntimeDeps.runTask");
2954
3445
  }
@@ -2966,6 +3457,7 @@ export async function executeTaskflow(state, deps) {
2966
3457
  requestApproval: deps.requestApproval,
2967
3458
  loadFlow: deps.loadFlow,
2968
3459
  _stack: deps._stack,
3460
+ _dynamic: deps._dynamic,
2969
3461
  });
2970
3462
  }
2971
3463
  return await runTaskflowLayers(state, deps);