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