taskflow-core 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 +1 -0
- package/dist/exec/driver.d.ts.map +1 -1
- package/dist/exec/driver.js +15 -2
- package/dist/exec/driver.js.map +1 -1
- package/dist/exec/step-kinds.d.ts.map +1 -1
- package/dist/exec/step-kinds.js +28 -3
- package/dist/exec/step-kinds.js.map +1 -1
- package/dist/exec/step.d.ts +1 -0
- package/dist/exec/step.d.ts.map +1 -1
- package/dist/exec/step.js +18 -0
- package/dist/exec/step.js.map +1 -1
- package/dist/flowir/compile.d.ts.map +1 -1
- package/dist/flowir/compile.js +13 -0
- package/dist/flowir/compile.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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -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/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/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 +39 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +542 -50
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts +68 -5
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +163 -22
- package/dist/schema.js.map +1 -1
- package/dist/store.d.ts +7 -0
- package/dist/store.d.ts.map +1 -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 +3 -2
package/dist/runtime.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import * as fs from "node:fs";
|
|
14
|
-
import { coerceArray, evaluateCondition, interpolate, safeParse, tryEvaluateCondition } from "./interpolate.js";
|
|
14
|
+
import { coerceArray, evaluateCondition, interpolate, interpolateValue, safeParse, tryEvaluateCondition } from "./interpolate.js";
|
|
15
15
|
import { contractViolations } from "./contract.js";
|
|
16
16
|
import { isFailed, isTransientError, mapWithConcurrencyLimit, PHASE_TIMEOUT_ABORT_GRACE_MS, sanitizeErrorMessage } from "./runner-core.js";
|
|
17
17
|
/** Default runner used when no host injected one: fail loudly rather than
|
|
@@ -28,7 +28,7 @@ const noRunnerInjected = async (_cwd, _agents, agentName, task) => ({
|
|
|
28
28
|
});
|
|
29
29
|
export { PHASE_TIMEOUT_ABORT_GRACE_MS } from "./runner-core.js";
|
|
30
30
|
import { aggregateUsage, emptyUsage } from "./usage.js";
|
|
31
|
-
import { dependenciesOf, finalPhase, LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, MAX_DYNAMIC_MAP_ITEMS, MAX_DYNAMIC_NESTING, MAX_DYNAMIC_PHASES, parseTtlMs, resolveArgs, topoLayers, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, validateTaskflow } from "./schema.js";
|
|
31
|
+
import { dependenciesOf, finalPhase, LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, MAX_DYNAMIC_MAP_ITEMS, MAX_DYNAMIC_NESTING, MAX_DYNAMIC_PHASES, parseTtlMs, resolveArgs, topoLayers, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, validateInvocationArgs, validateTaskflow } from "./schema.js";
|
|
32
32
|
import { verifyTaskflow } from "./verify.js";
|
|
33
33
|
import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, scoreResultJSON, scorerShapeErrors } from "./scorers.js";
|
|
34
34
|
import { parseGateVerdict, overBudget as overBudgetCheck, parseTournamentWinner } from "./deterministic.js";
|
|
@@ -45,6 +45,8 @@ import { compileTaskflowToIR, phaseFingerprint } from "./flowir/index.js";
|
|
|
45
45
|
import { computeStaleFrontier, declaredReadMapOfDef, readMapOf } from "./stale.js";
|
|
46
46
|
import { ctxDirFor, drainPendingSpawns, initCtxDir, registerNode, setNodeStatus } from "./context-store.js";
|
|
47
47
|
import { allocateWorkspace, isWorkspaceKeyword } from "./workspace.js";
|
|
48
|
+
import { cwdArgName, directoryIdentity, isPathWithin, resolveCwdArg, } from "./cwd-bridge.js";
|
|
49
|
+
import { createResolveOnlyWorkspaceSession, } from "./resources/execution.js";
|
|
48
50
|
/** Compute the incremental-reuse summary from a run's terminal phase states.
|
|
49
51
|
* Pure, total, never throws. A phase is "reused" iff it carries a `cacheHit`
|
|
50
52
|
* marker (set by `cachedPhase` for both within-run resume and cross-run hits). */
|
|
@@ -443,7 +445,7 @@ function liveSink(state, phaseId, emitProgress) {
|
|
|
443
445
|
*/
|
|
444
446
|
const CONTEXT_MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
445
447
|
const MAX_TOTAL_CONTEXT_CHARS = 200_000;
|
|
446
|
-
async function resolvePhaseContext(phase, ctx) {
|
|
448
|
+
async function resolvePhaseContext(phase, ctx, cwd, boundary) {
|
|
447
449
|
const entries = phase.context;
|
|
448
450
|
if (!entries || entries.length === 0)
|
|
449
451
|
return "";
|
|
@@ -482,20 +484,29 @@ async function resolvePhaseContext(phase, ctx) {
|
|
|
482
484
|
const blocks = [];
|
|
483
485
|
for (const p of filtered) {
|
|
484
486
|
try {
|
|
485
|
-
const abs = path.resolve(p);
|
|
486
|
-
|
|
487
|
+
const abs = path.resolve(cwd, p);
|
|
488
|
+
if (boundary && !isPathWithin(boundary, abs)) {
|
|
489
|
+
throw new Error(`TF_CWD_BOUNDARY_ESCAPE: context path '${p}' escapes the inherited cwd boundary`);
|
|
490
|
+
}
|
|
491
|
+
const canonical = fs.realpathSync(abs);
|
|
492
|
+
if (boundary && !isPathWithin(boundary, canonical)) {
|
|
493
|
+
throw new Error(`TF_CWD_BOUNDARY_ESCAPE: context path '${p}' resolves outside the inherited cwd boundary`);
|
|
494
|
+
}
|
|
495
|
+
const stat = fs.statSync(canonical);
|
|
487
496
|
if (!stat.isFile())
|
|
488
497
|
continue;
|
|
489
498
|
if (stat.size > CONTEXT_MAX_FILE_BYTES)
|
|
490
499
|
continue;
|
|
491
|
-
const content = fs.readFileSync(
|
|
500
|
+
const content = fs.readFileSync(canonical, "utf-8");
|
|
492
501
|
const truncated = content.length > limit
|
|
493
502
|
? content.slice(0, limit) + `\n... [truncated ${content.length - limit} chars]`
|
|
494
503
|
: content;
|
|
495
504
|
const ext = path.extname(p).slice(1) || "txt";
|
|
496
505
|
blocks.push(`## File: ${p}\n\n\`\`\`${ext}\n${truncated}\n\`\`\``);
|
|
497
506
|
}
|
|
498
|
-
catch {
|
|
507
|
+
catch (error) {
|
|
508
|
+
if (error instanceof Error && error.message.startsWith("TF_CWD_BOUNDARY_ESCAPE:"))
|
|
509
|
+
throw error;
|
|
499
510
|
console.warn(`[taskflow] Skipped unreadable context file: ${p}`);
|
|
500
511
|
}
|
|
501
512
|
}
|
|
@@ -537,19 +548,85 @@ function spawnedOverBudget(state, local) {
|
|
|
537
548
|
* two isolation-leak bugs in the 0.0.23 review).
|
|
538
549
|
*/
|
|
539
550
|
function resolveEffCwd(deps, phase) {
|
|
540
|
-
|
|
551
|
+
if (deps._cwdOverride)
|
|
552
|
+
return deps._cwdOverride;
|
|
553
|
+
if (!phase.cwd || isWorkspaceKeyword(phase.cwd))
|
|
554
|
+
return deps.cwd;
|
|
555
|
+
// Node resolves a relative spawn cwd against the Taskflow process cwd. That
|
|
556
|
+
// is not necessarily the invocation root, so anchor legacy literals here.
|
|
557
|
+
return path.resolve(deps.cwd, phase.cwd);
|
|
558
|
+
}
|
|
559
|
+
function flowTreeUsesCwdBridge(def, loadFlow, seenUses = new Set()) {
|
|
560
|
+
if (def.phases.some((phase) => cwdArgName(phase.cwd) !== undefined))
|
|
561
|
+
return true;
|
|
562
|
+
if (!loadFlow)
|
|
563
|
+
return false;
|
|
564
|
+
for (const phase of def.phases) {
|
|
565
|
+
if ((phase.type ?? "agent") !== "flow" || !phase.use)
|
|
566
|
+
continue;
|
|
567
|
+
if (seenUses.has(phase.use))
|
|
568
|
+
continue;
|
|
569
|
+
seenUses.add(phase.use);
|
|
570
|
+
try {
|
|
571
|
+
const child = loadFlow(phase.use);
|
|
572
|
+
if (child && flowTreeUsesCwdBridge(child, loadFlow, seenUses))
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
// Unknown is treated as capability-bearing: disable reuse, then let the
|
|
577
|
+
// normal phase execution path report the loader failure coherently.
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Freeze the saved-flow namespace for one top-level execution. Capability
|
|
585
|
+
* discovery and phase execution must observe the same definition, including
|
|
586
|
+
* the same loader error, or a mutable loader could introduce a bridge only
|
|
587
|
+
* after the root binding and cache policy were decided.
|
|
588
|
+
*/
|
|
589
|
+
function snapshotFlowLoader(deps) {
|
|
590
|
+
if (!deps.loadFlow || deps._flowLoaderSnapshot)
|
|
591
|
+
return deps;
|
|
592
|
+
const source = deps.loadFlow;
|
|
593
|
+
const snapshot = new Map();
|
|
594
|
+
const loadFlow = (name) => {
|
|
595
|
+
let entry = snapshot.get(name);
|
|
596
|
+
if (!entry) {
|
|
597
|
+
try {
|
|
598
|
+
const loaded = source(name);
|
|
599
|
+
// Loader-owned objects may be mutated asynchronously. Capability scan
|
|
600
|
+
// and execution operate on a detached structured snapshot, never the
|
|
601
|
+
// loader's live reference.
|
|
602
|
+
entry = { ok: true, value: loaded === undefined ? undefined : structuredClone(loaded) };
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
entry = { ok: false, error };
|
|
606
|
+
}
|
|
607
|
+
snapshot.set(name, entry);
|
|
608
|
+
}
|
|
609
|
+
if (!entry.ok)
|
|
610
|
+
throw entry.error;
|
|
611
|
+
return entry.value;
|
|
612
|
+
};
|
|
613
|
+
return { ...deps, loadFlow, _flowLoaderSnapshot: snapshot };
|
|
614
|
+
}
|
|
615
|
+
function sameDirectoryIdentity(a, b) {
|
|
616
|
+
return !!a && !!b && a.canonicalPath === b.canonicalPath && a.device === b.device && a.inode === b.inode;
|
|
541
617
|
}
|
|
542
618
|
async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, deps, state, localSpawnUsage) {
|
|
543
619
|
const stack = deps._stack ?? [];
|
|
544
620
|
const inlineDepth = stack.filter((s) => s.startsWith("def:")).length;
|
|
545
621
|
if (inlineDepth >= MAX_DYNAMIC_NESTING) {
|
|
546
|
-
|
|
622
|
+
const error = `spawned subflow rejected: nesting exceeded MAX_DYNAMIC_NESTING (${MAX_DYNAMIC_NESTING})`;
|
|
623
|
+
return { output: `(${error})`, usage: emptyUsage(), failed: true, error };
|
|
547
624
|
}
|
|
548
625
|
const wrapped = normalizeInlineDef(subflowSpec, childNodeId);
|
|
549
626
|
if (!wrapped)
|
|
550
|
-
return { output: "(spawned subflow is not a Taskflow / phases array)", usage: emptyUsage() };
|
|
627
|
+
return { output: "(spawned subflow is not a Taskflow / phases array)", usage: emptyUsage(), failed: true, error: "spawned subflow is not a Taskflow / phases array" };
|
|
551
628
|
if (wrapped.phases.length === 0)
|
|
552
|
-
return { output: "(spawned subflow had zero phases — no-op)", usage: emptyUsage() };
|
|
629
|
+
return { output: "(spawned subflow had zero phases — no-op)", usage: emptyUsage(), failed: false };
|
|
553
630
|
// Inner phases without their own agent inherit the assignment's defaultAgent.
|
|
554
631
|
if (defaultAgent) {
|
|
555
632
|
for (const p of wrapped.phases)
|
|
@@ -559,12 +636,15 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
|
|
|
559
636
|
const spawnCwd = resolveEffCwd(deps, phase);
|
|
560
637
|
const dynCwd = spawnCwd;
|
|
561
638
|
const v = validateTaskflow(wrapped, { dynamic: true, cwd: dynCwd });
|
|
562
|
-
if (!v.ok)
|
|
563
|
-
|
|
639
|
+
if (!v.ok) {
|
|
640
|
+
const error = `spawned subflow failed validation: ${v.errors.join("; ")}`;
|
|
641
|
+
return { output: `(${error})`, usage: emptyUsage(), failed: true, error };
|
|
642
|
+
}
|
|
564
643
|
const ver = verifyTaskflow({ name: wrapped.name, phases: wrapped.phases, budget: wrapped.budget, concurrency: wrapped.concurrency });
|
|
565
644
|
if (!ver.ok) {
|
|
566
645
|
const errs = ver.issues.filter((i) => i.severity === "error").map((i) => i.message);
|
|
567
|
-
|
|
646
|
+
const error = `spawned subflow failed verification: ${errs.join("; ")}`;
|
|
647
|
+
return { output: `(${error})`, usage: emptyUsage(), failed: true, error };
|
|
568
648
|
}
|
|
569
649
|
// The generated sub-flow gets only what remains after both already-folded
|
|
570
650
|
// parent spend and siblings/ancestors in this still-running spawn batch. USD
|
|
@@ -591,6 +671,8 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
|
|
|
591
671
|
const subResult = await executeTaskflow(subState, {
|
|
592
672
|
...deps,
|
|
593
673
|
cwd: dynCwd,
|
|
674
|
+
_cacheCwdIdentity: phase.cwd !== undefined || deps._cacheCwdIdentity !== undefined ? dynCwd : undefined,
|
|
675
|
+
_dynamic: true,
|
|
594
676
|
// The parent phase's isolated workspace (if any) applies only to the
|
|
595
677
|
// parent — each spawned sub-phase resolves its own cwd. Clear the
|
|
596
678
|
// override so the whole subflow doesn't inherit the parent's dir
|
|
@@ -606,16 +688,23 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
|
|
|
606
688
|
// Sum every sub-phase's usage so the parent's budget guard sees spawn spend
|
|
607
689
|
// (verdict Issue 2).
|
|
608
690
|
const usage = aggregateUsage(Object.values(subResult.state.phases).map((p) => p.usage ?? emptyUsage()));
|
|
609
|
-
return {
|
|
691
|
+
return {
|
|
692
|
+
output: subResult.finalOutput ?? "",
|
|
693
|
+
usage,
|
|
694
|
+
failed: !subResult.ok,
|
|
695
|
+
...(!subResult.ok ? { error: sanitizeErrorMessage(subResult.finalOutput || "spawned subflow failed") } : {}),
|
|
696
|
+
};
|
|
610
697
|
}
|
|
611
698
|
catch (e) {
|
|
612
|
-
|
|
699
|
+
const error = sanitizeErrorMessage(e instanceof Error ? e.message : String(e));
|
|
700
|
+
return { output: `(spawned subflow failed: ${error})`, usage: emptyUsage(), failed: true, error };
|
|
613
701
|
}
|
|
614
702
|
}
|
|
615
703
|
async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps, state, run, ledger = { usage: emptyUsage() }) {
|
|
616
704
|
const capped = assignments.slice(0, MAX_DYNAMIC_MAP_ITEMS);
|
|
617
705
|
const lines = [];
|
|
618
706
|
const usages = [];
|
|
707
|
+
const errors = [];
|
|
619
708
|
// Effective cwd for flat spawned tasks: honour a workspace override and never
|
|
620
709
|
// pass a reserved keyword through to the runner.
|
|
621
710
|
const spawnCwd = resolveEffCwd(deps, phase);
|
|
@@ -635,11 +724,43 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
|
|
|
635
724
|
out = sub.output;
|
|
636
725
|
usages.push(sub.usage);
|
|
637
726
|
ledger.usage = aggregateUsage([ledger.usage, sub.usage]);
|
|
638
|
-
|
|
727
|
+
if (sub.failed)
|
|
728
|
+
errors.push(sub.error ?? `spawned subflow ${childNodeId} failed`);
|
|
729
|
+
setNodeStatus(ctxDir, childNodeId, sub.failed ? "failed" : "done");
|
|
639
730
|
}
|
|
640
731
|
else {
|
|
641
|
-
const
|
|
732
|
+
const task = a.task ?? "";
|
|
733
|
+
const runOptions = {
|
|
734
|
+
model: phase.model,
|
|
735
|
+
thinking: phase.thinking,
|
|
736
|
+
tools: phase.tools,
|
|
737
|
+
cwd: spawnCwd,
|
|
738
|
+
signal: deps.signal,
|
|
739
|
+
ctxDir,
|
|
740
|
+
nodeId: childNodeId,
|
|
741
|
+
};
|
|
742
|
+
const invoke = () => run(spawnCwd, deps.agents, agentName, task, runOptions, deps.globalThinking);
|
|
743
|
+
// ctx_spawn is part of the phase's execution tree, not bookkeeping.
|
|
744
|
+
// Inherit the exact cwd capability and give every descendant a unique
|
|
745
|
+
// unit owner so no child can bypass lease/WAL/permit coordination.
|
|
746
|
+
const r = deps._workspaceBinding
|
|
747
|
+
? await deps._workspaceBinding.runAgent({
|
|
748
|
+
agents: deps.agents,
|
|
749
|
+
agentName,
|
|
750
|
+
task,
|
|
751
|
+
opts: runOptions,
|
|
752
|
+
globalThinking: deps.globalThinking,
|
|
753
|
+
unitId: childNodeId,
|
|
754
|
+
invoke,
|
|
755
|
+
})
|
|
756
|
+
: await invoke();
|
|
642
757
|
out = r.output ?? "";
|
|
758
|
+
if (isFailed(r)) {
|
|
759
|
+
const detail = sanitizeErrorMessage(r.errorMessage ?? r.stderr ?? "spawned child failed");
|
|
760
|
+
errors.push(detail);
|
|
761
|
+
if (!out)
|
|
762
|
+
out = `(spawned child failed: ${detail})`;
|
|
763
|
+
}
|
|
643
764
|
if (r.usage) {
|
|
644
765
|
usages.push(r.usage);
|
|
645
766
|
ledger.usage = aggregateUsage([ledger.usage, r.usage]);
|
|
@@ -652,19 +773,27 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
|
|
|
652
773
|
if (rec.reports)
|
|
653
774
|
out += rec.reports;
|
|
654
775
|
usages.push(rec.usage);
|
|
776
|
+
errors.push(...rec.errors);
|
|
655
777
|
}
|
|
656
778
|
}
|
|
657
779
|
}
|
|
658
780
|
catch (e) {
|
|
659
781
|
setNodeStatus(ctxDir, childNodeId, "failed");
|
|
660
|
-
|
|
782
|
+
const detail = sanitizeErrorMessage(e instanceof Error ? e.message : String(e));
|
|
783
|
+
errors.push(detail);
|
|
784
|
+
out = `(spawned child failed: ${detail})`;
|
|
661
785
|
}
|
|
662
786
|
lines.push(`### spawned child ${idx} (${agentName})\n${out}`);
|
|
663
787
|
}
|
|
664
788
|
const usage = aggregateUsage(usages);
|
|
665
789
|
if (lines.length === 0)
|
|
666
|
-
return { reports: undefined, usage };
|
|
667
|
-
return {
|
|
790
|
+
return { reports: undefined, usage, failed: errors.length > 0, errors };
|
|
791
|
+
return {
|
|
792
|
+
reports: `\n\n<!-- ctx_spawn: ${lines.length} child report(s) -->\n${lines.join("\n\n")}`,
|
|
793
|
+
usage,
|
|
794
|
+
failed: errors.length > 0,
|
|
795
|
+
errors,
|
|
796
|
+
};
|
|
668
797
|
}
|
|
669
798
|
async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
|
|
670
799
|
// Trace: phase-start (fail-open). Record whether this phase is replayable
|
|
@@ -732,9 +861,163 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
|
|
|
732
861
|
}
|
|
733
862
|
return ps;
|
|
734
863
|
};
|
|
864
|
+
const cwdArg = cwdArgName(phase.cwd);
|
|
865
|
+
let innerOpts = { ...opts, upstreamDeps: deps };
|
|
866
|
+
if ((cwdArg !== undefined || deps._dynamic === true || deps._cwdBoundary !== undefined) && phase.when !== undefined) {
|
|
867
|
+
const whenReadRefs = [];
|
|
868
|
+
const whenCtx = buildInterpolationContext(state, lastCompletedOutput(state, phase), undefined, (ref) => whenReadRefs.push(ref));
|
|
869
|
+
const whenResult = evaluateCondition(phase.when, whenCtx);
|
|
870
|
+
traceDecision(deps, state, phase.id, {
|
|
871
|
+
type: "when-guard",
|
|
872
|
+
expression: phase.when,
|
|
873
|
+
result: whenResult,
|
|
874
|
+
});
|
|
875
|
+
if (!whenResult) {
|
|
876
|
+
return stamp({
|
|
877
|
+
id: phase.id,
|
|
878
|
+
status: "skipped",
|
|
879
|
+
error: `Condition not met: ${phase.when}`,
|
|
880
|
+
endedAt: Date.now(),
|
|
881
|
+
usage: emptyUsage(),
|
|
882
|
+
reads: readRefsToReads(whenReadRefs, state),
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
innerOpts = { ...innerOpts, whenPrechecked: true, whenReadRefs };
|
|
886
|
+
}
|
|
887
|
+
if (deps._dynamic === true && ((phase.context?.length ?? 0) > 0 || phase.cwd !== undefined)) {
|
|
888
|
+
return stamp({
|
|
889
|
+
id: phase.id,
|
|
890
|
+
status: "failed",
|
|
891
|
+
error: "TF_DYNAMIC_RESOURCE_FORBIDDEN: generated sub-flows cannot declare cwd or context file pre-reads",
|
|
892
|
+
endedAt: Date.now(),
|
|
893
|
+
usage: emptyUsage(),
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
if (cwdArg !== undefined) {
|
|
897
|
+
const spec = state.def.args?.[cwdArg];
|
|
898
|
+
if (spec?.type !== "relative-path") {
|
|
899
|
+
return stamp({
|
|
900
|
+
id: phase.id,
|
|
901
|
+
status: "failed",
|
|
902
|
+
error: `TF_CWD_ARG_INVALID: cwd argument '${cwdArg}' is not declared with type 'relative-path'`,
|
|
903
|
+
endedAt: Date.now(),
|
|
904
|
+
usage: emptyUsage(),
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
const bridgeMode = deps.workspaceSession ? "resolve-only" : deps.cwdBridgeMode;
|
|
908
|
+
const bound = resolveCwdArg(deps.cwd, cwdArg, state.args[cwdArg], bridgeMode);
|
|
909
|
+
if (!bound.ok) {
|
|
910
|
+
return stamp({
|
|
911
|
+
id: phase.id,
|
|
912
|
+
status: "failed",
|
|
913
|
+
error: `${bound.code}: ${bound.message}`,
|
|
914
|
+
endedAt: Date.now(),
|
|
915
|
+
usage: emptyUsage(),
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
if (deps._cwdBoundary && !isPathWithin(deps._cwdBoundary, bound.value.absolutePath)) {
|
|
919
|
+
return stamp({
|
|
920
|
+
id: phase.id,
|
|
921
|
+
status: "failed",
|
|
922
|
+
error: `TF_CWD_BOUNDARY_ESCAPE: cwd argument '${cwdArg}' resolves outside the inherited cwd boundary`,
|
|
923
|
+
endedAt: Date.now(),
|
|
924
|
+
usage: emptyUsage(),
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
let workspaceBinding;
|
|
928
|
+
try {
|
|
929
|
+
workspaceBinding = await deps.workspaceSession?.bindPhase({
|
|
930
|
+
invocationRoot: deps.cwd,
|
|
931
|
+
runId: state.runId,
|
|
932
|
+
phaseId: phase.id,
|
|
933
|
+
argName: cwdArg,
|
|
934
|
+
argDefinitions: state.def.args ?? {},
|
|
935
|
+
argValues: state.args,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
return stamp({
|
|
940
|
+
id: phase.id,
|
|
941
|
+
status: "failed",
|
|
942
|
+
error: error instanceof Error ? error.message : String(error),
|
|
943
|
+
endedAt: Date.now(),
|
|
944
|
+
usage: emptyUsage(),
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
if (workspaceBinding && workspaceBinding.absolutePath !== bound.value.absolutePath) {
|
|
948
|
+
return stamp({
|
|
949
|
+
id: phase.id,
|
|
950
|
+
status: "failed",
|
|
951
|
+
error: "TFWS_IDENTITY_MISMATCH: compatibility resolver and capability resolver selected different cwd identities",
|
|
952
|
+
endedAt: Date.now(),
|
|
953
|
+
usage: emptyUsage(),
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
const innerDeps = {
|
|
957
|
+
...deps,
|
|
958
|
+
_cwdOverride: bound.value.absolutePath,
|
|
959
|
+
_cwdBoundary: bound.value.absolutePath,
|
|
960
|
+
_cacheCwdIdentity: bound.value.absolutePath,
|
|
961
|
+
_disableCache: true,
|
|
962
|
+
_workspaceBinding: workspaceBinding,
|
|
963
|
+
};
|
|
964
|
+
const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts);
|
|
965
|
+
ps.warnings = [
|
|
966
|
+
...(ps.warnings ?? []),
|
|
967
|
+
`cwd bridge: resolve-only {args.${cwdArg}} -> ${bound.value.logicalPath}; principal/root authorization, cross-process lease, and write journal are active, but filesystem access outside this directory is not sandbox-enforced`,
|
|
968
|
+
];
|
|
969
|
+
return stamp(ps);
|
|
970
|
+
}
|
|
971
|
+
if (deps._cwdBoundary && phase.cwd) {
|
|
972
|
+
if (isWorkspaceKeyword(phase.cwd)) {
|
|
973
|
+
return stamp({
|
|
974
|
+
id: phase.id,
|
|
975
|
+
status: "failed",
|
|
976
|
+
error: `TF_CWD_BOUNDARY_ESCAPE: workspace provider '${phase.cwd}' cannot expand an inherited cwd boundary`,
|
|
977
|
+
endedAt: Date.now(),
|
|
978
|
+
usage: emptyUsage(),
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
const selected = directoryIdentity(path.resolve(deps.cwd, phase.cwd));
|
|
982
|
+
if (!selected || !isPathWithin(deps._cwdBoundary, selected.canonicalPath)) {
|
|
983
|
+
return stamp({
|
|
984
|
+
id: phase.id,
|
|
985
|
+
status: "failed",
|
|
986
|
+
error: `TF_CWD_BOUNDARY_ESCAPE: cwd '${phase.cwd}' must select an existing directory inside the inherited cwd boundary`,
|
|
987
|
+
endedAt: Date.now(),
|
|
988
|
+
usage: emptyUsage(),
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
let narrowedBinding;
|
|
992
|
+
try {
|
|
993
|
+
narrowedBinding = await deps.workspaceSession?.bindPhase({
|
|
994
|
+
invocationRoot: selected.canonicalPath,
|
|
995
|
+
runId: state.runId,
|
|
996
|
+
phaseId: phase.id,
|
|
997
|
+
argDefinitions: state.def.args ?? {},
|
|
998
|
+
argValues: state.args,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
catch (error) {
|
|
1002
|
+
return stamp({
|
|
1003
|
+
id: phase.id,
|
|
1004
|
+
status: "failed",
|
|
1005
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1006
|
+
endedAt: Date.now(),
|
|
1007
|
+
usage: emptyUsage(),
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
return stamp(await executePhaseInner(phase, state, {
|
|
1011
|
+
...deps,
|
|
1012
|
+
_cwdOverride: selected.canonicalPath,
|
|
1013
|
+
_cwdBoundary: selected.canonicalPath,
|
|
1014
|
+
_cacheCwdIdentity: selected.canonicalPath,
|
|
1015
|
+
_workspaceBinding: narrowedBinding,
|
|
1016
|
+
}, prior, emitProgress, _retryDepth, innerOpts));
|
|
1017
|
+
}
|
|
735
1018
|
// Non-keyword cwd (or none): no workspace lifecycle — run directly.
|
|
736
1019
|
if (!isWorkspaceKeyword(phase.cwd)) {
|
|
737
|
-
return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth,
|
|
1020
|
+
return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, innerOpts));
|
|
738
1021
|
}
|
|
739
1022
|
let ws;
|
|
740
1023
|
try {
|
|
@@ -748,9 +1031,9 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
|
|
|
748
1031
|
catch {
|
|
749
1032
|
ws = undefined; // fail-open: run in the base cwd
|
|
750
1033
|
}
|
|
751
|
-
const innerDeps = ws ? { ...deps, _cwdOverride: ws.dir } : deps;
|
|
1034
|
+
const innerDeps = ws ? { ...deps, _cwdOverride: ws.dir, _cacheCwdIdentity: ws.dir } : deps;
|
|
752
1035
|
try {
|
|
753
|
-
const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth,
|
|
1036
|
+
const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts);
|
|
754
1037
|
if (ws && (ws.kind !== "inherited" || ws.note)) {
|
|
755
1038
|
const tag = ws.kind === "inherited" ? "workspace" : `workspace:${ws.kind}`;
|
|
756
1039
|
const msg = ws.note ? `${tag} — ${ws.note}` : `${tag} at ${ws.dir}`;
|
|
@@ -800,7 +1083,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
800
1083
|
// M3 observed-readSet: collect every upstream ref this phase resolves, so we
|
|
801
1084
|
// can record what its result ACTUALLY depended on (not just its declared
|
|
802
1085
|
// dependsOn). Shared by every interpolation in this phase (task / when / …).
|
|
803
|
-
const readRefs = [];
|
|
1086
|
+
const readRefs = [...(opts?.whenReadRefs ?? [])];
|
|
804
1087
|
const onRead = (ref) => {
|
|
805
1088
|
readRefs.push(ref);
|
|
806
1089
|
};
|
|
@@ -809,7 +1092,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
809
1092
|
// dependencies. Evaluate them inside executePhaseInner so every upstream
|
|
810
1093
|
// interpolation is captured by the shared onRead hook, not silently dropped
|
|
811
1094
|
// by a separate out-of-band context.
|
|
812
|
-
if (phase.when !== undefined) {
|
|
1095
|
+
if (phase.when !== undefined && opts?.whenPrechecked !== true) {
|
|
813
1096
|
const whenResult = evaluateCondition(phase.when, ctx);
|
|
814
1097
|
traceDecision(deps, state, phase.id, {
|
|
815
1098
|
type: "when-guard",
|
|
@@ -827,7 +1110,10 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
827
1110
|
};
|
|
828
1111
|
}
|
|
829
1112
|
}
|
|
830
|
-
|
|
1113
|
+
// `context` keeps its historical invocation-root meaning. Phase cwd may be a
|
|
1114
|
+
// temporary/worktree directory; rebasing context there would silently stop
|
|
1115
|
+
// existing flows from reading authored source files.
|
|
1116
|
+
const preRead = await resolvePhaseContext(phase, ctx, deps.cwd, deps._cwdBoundary);
|
|
831
1117
|
// Resolve this phase's cache policy once. Default scope is "run-only" (the
|
|
832
1118
|
// historical within-run resume behavior). Only "cross-run" phases resolve a
|
|
833
1119
|
// fingerprint and consult the persistent store.
|
|
@@ -851,6 +1137,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
851
1137
|
if (phase.idempotent === false) {
|
|
852
1138
|
cacheScope = "off";
|
|
853
1139
|
}
|
|
1140
|
+
if (deps._disableCache) {
|
|
1141
|
+
cacheScope = "off";
|
|
1142
|
+
}
|
|
854
1143
|
const cc = {
|
|
855
1144
|
scope: cacheScope,
|
|
856
1145
|
ttlMs: phase.cache?.ttl ? (parseTtlMs(phase.cache.ttl) ?? undefined) : undefined,
|
|
@@ -869,17 +1158,33 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
869
1158
|
agentScope: state.def.agentScope,
|
|
870
1159
|
contextSharing: state.def.contextSharing === true,
|
|
871
1160
|
agentDefinitions: agentDefinitionsIdentity(deps.agents),
|
|
1161
|
+
executionCwd: phase.cwd !== undefined || deps._cacheCwdIdentity !== undefined ? effCwd : undefined,
|
|
1162
|
+
};
|
|
1163
|
+
const baseRun = (agentName, task, onLive, ctxNodeId, signal, onTerminalCommit) => {
|
|
1164
|
+
const runOptions = {
|
|
1165
|
+
model: phase.model,
|
|
1166
|
+
thinking: phase.thinking,
|
|
1167
|
+
tools: phase.tools,
|
|
1168
|
+
cwd: effCwd,
|
|
1169
|
+
signal: signal ?? deps.signal,
|
|
1170
|
+
onLive,
|
|
1171
|
+
ctxDir: ctxDir,
|
|
1172
|
+
nodeId: ctxDir ? ctxNodeId : undefined,
|
|
1173
|
+
onTerminalCommit,
|
|
1174
|
+
};
|
|
1175
|
+
const invoke = () => run(effCwd, deps.agents, agentName, task, runOptions, deps.globalThinking);
|
|
1176
|
+
return deps._workspaceBinding
|
|
1177
|
+
? deps._workspaceBinding.runAgent({
|
|
1178
|
+
agents: deps.agents,
|
|
1179
|
+
agentName,
|
|
1180
|
+
task,
|
|
1181
|
+
opts: runOptions,
|
|
1182
|
+
globalThinking: deps.globalThinking,
|
|
1183
|
+
unitId: ctxNodeId ?? phase.id,
|
|
1184
|
+
invoke,
|
|
1185
|
+
})
|
|
1186
|
+
: invoke();
|
|
872
1187
|
};
|
|
873
|
-
const baseRun = (agentName, task, onLive, ctxNodeId, signal) => run(effCwd, deps.agents, agentName, task, {
|
|
874
|
-
model: phase.model,
|
|
875
|
-
thinking: phase.thinking,
|
|
876
|
-
tools: phase.tools,
|
|
877
|
-
cwd: effCwd,
|
|
878
|
-
signal: signal ?? deps.signal,
|
|
879
|
-
onLive,
|
|
880
|
-
ctxDir: ctxDir,
|
|
881
|
-
nodeId: ctxDir ? ctxNodeId : undefined,
|
|
882
|
-
}, deps.globalThinking);
|
|
883
1188
|
// Wrap each subagent call in the phase's retry policy. Usage is summed across
|
|
884
1189
|
// attempts; the attempt count rides along on the result for the TUI.
|
|
885
1190
|
//
|
|
@@ -911,6 +1216,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
911
1216
|
// AbortController chains: run signal + optional extra (race cancel) + phase timeout.
|
|
912
1217
|
// Deterministic: a timed-out call is never retried (would double-spend).
|
|
913
1218
|
let timedOut = false;
|
|
1219
|
+
let terminalCommitted = false;
|
|
914
1220
|
let timer;
|
|
915
1221
|
let forceReturnTimer;
|
|
916
1222
|
const removers = [];
|
|
@@ -936,10 +1242,21 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
936
1242
|
}
|
|
937
1243
|
}
|
|
938
1244
|
try {
|
|
939
|
-
const
|
|
1245
|
+
const onTerminalCommit = () => {
|
|
1246
|
+
if (timedOut)
|
|
1247
|
+
return;
|
|
1248
|
+
terminalCommitted = true;
|
|
1249
|
+
if (timer) {
|
|
1250
|
+
clearTimeout(timer);
|
|
1251
|
+
timer = undefined;
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
const invocation = baseRun(agentName, task, onLive, ctxNodeId, callSignal, onTerminalCommit);
|
|
940
1255
|
if (phaseTimeoutMs && timeoutController) {
|
|
941
1256
|
const timeoutFallback = new Promise((resolve) => {
|
|
942
1257
|
timer = setTimeout(() => {
|
|
1258
|
+
if (terminalCommitted)
|
|
1259
|
+
return;
|
|
943
1260
|
timedOut = true;
|
|
944
1261
|
timeoutController?.abort();
|
|
945
1262
|
forceReturnTimer = setTimeout(() => resolve({
|
|
@@ -952,6 +1269,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
952
1269
|
stopReason: "error",
|
|
953
1270
|
errorMessage: `Phase runner did not stop within ${PHASE_TIMEOUT_ABORT_GRACE_MS}ms after abort`,
|
|
954
1271
|
phaseTimeout: true,
|
|
1272
|
+
completionSource: "phase-timeout",
|
|
955
1273
|
}), PHASE_TIMEOUT_ABORT_GRACE_MS);
|
|
956
1274
|
}, phaseTimeoutMs);
|
|
957
1275
|
});
|
|
@@ -979,12 +1297,18 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
979
1297
|
stopReason: "error",
|
|
980
1298
|
errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`,
|
|
981
1299
|
phaseTimeout: true,
|
|
1300
|
+
completionSource: "phase-timeout",
|
|
982
1301
|
};
|
|
983
1302
|
usages.push(last.usage);
|
|
984
1303
|
traceEmit(deps, {
|
|
985
1304
|
ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "subagent-call",
|
|
986
1305
|
input: { agent: agentName, model: phase.model, task, preRead, nodePath: ctxNodeId ?? phase.id, attempt },
|
|
987
|
-
output: {
|
|
1306
|
+
output: {
|
|
1307
|
+
text: last.output, model: last.model, usage: last.usage, stopReason: last.stopReason,
|
|
1308
|
+
completionSource: last.completionSource,
|
|
1309
|
+
reapedAfterTerminal: last.reapedAfterTerminal,
|
|
1310
|
+
terminalGraceMs: last.terminalGraceMs,
|
|
1311
|
+
},
|
|
988
1312
|
});
|
|
989
1313
|
traceFlush(deps, phase.id);
|
|
990
1314
|
break;
|
|
@@ -1015,7 +1339,12 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1015
1339
|
traceEmit(deps, {
|
|
1016
1340
|
ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "subagent-call",
|
|
1017
1341
|
input: { agent: agentName, model: phase.model, task, preRead, nodePath: ctxNodeId ?? phase.id, attempt },
|
|
1018
|
-
output: {
|
|
1342
|
+
output: {
|
|
1343
|
+
text: last.output, model: last.model, usage: last.usage, stopReason: last.stopReason,
|
|
1344
|
+
completionSource: last.completionSource,
|
|
1345
|
+
reapedAfterTerminal: last.reapedAfterTerminal,
|
|
1346
|
+
terminalGraceMs: last.terminalGraceMs,
|
|
1347
|
+
},
|
|
1019
1348
|
});
|
|
1020
1349
|
traceFlush(deps, phase.id);
|
|
1021
1350
|
if (!isFailed(last))
|
|
@@ -1023,6 +1352,20 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1023
1352
|
// Stop retrying on abort (run-level or race cancel) or once over budget.
|
|
1024
1353
|
if (deps.signal?.aborted || extraSignal?.aborted || overBudget(state).over)
|
|
1025
1354
|
break;
|
|
1355
|
+
if (deps._workspaceBinding) {
|
|
1356
|
+
// A failed RW-capability attempt has an unknown filesystem outcome and
|
|
1357
|
+
// is durably marked dirty. Retrying it cannot be proven idempotent until
|
|
1358
|
+
// workspace snapshots/restoration exist, so preserve the first failure
|
|
1359
|
+
// instead of replacing it with a later TFWS_RESOURCE_DIRTY refusal.
|
|
1360
|
+
const requestedRetry = (retry?.max ?? 0) > 0 || isTransientError(last);
|
|
1361
|
+
if (requestedRetry && last.workspaceMutationStarted) {
|
|
1362
|
+
last = {
|
|
1363
|
+
...last,
|
|
1364
|
+
errorMessage: `${last.errorMessage ?? last.stderr ?? "workspace execution failed"}\nTFWS_RETRY_UNSAFE: retry suppressed because the prior read-write attempt may have mutated the workspace; reconcile before a new attempt`,
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
break;
|
|
1368
|
+
}
|
|
1026
1369
|
// Decide whether THIS failure warrants another attempt. Explicit retry
|
|
1027
1370
|
// policy covers all failures up to its cap; the transient fallback covers
|
|
1028
1371
|
// only retryable provider errors. A non-transient failure with no explicit
|
|
@@ -1224,6 +1567,11 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1224
1567
|
const child = await runSpawnedChildren(spawned, ctxDir, itemNid, phase, deps, state, run);
|
|
1225
1568
|
if (child.reports)
|
|
1226
1569
|
r.output = `${r.output ?? ""}${child.reports}`;
|
|
1570
|
+
if (child.failed && deps._workspaceBinding) {
|
|
1571
|
+
r.exitCode = r.exitCode === 0 ? 1 : r.exitCode;
|
|
1572
|
+
r.stopReason = "error";
|
|
1573
|
+
r.errorMessage = `Workspace ctx_spawn descendant failed: ${child.errors.join("; ")}`;
|
|
1574
|
+
}
|
|
1227
1575
|
if (child.usage) {
|
|
1228
1576
|
r.usage = aggregateUsage([r.usage ?? emptyUsage(), child.usage]);
|
|
1229
1577
|
liveUsages[idx] = r.usage;
|
|
@@ -1468,7 +1816,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1468
1816
|
break;
|
|
1469
1817
|
attempt++;
|
|
1470
1818
|
if (_retryDepth < MAX_RETRY_DEPTH) {
|
|
1471
|
-
const
|
|
1819
|
+
const depsForUpstream = opts?.upstreamDeps ?? deps;
|
|
1472
1820
|
for (const depId of phase.dependsOn ?? []) {
|
|
1473
1821
|
const d = state.def.phases.find((p) => p.id === depId);
|
|
1474
1822
|
if (!d)
|
|
@@ -1547,6 +1895,10 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1547
1895
|
const child = await runSpawnedChildren(spawned, ctxDir, nid, phase, deps, state, run);
|
|
1548
1896
|
if (child.reports)
|
|
1549
1897
|
ps.output = `${ps.output ?? ""}${child.reports}`;
|
|
1898
|
+
if (child.failed && deps._workspaceBinding) {
|
|
1899
|
+
ps.status = "failed";
|
|
1900
|
+
ps.error = `Workspace ctx_spawn descendant failed: ${child.errors.join("; ")}`;
|
|
1901
|
+
}
|
|
1550
1902
|
// Fold spawned spend into this phase's usage so the run-wide budget
|
|
1551
1903
|
// guard accounts for it (verdict Issue 2).
|
|
1552
1904
|
ps.usage = aggregateUsage([ps.usage ?? emptyUsage(), child.usage]);
|
|
@@ -1575,7 +1927,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1575
1927
|
// NOTE: we intentionally pass the gate's `prior` (not the dep's own
|
|
1576
1928
|
// completed state) so the dep does NOT cache-hit and actually
|
|
1577
1929
|
// RE-RUNS — re-running upstream is the whole point of onBlock:retry.
|
|
1578
|
-
const
|
|
1930
|
+
const depsForUpstream = opts?.upstreamDeps ?? deps;
|
|
1579
1931
|
for (const depId of phase.dependsOn ?? []) {
|
|
1580
1932
|
const d = state.def.phases.find((p) => p.id === depId);
|
|
1581
1933
|
if (!d)
|
|
@@ -1637,7 +1989,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1637
1989
|
const SCRIPT_TIMEOUT_MS = phase.timeout ?? 60_000;
|
|
1638
1990
|
const reads = readRefs.length ? readRefsToReads(readRefs, state) : undefined;
|
|
1639
1991
|
try {
|
|
1640
|
-
const
|
|
1992
|
+
const invoke = () => runScriptCommand({
|
|
1641
1993
|
interpRunText,
|
|
1642
1994
|
arrayForm: Array.isArray(cmd),
|
|
1643
1995
|
cwd: effCwd,
|
|
@@ -1645,6 +1997,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1645
1997
|
stdinInput,
|
|
1646
1998
|
timeoutMs: SCRIPT_TIMEOUT_MS,
|
|
1647
1999
|
});
|
|
2000
|
+
const result = deps._workspaceBinding
|
|
2001
|
+
? await deps._workspaceBinding.runScript({ unitId: phase.id, signal: deps.signal, invoke })
|
|
2002
|
+
: await invoke();
|
|
1648
2003
|
const ps = scriptResultToPhaseState(phase, result, {
|
|
1649
2004
|
inputHash,
|
|
1650
2005
|
timeoutMs: SCRIPT_TIMEOUT_MS,
|
|
@@ -1961,16 +2316,29 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1961
2316
|
// Resolve sub-flow args (interpolate string values), then apply declared defaults.
|
|
1962
2317
|
const provided = {};
|
|
1963
2318
|
for (const [k, v] of Object.entries(phase.with ?? {})) {
|
|
1964
|
-
provided[k] =
|
|
2319
|
+
provided[k] = interpolateValue(v, ctx);
|
|
1965
2320
|
}
|
|
1966
2321
|
const subArgs = resolveArgs(subDef, provided);
|
|
2322
|
+
if (deps._dynamic === true) {
|
|
2323
|
+
const dynamicChild = validateTaskflow(subDef, { dynamic: true, cwd: effCwd, args: subArgs });
|
|
2324
|
+
if (!dynamicChild.ok) {
|
|
2325
|
+
return failPhase(phase.id, `dynamic nested flow '${subDef.name}' is invalid: ${dynamicChild.errors.join("; ")}`);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
// Re-check the exact loaded definition at the cache boundary. A loader may
|
|
2329
|
+
// change between the root pre-scan and this phase (or return aliases), and
|
|
2330
|
+
// a bridge-bearing child must never be skipped by a cached parent result.
|
|
2331
|
+
const nestedBridgeTree = flowTreeUsesCwdBridge(subDef, deps.loadFlow);
|
|
2332
|
+
if (nestedBridgeTree)
|
|
2333
|
+
deps._disableCache = true;
|
|
2334
|
+
const flowCc = nestedBridgeTree ? { ...cc, scope: "off" } : cc;
|
|
1967
2335
|
// Every sub-flow cache identity includes the resolved definition. A saved
|
|
1968
2336
|
// flow's name alone is insufficient: its contents can change without the
|
|
1969
2337
|
// parent definition moving.
|
|
1970
2338
|
const flowIdentity = `${hasDef ? "def" : "flow"}:${name}:${JSON.stringify(subDef)}`;
|
|
1971
|
-
const ck = cacheKeys(
|
|
2339
|
+
const ck = cacheKeys(flowCc, [phase.id, flowIdentity, preRead, JSON.stringify(subArgs)]);
|
|
1972
2340
|
const inputHash = ck.key;
|
|
1973
|
-
const cached = cachedPhase(
|
|
2341
|
+
const cached = cachedPhase(flowCc, ck);
|
|
1974
2342
|
if (cached) {
|
|
1975
2343
|
if (type === "expand" && expandMode === "graft" && cached.promotedPhases) {
|
|
1976
2344
|
const promo = promoteGraftPhases(state, cached.promotedPhases);
|
|
@@ -2012,6 +2380,8 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
2012
2380
|
// phases without an explicit cwd derive their subagents from the
|
|
2013
2381
|
// flow's cwd (not the caller's cwd).
|
|
2014
2382
|
cwd: effCwd,
|
|
2383
|
+
_cacheCwdIdentity: phase.cwd !== undefined || deps._cacheCwdIdentity !== undefined ? effCwd : undefined,
|
|
2384
|
+
_dynamic: hasDef || deps._dynamic === true ? true : undefined,
|
|
2015
2385
|
// The workspace override applies only to THIS flow phase, not to the
|
|
2016
2386
|
// nested sub-phases (each resolves its own cwd). Clear it so the child
|
|
2017
2387
|
// phases don't all inherit this phase's isolated dir as an override.
|
|
@@ -2040,6 +2410,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
2040
2410
|
},
|
|
2041
2411
|
});
|
|
2042
2412
|
const sp = Object.values(subState.phases);
|
|
2413
|
+
const nestedFailure = sp.find((nested) => nested.status === "failed")?.error;
|
|
2043
2414
|
// expand graft promote — pure helper (see runtime/phases/expand.ts)
|
|
2044
2415
|
const warnings = [];
|
|
2045
2416
|
let graftPromotedIds = [];
|
|
@@ -2073,7 +2444,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
2073
2444
|
running: 0,
|
|
2074
2445
|
failed: sp.filter((p) => p.status === "failed").length,
|
|
2075
2446
|
},
|
|
2076
|
-
error: subResult.ok
|
|
2447
|
+
error: subResult.ok
|
|
2448
|
+
? undefined
|
|
2449
|
+
: `sub-flow '${name}' ${subResult.state.status}${nestedFailure ? `: ${nestedFailure}` : ""}`,
|
|
2077
2450
|
inputHash,
|
|
2078
2451
|
reads: readRefsToReads(readRefs, state),
|
|
2079
2452
|
endedAt: Date.now(),
|
|
@@ -2082,7 +2455,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
2082
2455
|
? { promotedPhases: Object.fromEntries(graftPromotedIds.map((id) => [id, { ...subState.phases[id] }])) }
|
|
2083
2456
|
: {}),
|
|
2084
2457
|
};
|
|
2085
|
-
recordCache(
|
|
2458
|
+
recordCache(flowCc, flowPs);
|
|
2086
2459
|
return flowPs;
|
|
2087
2460
|
}
|
|
2088
2461
|
// loop-until-done: run the body repeatedly until `until` is truthy, the output
|
|
@@ -2503,6 +2876,7 @@ export function cacheKeys(cc, baseParts) {
|
|
|
2503
2876
|
`agent-scope:${cc.agentScope ?? "user"}`,
|
|
2504
2877
|
`context-sharing:${cc.contextSharing === true ? "1" : "0"}`,
|
|
2505
2878
|
`agents:${cc.agentDefinitions ?? ""}`,
|
|
2879
|
+
...(cc.executionCwd ? [`cwd:${cc.executionCwd}`] : []),
|
|
2506
2880
|
];
|
|
2507
2881
|
const fold = (parts) => cc.fingerprint ? hashInput(...parts, cc.fingerprint) : hashInput(...parts);
|
|
2508
2882
|
// Per-phase sub-fingerprint; falls back to the whole-flow hash when absent
|
|
@@ -2738,9 +3112,27 @@ export async function recomputeTaskflow(state, deps, seeds,
|
|
|
2738
3112
|
// Fail-safe default: a real recompute overwrites the run and spends tokens.
|
|
2739
3113
|
// The tool/command wrappers can explicitly opt into dryRun:false.
|
|
2740
3114
|
opts = { dryRun: true }) {
|
|
3115
|
+
deps = snapshotFlowLoader(deps);
|
|
2741
3116
|
// Never mutate the caller's RunState in-place. Recompute is a speculative
|
|
2742
3117
|
// replay; only the caller decides whether to persist the new state.
|
|
2743
3118
|
const newState = structuredClone(state);
|
|
3119
|
+
newState.args = resolveArgs(newState.def, newState.args);
|
|
3120
|
+
const invocationErrors = validateInvocationArgs(newState.def, newState.args);
|
|
3121
|
+
if (invocationErrors.length > 0) {
|
|
3122
|
+
throw new Error(`Taskflow '${newState.def.name}' invocation is invalid: ${invocationErrors.join("; ")}`);
|
|
3123
|
+
}
|
|
3124
|
+
const bridgeTree = flowTreeUsesCwdBridge(newState.def, deps.loadFlow);
|
|
3125
|
+
// Once a run has exercised the compatibility bridge, its persisted root
|
|
3126
|
+
// binding is permanent provenance. A later definition downgrade must not
|
|
3127
|
+
// silently turn cache/recompute back on for state produced with filesystem
|
|
3128
|
+
// authority.
|
|
3129
|
+
const bridgeTainted = bridgeTree || newState.cwdRootBinding !== undefined;
|
|
3130
|
+
if (bridgeTainted && opts.dryRun === false) {
|
|
3131
|
+
throw new Error("recompute dryRun:false is unavailable for cwd-bridge flows until workspace state restoration exists; run the whole flow instead");
|
|
3132
|
+
}
|
|
3133
|
+
if (!deps._disableCache && bridgeTainted) {
|
|
3134
|
+
deps = { ...deps, _disableCache: true };
|
|
3135
|
+
}
|
|
2744
3136
|
const reads = readMapOf(newState.phases);
|
|
2745
3137
|
// M2: derive the declared read-map fresh from the def so the frontier uses
|
|
2746
3138
|
// the UNION (observed ∪ declared). Derived here (not read from the persisted
|
|
@@ -2868,7 +3260,7 @@ opts = { dryRun: true }) {
|
|
|
2868
3260
|
newState.phases[id] = ps;
|
|
2869
3261
|
// A phase counts as "rerun" if it was a forced seed OR its result moved;
|
|
2870
3262
|
// otherwise it hit its cache (inputHash unchanged) → early cutoff.
|
|
2871
|
-
if (isSeed || ps.inputHash !== before) {
|
|
3263
|
+
if (isSeed || !ps.cacheHit || ps.inputHash !== before) {
|
|
2872
3264
|
rerun.push(id);
|
|
2873
3265
|
outputMoved.add(id);
|
|
2874
3266
|
decisions.push(isSeed
|
|
@@ -2924,7 +3316,106 @@ opts = { dryRun: true }) {
|
|
|
2924
3316
|
};
|
|
2925
3317
|
}
|
|
2926
3318
|
export async function executeTaskflow(state, deps) {
|
|
3319
|
+
deps = snapshotFlowLoader(deps);
|
|
2927
3320
|
const def = state.def;
|
|
3321
|
+
// Normalize defaults at the engine boundary too. Adapters already do this,
|
|
3322
|
+
// but direct Core callers, resume, and detached execution must behave the same.
|
|
3323
|
+
state.args = resolveArgs(def, state.args);
|
|
3324
|
+
const invocationErrors = validateInvocationArgs(def, state.args);
|
|
3325
|
+
if (invocationErrors.length > 0) {
|
|
3326
|
+
state.status = "failed";
|
|
3327
|
+
safeEmit(deps, state);
|
|
3328
|
+
return {
|
|
3329
|
+
state,
|
|
3330
|
+
finalOutput: `Taskflow '${def.name}' invocation is invalid: ${invocationErrors.join("; ")}`,
|
|
3331
|
+
ok: false,
|
|
3332
|
+
totalUsage: emptyUsage(),
|
|
3333
|
+
};
|
|
3334
|
+
}
|
|
3335
|
+
if (deps._dynamic === true) {
|
|
3336
|
+
const dynamicValidation = validateTaskflow(def, { dynamic: true, cwd: deps.cwd, args: state.args });
|
|
3337
|
+
if (!dynamicValidation.ok) {
|
|
3338
|
+
state.status = "failed";
|
|
3339
|
+
safeEmit(deps, state);
|
|
3340
|
+
return {
|
|
3341
|
+
state,
|
|
3342
|
+
finalOutput: `Dynamic taskflow '${def.name}' is invalid: ${dynamicValidation.errors.join("; ")}`,
|
|
3343
|
+
ok: false,
|
|
3344
|
+
totalUsage: emptyUsage(),
|
|
3345
|
+
};
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
// A cwd bridge carries compatibility read-write authority. Until workspace
|
|
3349
|
+
// state restoration exists, output-only cache hits could skip required file
|
|
3350
|
+
// mutations or let downstream phases observe stale files. Disable cache and
|
|
3351
|
+
// within-run resume reuse across the complete reachable flow tree.
|
|
3352
|
+
const bridgeTree = flowTreeUsesCwdBridge(def, deps.loadFlow);
|
|
3353
|
+
// Persisted binding is a permanent taint bit: saved-flow definitions can
|
|
3354
|
+
// change between resumes, but prior outputs may already depend on filesystem
|
|
3355
|
+
// mutations. Never regain cache/rebind privileges merely because the current
|
|
3356
|
+
// snapshot no longer declares the bridge.
|
|
3357
|
+
const bridgeTainted = bridgeTree || state.cwdRootBinding !== undefined;
|
|
3358
|
+
if (bridgeTainted) {
|
|
3359
|
+
const invocationRoot = directoryIdentity(deps.cwd);
|
|
3360
|
+
const statePathRoot = directoryIdentity(state.cwd);
|
|
3361
|
+
const launchRoot = state.invocationRootSnapshot;
|
|
3362
|
+
const recordedRoot = state.cwdRootBinding;
|
|
3363
|
+
const executablePhaseIds = new Set(def.phases.map((phase) => phase.id));
|
|
3364
|
+
const hasExecutablePriorState = Object.keys(state.phases).some((id) => executablePhaseIds.has(id));
|
|
3365
|
+
// Pre-seeded external dependencies are inputs, not evidence that a bridge
|
|
3366
|
+
// phase previously executed without a persisted root binding. Conversely,
|
|
3367
|
+
// a host's launch snapshot proves root continuity, not prior bridge
|
|
3368
|
+
// authorization: adding a bridge after ordinary phases ran still fails.
|
|
3369
|
+
const isLegacyResume = bridgeTree && recordedRoot === undefined && hasExecutablePriorState;
|
|
3370
|
+
if (isLegacyResume ||
|
|
3371
|
+
!sameDirectoryIdentity(statePathRoot, invocationRoot) ||
|
|
3372
|
+
(launchRoot !== undefined && !sameDirectoryIdentity(launchRoot, invocationRoot)) ||
|
|
3373
|
+
(recordedRoot !== undefined && !sameDirectoryIdentity(recordedRoot, invocationRoot))) {
|
|
3374
|
+
state.status = "failed";
|
|
3375
|
+
safeEmit(deps, state);
|
|
3376
|
+
return {
|
|
3377
|
+
state,
|
|
3378
|
+
finalOutput: `Taskflow '${def.name}' cwd-bridge invocation root does not match the run's persisted root; start a new run instead of rebinding on resume`,
|
|
3379
|
+
ok: false,
|
|
3380
|
+
totalUsage: emptyUsage(),
|
|
3381
|
+
};
|
|
3382
|
+
}
|
|
3383
|
+
state.cwdRootBinding ??= invocationRoot;
|
|
3384
|
+
// Freeze the invocation root to the canonical identity we just bound. In
|
|
3385
|
+
// particular, do not resolve phase cwd through a caller-provided symlink a
|
|
3386
|
+
// second time after the root-binding check.
|
|
3387
|
+
if (invocationRoot)
|
|
3388
|
+
deps = { ...deps, cwd: invocationRoot.canonicalPath };
|
|
3389
|
+
}
|
|
3390
|
+
if (!deps._disableCache && bridgeTainted) {
|
|
3391
|
+
deps = { ...deps, _disableCache: true };
|
|
3392
|
+
}
|
|
3393
|
+
// The explicit 0.2.1 resolve-only opt-in uses a W1a-compatible partial
|
|
3394
|
+
// control/durability scaffold. This does not upgrade its assurance: the
|
|
3395
|
+
// session is deliberately labelled resolve-only and no OS sandbox claim is
|
|
3396
|
+
// made. A native session must come from an exact approved host baseline cell.
|
|
3397
|
+
if (bridgeTree && deps.cwdBridgeMode === "resolve-only" && !deps.workspaceSession) {
|
|
3398
|
+
try {
|
|
3399
|
+
deps = {
|
|
3400
|
+
...deps,
|
|
3401
|
+
workspaceSession: await createResolveOnlyWorkspaceSession({
|
|
3402
|
+
invocationRoot: deps.cwd,
|
|
3403
|
+
controlDirectory: deps.workspaceControlDirectory,
|
|
3404
|
+
signal: deps.signal,
|
|
3405
|
+
}),
|
|
3406
|
+
};
|
|
3407
|
+
}
|
|
3408
|
+
catch (error) {
|
|
3409
|
+
state.status = "failed";
|
|
3410
|
+
safeEmit(deps, state);
|
|
3411
|
+
return {
|
|
3412
|
+
state,
|
|
3413
|
+
finalOutput: `Taskflow '${def.name}' workspace capability initialization failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
3414
|
+
ok: false,
|
|
3415
|
+
totalUsage: emptyUsage(),
|
|
3416
|
+
};
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
2928
3419
|
const runnerUsageAccounting = deps.runTask
|
|
2929
3420
|
?.usageAccounting;
|
|
2930
3421
|
if (!deps.usageAccounting && runnerUsageAccounting) {
|
|
@@ -2948,7 +3439,7 @@ export async function executeTaskflow(state, deps) {
|
|
|
2948
3439
|
// not yet persist compatible input hashes, so it must never blindly trust a
|
|
2949
3440
|
// prior `done` row.
|
|
2950
3441
|
const hasPriorState = Object.keys(state.phases).length > 0;
|
|
2951
|
-
if (eventKernelEnabled(deps) && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) {
|
|
3442
|
+
if (eventKernelEnabled(deps) && deps._cwdBoundary === undefined && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) {
|
|
2952
3443
|
if (!deps.runTask) {
|
|
2953
3444
|
throw new Error("event kernel requires RuntimeDeps.runTask");
|
|
2954
3445
|
}
|
|
@@ -2966,6 +3457,7 @@ export async function executeTaskflow(state, deps) {
|
|
|
2966
3457
|
requestApproval: deps.requestApproval,
|
|
2967
3458
|
loadFlow: deps.loadFlow,
|
|
2968
3459
|
_stack: deps._stack,
|
|
3460
|
+
_dynamic: deps._dynamic,
|
|
2969
3461
|
});
|
|
2970
3462
|
}
|
|
2971
3463
|
return await runTaskflowLayers(state, deps);
|