taskflow-core 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +80 -25
  2. package/dist/agents.js.map +1 -1
  3. package/dist/cache.d.ts.map +1 -1
  4. package/dist/compile.js.map +1 -1
  5. package/dist/context-store.js.map +1 -1
  6. package/dist/deterministic.d.ts +8 -0
  7. package/dist/deterministic.d.ts.map +1 -1
  8. package/dist/deterministic.js +23 -1
  9. package/dist/deterministic.js.map +1 -1
  10. package/dist/exec/driver.d.ts +47 -0
  11. package/dist/exec/driver.d.ts.map +1 -0
  12. package/dist/exec/driver.js +397 -0
  13. package/dist/exec/driver.js.map +1 -0
  14. package/dist/exec/events.d.ts +76 -0
  15. package/dist/exec/events.d.ts.map +1 -0
  16. package/dist/exec/events.js +108 -0
  17. package/dist/exec/events.js.map +1 -0
  18. package/dist/exec/fold.d.ts +45 -0
  19. package/dist/exec/fold.d.ts.map +1 -0
  20. package/dist/exec/fold.js +122 -0
  21. package/dist/exec/fold.js.map +1 -0
  22. package/dist/exec/index.d.ts +17 -0
  23. package/dist/exec/index.d.ts.map +1 -0
  24. package/dist/exec/index.js +17 -0
  25. package/dist/exec/index.js.map +1 -0
  26. package/dist/exec/kernel-policy.d.ts +35 -0
  27. package/dist/exec/kernel-policy.d.ts.map +1 -0
  28. package/dist/exec/kernel-policy.js +184 -0
  29. package/dist/exec/kernel-policy.js.map +1 -0
  30. package/dist/exec/step-kinds.d.ts +41 -0
  31. package/dist/exec/step-kinds.d.ts.map +1 -0
  32. package/dist/exec/step-kinds.js +574 -0
  33. package/dist/exec/step-kinds.js.map +1 -0
  34. package/dist/exec/step.d.ts +93 -0
  35. package/dist/exec/step.d.ts.map +1 -0
  36. package/dist/exec/step.js +436 -0
  37. package/dist/exec/step.js.map +1 -0
  38. package/dist/flowir/canonical-hash.d.ts +101 -0
  39. package/dist/flowir/canonical-hash.d.ts.map +1 -0
  40. package/dist/flowir/canonical-hash.js +219 -0
  41. package/dist/flowir/canonical-hash.js.map +1 -0
  42. package/dist/flowir/compile.d.ts +49 -0
  43. package/dist/flowir/compile.d.ts.map +1 -0
  44. package/dist/flowir/compile.js +219 -0
  45. package/dist/flowir/compile.js.map +1 -0
  46. package/dist/flowir/cond.d.ts +76 -0
  47. package/dist/flowir/cond.d.ts.map +1 -0
  48. package/dist/flowir/cond.js +214 -0
  49. package/dist/flowir/cond.js.map +1 -0
  50. package/dist/flowir/index.d.ts +20 -23
  51. package/dist/flowir/index.d.ts.map +1 -1
  52. package/dist/flowir/index.js +47 -35
  53. package/dist/flowir/index.js.map +1 -1
  54. package/dist/flowir/meta.d.ts +4 -4
  55. package/dist/flowir/meta.d.ts.map +1 -1
  56. package/dist/flowir/phasefp.d.ts.map +1 -1
  57. package/dist/flowir/phasefp.js +13 -1
  58. package/dist/flowir/phasefp.js.map +1 -1
  59. package/dist/flowir/schema.d.ts +260 -0
  60. package/dist/flowir/schema.d.ts.map +1 -0
  61. package/dist/flowir/schema.js +234 -0
  62. package/dist/flowir/schema.js.map +1 -0
  63. package/dist/flowir/translate.d.ts.map +1 -1
  64. package/dist/flowir/translate.js +3 -0
  65. package/dist/flowir/translate.js.map +1 -1
  66. package/dist/frontmatter.d.ts.map +1 -1
  67. package/dist/host/runner-types.d.ts +3 -0
  68. package/dist/host/runner-types.d.ts.map +1 -1
  69. package/dist/index.d.ts +2 -0
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +2 -0
  72. package/dist/index.js.map +1 -1
  73. package/dist/interpolate.js.map +1 -1
  74. package/dist/peek.js.map +1 -1
  75. package/dist/rates.d.ts +121 -0
  76. package/dist/rates.d.ts.map +1 -0
  77. package/dist/rates.js +187 -0
  78. package/dist/rates.js.map +1 -0
  79. package/dist/reflexion.js.map +1 -1
  80. package/dist/replay.d.ts +43 -50
  81. package/dist/replay.d.ts.map +1 -1
  82. package/dist/replay.js +629 -19
  83. package/dist/replay.js.map +1 -1
  84. package/dist/runner-core.d.ts +24 -0
  85. package/dist/runner-core.d.ts.map +1 -1
  86. package/dist/runner-core.js +225 -32
  87. package/dist/runner-core.js.map +1 -1
  88. package/dist/runtime/phases/approval.d.ts +23 -0
  89. package/dist/runtime/phases/approval.d.ts.map +1 -0
  90. package/dist/runtime/phases/approval.js +38 -0
  91. package/dist/runtime/phases/approval.js.map +1 -0
  92. package/dist/runtime/phases/expand.d.ts +29 -0
  93. package/dist/runtime/phases/expand.d.ts.map +1 -0
  94. package/dist/runtime/phases/expand.js +126 -0
  95. package/dist/runtime/phases/expand.js.map +1 -0
  96. package/dist/runtime/phases/parallel.d.ts +26 -0
  97. package/dist/runtime/phases/parallel.d.ts.map +1 -0
  98. package/dist/runtime/phases/parallel.js +15 -0
  99. package/dist/runtime/phases/parallel.js.map +1 -0
  100. package/dist/runtime/phases/race.d.ts +31 -0
  101. package/dist/runtime/phases/race.d.ts.map +1 -0
  102. package/dist/runtime/phases/race.js +220 -0
  103. package/dist/runtime/phases/race.js.map +1 -0
  104. package/dist/runtime/phases/script.d.ts +38 -0
  105. package/dist/runtime/phases/script.d.ts.map +1 -0
  106. package/dist/runtime/phases/script.js +139 -0
  107. package/dist/runtime/phases/script.js.map +1 -0
  108. package/dist/runtime.d.ts +29 -16
  109. package/dist/runtime.d.ts.map +1 -1
  110. package/dist/runtime.js +554 -292
  111. package/dist/runtime.js.map +1 -1
  112. package/dist/schema.d.ts +44 -12
  113. package/dist/schema.d.ts.map +1 -1
  114. package/dist/schema.js +91 -10
  115. package/dist/schema.js.map +1 -1
  116. package/dist/scorers.d.ts.map +1 -1
  117. package/dist/scorers.js.map +1 -1
  118. package/dist/store.d.ts +6 -0
  119. package/dist/store.d.ts.map +1 -1
  120. package/dist/store.js.map +1 -1
  121. package/dist/trace.d.ts +4 -1
  122. package/dist/trace.d.ts.map +1 -1
  123. package/dist/trace.js +33 -26
  124. package/dist/trace.js.map +1 -1
  125. package/dist/workspace.d.ts.map +1 -1
  126. package/package.json +5 -4
package/dist/runtime.js CHANGED
@@ -13,7 +13,7 @@ import * as path from "node:path";
13
13
  import * as fs from "node:fs";
14
14
  import { coerceArray, evaluateCondition, interpolate, safeParse, tryEvaluateCondition } from "./interpolate.js";
15
15
  import { contractViolations } from "./contract.js";
16
- import { isFailed, isTransientError, mapWithConcurrencyLimit, sanitizeErrorMessage } from "./runner-core.js";
16
+ import { isFailed, isTransientError, mapWithConcurrencyLimit, PHASE_TIMEOUT_ABORT_GRACE_MS, sanitizeErrorMessage } from "./runner-core.js";
17
17
  /** Default runner used when no host injected one: fail loudly rather than
18
18
  * silently spawn anything (core is host-neutral and cannot spawn pi/codex). */
19
19
  const noRunnerInjected = async (_cwd, _agents, agentName, task) => ({
@@ -26,11 +26,13 @@ const noRunnerInjected = async (_cwd, _agents, agentName, task) => ({
26
26
  errorMessage: "No subagent runner injected",
27
27
  stopReason: "error",
28
28
  });
29
+ export { PHASE_TIMEOUT_ABORT_GRACE_MS } from "./runner-core.js";
29
30
  import { aggregateUsage, emptyUsage } from "./usage.js";
30
- import { dependenciesOf, finalPhase, LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, MAX_DYNAMIC_MAP_ITEMS, MAX_DYNAMIC_NESTING, parseTtlMs, resolveArgs, topoLayers, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, validateTaskflow } from "./schema.js";
31
+ import { dependenciesOf, finalPhase, LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, MAX_DYNAMIC_MAP_ITEMS, MAX_DYNAMIC_NESTING, MAX_DYNAMIC_PHASES, parseTtlMs, resolveArgs, topoLayers, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, validateTaskflow } from "./schema.js";
31
32
  import { verifyTaskflow } from "./verify.js";
32
- import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, scoreResultJSON, scorerShapeErrors, WINNER_TOKEN_RE } from "./scorers.js";
33
- import { parseGateVerdict, overBudget as overBudgetCheck } from "./deterministic.js";
33
+ import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, scoreResultJSON, scorerShapeErrors } from "./scorers.js";
34
+ import { parseGateVerdict, overBudget as overBudgetCheck, parseTournamentWinner } from "./deterministic.js";
35
+ export { parseTournamentWinner } from "./deterministic.js";
34
36
  import {} from "./trace.js";
35
37
  // Re-export so existing `import { parseGateVerdict } from "./runtime.ts"` callers
36
38
  // (and tests) keep working; the implementation now lives in deterministic.ts.
@@ -252,13 +254,17 @@ function normalizeInlineDef(parsed, phaseId) {
252
254
  * than the parent's, never looser. A generated def cannot raise the spend cap by
253
255
  * declaring its own large budget. Each dimension becomes min(child, parent).
254
256
  */
255
- function clampSubFlowBudget(sub, parentBudget) {
257
+ function clampSubFlowBudget(sub, parentBudget, spent = emptyUsage()) {
256
258
  if (!parentBudget)
257
259
  return sub;
258
260
  const child = sub.budget;
261
+ const remainingUSD = parentBudget.maxUSD === undefined ? Infinity : Math.max(0, parentBudget.maxUSD - spent.cost);
262
+ const remainingTokens = parentBudget.maxTokens === undefined
263
+ ? Infinity
264
+ : Math.max(0, parentBudget.maxTokens - (spent.input + spent.output));
259
265
  const clamped = {
260
- maxUSD: Math.min(child?.maxUSD ?? Infinity, parentBudget.maxUSD ?? Infinity),
261
- maxTokens: Math.min(child?.maxTokens ?? Infinity, parentBudget.maxTokens ?? Infinity),
266
+ maxUSD: Math.min(child?.maxUSD ?? Infinity, remainingUSD),
267
+ maxTokens: Math.min(child?.maxTokens ?? Infinity, remainingTokens),
262
268
  };
263
269
  // Drop Infinity dimensions (no cap on that axis).
264
270
  const budget = {};
@@ -344,6 +350,37 @@ function traceEmit(deps, event) {
344
350
  /* trace is best-effort; never run-breaking */
345
351
  }
346
352
  }
353
+ /** Emit a `decision` event (S1: full decision coverage for fold/replay). Fail-open. */
354
+ function traceDecision(deps, state, phaseId, decision) {
355
+ traceEmit(deps, {
356
+ ts: Date.now(),
357
+ runId: state.runId,
358
+ phaseId,
359
+ kind: "decision",
360
+ decision,
361
+ });
362
+ }
363
+ /** Emit gate decision as gate-score when scores present, else gate-verdict. */
364
+ function traceGateDecision(deps, state, phaseId, gate, judgeOutput) {
365
+ if (gate.scores) {
366
+ traceDecision(deps, state, phaseId, {
367
+ type: "gate-score",
368
+ target: "",
369
+ results: gate.scores.results,
370
+ combined: gate.scores.combined,
371
+ threshold: gate.scores.threshold,
372
+ verdict: gate.verdict,
373
+ judgeOutput,
374
+ });
375
+ }
376
+ else {
377
+ traceDecision(deps, state, phaseId, {
378
+ type: "gate-verdict",
379
+ value: gate.verdict,
380
+ reason: gate.reason,
381
+ });
382
+ }
383
+ }
347
384
  /** Fail-open trace flush at phase-end. */
348
385
  function traceFlush(deps, phaseId) {
349
386
  try {
@@ -355,7 +392,7 @@ function traceFlush(deps, phaseId) {
355
392
  }
356
393
  /** Emit a `decision: unreplayable` marker for a phase whose inputs the trace
357
394
  * cannot fully capture (Shared Context Tree, inner sub-flows, context files,
358
- * unobservable interpolation deps). A future replay marks such phases
395
+ * unobservable interpolation deps). Offline replay marks such phases
359
396
  * `needs-live-rerun` instead of silently reusing a recorded output.
360
397
  * Single-phase analog of `hasUnobservedDependencies`. Fail-open. */
361
398
  function emitUnreplayableMarker(deps, state, phase) {
@@ -371,7 +408,7 @@ function emitUnreplayableMarker(deps, state, phase) {
371
408
  function unreplayableReason(state, phase) {
372
409
  if (phase.shareContext === true || state.def.contextSharing === true)
373
410
  return "context-sharing";
374
- if (phase.type === "flow")
411
+ if (phase.type === "flow" || phase.type === "expand")
375
412
  return "inner-flow";
376
413
  if (phase.context && phase.context.length > 0)
377
414
  return "context-files";
@@ -469,6 +506,16 @@ async function resolvePhaseContext(phase, ctx) {
469
506
  }
470
507
  return result;
471
508
  }
509
+ function spawnedOverBudget(state, local) {
510
+ const budget = state.def.budget;
511
+ if (!budget)
512
+ return false;
513
+ return overBudgetCheck({
514
+ maxUSD: budget.maxUSD,
515
+ maxTokens: budget.maxTokens,
516
+ usages: [...Object.values(state.phases).map((p) => p.usage ?? emptyUsage()), local],
517
+ }).over;
518
+ }
472
519
  /**
473
520
  * Run an inline sub-flow queued via `ctx_spawn({subflow})`. Reuses the SAME
474
521
  * validation + execution machinery as a `flow{def}` phase (normalizeInlineDef →
@@ -492,7 +539,7 @@ async function resolvePhaseContext(phase, ctx) {
492
539
  function resolveEffCwd(deps, phase) {
493
540
  return deps._cwdOverride ?? (isWorkspaceKeyword(phase.cwd) ? deps.cwd : phase.cwd ?? deps.cwd);
494
541
  }
495
- async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, deps, state) {
542
+ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, deps, state, localSpawnUsage) {
496
543
  const stack = deps._stack ?? [];
497
544
  const inlineDepth = stack.filter((s) => s.startsWith("def:")).length;
498
545
  if (inlineDepth >= MAX_DYNAMIC_NESTING) {
@@ -519,7 +566,16 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
519
566
  const errs = ver.issues.filter((i) => i.severity === "error").map((i) => i.message);
520
567
  return { output: `(spawned subflow failed verification: ${errs.join("; ")})`, usage: emptyUsage() };
521
568
  }
522
- const subDef = clampSubFlowBudget(wrapped, state.def.budget);
569
+ // The generated sub-flow gets only what remains after both already-folded
570
+ // parent spend and siblings/ancestors in this still-running spawn batch. USD
571
+ // and tokens are clamped independently by clampSubFlowBudget. Like the main
572
+ // runtime, this is an atomic-call ceiling: one call may cross the cap, then no
573
+ // subsequent call is admitted.
574
+ const parentAndBatchSpent = aggregateUsage([
575
+ ...Object.values(state.phases).map((p) => p.usage ?? emptyUsage()),
576
+ localSpawnUsage,
577
+ ]);
578
+ const subDef = clampSubFlowBudget(wrapped, state.def.budget, parentAndBatchSpent);
523
579
  const subState = {
524
580
  runId: newRunId(subDef.name),
525
581
  flowName: subDef.name,
@@ -556,7 +612,7 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
556
612
  return { output: `(spawned subflow failed: ${e instanceof Error ? e.message : String(e)})`, usage: emptyUsage() };
557
613
  }
558
614
  }
559
- async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps, state, run) {
615
+ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps, state, run, ledger = { usage: emptyUsage() }) {
560
616
  const capped = assignments.slice(0, MAX_DYNAMIC_MAP_ITEMS);
561
617
  const lines = [];
562
618
  const usages = [];
@@ -565,7 +621,7 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
565
621
  const spawnCwd = resolveEffCwd(deps, phase);
566
622
  let idx = 0;
567
623
  for (const a of capped) {
568
- if (deps.signal?.aborted || overBudget(state).over)
624
+ if (deps.signal?.aborted || spawnedOverBudget(state, ledger.usage))
569
625
  break;
570
626
  idx++;
571
627
  const childNodeId = `${parentNodeId}--c${idx}`.replace(/[^A-Za-z0-9._-]+/g, "_");
@@ -575,21 +631,24 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
575
631
  let out = "";
576
632
  try {
577
633
  if (isSubflow) {
578
- const sub = await runInlineSubflow(a.subflow, a.defaultAgent ?? phase.agent, childNodeId, phase, deps, state);
634
+ const sub = await runInlineSubflow(a.subflow, a.defaultAgent ?? phase.agent, childNodeId, phase, deps, state, ledger.usage);
579
635
  out = sub.output;
580
636
  usages.push(sub.usage);
637
+ ledger.usage = aggregateUsage([ledger.usage, sub.usage]);
581
638
  setNodeStatus(ctxDir, childNodeId, "done");
582
639
  }
583
640
  else {
584
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);
585
642
  out = r.output ?? "";
586
- if (r.usage)
643
+ if (r.usage) {
587
644
  usages.push(r.usage);
645
+ ledger.usage = aggregateUsage([ledger.usage, r.usage]);
646
+ }
588
647
  setNodeStatus(ctxDir, childNodeId, isFailed(r) ? "failed" : "done");
589
648
  // A child may itself have queued spawns — recurse (depth-capped by the tool).
590
649
  const grand = drainPendingSpawns(ctxDir, childNodeId);
591
- if (grand.length > 0 && !deps.signal?.aborted && !overBudget(state).over) {
592
- const rec = await runSpawnedChildren(grand, ctxDir, childNodeId, phase, deps, state, run);
650
+ if (grand.length > 0 && !deps.signal?.aborted && !spawnedOverBudget(state, ledger.usage)) {
651
+ const rec = await runSpawnedChildren(grand, ctxDir, childNodeId, phase, deps, state, run, ledger);
593
652
  if (rec.reports)
594
653
  out += rec.reports;
595
654
  usages.push(rec.usage);
@@ -609,8 +668,15 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
609
668
  }
610
669
  async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
611
670
  // Trace: phase-start (fail-open). Record whether this phase is replayable
612
- // up front so a future replay can short-circuit it without walking events.
613
- traceEmit(deps, { ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "phase-start" });
671
+ // up front so replay can short-circuit it without guessing from output.
672
+ traceEmit(deps, {
673
+ ts: Date.now(),
674
+ runId: state.runId,
675
+ phaseId: phase.id,
676
+ kind: "phase-start",
677
+ dependencies: dependenciesOf(phase),
678
+ optional: phase.optional === true,
679
+ });
614
680
  if (deps.trace)
615
681
  emitUnreplayableMarker(deps, state, phase);
616
682
  let result;
@@ -630,6 +696,13 @@ async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth
630
696
  }
631
697
  if (threw)
632
698
  return result; // unreachable; satisfies TS
699
+ // S1: cache-hit decision (within-run or cross-run) for fold/replay.
700
+ if (result.cacheHit) {
701
+ traceDecision(deps, state, phase.id, {
702
+ type: "cache-hit",
703
+ scope: result.cacheHit === "cross-run" ? "cross-run" : "run-only",
704
+ });
705
+ }
633
706
  // Trace: phase-end with the real status, then flush buffered events.
634
707
  traceEmit(deps, {
635
708
  ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "phase-end",
@@ -644,6 +717,8 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
644
717
  // every type branch inside executePhaseInner is covered. A skipped phase ran
645
718
  // nothing — no side effect to record.
646
719
  const stamp = (ps) => {
720
+ if (phase.optional === true)
721
+ ps.optional = true;
647
722
  if (phase.idempotent === false && ps.status !== "skipped") {
648
723
  ps.sideEffect = true;
649
724
  // Resume double-fire warning (issue #20): a non-idempotent phase is never
@@ -735,7 +810,13 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
735
810
  // interpolation is captured by the shared onRead hook, not silently dropped
736
811
  // by a separate out-of-band context.
737
812
  if (phase.when !== undefined) {
738
- if (!evaluateCondition(phase.when, ctx)) {
813
+ const whenResult = evaluateCondition(phase.when, ctx);
814
+ traceDecision(deps, state, phase.id, {
815
+ type: "when-guard",
816
+ expression: phase.when,
817
+ result: whenResult,
818
+ });
819
+ if (!whenResult) {
739
820
  return {
740
821
  id: phase.id,
741
822
  status: "skipped",
@@ -755,7 +836,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
755
836
  // each run (schema already rejects explicit cross-run, but the default-scope
756
837
  // path must also be blocked). If flowDefHash failed, cross-run is unsafe
757
838
  // because the key degrades to flowName-only and reopens cross-flow collisions.
758
- const CROSS_RUN_BLOCKED_TYPES = new Set(["gate", "approval", "loop", "tournament", "script"]);
839
+ const CROSS_RUN_BLOCKED_TYPES = new Set(["gate", "approval", "loop", "tournament", "script", "race", "expand"]);
759
840
  if (cacheScope === "cross-run" && CROSS_RUN_BLOCKED_TYPES.has(type)) {
760
841
  cacheScope = "run-only";
761
842
  }
@@ -785,6 +866,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
785
866
  thinking: phase.thinking,
786
867
  tools: phase.tools,
787
868
  preRead,
869
+ agentScope: state.def.agentScope,
870
+ contextSharing: state.def.contextSharing === true,
871
+ agentDefinitions: agentDefinitionsIdentity(deps.agents),
788
872
  };
789
873
  const baseRun = (agentName, task, onLive, ctxNodeId, signal) => run(effCwd, deps.agents, agentName, task, {
790
874
  model: phase.model,
@@ -813,44 +897,77 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
813
897
  const phaseTimeoutMs = type !== "script" && typeof phase.timeout === "number" && Number.isFinite(phase.timeout) && phase.timeout >= 1000
814
898
  ? phase.timeout
815
899
  : undefined;
816
- const runOne = async (agentName, task, onLive, ctxNodeId, check) => {
900
+ const runOne = async (agentName, task, onLive, ctxNodeId, check,
901
+ /** Extra abort (e.g. race branch cancelLosers) — chained with run + phase timeout. */
902
+ extraSignal) => {
817
903
  const explicitMax = Math.max(1, 1 + Math.max(0, Math.floor(retry?.max ?? 0)));
818
904
  // Allow enough attempts to cover whichever policy applies on a given attempt.
819
905
  const maxAttempts = Math.max(explicitMax, 1 + DEFAULT_TRANSIENT_RETRIES);
820
906
  const usages = [];
821
907
  let last;
822
908
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
823
- if (deps.signal?.aborted)
909
+ if (deps.signal?.aborted || extraSignal?.aborted)
824
910
  break;
825
- // Phase timeout — an AbortController chained to the run's signal that also
826
- // fires after `phase.timeout` ms. Deterministic: a timed-out call is never
827
- // retried (retrying a call that just burned its full cap would double-spend).
911
+ // AbortController chains: run signal + optional extra (race cancel) + phase timeout.
912
+ // Deterministic: a timed-out call is never retried (would double-spend).
828
913
  let timedOut = false;
829
914
  let timer;
830
- let onParentAbort;
915
+ let forceReturnTimer;
916
+ const removers = [];
831
917
  let callSignal;
832
- if (phaseTimeoutMs) {
918
+ let timeoutController;
919
+ if (phaseTimeoutMs || extraSignal) {
833
920
  const ac = new AbortController();
921
+ timeoutController = ac;
834
922
  callSignal = ac.signal;
835
- if (deps.signal?.aborted)
923
+ if (deps.signal?.aborted || extraSignal?.aborted)
836
924
  ac.abort();
837
- else if (deps.signal) {
838
- onParentAbort = () => ac.abort();
839
- deps.signal.addEventListener("abort", onParentAbort, { once: true });
925
+ else {
926
+ if (deps.signal) {
927
+ const fn = () => ac.abort();
928
+ deps.signal.addEventListener("abort", fn, { once: true });
929
+ removers.push(() => deps.signal?.removeEventListener("abort", fn));
930
+ }
931
+ if (extraSignal) {
932
+ const fn = () => ac.abort();
933
+ extraSignal.addEventListener("abort", fn, { once: true });
934
+ removers.push(() => extraSignal.removeEventListener("abort", fn));
935
+ }
840
936
  }
841
- timer = setTimeout(() => {
842
- timedOut = true;
843
- ac.abort();
844
- }, phaseTimeoutMs);
845
937
  }
846
938
  try {
847
- last = await baseRun(agentName, task, onLive, ctxNodeId, callSignal);
939
+ const invocation = baseRun(agentName, task, onLive, ctxNodeId, callSignal);
940
+ if (phaseTimeoutMs && timeoutController) {
941
+ const timeoutFallback = new Promise((resolve) => {
942
+ timer = setTimeout(() => {
943
+ timedOut = true;
944
+ timeoutController?.abort();
945
+ forceReturnTimer = setTimeout(() => resolve({
946
+ agent: agentName,
947
+ task,
948
+ exitCode: 1,
949
+ output: "",
950
+ stderr: "",
951
+ usage: emptyUsage(),
952
+ stopReason: "error",
953
+ errorMessage: `Phase runner did not stop within ${PHASE_TIMEOUT_ABORT_GRACE_MS}ms after abort`,
954
+ phaseTimeout: true,
955
+ }), PHASE_TIMEOUT_ABORT_GRACE_MS);
956
+ }, phaseTimeoutMs);
957
+ });
958
+ last = await Promise.race([invocation, timeoutFallback]);
959
+ }
960
+ else {
961
+ last = await invocation;
962
+ }
848
963
  }
849
964
  finally {
850
965
  if (timer)
851
966
  clearTimeout(timer);
852
- if (onParentAbort)
853
- deps.signal?.removeEventListener("abort", onParentAbort);
967
+ if (forceReturnTimer)
968
+ clearTimeout(forceReturnTimer);
969
+ for (const r of removers)
970
+ r();
854
971
  }
855
972
  if (timedOut) {
856
973
  // Reclassify the abort as a phase timeout: a distinct, deterministic
@@ -864,6 +981,12 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
864
981
  phaseTimeout: true,
865
982
  };
866
983
  usages.push(last.usage);
984
+ traceEmit(deps, {
985
+ ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "subagent-call",
986
+ 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 },
988
+ });
989
+ traceFlush(deps, phase.id);
867
990
  break;
868
991
  }
869
992
  usages.push(last.usage);
@@ -886,10 +1009,19 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
886
1009
  const liveRetry = state.phases[phase.id];
887
1010
  if (liveRetry)
888
1011
  liveRetry.usage = aggregateUsage(usages);
1012
+ // Persist every attempt, not only the final aggregate. This is required
1013
+ // for honest replay/cost accounting when a transient or explicit retry
1014
+ // succeeds after earlier spend.
1015
+ traceEmit(deps, {
1016
+ ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "subagent-call",
1017
+ 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 },
1019
+ });
1020
+ traceFlush(deps, phase.id);
889
1021
  if (!isFailed(last))
890
1022
  break;
891
- // Stop retrying on abort or once the run is over budget.
892
- if (deps.signal?.aborted || overBudget(state).over)
1023
+ // Stop retrying on abort (run-level or race cancel) or once over budget.
1024
+ if (deps.signal?.aborted || extraSignal?.aborted || overBudget(state).over)
893
1025
  break;
894
1026
  // Decide whether THIS failure warrants another attempt. Explicit retry
895
1027
  // policy covers all failures up to its cap; the transient fallback covers
@@ -919,8 +1051,29 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
919
1051
  // backoff.
920
1052
  const factor = retry ? (retry.factor ?? 1) : DEFAULT_TRANSIENT_FACTOR;
921
1053
  const wait = Math.min(60000, Math.round(baseMs * factor ** attempt));
922
- if (wait > 0)
923
- await delay(wait, deps.signal);
1054
+ if (wait > 0) {
1055
+ // Honor run abort and/or race-branch cancel during backoff.
1056
+ if (deps.signal && extraSignal) {
1057
+ const ac = new AbortController();
1058
+ const ab = () => ac.abort();
1059
+ if (deps.signal.aborted || extraSignal.aborted)
1060
+ ac.abort();
1061
+ else {
1062
+ deps.signal.addEventListener("abort", ab, { once: true });
1063
+ extraSignal.addEventListener("abort", ab, { once: true });
1064
+ }
1065
+ try {
1066
+ await delay(wait, ac.signal);
1067
+ }
1068
+ finally {
1069
+ deps.signal.removeEventListener("abort", ab);
1070
+ extraSignal.removeEventListener("abort", ab);
1071
+ }
1072
+ }
1073
+ else {
1074
+ await delay(wait, extraSignal ?? deps.signal);
1075
+ }
1076
+ }
924
1077
  }
925
1078
  // Aborted before any attempt ran → return a clean aborted result (no crash).
926
1079
  if (!last) {
@@ -939,29 +1092,6 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
939
1092
  if (usages.length > 1)
940
1093
  last.usage = aggregateUsage(usages);
941
1094
  last.attempts = usages.length;
942
- // Trace: record this subagent call (the load-bearing record a future
943
- // replay consumes). Fail-open. nodePath carries the item/variant
944
- // discriminator (e.g. "review", "review-item-3", "gate-judge").
945
- traceEmit(deps, {
946
- ts: Date.now(),
947
- runId: state.runId,
948
- phaseId: phase.id,
949
- kind: "subagent-call",
950
- input: {
951
- agent: agentName,
952
- model: phase.model,
953
- task,
954
- preRead,
955
- nodePath: ctxNodeId ?? phase.id,
956
- attempt: usages.length > 0 ? usages.length - 1 : undefined,
957
- },
958
- output: {
959
- text: last.output,
960
- model: last.model,
961
- usage: last.usage,
962
- stopReason: last.stopReason,
963
- },
964
- });
965
1095
  return last;
966
1096
  };
967
1097
  const parseJson = phase.output === "json";
@@ -1000,7 +1130,11 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1000
1130
  emitProgress();
1001
1131
  };
1002
1132
  refresh();
1003
- return mapWithConcurrencyLimit(items, concurrency, async (it, idx) => {
1133
+ // Usage is only authoritative after a call reports it. Serial admission for
1134
+ // budgeted fan-out prevents N siblings from all observing the same remaining
1135
+ // allowance and overshooting it concurrently.
1136
+ const admissionConcurrency = state.def.budget ? 1 : concurrency;
1137
+ return mapWithConcurrencyLimit(items, admissionConcurrency, async (it, idx) => {
1004
1138
  // Budget guard: stop spawning new fan-out items once the run is over budget.
1005
1139
  if (overBudget(state).over) {
1006
1140
  done++;
@@ -1227,6 +1361,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1227
1361
  };
1228
1362
  if (readRefs.length)
1229
1363
  ps.reads = readRefsToReads(readRefs, state);
1364
+ traceGateDecision(deps, state, phase.id, ps.gate, undefined);
1230
1365
  return ps;
1231
1366
  }
1232
1367
  const report = targetResolved
@@ -1259,6 +1394,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1259
1394
  const verdict = final.passed ? "pass" : "block";
1260
1395
  ps.gate = { verdict, reason: judged.reason, scores: { results, combined: final.combined, threshold } };
1261
1396
  ps.json = scoreResultJSON(results, final.combined, verdict, threshold, { score: judged.score, reason: judged.reason });
1397
+ traceGateDecision(deps, state, phase.id, ps.gate, r.output);
1262
1398
  }
1263
1399
  if (readRefs.length)
1264
1400
  ps.reads = readRefsToReads(readRefs, state);
@@ -1394,13 +1530,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1394
1530
  ps.warnings = [...(ps.warnings ?? []), refWarning];
1395
1531
  if (type === "gate" && ps.status === "done") {
1396
1532
  ps.gate = parseGateVerdict(r.output);
1397
- // Trace: record the gate verdict decision (fail-open). A future replay
1398
- // re-adjudicates this against a changed threshold.
1533
+ // Trace: gate decision (fail-open). Replay re-adjudicates thresholds.
1399
1534
  if (ps.gate)
1400
- traceEmit(deps, {
1401
- ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "decision",
1402
- decision: { type: "gate-verdict", value: ps.gate.verdict, reason: ps.gate.reason },
1403
- });
1535
+ traceGateDecision(deps, state, phase.id, ps.gate);
1404
1536
  }
1405
1537
  // Shared Context Tree: register this node, mark its terminal status, and
1406
1538
  // pick up any ctx_spawn intents the subagent queued. The spawned child
@@ -1470,9 +1602,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1470
1602
  recordCache(cc, ps);
1471
1603
  return ps;
1472
1604
  }
1473
- // script — zero-token shell command. Spawns a child process, pipes
1474
- // interpolated `input` to stdin, captures stdout as the phase output.
1605
+ // script — zero-token shell (spawn/timeout/size caps in runtime/phases/script.ts)
1475
1606
  if (type === "script") {
1607
+ const { runScriptCommand, scriptResultToPhaseState, scriptSpawnErrorToPhaseState } = await import("./runtime/phases/script.js");
1476
1608
  const cmd = phase.run;
1477
1609
  if (!cmd) {
1478
1610
  return {
@@ -1483,19 +1615,16 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1483
1615
  usage: emptyUsage(),
1484
1616
  };
1485
1617
  }
1486
- // Interpolate the command.
1487
1618
  // Array form: interpolate each element (safe — schema rejects placeholders in string form).
1488
1619
  // String form: skip interpolation — schema already guarantees no {placeholders}.
1489
1620
  const interpRun = Array.isArray(cmd)
1490
1621
  ? cmd.map((s) => interpolate(s, ctx))
1491
1622
  : [{ text: cmd, missing: [] }];
1492
- // Warn unresolved references.
1493
1623
  for (const r of interpRun) {
1494
1624
  if (r.missing.length)
1495
1625
  warnUnresolvedRefs(phase.id, r.missing);
1496
1626
  }
1497
1627
  const interpRunText = interpRun.map((r) => r.text);
1498
- // Interpolate stdin input if provided.
1499
1628
  const stdinInterp = phase.input !== undefined ? interpolate(phase.input, ctx) : undefined;
1500
1629
  if (stdinInterp?.missing.length)
1501
1630
  warnUnresolvedRefs(phase.id, stdinInterp.missing);
@@ -1505,123 +1634,36 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1505
1634
  const cached = cachedPhase(cc, ck);
1506
1635
  if (cached)
1507
1636
  return cached;
1508
- const MAX_STDOUT = 1_048_576; // 1 MB cap
1509
- const SCRIPT_TIMEOUT_MS = (phase.timeout ?? 60_000); // default 60 s, configurable via phase.timeout
1510
- const SIGKILL_GRACE_MS = 5_000; // grace period after SIGTERM before SIGKILL
1637
+ const SCRIPT_TIMEOUT_MS = phase.timeout ?? 60_000;
1638
+ const reads = readRefs.length ? readRefsToReads(readRefs, state) : undefined;
1511
1639
  try {
1512
- const { spawn } = await import("node:child_process");
1513
- const result = await new Promise((resolve, reject) => {
1514
- // Array command → direct spawn (safe); string → shell (rejected at validation if it has placeholders).
1515
- const child = Array.isArray(cmd)
1516
- ? spawn(interpRunText[0], interpRunText.slice(1), {
1517
- cwd: effCwd,
1518
- env: process.env,
1519
- shell: false,
1520
- signal: deps.signal,
1521
- })
1522
- : spawn(interpRunText[0], [], {
1523
- cwd: effCwd,
1524
- env: process.env,
1525
- shell: true,
1526
- signal: deps.signal,
1527
- });
1528
- let stdout = "";
1529
- let stderr = "";
1530
- let stdoutOversize = false;
1531
- let timedOut = false;
1532
- child.stdout?.on("data", (d) => {
1533
- if (stdout.length < MAX_STDOUT) {
1534
- const need = MAX_STDOUT - stdout.length;
1535
- stdout += d.toString().slice(0, need);
1536
- if (stdout.length >= MAX_STDOUT)
1537
- stdoutOversize = true;
1538
- }
1539
- });
1540
- child.stderr?.on("data", (d) => {
1541
- if (stderr.length < 500) {
1542
- stderr += d.toString().slice(0, 500 - stderr.length);
1543
- }
1544
- });
1545
- // Timeout guard: SIGTERM first, then SIGKILL after grace period.
1546
- let sigkillTimer;
1547
- const timer = setTimeout(() => {
1548
- timedOut = true;
1549
- child.kill("SIGTERM");
1550
- // SIGKILL fallback if process ignores SIGTERM.
1551
- sigkillTimer = setTimeout(() => {
1552
- try {
1553
- child.kill("SIGKILL");
1554
- }
1555
- catch { /* already dead */ }
1556
- }, SIGKILL_GRACE_MS);
1557
- }, SCRIPT_TIMEOUT_MS);
1558
- child.on("error", (err) => {
1559
- clearTimeout(timer);
1560
- clearTimeout(sigkillTimer);
1561
- reject(err);
1562
- });
1563
- child.on("close", (code) => {
1564
- clearTimeout(timer);
1565
- clearTimeout(sigkillTimer);
1566
- resolve({ stdout, stderr, code, stdoutOversize, timedOut });
1567
- });
1568
- if (stdinInput !== undefined) {
1569
- child.stdin?.on("error", () => { }); // swallow EPIPE when child closes stdin early
1570
- child.stdin?.write(stdinInput);
1571
- child.stdin?.end();
1572
- }
1640
+ const result = await runScriptCommand({
1641
+ interpRunText,
1642
+ arrayForm: Array.isArray(cmd),
1643
+ cwd: effCwd,
1644
+ signal: deps.signal,
1645
+ stdinInput,
1646
+ timeoutMs: SCRIPT_TIMEOUT_MS,
1573
1647
  });
1574
- if (result.code !== 0 || result.timedOut) {
1575
- const ps = {
1576
- id: phase.id,
1577
- status: "failed",
1578
- output: result.stdout,
1579
- error: result.timedOut
1580
- ? `Script timed out after ${SCRIPT_TIMEOUT_MS}ms`
1581
- : `Script exited with code ${result.code}${result.stderr ? ": " + result.stderr.slice(0, 500) : ""}${result.stdoutOversize ? " [stdout truncated at 1 MB]" : ""}`,
1582
- timedOut: result.timedOut || undefined,
1583
- usage: emptyUsage(),
1584
- inputHash,
1585
- endedAt: Date.now(),
1586
- };
1587
- if (readRefs.length)
1588
- ps.reads = readRefsToReads(readRefs, state);
1589
- // Non-zero exit: cache (deterministic failure). Timeout: don't cache (transient, like spawn errors).
1590
- if (!result.timedOut)
1591
- recordCache(cc, ps);
1592
- return ps;
1593
- }
1594
- const ps = {
1595
- id: phase.id,
1596
- status: "done",
1597
- output: result.stdout.trimEnd() + (result.stdoutOversize ? "\n[stdout truncated at 1 MB]" : ""),
1598
- usage: emptyUsage(),
1648
+ const ps = scriptResultToPhaseState(phase, result, {
1599
1649
  inputHash,
1600
- endedAt: Date.now(),
1601
- };
1602
- if (readRefs.length)
1603
- ps.reads = readRefsToReads(readRefs, state);
1604
- recordCache(cc, ps);
1650
+ timeoutMs: SCRIPT_TIMEOUT_MS,
1651
+ reads,
1652
+ });
1653
+ // Non-zero exit: cache (deterministic). Timeout/spawn: don't cache (transient).
1654
+ if (ps.status === "done" || (ps.status === "failed" && !ps.timedOut)) {
1655
+ recordCache(cc, ps);
1656
+ }
1605
1657
  return ps;
1606
1658
  }
1607
1659
  catch (err) {
1608
- const msg = err instanceof Error ? err.message : String(err);
1609
- // Note: intentionally NOT cached — spawn errors (ENOENT, permission denied, etc.)
1610
- // are treated as transient. The phase will re-execute on resume/retry.
1611
- const ps = {
1612
- id: phase.id,
1613
- status: "failed",
1614
- error: `Script error: ${msg}`,
1615
- usage: emptyUsage(),
1616
- inputHash,
1617
- endedAt: Date.now(),
1618
- };
1619
- if (readRefs.length)
1620
- ps.reads = readRefsToReads(readRefs, state);
1621
- return ps;
1660
+ // Spawn errors intentionally NOT cached — re-execute on resume/retry.
1661
+ return scriptSpawnErrorToPhaseState(phase.id, err, { inputHash, reads });
1622
1662
  }
1623
1663
  }
1664
+ // parallel — all branches; merge via shared mergePhaseState (phases/parallel.ts)
1624
1665
  if (type === "parallel") {
1666
+ const { executeParallelBranches } = await import("./runtime/phases/parallel.js");
1625
1667
  const branches = (phase.branches ?? []).map((b) => {
1626
1668
  const r = interpolate(b.task, ctx);
1627
1669
  return {
@@ -1634,10 +1676,36 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1634
1676
  const cached = cachedPhase(cc, ck);
1635
1677
  if (cached)
1636
1678
  return cached;
1637
- const results = await runFanout(branches);
1638
- const ps = mergePhaseState(phase.id, results, inputHash, parseJson);
1639
- if (readRefs.length)
1640
- ps.reads = readRefsToReads(readRefs, state);
1679
+ const ps = await executeParallelBranches(phase, branches, runFanout, mergePhaseState, {
1680
+ inputHash,
1681
+ parseJson,
1682
+ reads: readRefs.length ? readRefsToReads(readRefs, state) : undefined,
1683
+ });
1684
+ recordCache(cc, ps);
1685
+ return ps;
1686
+ }
1687
+ // Horizon B: race — implementation lives in runtime/phases/race.ts
1688
+ if (type === "race") {
1689
+ const { executeRaceBranches } = await import("./runtime/phases/race.js");
1690
+ const branches = (phase.branches ?? []).map((b) => {
1691
+ const r = interpolate(b.task, ctx);
1692
+ return {
1693
+ agent: resolveAgent(b.agent ?? phase.agent, deps, state),
1694
+ task: preRead + r.text,
1695
+ };
1696
+ });
1697
+ const ck = cacheKeys(cc, [phase.id, "race", phase.model ?? "", JSON.stringify(branches)]);
1698
+ const inputHash = ck.key;
1699
+ const cached = cachedPhase(cc, ck);
1700
+ if (cached)
1701
+ return cached;
1702
+ const raceRunOne = (agent, task, branchSignal) => runOne(agent, task, undefined, undefined, undefined, branchSignal);
1703
+ const ps = await executeRaceBranches(phase, branches, raceRunOne, isFailed, {
1704
+ inputHash,
1705
+ parseJson,
1706
+ readRefs: readRefs.length ? readRefsToReads(readRefs, state) : undefined,
1707
+ parentSignal: deps.signal,
1708
+ });
1641
1709
  recordCache(cc, ps);
1642
1710
  return ps;
1643
1711
  }
@@ -1735,7 +1803,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1735
1803
  recordCache(cc, ps);
1736
1804
  return ps;
1737
1805
  }
1806
+ // approval — HITL pause (decision → PhaseState in runtime/phases/approval.ts)
1738
1807
  if (type === "approval") {
1808
+ const { approvalDecisionToPhaseState } = await import("./runtime/phases/approval.js");
1739
1809
  const readRefs = [];
1740
1810
  const ctx = buildInterpolationContext(state, previousOutput, undefined, (ref) => readRefs.push(ref));
1741
1811
  const message = interpolate(phase.task ?? "Approve to continue?", ctx).text;
@@ -1744,48 +1814,51 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1744
1814
  const cached = cachedPhase(cc, ck);
1745
1815
  if (cached)
1746
1816
  return cached;
1747
- // Non-interactive (headless/CI/detached): auto-REJECT, fail-open, but record it.
1748
- // Approval gates are safety boundaries — bypassing them silently in CI would
1749
- // let unreviewed work ship. Detached/CI runs must not bypass approval gates.
1817
+ const reads = readRefsToReads(readRefs, state);
1818
+ // Non-interactive (headless/CI/detached): auto-REJECT — safety boundary, never bypass.
1750
1819
  if (!deps.requestApproval) {
1751
- return {
1752
- id: phase.id,
1753
- status: "done",
1754
- output: "(auto-rejected: no interactive approver available)",
1755
- approval: { decision: "reject", auto: true },
1756
- gate: { verdict: "block", reason: "(auto-rejected: no interactive approver available)" },
1757
- usage: emptyUsage(),
1820
+ return approvalDecisionToPhaseState(phase.id, { decision: "reject" }, {
1758
1821
  inputHash,
1759
- reads: readRefsToReads(readRefs, state),
1760
- endedAt: Date.now(),
1761
- };
1762
- }
1763
- const decision = await deps.requestApproval({ phaseId: phase.id, message, upstream: previousOutput });
1764
- const note = decision.note?.trim();
1765
- const ps = {
1766
- id: phase.id,
1767
- status: "done",
1768
- output: note || `(${decision.decision})`,
1769
- approval: { decision: decision.decision, note },
1770
- usage: emptyUsage(),
1771
- inputHash,
1772
- reads: readRefsToReads(readRefs, state),
1773
- endedAt: Date.now(),
1774
- };
1775
- // A rejection halts the flow via the same mechanism as a blocking gate.
1776
- if (decision.decision === "reject") {
1777
- ps.gate = { verdict: "block", reason: note || "Rejected by user" };
1822
+ reads,
1823
+ auto: true,
1824
+ });
1778
1825
  }
1779
- return ps;
1826
+ const decision = await deps.requestApproval({
1827
+ phaseId: phase.id,
1828
+ message,
1829
+ upstream: previousOutput,
1830
+ });
1831
+ return approvalDecisionToPhaseState(phase.id, decision, { inputHash, reads });
1780
1832
  }
1781
- if (type === "flow") {
1833
+ if (type === "flow" || type === "expand") {
1782
1834
  const readRefs = [];
1783
1835
  const ctx = buildInterpolationContext(state, previousOutput, undefined, (ref) => readRefs.push(ref));
1784
- const hasDef = phase.def !== undefined;
1836
+ // expand always requires `def`; flow may use `use` or `def`.
1837
+ const hasDef = type === "expand" ? phase.def !== undefined : phase.def !== undefined;
1785
1838
  const stack = deps._stack ?? [];
1839
+ const { resolveExpandMode, resolveMaxNodes, prefixGraftFragment, promoteGraftPhases } = await import("./runtime/phases/expand.js");
1840
+ const expandMode = type === "expand" ? resolveExpandMode(phase) : "nested";
1841
+ const maxNodes = type === "expand" ? resolveMaxNodes(phase, MAX_DYNAMIC_PHASES) : 50;
1842
+ if (type === "expand" && expandMode === "graft") {
1843
+ // Rerun replacement semantics: prior promoted children belong to the old
1844
+ // fragment and must disappear before ANY new resolution path. This is
1845
+ // intentionally before parse/validation/empty/sub-flow failure returns so
1846
+ // stale children and their usage cannot survive a failed or empty v2 plan.
1847
+ const declaredIds = new Set(state.def.phases.map((p) => p.id));
1848
+ for (const oldId of Object.keys(prior?.promotedPhases ?? {})) {
1849
+ // Definition evolution may promote an old dynamic id into a real
1850
+ // authored parent phase. That declared phase owns its state now and must
1851
+ // never be deleted by stale graft metadata.
1852
+ if (!declaredIds.has(oldId))
1853
+ delete state.phases[oldId];
1854
+ }
1855
+ }
1786
1856
  let subDef;
1787
1857
  let name;
1788
1858
  let recursionKey; // identity used for cache key + recursion guard
1859
+ if (type === "expand" && !hasDef) {
1860
+ return failPhase(phase.id, `expand phase '${phase.id}' requires 'def'`);
1861
+ }
1789
1862
  if (hasDef) {
1790
1863
  // --- Inline `def`: resolve at runtime, validate, fail-OPEN on any error. ---
1791
1864
  // Fail-open contract: a bad def NEVER aborts the run. The phase resolves
@@ -1822,7 +1895,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1822
1895
  parsed = rawDef;
1823
1896
  }
1824
1897
  // Accept a full Taskflow, a bare phases array, or {phases:[...]}; wrap the latter two.
1825
- const wrapped = normalizeInlineDef(parsed, phase.id);
1898
+ let wrapped = normalizeInlineDef(parsed, phase.id);
1826
1899
  if (!wrapped) {
1827
1900
  return defFailOpen("inline def is not a Taskflow, phases array, or {phases:[...]}");
1828
1901
  }
@@ -1835,11 +1908,20 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1835
1908
  output: "",
1836
1909
  json: parseJson ? safeParse("") : undefined,
1837
1910
  usage: emptyUsage(),
1838
- inputHash: hashInput(phase.id, "flow-def-empty"),
1911
+ inputHash: hashInput(phase.id, type === "expand" ? "expand-def-empty" : "flow-def-empty"),
1839
1912
  reads: readRefsToReads(readRefs, state),
1840
1913
  endedAt: Date.now(),
1841
1914
  };
1842
1915
  }
1916
+ // expand: cap fragment size + prefix ids for graft (helpers in phases/expand.ts).
1917
+ if (type === "expand") {
1918
+ if (wrapped.phases.length > maxNodes) {
1919
+ return defFailOpen(`expand fragment has ${wrapped.phases.length} phases (maxNodes=${maxNodes})`);
1920
+ }
1921
+ if (expandMode === "graft") {
1922
+ wrapped = prefixGraftFragment(wrapped, phase.id);
1923
+ }
1924
+ }
1843
1925
  // Validate with `dynamic` hardening (breadth caps + cwd containment) since
1844
1926
  // this content is LLM-authored / untrusted. cwd anchors containment checks.
1845
1927
  const dynCwd = effCwd;
@@ -1882,20 +1964,30 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1882
1964
  provided[k] = typeof v === "string" ? interpolate(v, ctx).text : v;
1883
1965
  }
1884
1966
  const subArgs = resolveArgs(subDef, provided);
1885
- // For inline defs the cache identity must include the resolved def content so
1886
- // that a different generated plan yields a different key (and an identical plan
1887
- // hits cache). For saved flows the name is the identity (historical behavior).
1888
- const flowIdentity = hasDef ? `def:${JSON.stringify(subDef)}` : `flow:${name}`;
1967
+ // Every sub-flow cache identity includes the resolved definition. A saved
1968
+ // flow's name alone is insufficient: its contents can change without the
1969
+ // parent definition moving.
1970
+ const flowIdentity = `${hasDef ? "def" : "flow"}:${name}:${JSON.stringify(subDef)}`;
1889
1971
  const ck = cacheKeys(cc, [phase.id, flowIdentity, preRead, JSON.stringify(subArgs)]);
1890
1972
  const inputHash = ck.key;
1891
1973
  const cached = cachedPhase(cc, ck);
1892
- if (cached)
1974
+ if (cached) {
1975
+ if (type === "expand" && expandMode === "graft" && cached.promotedPhases) {
1976
+ const promo = promoteGraftPhases(state, cached.promotedPhases);
1977
+ if (promo.promotedIds.length > 0) {
1978
+ cached.promotedPhases = Object.fromEntries(promo.promotedIds.map((id) => [id, { ...cached.promotedPhases[id] }]));
1979
+ }
1980
+ else {
1981
+ delete cached.promotedPhases;
1982
+ }
1983
+ }
1893
1984
  return cached;
1985
+ }
1894
1986
  const live = state.phases[phase.id];
1895
- // Sub-flows enforce their own budget; if they declare none, inherit the
1896
- // parent cap as a soft per-flow ceiling (best-effort — spend does not cross
1897
- // flow boundaries, so the parent's already-spent total is not subtracted).
1898
- const subDefEffective = subDef.budget || !state.def.budget ? subDef : { ...subDef, budget: state.def.budget };
1987
+ // A nested flow receives only the parent's remaining allowance, then its
1988
+ // own cap (if any) may tighten each dimension independently.
1989
+ const parentSpent = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage()));
1990
+ const subDefEffective = clampSubFlowBudget(subDef, state.def.budget, parentSpent);
1899
1991
  const subState = {
1900
1992
  runId: newRunId(subDef.name),
1901
1993
  flowName: subDef.name,
@@ -1913,6 +2005,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1913
2005
  const subRunTask = (cwd, agents, agentName, subTask, opts, globalThinking) => baseRunTask(cwd, agents, agentName, preRead + subTask, opts, globalThinking);
1914
2006
  const subResult = await executeTaskflow(subState, {
1915
2007
  ...deps,
2008
+ // A trace file is scoped to one runId. The parent flow phase carries an
2009
+ // unreplayable marker; nested events must not be mixed into that file.
2010
+ trace: undefined,
1916
2011
  // Override deps.cwd with the flow phase's own cwd so that sub-flow
1917
2012
  // phases without an explicit cwd derive their subagents from the
1918
2013
  // flow's cwd (not the caller's cwd).
@@ -1945,12 +2040,30 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1945
2040
  },
1946
2041
  });
1947
2042
  const sp = Object.values(subState.phases);
2043
+ // expand graft promote — pure helper (see runtime/phases/expand.ts)
2044
+ const warnings = [];
2045
+ let graftPromotedIds = [];
2046
+ if (type === "expand" && expandMode === "graft" && subResult.ok) {
2047
+ const promo = promoteGraftPhases(state, subState.phases);
2048
+ warnings.push(...promo.warnings);
2049
+ graftPromotedIds = promo.promotedIds;
2050
+ }
2051
+ // Graft accounting is ownership-based. Successfully promoted children carry
2052
+ // their own usage in parent state; children skipped on id collision remain
2053
+ // owned by the expand phase and their residual usage must stay here. This
2054
+ // yields: all promoted => 0, all collision => all child usage, mixed => only
2055
+ // collision residual (no loss and no double count).
2056
+ const phaseUsage = type === "expand" && expandMode === "graft" && subResult.ok
2057
+ ? aggregateUsage(Object.entries(subState.phases)
2058
+ .filter(([id]) => !graftPromotedIds.includes(id))
2059
+ .map(([, ps]) => ps.usage ?? emptyUsage()))
2060
+ : subResult.totalUsage;
1948
2061
  const flowPs = {
1949
2062
  id: phase.id,
1950
2063
  status: subResult.ok ? "done" : "failed",
1951
2064
  output: subResult.finalOutput,
1952
2065
  json: parseJson ? safeParse(subResult.finalOutput) : undefined,
1953
- usage: subResult.totalUsage,
2066
+ usage: phaseUsage,
1954
2067
  // B-F015: include failed in `done` so the renderer's
1955
2068
  // `done - failed` formula gives the success count (matches the
1956
2069
  // map/parallel runner's overlapping-counter convention).
@@ -1964,6 +2077,10 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1964
2077
  inputHash,
1965
2078
  reads: readRefsToReads(readRefs, state),
1966
2079
  endedAt: Date.now(),
2080
+ ...(warnings.length ? { warnings } : {}),
2081
+ ...(type === "expand" && expandMode === "graft" && subResult.ok && graftPromotedIds.length > 0
2082
+ ? { promotedPhases: Object.fromEntries(graftPromotedIds.map((id) => [id, { ...subState.phases[id] }])) }
2083
+ : {}),
1967
2084
  };
1968
2085
  recordCache(cc, flowPs);
1969
2086
  return flowPs;
@@ -2208,6 +2325,12 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2208
2325
  }
2209
2326
  // Only one competitor survived → no contest; it wins by default (skip judge).
2210
2327
  if (ok.length === 1) {
2328
+ const w = ranIdx(ok[0]);
2329
+ traceDecision(deps, state, phase.id, {
2330
+ type: "tournament-winner",
2331
+ value: w,
2332
+ reason: "only surviving variant",
2333
+ });
2211
2334
  return {
2212
2335
  id: phase.id,
2213
2336
  status: "done",
@@ -2216,7 +2339,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2216
2339
  usage: variantUsage,
2217
2340
  model: ok[0].model,
2218
2341
  budgetTruncated: budgetSkipCount > 0 || undefined,
2219
- tournament: { variants: competitors.length, winner: ranIdx(ok[0]), mode, reason: "only surviving variant" },
2342
+ tournament: { variants: competitors.length, winner: w, mode, reason: "only surviving variant" },
2220
2343
  inputHash,
2221
2344
  reads: readRefsToReads(readRefs, state),
2222
2345
  endedAt: Date.now(),
@@ -2278,6 +2401,11 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2278
2401
  const chosen = winnerIneligible ? ok[0] : winnerResult;
2279
2402
  const winnerIdx = ranIdx(chosen);
2280
2403
  const output = mode === "aggregate" ? judgeRes.output : chosen.output;
2404
+ traceDecision(deps, state, phase.id, {
2405
+ type: "tournament-winner",
2406
+ value: winnerIdx,
2407
+ reason,
2408
+ });
2281
2409
  return {
2282
2410
  id: phase.id,
2283
2411
  status: "done",
@@ -2334,9 +2462,24 @@ function lastCompletedOutput(state, phase) {
2334
2462
  }
2335
2463
  return undefined;
2336
2464
  }
2465
+ /** Stable cache identity for the fully resolved agent pool. File paths are
2466
+ * excluded: content/config, not installation location, determines output. */
2467
+ export function agentDefinitionsIdentity(agents) {
2468
+ return JSON.stringify(agents
2469
+ .map((a) => ({
2470
+ name: a.name,
2471
+ description: a.description,
2472
+ systemPrompt: a.systemPrompt,
2473
+ model: a.model ?? "",
2474
+ thinking: a.thinking ?? "",
2475
+ tools: [...(a.tools ?? [])].sort(),
2476
+ source: a.source,
2477
+ }))
2478
+ .sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)));
2479
+ }
2337
2480
  /** Fold the phase fingerprint into the base hash parts to form the cache keys.
2338
2481
  *
2339
- * Four keys are produced for backward compatibility (see
2482
+ * Four keys are derived for tooling/backward compatibility (see
2340
2483
  * docs/internal/cache-migration.md):
2341
2484
  * - `key` : `v3:phasefp:<subfp>` — the current write key (per-phase
2342
2485
  * structural sub-fingerprint; falls back to the whole-flow hash when
@@ -2344,9 +2487,9 @@ function lastCompletedOutput(state, phase) {
2344
2487
  * - `v2Key` : `v2:flowdef:<flowDefHash>` — pre-M6 whole-flow key.
2345
2488
  * - `bareKey` : bare `flowdef:<flowDefHash>` (unversioned) — pre-H1 entries.
2346
2489
  * - `legacyKey`: the flowdef line omitted — pre-flowDefHash entries.
2347
- * `cachedPhase` consults all four READ-ONLY on a miss; `recordCache` writes
2348
- * only `key`. This means an upgrade never produces a miss-storm: existing
2349
- * entries (whichever shape) still hit, and new writes converge on `key`. */
2490
+ * `cachedPhase` consults the first three safe tiers READ-ONLY on a miss;
2491
+ * `legacyKey` is never read because it has no structural identity.
2492
+ * `recordCache` writes only `key`. */
2350
2493
  export function cacheKeys(cc, baseParts) {
2351
2494
  // Fold the full cache identity into the hash: flow name (prevents collisions
2352
2495
  // across different flows that share a phase.id + task + model), the per-phase
@@ -2357,6 +2500,9 @@ export function cacheKeys(cc, baseParts) {
2357
2500
  `think:${cc.thinking ?? ""}`,
2358
2501
  `tools:${JSON.stringify(cc.tools ?? [])}`,
2359
2502
  `ctx:${cc.preRead ?? ""}`,
2503
+ `agent-scope:${cc.agentScope ?? "user"}`,
2504
+ `context-sharing:${cc.contextSharing === true ? "1" : "0"}`,
2505
+ `agents:${cc.agentDefinitions ?? ""}`,
2360
2506
  ];
2361
2507
  const fold = (parts) => cc.fingerprint ? hashInput(...parts, cc.fingerprint) : hashInput(...parts);
2362
2508
  // Per-phase sub-fingerprint; falls back to the whole-flow hash when absent
@@ -2375,13 +2521,13 @@ export function cacheKeys(cc, baseParts) {
2375
2521
  * - "off": never reuse (even within-run).
2376
2522
  * - "run-only": within-run resume only (historical behavior).
2377
2523
  * - "cross-run": within-run first, then the persistent cross-run store.
2378
- * On a cross-run hit, usage is zeroed and `cacheHit` records the source.
2379
- *
2380
- * The cross-run read is FOUR-TIER and READ-ONLY for fallback keys: it tries
2524
+ * On a cross-run hit, usage is zeroed and `cacheHit` records the source.
2525
+ *
2526
+ * The cross-run read is three-tier and READ-ONLY for fallback keys: it tries
2381
2527
  * `keys.key` (current `v3:phasefp:` shape) first, then `keys.v2Key` (pre-M6
2382
- * `v2:flowdef:`), then `keys.bareKey` (pre-H1 bare `flowdef:`), then
2383
- * `keys.legacyKey` (pre-flowDefHash, no flowdef line).
2384
- * A hit on ANY tier is restored as a cache hit; we do NOT write-through (no
2528
+ * `v2:flowdef:`), then `keys.bareKey` (pre-H1 bare `flowdef:`). The older
2529
+ * no-flowdef key is deliberately unsafe and ignored.
2530
+ * A hit on any safe tier is restored as a cache hit; we do NOT write-through (no
2385
2531
  * re-store under the new key) so the cache size stays stable and the legacy
2386
2532
  * entry ages out naturally. See docs/internal/cache-migration.md.
2387
2533
  */
@@ -2397,9 +2543,13 @@ function cachedPhase(cc, keys) {
2397
2543
  if (cc.prior && cc.prior.status === "done" && cc.prior.inputHash === keys.key) {
2398
2544
  return { ...cc.prior, status: "done", cacheHit: "run-only" };
2399
2545
  }
2400
- // 2. cross-run memoization (opt-in) — four-tier read-only fallback.
2546
+ // 2. cross-run memoization (opt-in) — three safe read-only tiers.
2401
2547
  if (cc.scope === "cross-run") {
2402
- for (const k of [keys.key, keys.v2Key, keys.bareKey, keys.legacyKey]) {
2548
+ // The pre-flow-definition legacy key is intentionally NOT read: it omits
2549
+ // all structural identity and can return stale output after any semantic
2550
+ // flow change. v2/bare remain safe because they include today's definition
2551
+ // hash; old entries whose historical hash omitted new fields simply miss.
2552
+ for (const k of [keys.key, keys.v2Key, keys.bareKey]) {
2403
2553
  const e = cc.store.get(k, cc.ttlMs);
2404
2554
  if (!e)
2405
2555
  continue;
@@ -2484,12 +2634,8 @@ function defaultAgent(deps) {
2484
2634
  * JSON verdict object whose value is non-blocking (e.g. `{"verdict":"No issues
2485
2635
  * found"}`) is an *explicit* pass, not ambiguity, and still resolves to pass.
2486
2636
  */
2487
- // `parseGateVerdict` is implemented in `deterministic.ts` (a pure seam a
2488
- // future replay can import without dragging in the process-spawning runner).
2489
- // It is imported at the top of this file and re-exported from the barrel.
2490
- function asReason(v) {
2491
- return typeof v === "string" && v.trim() ? v.trim() : undefined;
2492
- }
2637
+ // `parseGateVerdict` / `parseTournamentWinner` live in `deterministic.ts`
2638
+ // (pure seam for replay + event kernel). Re-exported via the barrel.
2493
2639
  /**
2494
2640
  * If a gate phase relies on free-text verdict parsing (no `output:"json"` +
2495
2641
  * `expect` contract) and its task does not already demand a `VERDICT:` marker,
@@ -2513,30 +2659,7 @@ function appendGateFormatSuffix(task, phase) {
2513
2659
  `End your response with exactly one line in this exact form (no Markdown, no bold, no extra words):\n` +
2514
2660
  `VERDICT: PASS\nor\nVERDICT: BLOCK`);
2515
2661
  }
2516
- /**
2517
- * Parse a judge's pick of the winning variant. Accepts JSON ({"winner":n} or
2518
- * {"best":n}) or a `WINNER: n` line (last match wins). Clamps to [1, count].
2519
- * Fail-open: an unreadable verdict defaults to variant 1 so the work is never
2520
- * lost. Returns the 1-based index plus an optional reason.
2521
- */
2522
- export function parseTournamentWinner(output, count) {
2523
- const clamp = (n) => Math.min(Math.max(1, Math.floor(n)), Math.max(1, count));
2524
- const json = safeParse(output);
2525
- if (json && typeof json === "object") {
2526
- const o = json;
2527
- const raw = o.winner ?? o.best ?? o.choice;
2528
- const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
2529
- if (Number.isFinite(n))
2530
- return { winner: clamp(n), reason: asReason(o.reason) };
2531
- }
2532
- const matches = [...output.matchAll(WINNER_TOKEN_RE)];
2533
- if (matches.length) {
2534
- const n = Number(matches[matches.length - 1][1]);
2535
- if (Number.isFinite(n))
2536
- return { winner: clamp(n) };
2537
- }
2538
- return { winner: 1, reason: "no parseable winner; defaulted to variant 1" };
2539
- }
2662
+ /* parseTournamentWinner lives in deterministic.ts (shared with event kernel). */
2540
2663
  /**
2541
2664
  * Best-effort invocation of the user-provided `persist` + `onProgress` callbacks.
2542
2665
  *
@@ -2729,6 +2852,19 @@ opts = { dryRun: true }) {
2729
2852
  const changedUpstreams = depsFor(id).filter((u) => outputMoved.has(u));
2730
2853
  try {
2731
2854
  const ps = await executePhase(phase, newState, deps, newState.phases[id], noop, 0, execOpts);
2855
+ if (ps.status === "failed") {
2856
+ // Recompute is speculative. Preserve the last known-good row for the
2857
+ // failed phase and every downstream phase, then stop. Mark the report
2858
+ // aborted so callers cannot persist a partially refreshed graph.
2859
+ rerun.push(id);
2860
+ decisions.push({
2861
+ phaseId: id,
2862
+ outcome: "failed",
2863
+ reason: ps.error ?? "re-execution returned a failed phase",
2864
+ });
2865
+ aborted = true;
2866
+ break;
2867
+ }
2732
2868
  newState.phases[id] = ps;
2733
2869
  // A phase counts as "rerun" if it was a forced seed OR its result moved;
2734
2870
  // otherwise it hit its cache (inputHash unchanged) → early cutoff.
@@ -2756,11 +2892,16 @@ opts = { dryRun: true }) {
2756
2892
  });
2757
2893
  }
2758
2894
  }
2759
- catch {
2895
+ catch (error) {
2760
2896
  // A failing recompute phase is recorded as rerun (it was attempted).
2761
2897
  rerun.push(id);
2762
- outputMoved.add(id);
2763
- decisions.push({ phaseId: id, outcome: "failed", reason: "re-execution attempted but the phase failed" });
2898
+ decisions.push({
2899
+ phaseId: id,
2900
+ outcome: "failed",
2901
+ reason: `re-execution threw: ${error instanceof Error ? error.message : String(error)}`,
2902
+ });
2903
+ aborted = true;
2904
+ break;
2764
2905
  }
2765
2906
  }
2766
2907
  // Frontier-external phases were never touched — record them as reused.
@@ -2784,7 +2925,49 @@ opts = { dryRun: true }) {
2784
2925
  }
2785
2926
  export async function executeTaskflow(state, deps) {
2786
2927
  const def = state.def;
2928
+ const runnerUsageAccounting = deps.runTask
2929
+ ?.usageAccounting;
2930
+ if (!deps.usageAccounting && runnerUsageAccounting) {
2931
+ // Preserve a runner-advertised capability across the wrapper functions used
2932
+ // by nested flow/context execution; those wrappers would otherwise erase a
2933
+ // property attached to the original runTask function.
2934
+ deps = { ...deps, usageAccounting: runnerUsageAccounting };
2935
+ }
2787
2936
  try {
2937
+ if (deps.usageAccounting === "unavailable" && def.budget) {
2938
+ throw new Error(`Usage accounting is unavailable for this host; refusing budgeted flow '${def.name}' because its token/USD ceiling cannot be enforced`);
2939
+ }
2940
+ if (deps.usageAccounting === "tokens-only" && def.budget?.maxUSD !== undefined) {
2941
+ throw new Error("This host reports tokens but not cost, so budget.maxUSD cannot be enforced. " +
2942
+ "Use budget.maxTokens or a host with cost accounting.");
2943
+ }
2944
+ // S2 strangler (default OFF): all phase kinds may use the event kernel when enabled.
2945
+ const { eventKernelEnabled, canUseEventKernel, runEventKernel } = await import("./exec/driver.js");
2946
+ // Existing phase state requires the imperative cache/inputHash machinery to
2947
+ // validate definition and idempotency before reuse. The event kernel does
2948
+ // not yet persist compatible input hashes, so it must never blindly trust a
2949
+ // prior `done` row.
2950
+ const hasPriorState = Object.keys(state.phases).length > 0;
2951
+ if (eventKernelEnabled(deps) && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) {
2952
+ if (!deps.runTask) {
2953
+ throw new Error("event kernel requires RuntimeDeps.runTask");
2954
+ }
2955
+ return await runEventKernel(state, {
2956
+ cwd: deps.cwd,
2957
+ agents: deps.agents,
2958
+ runTask: deps.runTask,
2959
+ signal: deps.signal,
2960
+ globalThinking: deps.globalThinking,
2961
+ usageAccounting: deps.usageAccounting,
2962
+ trace: deps.trace,
2963
+ persist: deps.persist,
2964
+ onProgress: deps.onProgress,
2965
+ eventKernel: deps.eventKernel,
2966
+ requestApproval: deps.requestApproval,
2967
+ loadFlow: deps.loadFlow,
2968
+ _stack: deps._stack,
2969
+ });
2970
+ }
2788
2971
  return await runTaskflowLayers(state, deps);
2789
2972
  }
2790
2973
  catch (e) {
@@ -2806,6 +2989,31 @@ export async function executeTaskflow(state, deps) {
2806
2989
  }
2807
2990
  async function runTaskflowLayers(state, deps) {
2808
2991
  const def = state.def;
2992
+ // Ownership migration must happen before ANY phase in the new definition is
2993
+ // scheduled. Definition evolution may remove/rename ordinary phases as well
2994
+ // as graft children; neither their terminal failure nor their usage may leak
2995
+ // into the new run. Dynamic promoted state is intentionally cleared here too
2996
+ // and is restored only by its owning expand phase (from its current result or
2997
+ // cache). An id newly promoted to a real authored phase is preserved because
2998
+ // it is present in `declaredPhaseIds` and the scheduler will validate/rerun it.
2999
+ // Cleaning inside the expand phase would be too late: an unrelated authored
3000
+ // phase may already have been scheduled and stale usage already counted.
3001
+ const declaredPhaseIds = new Set(def.phases.map((p) => p.id));
3002
+ // A pre-seeded state may intentionally supply an external dependency (for
3003
+ // example an embedding host injects `src` and the definition starts at a map
3004
+ // that depends on it). Those ids are part of the new definition's dependency
3005
+ // contract even though they have no executable Phase row, so preserve them.
3006
+ const externalDependencyIds = new Set(def.phases.flatMap((phase) => dependenciesOf(phase)).filter((id) => !declaredPhaseIds.has(id)));
3007
+ for (const oldId of Object.keys(state.phases)) {
3008
+ if (!declaredPhaseIds.has(oldId) && !externalDependencyIds.has(oldId))
3009
+ delete state.phases[oldId];
3010
+ }
3011
+ for (const previous of Object.values(state.phases)) {
3012
+ for (const oldId of Object.keys(previous.promotedPhases ?? {})) {
3013
+ if (!declaredPhaseIds.has(oldId))
3014
+ delete state.phases[oldId];
3015
+ }
3016
+ }
2809
3017
  const layers = topoLayers(def.phases);
2810
3018
  // Content-fingerprint the desugared definition ONCE per run and fold it into
2811
3019
  // every phase's cache key (overstory hash algorithm; see ./flowir/hash.ts).
@@ -2819,16 +3027,20 @@ async function runTaskflowLayers(state, deps) {
2819
3027
  // plane) is persisted for audit/provenance. The declared plane is also
2820
3028
  // derived fresh from `def` in recompute (so old runs get union semantics
2821
3029
  // too); the persisted copy is for display.
2822
- if (state.flowDefHash === undefined) {
2823
- try {
2824
- const ir = await compileTaskflowToIR(def);
2825
- state.flowDefHash = ir.hash ?? "failed";
2826
- state.declaredDeps = ir.meta.declaredDeps;
2827
- if (ir.errors.length) {
2828
- console.warn(`[taskflow] IR compile errors for '${def.name}': ${ir.errors.map((e) => e.message).join("; ")}`);
2829
- }
3030
+ try {
3031
+ const ir = await compileTaskflowToIR(def);
3032
+ const nextHash = ir.hash ?? "failed";
3033
+ if (state.flowDefHash !== nextHash) {
3034
+ state.flowDefHash = nextHash;
3035
+ state.phaseFingerprints = undefined;
2830
3036
  }
2831
- catch (e) {
3037
+ state.declaredDeps = ir.meta.declaredDeps;
3038
+ if (ir.errors.length) {
3039
+ console.warn(`[taskflow] IR compile errors for '${def.name}': ${ir.errors.map((e) => e.message).join("; ")}`);
3040
+ }
3041
+ }
3042
+ catch (e) {
3043
+ if (state.flowDefHash === undefined) {
2832
3044
  // Fail-safe: warn loudly rather than silently degrading to the legacy
2833
3045
  // flowName-only key, which would reopen the cross-flow collision hole.
2834
3046
  console.warn(`[taskflow] flowDefHash failed for '${def.name}': ${e instanceof Error ? e.message : String(e)}. ` +
@@ -2875,7 +3087,11 @@ async function runTaskflowLayers(state, deps) {
2875
3087
  break;
2876
3088
  }
2877
3089
  // Phases within a layer have no inter-dependencies → run concurrently.
2878
- const layerConcurrency = Math.max(1, def.concurrency ?? 8);
3090
+ // A usage report arrives only after a subagent call. With a declared hard
3091
+ // budget, concurrent layer admission would let every sibling observe the
3092
+ // same remaining allowance. Serialize admission so no additional call can
3093
+ // begin after a previous call has exhausted the cap.
3094
+ const layerConcurrency = def.budget ? 1 : Math.max(1, def.concurrency ?? 8);
2879
3095
  await mapWithConcurrencyLimit(layer, layerConcurrency, async (phase) => {
2880
3096
  // Snapshot prior state BEFORE marking running, so resume cache checks work.
2881
3097
  const prior = state.phases[phase.id];
@@ -2900,8 +3116,27 @@ async function runTaskflowLayers(state, deps) {
2900
3116
  else if (!depsSatisfied)
2901
3117
  skipReason = join === "any" ? "All dependencies failed or were skipped" : "Upstream dependency not satisfied";
2902
3118
  if (skipReason) {
2903
- if (skipReason.startsWith("Budget exceeded"))
3119
+ if (skipReason.startsWith("Budget exceeded")) {
2904
3120
  budgetBlocked = true;
3121
+ // S1: budget-hit decision so fold/replay can re-tally under new caps.
3122
+ traceDecision(deps, state, phase.id, {
3123
+ type: "budget-hit",
3124
+ value: budgetReason || "budget",
3125
+ reason: skipReason,
3126
+ });
3127
+ // executePhase already flushed its phase-end batch. Flush this
3128
+ // post-completion decision too so FileTraceSink cannot strand it.
3129
+ traceFlush(deps, phase.id);
3130
+ }
3131
+ // Synthetic phase-start/end so fold sees a complete phase lifecycle.
3132
+ traceEmit(deps, {
3133
+ ts: Date.now(),
3134
+ runId: state.runId,
3135
+ phaseId: phase.id,
3136
+ kind: "phase-start",
3137
+ dependencies: dependenciesOf(phase),
3138
+ optional: phase.optional === true,
3139
+ });
2905
3140
  state.phases[phase.id] = {
2906
3141
  id: phase.id,
2907
3142
  status: "skipped",
@@ -2909,6 +3144,15 @@ async function runTaskflowLayers(state, deps) {
2909
3144
  endedAt: Date.now(),
2910
3145
  usage: emptyUsage(),
2911
3146
  };
3147
+ traceEmit(deps, {
3148
+ ts: Date.now(),
3149
+ runId: state.runId,
3150
+ phaseId: phase.id,
3151
+ kind: "phase-end",
3152
+ status: "skipped",
3153
+ error: skipReason,
3154
+ });
3155
+ traceFlush(deps, phase.id);
2912
3156
  safeEmit(deps, state);
2913
3157
  return;
2914
3158
  }
@@ -2952,11 +3196,29 @@ async function runTaskflowLayers(state, deps) {
2952
3196
  // acceptable: budgetBlocked prevents cascading into subsequent layers.
2953
3197
  const ob = overBudget(state);
2954
3198
  if (ob.over) {
3199
+ if (!budgetBlocked) {
3200
+ // First time we detect the ceiling after a phase completes.
3201
+ traceDecision(deps, state, phase.id, {
3202
+ type: "budget-hit",
3203
+ value: "budget",
3204
+ reason: ob.reason,
3205
+ });
3206
+ traceFlush(deps, phase.id);
3207
+ }
2955
3208
  budgetBlocked = true;
2956
3209
  budgetReason = ob.reason;
2957
3210
  }
2958
3211
  safeEmit(deps, state);
2959
3212
  });
3213
+ // The signal can flip while a layer is in flight. Checking only at the
3214
+ // beginning of the next layer lets an abort during the final layer fall
3215
+ // through to `completed` (notably when a non-cooperative race branch later
3216
+ // reports success). Cancellation is terminal for this invocation: preserve
3217
+ // the phase evidence gathered above, but classify the run as resumable.
3218
+ if (deps.signal?.aborted) {
3219
+ aborted = true;
3220
+ break;
3221
+ }
2960
3222
  }
2961
3223
  const fp = finalPhase(def.phases);
2962
3224
  let finalState = state.phases[fp.id];
@@ -2968,7 +3230,7 @@ async function runTaskflowLayers(state, deps) {
2968
3230
  finalState = doneInOrder[doneInOrder.length - 1];
2969
3231
  }
2970
3232
  // A failed non-optional phase fails the run; optional failures are tolerated.
2971
- const anyFailed = Object.entries(state.phases).some(([id, p]) => p.status === "failed" && !byId.get(id)?.optional);
3233
+ const anyFailed = Object.entries(state.phases).some(([id, p]) => p.status === "failed" && !byId.get(id)?.optional && !p.optional);
2972
3234
  state.status = aborted
2973
3235
  ? "paused"
2974
3236
  : gateBlocked || budgetBlocked