taskflow-core 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +3 -3
  2. package/dist/build-info.d.ts +48 -0
  3. package/dist/build-info.d.ts.map +1 -0
  4. package/dist/build-info.js +112 -0
  5. package/dist/build-info.js.map +1 -0
  6. package/dist/build-info.json +4 -0
  7. package/dist/exec/driver.d.ts +5 -0
  8. package/dist/exec/driver.d.ts.map +1 -1
  9. package/dist/exec/driver.js +25 -16
  10. package/dist/exec/driver.js.map +1 -1
  11. package/dist/exec/kernel-policy.d.ts.map +1 -1
  12. package/dist/exec/kernel-policy.js +10 -0
  13. package/dist/exec/kernel-policy.js.map +1 -1
  14. package/dist/exec/step-kinds.d.ts +14 -1
  15. package/dist/exec/step-kinds.d.ts.map +1 -1
  16. package/dist/exec/step-kinds.js +98 -3
  17. package/dist/exec/step-kinds.js.map +1 -1
  18. package/dist/exec/step.d.ts +21 -0
  19. package/dist/exec/step.d.ts.map +1 -1
  20. package/dist/exec/step.js +22 -5
  21. package/dist/exec/step.js.map +1 -1
  22. package/dist/final-output.d.ts +53 -0
  23. package/dist/final-output.d.ts.map +1 -0
  24. package/dist/final-output.js +65 -0
  25. package/dist/final-output.js.map +1 -0
  26. package/dist/flowir/canonical-hash.d.ts.map +1 -1
  27. package/dist/flowir/canonical-hash.js +2 -0
  28. package/dist/flowir/canonical-hash.js.map +1 -1
  29. package/dist/flowir/compile.d.ts.map +1 -1
  30. package/dist/flowir/compile.js +4 -0
  31. package/dist/flowir/compile.js.map +1 -1
  32. package/dist/flowir/meta.d.ts +3 -0
  33. package/dist/flowir/meta.d.ts.map +1 -1
  34. package/dist/flowir/schema.d.ts +3 -0
  35. package/dist/flowir/schema.d.ts.map +1 -1
  36. package/dist/flowir/schema.js.map +1 -1
  37. package/dist/flowir/translate.d.ts.map +1 -1
  38. package/dist/flowir/translate.js +4 -0
  39. package/dist/flowir/translate.js.map +1 -1
  40. package/dist/index.d.ts +3 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +3 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/resume.d.ts +76 -0
  45. package/dist/resume.d.ts.map +1 -0
  46. package/dist/resume.js +190 -0
  47. package/dist/resume.js.map +1 -0
  48. package/dist/runtime/phases/parallel.d.ts +3 -0
  49. package/dist/runtime/phases/parallel.d.ts.map +1 -1
  50. package/dist/runtime/phases/parallel.js.map +1 -1
  51. package/dist/runtime/phases/reduce.d.ts +47 -0
  52. package/dist/runtime/phases/reduce.d.ts.map +1 -0
  53. package/dist/runtime/phases/reduce.js +195 -0
  54. package/dist/runtime/phases/reduce.js.map +1 -0
  55. package/dist/runtime.d.ts +28 -1
  56. package/dist/runtime.d.ts.map +1 -1
  57. package/dist/runtime.js +392 -59
  58. package/dist/runtime.js.map +1 -1
  59. package/dist/schema.d.ts +67 -0
  60. package/dist/schema.d.ts.map +1 -1
  61. package/dist/schema.js +177 -1
  62. package/dist/schema.js.map +1 -1
  63. package/dist/store.d.ts +49 -0
  64. package/dist/store.d.ts.map +1 -1
  65. package/dist/store.js +5 -1
  66. package/dist/store.js.map +1 -1
  67. package/package.json +2 -2
package/dist/runtime.js CHANGED
@@ -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, validateInvocationArgs, validateTaskflow } from "./schema.js";
31
+ import { asArray, dependenciesOf, LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, MAX_DYNAMIC_MAP_ITEMS, MAX_DYNAMIC_NESTING, MAX_DYNAMIC_PHASES, parseTtlMs, resolveArgs, topoLayers, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, validateInvocationArgs, validateTaskflow } from "./schema.js";
32
32
  import { verifyTaskflow } from "./verify.js";
33
33
  import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, scoreResultJSON, scorerShapeErrors } from "./scorers.js";
34
34
  import { parseGateVerdict, overBudget as overBudgetCheck, parseTournamentWinner } from "./deterministic.js";
@@ -40,6 +40,7 @@ export { parseGateVerdict };
40
40
  import { runCodeCompilesScorer } from "./scorer-runtime.js";
41
41
  import { buildReflexionSummary, isContractViolation, REFLEXION_SENTINEL } from "./reflexion.js";
42
42
  import { hashInput, newRunId, runsDir } from "./store.js";
43
+ import { resolveFinalOutput } from "./final-output.js";
43
44
  import { CacheStore, resolveFingerprint } from "./cache.js";
44
45
  import { compileTaskflowToIR, phaseFingerprint } from "./flowir/index.js";
45
46
  import { computeStaleFrontier, declaredReadMapOfDef, readMapOf } from "./stale.js";
@@ -47,6 +48,11 @@ import { ctxDirFor, drainPendingSpawns, initCtxDir, registerNode, setNodeStatus
47
48
  import { allocateWorkspace, isWorkspaceKeyword } from "./workspace.js";
48
49
  import { cwdArgName, directoryIdentity, isPathWithin, resolveCwdArg, } from "./cwd-bridge.js";
49
50
  import { createResolveOnlyWorkspaceSession, } from "./resources/execution.js";
51
+ // Re-export the shared final-output helper + types so callers importing from
52
+ // the runtime barrel get them from one place. The event kernel
53
+ // (`exec/driver.ts`) imports these directly from `./final-output.ts` to avoid
54
+ // a static runtime↔driver import cycle.
55
+ export { resolveFinalOutput, } from "./final-output.js";
50
56
  /** Compute the incremental-reuse summary from a run's terminal phase states.
51
57
  * Pure, total, never throws. A phase is "reused" iff it carries a `cacheHit`
52
58
  * marker (set by `cachedPhase` for both within-run resume and cross-run hits). */
@@ -556,6 +562,13 @@ function resolveEffCwd(deps, phase) {
556
562
  // is not necessarily the invocation root, so anchor legacy literals here.
557
563
  return path.resolve(deps.cwd, phase.cwd);
558
564
  }
565
+ /** Resolve a literal per-branch cwd once for BOTH cache identity and execution.
566
+ * Existing directories are realpath-canonicalized so aliases/symlinks cannot
567
+ * collide with a different invocation root under the same raw cwd string. */
568
+ function resolveBranchCwd(deps, cwd) {
569
+ const resolved = path.resolve(deps.cwd, cwd);
570
+ return directoryIdentity(resolved)?.canonicalPath ?? resolved;
571
+ }
559
572
  function flowTreeUsesCwdBridge(def, loadFlow, seenUses = new Set()) {
560
573
  if (def.phases.some((phase) => cwdArgName(phase.cwd) !== undefined))
561
574
  return true;
@@ -700,14 +713,11 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
700
713
  return { output: `(spawned subflow failed: ${error})`, usage: emptyUsage(), failed: true, error };
701
714
  }
702
715
  }
703
- async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps, state, run, ledger = { usage: emptyUsage() }) {
716
+ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps, state, runChild, ledger = { usage: emptyUsage() }) {
704
717
  const capped = assignments.slice(0, MAX_DYNAMIC_MAP_ITEMS);
705
718
  const lines = [];
706
719
  const usages = [];
707
720
  const errors = [];
708
- // Effective cwd for flat spawned tasks: honour a workspace override and never
709
- // pass a reserved keyword through to the runner.
710
- const spawnCwd = resolveEffCwd(deps, phase);
711
721
  let idx = 0;
712
722
  for (const a of capped) {
713
723
  if (deps.signal?.aborted || spawnedOverBudget(state, ledger.usage))
@@ -730,30 +740,11 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
730
740
  }
731
741
  else {
732
742
  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();
743
+ // Flat ctx_spawn children use the SAME runOne policy as their parent:
744
+ // phase timeout, idle watchdog, retries, budget accounting, tracing,
745
+ // prompt diagnostics, cwd capability, and workspace binding.
746
+ const usageBefore = ledger.usage;
747
+ const r = await runChild(agentName, task, childNodeId, usageBefore);
757
748
  out = r.output ?? "";
758
749
  if (isFailed(r)) {
759
750
  const detail = sanitizeErrorMessage(r.errorMessage ?? r.stderr ?? "spawned child failed");
@@ -769,7 +760,7 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
769
760
  // A child may itself have queued spawns — recurse (depth-capped by the tool).
770
761
  const grand = drainPendingSpawns(ctxDir, childNodeId);
771
762
  if (grand.length > 0 && !deps.signal?.aborted && !spawnedOverBudget(state, ledger.usage)) {
772
- const rec = await runSpawnedChildren(grand, ctxDir, childNodeId, phase, deps, state, run, ledger);
763
+ const rec = await runSpawnedChildren(grand, ctxDir, childNodeId, phase, deps, state, runChild, ledger);
773
764
  if (rec.reports)
774
765
  out += rec.reports;
775
766
  usages.push(rec.usage);
@@ -810,8 +801,10 @@ async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth
810
801
  emitUnreplayableMarker(deps, state, phase);
811
802
  let result;
812
803
  let threw = false;
804
+ const promptCalls = [];
805
+ const trackedOpts = { ...opts, promptCalls };
813
806
  try {
814
- result = await executePhaseImpl(phase, state, deps, prior, emitProgress, _retryDepth, opts);
807
+ result = await executePhaseImpl(phase, state, deps, prior, emitProgress, _retryDepth, trackedOpts);
815
808
  }
816
809
  catch (e) {
817
810
  threw = true;
@@ -825,6 +818,9 @@ async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth
825
818
  }
826
819
  if (threw)
827
820
  return result; // unreachable; satisfies TS
821
+ if (promptCalls.length > 0 && !result.cacheHit) {
822
+ setPromptStats(result, promptCalls);
823
+ }
828
824
  // S1: cache-hit decision (within-run or cross-run) for fold/replay.
829
825
  if (result.cacheHit) {
830
826
  traceDecision(deps, state, phase.id, {
@@ -1053,7 +1049,13 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
1053
1049
  async function executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
1054
1050
  const type = phase.type ?? "agent";
1055
1051
  const concurrency = phase.concurrency ?? state.def.concurrency ?? 8;
1056
- const previousOutput = lastCompletedOutput(state, phase);
1052
+ // BREAKING (dogfood issue 1): a reduce phase's {previous.output} aggregates
1053
+ // ALL completed `from[]` outputs in from-array order (one → raw, many →
1054
+ // `### <id>\n\n<output>` joined by `\n\n---\n\n`). Other phase types keep the
1055
+ // historical "last completed dependency" behavior. The aggregated from-ids
1056
+ // are recorded as observed reads (below) so staleness tracks them.
1057
+ const reduceAgg = type === "reduce" ? aggregateReduceFrom(state, phase) : undefined;
1058
+ const previousOutput = reduceAgg ? reduceAgg.value : lastCompletedOutput(state, phase);
1057
1059
  const run = deps.runTask ?? noRunnerInjected;
1058
1060
  // Effective working directory for THIS phase's execution. When an isolated
1059
1061
  // workspace was allocated (worktree isolation), `_cwdOverride` is its dir and
@@ -1087,6 +1089,14 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1087
1089
  const onRead = (ref) => {
1088
1090
  readRefs.push(ref);
1089
1091
  };
1092
+ // Record the reduce-aggregated from-ids as observed reads: the phase consumed
1093
+ // these upstream outputs (folded into {previous.output}) even when the task
1094
+ // references {previous.output} once (a single placeholder read would otherwise
1095
+ // record only "previous.output", not the real upstream ids).
1096
+ if (reduceAgg) {
1097
+ for (const id of reduceAgg.ids)
1098
+ readRefs.push(`steps.${id}.output`);
1099
+ }
1090
1100
  const ctx = buildInterpolationContext(state, previousOutput, undefined, onRead);
1091
1101
  // M3 observed-readSet: when conditions are part of the phase's real
1092
1102
  // dependencies. Evaluate them inside executePhaseInner so every upstream
@@ -1158,21 +1168,60 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1158
1168
  agentScope: state.def.agentScope,
1159
1169
  contextSharing: state.def.contextSharing === true,
1160
1170
  agentDefinitions: agentDefinitionsIdentity(deps.agents),
1161
- executionCwd: phase.cwd !== undefined || deps._cacheCwdIdentity !== undefined ? effCwd : undefined,
1171
+ executionCwd: directoryIdentity(effCwd)?.canonicalPath ?? path.resolve(effCwd),
1162
1172
  };
1163
- const baseRun = (agentName, task, onLive, ctxNodeId, signal, onTerminalCommit) => {
1173
+ // Effective idle watchdog (ms): phase overrides flow; host default (300000)
1174
+ // applies when neither sets it. `0` disables the watchdog (validation already
1175
+ // required a finite wall `timeout` in that case). Threaded into RunOptions so
1176
+ // every host runner that delegates to runSubagentProcess honors it.
1177
+ const effIdleTimeoutMs = resolveIdleTimeoutMs(phase, state.def);
1178
+ const baseRun = (agentName, task, onLive, ctxNodeId, signal, callCwd, onTerminalCommit) => {
1179
+ const invocationCwd = callCwd ? path.resolve(deps.cwd, callCwd) : effCwd;
1180
+ // A per-branch cwd cannot replace a phase-level workspace/cwd-bridge
1181
+ // binding: that would bypass the binding's containment/dirty-state
1182
+ // lifecycle. Validation rejects this shape; keep a runtime fail-closed
1183
+ // guard for direct callers or legacy persisted definitions.
1184
+ if (callCwd && (deps._workspaceBinding || isWorkspaceKeyword(phase.cwd))) {
1185
+ return Promise.resolve({
1186
+ agent: agentName,
1187
+ task,
1188
+ exitCode: 1,
1189
+ output: "",
1190
+ stderr: "TF_CWD_BRANCH_BINDING_CONFLICT: per-branch cwd cannot override a phase workspace binding",
1191
+ usage: emptyUsage(),
1192
+ stopReason: "error",
1193
+ errorMessage: "TF_CWD_BRANCH_BINDING_CONFLICT: per-branch cwd cannot override a phase workspace binding",
1194
+ });
1195
+ }
1196
+ if (callCwd && deps._cwdBoundary) {
1197
+ const selected = directoryIdentity(invocationCwd);
1198
+ if (!selected || !isPathWithin(deps._cwdBoundary, selected.canonicalPath)) {
1199
+ return Promise.resolve({
1200
+ agent: agentName,
1201
+ task,
1202
+ exitCode: 1,
1203
+ output: "",
1204
+ stderr: "TF_CWD_BOUNDARY_ESCAPE: per-branch cwd must select an existing directory inside the inherited cwd boundary",
1205
+ usage: emptyUsage(),
1206
+ stopReason: "error",
1207
+ errorMessage: "TF_CWD_BOUNDARY_ESCAPE: per-branch cwd must select an existing directory inside the inherited cwd boundary",
1208
+ });
1209
+ }
1210
+ }
1211
+ opts?.promptCalls?.push(task);
1164
1212
  const runOptions = {
1165
1213
  model: phase.model,
1166
1214
  thinking: phase.thinking,
1167
1215
  tools: phase.tools,
1168
- cwd: effCwd,
1216
+ cwd: invocationCwd,
1169
1217
  signal: signal ?? deps.signal,
1170
1218
  onLive,
1171
1219
  ctxDir: ctxDir,
1172
1220
  nodeId: ctxDir ? ctxNodeId : undefined,
1221
+ idleTimeoutMs: effIdleTimeoutMs,
1173
1222
  onTerminalCommit,
1174
1223
  };
1175
- const invoke = () => run(effCwd, deps.agents, agentName, task, runOptions, deps.globalThinking);
1224
+ const invoke = () => run(invocationCwd, deps.agents, agentName, task, runOptions, deps.globalThinking);
1176
1225
  return deps._workspaceBinding
1177
1226
  ? deps._workspaceBinding.runAgent({
1178
1227
  agents: deps.agents,
@@ -1204,7 +1253,18 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1204
1253
  : undefined;
1205
1254
  const runOne = async (agentName, task, onLive, ctxNodeId, check,
1206
1255
  /** Extra abort (e.g. race branch cancelLosers) — chained with run + phase timeout. */
1207
- extraSignal) => {
1256
+ extraSignal,
1257
+ /** Per-call cwd override (e.g. a parallel branch's literal cwd). Falls back
1258
+ * to the phase effective cwd when absent. Workspace keywords are rejected
1259
+ * by validation for branches, so this is always a literal path or undefined. */
1260
+ callCwd,
1261
+ /** Usage already spent by earlier calls inside this same multi-call phase
1262
+ * (tree reduce). Included in live budget accounting, but not returned as
1263
+ * this call's own usage. */
1264
+ usageBefore,
1265
+ /** Optional hard admission guard invoked immediately before EVERY actual
1266
+ * runner attempt. Return an error message to deny that attempt. */
1267
+ beforeAttempt) => {
1208
1268
  const explicitMax = Math.max(1, 1 + Math.max(0, Math.floor(retry?.max ?? 0)));
1209
1269
  // Allow enough attempts to cover whichever policy applies on a given attempt.
1210
1270
  const maxAttempts = Math.max(explicitMax, 1 + DEFAULT_TRANSIENT_RETRIES);
@@ -1213,6 +1273,20 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1213
1273
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
1214
1274
  if (deps.signal?.aborted || extraSignal?.aborted)
1215
1275
  break;
1276
+ const admissionError = beforeAttempt?.();
1277
+ if (admissionError) {
1278
+ last = {
1279
+ agent: agentName,
1280
+ task,
1281
+ exitCode: 1,
1282
+ output: "",
1283
+ stderr: admissionError,
1284
+ usage: emptyUsage(),
1285
+ stopReason: "error",
1286
+ errorMessage: admissionError,
1287
+ };
1288
+ break;
1289
+ }
1216
1290
  // AbortController chains: run signal + optional extra (race cancel) + phase timeout.
1217
1291
  // Deterministic: a timed-out call is never retried (would double-spend).
1218
1292
  let timedOut = false;
@@ -1251,7 +1325,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1251
1325
  timer = undefined;
1252
1326
  }
1253
1327
  };
1254
- const invocation = baseRun(agentName, task, onLive, ctxNodeId, callSignal, onTerminalCommit);
1328
+ const invocation = baseRun(agentName, task, onLive, ctxNodeId, callSignal, callCwd, onTerminalCommit);
1255
1329
  if (phaseTimeoutMs && timeoutController) {
1256
1330
  const timeoutFallback = new Promise((resolve) => {
1257
1331
  timer = setTimeout(() => {
@@ -1332,7 +1406,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1332
1406
  // so the TUI / budget guard see the in-flight spend on every attempt.
1333
1407
  const liveRetry = state.phases[phase.id];
1334
1408
  if (liveRetry)
1335
- liveRetry.usage = aggregateUsage(usages);
1409
+ liveRetry.usage = aggregateUsage(usageBefore ? [usageBefore, ...usages] : usages);
1336
1410
  // Persist every attempt, not only the final aggregate. This is required
1337
1411
  // for honest replay/cost accounting when a transient or explicit retry
1338
1412
  // succeeds after earlier spend.
@@ -1432,11 +1506,33 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1432
1506
  attempts: 0,
1433
1507
  };
1434
1508
  }
1435
- if (usages.length > 1)
1509
+ if (usages.length > 0)
1436
1510
  last.usage = aggregateUsage(usages);
1437
1511
  last.attempts = usages.length;
1438
1512
  return last;
1439
1513
  };
1514
+ const runSpawnedChild = async (agentName, task, childNodeId, spawnedUsageBefore) => {
1515
+ // runOne updates the live phase usage so retry/budget admission sees current
1516
+ // spend. A spawned child is folded into the parent result later, so restore
1517
+ // the parent's pre-child usage after the call to avoid replacing/doubling it.
1518
+ const livePhase = state.phases[phase.id];
1519
+ const parentUsage = livePhase?.usage ? { ...livePhase.usage } : undefined;
1520
+ const totalUsageBefore = aggregateUsage([
1521
+ parentUsage ?? emptyUsage(),
1522
+ spawnedUsageBefore ?? emptyUsage(),
1523
+ ]);
1524
+ try {
1525
+ return await runOne(agentName, task, undefined, childNodeId, undefined, undefined, undefined, totalUsageBefore);
1526
+ }
1527
+ finally {
1528
+ if (livePhase) {
1529
+ if (parentUsage)
1530
+ livePhase.usage = parentUsage;
1531
+ else
1532
+ livePhase.usage = undefined;
1533
+ }
1534
+ }
1535
+ };
1440
1536
  const parseJson = phase.output === "json";
1441
1537
  // Output contract (`expect`): validates the parsed JSON output of a finished
1442
1538
  // subagent call. Wired into `runOne` so a violation counts as a failed attempt
@@ -1531,12 +1627,16 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1531
1627
  if (l.model)
1532
1628
  latestModel = l.model;
1533
1629
  refresh();
1534
- }, ctxDir ? nodeIdFor(String(idx)) : undefined);
1630
+ }, ctxDir ? nodeIdFor(String(idx)) : undefined, undefined, undefined, it.cwd);
1535
1631
  running--;
1536
1632
  done++;
1537
1633
  if (isFailed(r))
1538
1634
  failed++;
1539
1635
  liveUsages[idx] = r.usage;
1636
+ // Publish the just-finished sibling's spend before considering any
1637
+ // ctx_spawn intents. Budgeted fan-out allows one atomic call to cross the
1638
+ // ceiling, but must not admit descendants after that overshoot.
1639
+ refresh();
1540
1640
  // Per-item cross-run cache record (map only): persist a successful fresh
1541
1641
  // item so a later run with this item unchanged hits per-item instead of
1542
1642
  // re-running. Failed and budget-skipped items are never cached (a stale
@@ -1564,7 +1664,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1564
1664
  // post-run drain below only covers single-agent phases).
1565
1665
  const spawned = drainPendingSpawns(ctxDir, itemNid);
1566
1666
  if (spawned.length > 0 && !deps.signal?.aborted && !overBudget(state).over) {
1567
- const child = await runSpawnedChildren(spawned, ctxDir, itemNid, phase, deps, state, run);
1667
+ const child = await runSpawnedChildren(spawned, ctxDir, itemNid, phase, deps, state, runSpawnedChild, { usage: emptyUsage() });
1568
1668
  if (child.reports)
1569
1669
  r.output = `${r.output ?? ""}${child.reports}`;
1570
1670
  if (child.failed && deps._workspaceBinding) {
@@ -1587,6 +1687,96 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1587
1687
  // Single-agent phases: agent, gate, and reduce all run one subagent on an
1588
1688
  // interpolated task. gate additionally parses a verdict; reduce simply pulls
1589
1689
  // its inputs from `from` phases (already exposed via interpolation).
1690
+ //
1691
+ // Tree reduce (`reduceStrategy: "tree"`) is handled FIRST: it runs batched
1692
+ // intermediate reducer calls over the aggregated `from[]` inputs, reusing
1693
+ // `runOne` so retry/timeout/budget/idleTimeout behavior is identical to a
1694
+ // one-shot call. It forces the imperative runtime (the event kernel falls
1695
+ // back via kernelUnsupportedReason). The corrected `{previous.output}`
1696
+ // aggregation (all completed `from[]` sources) applies to EVERY round.
1697
+ if (type === "reduce" && phase.reduceStrategy === "tree") {
1698
+ const stratBs = phase.batchSize;
1699
+ const batchSize = typeof stratBs === "number" && Number.isFinite(stratBs) && stratBs >= 2 ? Math.floor(stratBs) : 2;
1700
+ const { collectTreeReduceInputs, executeTreeReduction, treeReduceCacheParts, } = await import("./runtime/phases/reduce.js");
1701
+ const inputs = collectTreeReduceInputs(state, phase);
1702
+ // 0–1 completed inputs: tree reduction is a no-op — fall through to the
1703
+ // one-shot path (which handles the degenerate case correctly).
1704
+ if (inputs.length >= 2) {
1705
+ const agentName = resolveAgent(phase.agent, deps, state);
1706
+ const cacheKey = cacheKeys(cc, treeReduceCacheParts(state, phase, inputs, batchSize));
1707
+ const cached = cachedPhase(cc, cacheKey);
1708
+ if (cached)
1709
+ return cached;
1710
+ const execution = await executeTreeReduction({
1711
+ phase,
1712
+ inputs,
1713
+ batchSize,
1714
+ agentName,
1715
+ inputHash: cacheKey.key,
1716
+ isAborted: () => deps.signal?.aborted === true,
1717
+ isOverBudget: () => overBudget(state).over,
1718
+ resolveTask: (batchValue) => {
1719
+ const batchCtx = buildInterpolationContext(state, batchValue, undefined, onRead);
1720
+ const interp = interpolate(phase.task ?? "", batchCtx);
1721
+ return {
1722
+ task: appendGateFormatSuffix(preRead + interp.text, phase),
1723
+ warning: warnUnresolvedRefs(phase.id, interp.missing),
1724
+ };
1725
+ },
1726
+ runOne: async (task, usageBefore, beforeAttempt, callId) => {
1727
+ const batchNodeId = nodeIdFor(`tree-${callId}`);
1728
+ if (ctxDir) {
1729
+ try {
1730
+ registerNode(ctxDir, batchNodeId, phase.id, undefined, "running");
1731
+ }
1732
+ catch { /* fail-open */ }
1733
+ }
1734
+ const result = await runOne(agentName, task, liveSink(state, phase.id, emitProgress), batchNodeId, contractCheck, undefined, undefined, usageBefore, beforeAttempt);
1735
+ if (ctxDir) {
1736
+ try {
1737
+ const spawned = drainPendingSpawns(ctxDir, batchNodeId);
1738
+ if (spawned.length > 0 && !deps.signal?.aborted && !overBudget(state).over) {
1739
+ const child = await runSpawnedChildren(spawned, ctxDir, batchNodeId, phase, deps, state, runSpawnedChild, { usage: emptyUsage() });
1740
+ if (child.reports)
1741
+ result.output = `${result.output ?? ""}${child.reports}`;
1742
+ if (child.failed && deps._workspaceBinding) {
1743
+ result.exitCode = result.exitCode === 0 ? 1 : result.exitCode;
1744
+ result.stopReason = "error";
1745
+ result.errorMessage = `Workspace ctx_spawn descendant failed: ${child.errors.join("; ")}`;
1746
+ }
1747
+ result.usage = aggregateUsage([result.usage ?? emptyUsage(), child.usage]);
1748
+ }
1749
+ setNodeStatus(ctxDir, batchNodeId, isFailed(result) ? "failed" : "done");
1750
+ }
1751
+ catch { /* fail-open */ }
1752
+ }
1753
+ const livePhase = state.phases[phase.id];
1754
+ if (livePhase) {
1755
+ livePhase.usage = aggregateUsage([
1756
+ usageBefore ?? emptyUsage(),
1757
+ result.usage ?? emptyUsage(),
1758
+ ]);
1759
+ }
1760
+ return result;
1761
+ },
1762
+ });
1763
+ const ps = execution.phaseState;
1764
+ if (parseJson && ps.status === "done")
1765
+ ps.json = safeParse(ps.output ?? "");
1766
+ if (readRefs.length)
1767
+ ps.reads = readRefsToReads(readRefs, state);
1768
+ if (execution.refWarning)
1769
+ ps.warnings = [...(ps.warnings ?? []), execution.refWarning];
1770
+ if (ps.budgetTruncated) {
1771
+ ps.warnings = [...(ps.warnings ?? []), "tree reduction stopped by the run budget; output is partial"];
1772
+ }
1773
+ attachReduceInputStats(ps, state, phase);
1774
+ if (execution.cacheable)
1775
+ recordCache(cc, ps);
1776
+ return ps;
1777
+ }
1778
+ // inputs.length < 2 → fall through to one-shot (below).
1779
+ }
1590
1780
  if (type === "agent" || type === "gate" || type === "reduce") {
1591
1781
  // Eval gate: zero-token machine checks before the LLM gate.
1592
1782
  if (type === "gate" && Array.isArray(phase.eval) && phase.eval.length > 0) {
@@ -1876,6 +2066,11 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1876
2066
  ps.reads = readRefsToReads(readRefs, state);
1877
2067
  if (refWarning)
1878
2068
  ps.warnings = [...(ps.warnings ?? []), refWarning];
2069
+ // Prompt-size diagnostics for the single agent call (durable).
2070
+ attachPromptStats(ps, [fullTask]);
2071
+ // reduce: record aggregate input stats.
2072
+ if (type === "reduce")
2073
+ attachReduceInputStats(ps, state, phase);
1879
2074
  if (type === "gate" && ps.status === "done") {
1880
2075
  ps.gate = parseGateVerdict(r.output);
1881
2076
  // Trace: gate decision (fail-open). Replay re-adjudicates thresholds.
@@ -1892,7 +2087,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
1892
2087
  registerNode(ctxDir, nid, phase.id, undefined, ps.status === "failed" ? "failed" : "done");
1893
2088
  const spawned = drainPendingSpawns(ctxDir, nid);
1894
2089
  if (spawned.length > 0 && !deps.signal?.aborted && !overBudget(state).over) {
1895
- const child = await runSpawnedChildren(spawned, ctxDir, nid, phase, deps, state, run);
2090
+ const child = await runSpawnedChildren(spawned, ctxDir, nid, phase, deps, state, runSpawnedChild, { usage: emptyUsage() });
1896
2091
  if (child.reports)
1897
2092
  ps.output = `${ps.output ?? ""}${child.reports}`;
1898
2093
  if (child.failed && deps._workspaceBinding) {
@@ -2024,6 +2219,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
2024
2219
  return {
2025
2220
  agent: resolveAgent(b.agent ?? phase.agent, deps, state),
2026
2221
  task: preRead + r.text,
2222
+ cwd: typeof b.cwd === "string" ? resolveBranchCwd(deps, b.cwd) : undefined,
2027
2223
  };
2028
2224
  });
2029
2225
  const ck = cacheKeys(cc, [phase.id, phase.model ?? "", JSON.stringify(branches)]);
@@ -2835,6 +3031,147 @@ function lastCompletedOutput(state, phase) {
2835
3031
  }
2836
3032
  return undefined;
2837
3033
  }
3034
+ /** A conservative prompt-size warning threshold in estimated tokens. Crossed
3035
+ * → the phase records a `warnings` entry so an author notices they may be
3036
+ * approaching a model's context limit. Approximate (ceil(chars/4)), not a
3037
+ * real tokenizer count. Chosen conservatively well below typical 128K context
3038
+ * windows so the warning fires with ample headroom. */
3039
+ export const PROMPT_SIZE_WARN_TOKENS = 32_000;
3040
+ /** Compute durable prompt-size diagnostics for a resolved prompt string:
3041
+ * exact UTF-8 byte count, character count, and a documented approximate token
3042
+ * estimate (ceil(chars/4)). Never throws. */
3043
+ export function promptSizeStats(text) {
3044
+ let chars = 0;
3045
+ for (const _char of text)
3046
+ chars++; // Unicode code points, not UTF-16 code units
3047
+ const bytes = Buffer.byteLength(text, "utf8");
3048
+ const estTokens = Math.ceil(chars / 4);
3049
+ return { bytes, chars, estTokens };
3050
+ }
3051
+ /** Replace a phase's call diagnostics with the exact prompts captured by the
3052
+ * shared runner boundary. This covers single calls, retries, fan-out items,
3053
+ * loop/tournament/judge calls, and tree-reduce rounds without per-branch drift. */
3054
+ function setPromptStats(ps, prompts) {
3055
+ try {
3056
+ const calls = prompts.map((text) => promptSizeStats(text));
3057
+ const reduceInputs = ps.promptStats?.reduceInputs;
3058
+ ps.promptStats = { calls, ...(reduceInputs ? { reduceInputs } : {}) };
3059
+ ps.warnings = (ps.warnings ?? []).filter((warning) => !warning.startsWith("Prompt size ≈"));
3060
+ for (const call of calls) {
3061
+ if (call.estTokens >= PROMPT_SIZE_WARN_TOKENS) {
3062
+ ps.warnings.push(`Prompt size ≈${call.estTokens} tokens (${call.chars} chars, ${call.bytes} bytes) exceeds the conservative ${PROMPT_SIZE_WARN_TOKENS}-token warning threshold — the prompt may be approaching a model's context limit.`);
3063
+ }
3064
+ }
3065
+ if (ps.warnings.length === 0)
3066
+ delete ps.warnings;
3067
+ }
3068
+ catch {
3069
+ /* diagnostics must never sink the phase */
3070
+ }
3071
+ }
3072
+ /** Attach durable prompt-size diagnostics to a PhaseState. `prompts` is the list
3073
+ * of resolved prompt strings actually sent to a subagent (one entry per call —
3074
+ * one for an agent phase, multiple for a tree reduce). Each becomes a
3075
+ * `{bytes, chars, estTokens}` record on `ps.promptStats.calls`. When any call
3076
+ * crosses {@link PROMPT_SIZE_WARN_TOKENS}, a `warnings` entry is appended so an
3077
+ * author notices they may be approaching a model's context limit (the estimate
3078
+ * is conservative: ceil(chars/4), not a real tokenizer count). Never throws. */
3079
+ function attachPromptStats(ps, prompts) {
3080
+ try {
3081
+ const calls = prompts.map((t) => promptSizeStats(t));
3082
+ const existing = ps.promptStats;
3083
+ ps.promptStats = existing
3084
+ ? { ...existing, calls: [...existing.calls, ...calls] }
3085
+ : { calls };
3086
+ for (const c of calls) {
3087
+ if (c.estTokens >= PROMPT_SIZE_WARN_TOKENS) {
3088
+ ps.warnings = [...(ps.warnings ?? []), `Prompt size ≈${c.estTokens} tokens (${c.chars} chars, ${c.bytes} bytes) exceeds the conservative ${PROMPT_SIZE_WARN_TOKENS}-token warning threshold — the prompt may be approaching a model's context limit.`];
3089
+ }
3090
+ }
3091
+ }
3092
+ catch {
3093
+ /* diagnostics must never sink the phase */
3094
+ }
3095
+ }
3096
+ /** Attach aggregate input stats for a reduce phase: count + total bytes/chars/
3097
+ * estTokens over the completed `from[]` inputs being reduced. Stored on
3098
+ * `ps.promptStats.reduceInputs` so post-hoc inspection can account for input
3099
+ * size across reduce rounds. Never throws. */
3100
+ function attachReduceInputStats(ps, state, phase) {
3101
+ try {
3102
+ const s = reduceInputStats(state, phase);
3103
+ const existing = ps.promptStats;
3104
+ ps.promptStats = existing
3105
+ ? { ...existing, reduceInputs: s }
3106
+ : { calls: [], reduceInputs: s };
3107
+ }
3108
+ catch {
3109
+ /* diagnostics must never sink the phase */
3110
+ }
3111
+ }
3112
+ /** Format a single reduce-from input as a labeled section. */
3113
+ function formatReduceInput(id, output) {
3114
+ return `### ${id}\n\n${output}`;
3115
+ }
3116
+ /** Aggregate completed `from[]` outputs for a reduce phase's {previous.output}.
3117
+ *
3118
+ * BREAKING correction (dogfood issue 1): a reduce phase's {previous.output}
3119
+ * resolves to ALL completed `from[]` outputs in from-array order — not just
3120
+ * the last completed dependency. One completed input → its raw output.
3121
+ * Multiple → `### <id>\n\n<output>` sections joined by `\n\n---\n\n`.
3122
+ * `join:"any"` includes only completed branches (skipped/failed are omitted).
3123
+ *
3124
+ * Returns `{ value, ids }` where `ids` are the from-ids actually aggregated
3125
+ * (so the runtime can record them as observed reads). `value` is undefined
3126
+ * when no `from[]` phase completed (e.g. all skipped under join:any). */
3127
+ function aggregateReduceFrom(state, phase) {
3128
+ const fromIds = asArray(phase.from);
3129
+ const completed = [];
3130
+ for (const id of fromIds) {
3131
+ const ps = state.phases[id];
3132
+ if (ps?.status === "done" && ps.output !== undefined) {
3133
+ completed.push({ id, output: ps.output });
3134
+ }
3135
+ }
3136
+ if (completed.length === 0)
3137
+ return { value: undefined, ids: [] };
3138
+ if (completed.length === 1)
3139
+ return { value: completed[0].output, ids: [completed[0].id] };
3140
+ const value = completed.map((c) => formatReduceInput(c.id, c.output)).join("\n\n---\n\n");
3141
+ return { value, ids: completed.map((c) => c.id) };
3142
+ }
3143
+ /** Aggregate stats over the completed `from[]` inputs for reduce diagnostics. */
3144
+ function reduceInputStats(state, phase) {
3145
+ const fromIds = asArray(phase.from);
3146
+ let count = 0;
3147
+ let totalBytes = 0;
3148
+ let totalChars = 0;
3149
+ let totalEstTokens = 0;
3150
+ for (const id of fromIds) {
3151
+ const ps = state.phases[id];
3152
+ if (ps?.status === "done" && ps.output !== undefined) {
3153
+ count++;
3154
+ const s = promptSizeStats(ps.output);
3155
+ totalBytes += s.bytes;
3156
+ totalChars += s.chars;
3157
+ totalEstTokens += s.estTokens;
3158
+ }
3159
+ }
3160
+ return { count, totalBytes, totalChars, totalEstTokens };
3161
+ }
3162
+ /** Resolve the effective idle-watchdog ms for a phase: phase overrides flow;
3163
+ * returns `undefined` when neither sets it (host default 300000 applies).
3164
+ * `0` is passed through (disables the watchdog — validation already ensured a
3165
+ * finite wall `timeout` exists in that case). */
3166
+ export function resolveIdleTimeoutMs(phase, def) {
3167
+ const p = phase.idleTimeout;
3168
+ if (typeof p === "number" && Number.isFinite(p))
3169
+ return p;
3170
+ const f = def.idleTimeout;
3171
+ if (typeof f === "number" && Number.isFinite(f))
3172
+ return f;
3173
+ return undefined;
3174
+ }
2838
3175
  /** Stable cache identity for the fully resolved agent pool. File paths are
2839
3176
  * excluded: content/config, not installation location, determines output. */
2840
3177
  export function agentDefinitionsIdentity(agents) {
@@ -3567,6 +3904,8 @@ async function runTaskflowLayers(state, deps) {
3567
3904
  let gateBlocked = false;
3568
3905
  let gateReason = "";
3569
3906
  let gateOutput = "";
3907
+ /** Id of the blocking gate/approval phase (source of `gateOutput`). */
3908
+ let gatePhaseId;
3570
3909
  // `budgetBlocked` gates the skipping of remaining phases once the cap is hit
3571
3910
  // and also drives the terminal "blocked" status — a maxUSD ceiling must never
3572
3911
  // silently do nothing.
@@ -3674,6 +4013,7 @@ async function runTaskflowLayers(state, deps) {
3674
4013
  gateBlocked = true;
3675
4014
  gateReason = ps.gate.reason ?? "";
3676
4015
  gateOutput = ps.output ?? "";
4016
+ gatePhaseId = phase.id;
3677
4017
  }
3678
4018
  // A fan-out cut short by the cap is itself a budget skip.
3679
4019
  if (ps.budgetTruncated) {
@@ -3712,15 +4052,6 @@ async function runTaskflowLayers(state, deps) {
3712
4052
  break;
3713
4053
  }
3714
4054
  }
3715
- const fp = finalPhase(def.phases);
3716
- let finalState = state.phases[fp.id];
3717
- // If the designated final phase produced no output (skipped/blocked), fall
3718
- // back to the last phase (in definition order) that actually completed.
3719
- if (!finalState || finalState.status !== "done") {
3720
- const doneInOrder = def.phases.map((p) => state.phases[p.id]).filter((p) => p?.status === "done");
3721
- if (doneInOrder.length)
3722
- finalState = doneInOrder[doneInOrder.length - 1];
3723
- }
3724
4055
  // A failed non-optional phase fails the run; optional failures are tolerated.
3725
4056
  const anyFailed = Object.entries(state.phases).some(([id, p]) => p.status === "failed" && !byId.get(id)?.optional && !p.optional);
3726
4057
  state.status = aborted
@@ -3731,13 +4062,14 @@ async function runTaskflowLayers(state, deps) {
3731
4062
  ? "failed"
3732
4063
  : "completed";
3733
4064
  safeEmit(deps, state);
3734
- let finalOutput = finalState?.output ?? "(no output)";
3735
- if (gateBlocked) {
3736
- finalOutput = `Gate blocked the workflow.${gateReason ? `\nReason: ${gateReason}` : ""}${gateOutput ? `\n\n${gateOutput}` : ""}`;
3737
- }
3738
- else if (budgetBlocked) {
3739
- finalOutput = `Budget exceeded — run halted.${budgetReason ? `\nReason: ${budgetReason}` : ""}${finalState?.output ? `\n\n${finalState.output}` : ""}`;
3740
- }
4065
+ const { finalOutput, outputSourcePhaseId } = resolveFinalOutput(def.phases, state, {
4066
+ gate: gateBlocked,
4067
+ gateReason,
4068
+ gateOutput,
4069
+ gatePhaseId,
4070
+ budget: budgetBlocked,
4071
+ budgetReason,
4072
+ });
3741
4073
  const totalUsage = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage()));
3742
4074
  return {
3743
4075
  state,
@@ -3745,6 +4077,7 @@ async function runTaskflowLayers(state, deps) {
3745
4077
  ok: state.status === "completed",
3746
4078
  totalUsage,
3747
4079
  reuse: summarizeReuse(state),
4080
+ outputSourcePhaseId,
3748
4081
  };
3749
4082
  }
3750
4083
  //# sourceMappingURL=runtime.js.map