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