taskflow-core 0.1.8 → 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 +248 -808
- 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/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 +48 -0
- package/dist/exec/driver.d.ts.map +1 -0
- package/dist/exec/driver.js +410 -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 +599 -0
- package/dist/exec/step-kinds.js.map +1 -0
- package/dist/exec/step.d.ts +94 -0
- package/dist/exec/step.d.ts.map +1 -0
- package/dist/exec/step.js +454 -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 +232 -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/host/runner-types.d.ts +19 -0
- package/dist/host/runner-types.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -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/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/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/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 +53 -0
- package/dist/runner-core.d.ts.map +1 -1
- package/dist/runner-core.js +521 -53
- 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 +177 -0
- package/dist/runtime/phases/script.js.map +1 -0
- package/dist/runtime.d.ts +68 -16
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +1091 -337
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts +104 -9
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +249 -32
- package/dist/schema.js.map +1 -1
- package/dist/store.d.ts +13 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/trace.d.ts +7 -1
- package/dist/trace.d.ts.map +1 -1
- package/dist/trace.js +33 -26
- package/dist/trace.js.map +1 -1
- package/package.json +7 -5
package/dist/runtime.js
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
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
|
-
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, validateInvocationArgs, 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.
|
|
@@ -43,6 +45,8 @@ import { compileTaskflowToIR, phaseFingerprint } from "./flowir/index.js";
|
|
|
43
45
|
import { computeStaleFrontier, declaredReadMapOfDef, readMapOf } from "./stale.js";
|
|
44
46
|
import { ctxDirFor, drainPendingSpawns, initCtxDir, registerNode, setNodeStatus } from "./context-store.js";
|
|
45
47
|
import { allocateWorkspace, isWorkspaceKeyword } from "./workspace.js";
|
|
48
|
+
import { cwdArgName, directoryIdentity, isPathWithin, resolveCwdArg, } from "./cwd-bridge.js";
|
|
49
|
+
import { createResolveOnlyWorkspaceSession, } from "./resources/execution.js";
|
|
46
50
|
/** Compute the incremental-reuse summary from a run's terminal phase states.
|
|
47
51
|
* Pure, total, never throws. A phase is "reused" iff it carries a `cacheHit`
|
|
48
52
|
* marker (set by `cachedPhase` for both within-run resume and cross-run hits). */
|
|
@@ -252,13 +256,17 @@ function normalizeInlineDef(parsed, phaseId) {
|
|
|
252
256
|
* than the parent's, never looser. A generated def cannot raise the spend cap by
|
|
253
257
|
* declaring its own large budget. Each dimension becomes min(child, parent).
|
|
254
258
|
*/
|
|
255
|
-
function clampSubFlowBudget(sub, parentBudget) {
|
|
259
|
+
function clampSubFlowBudget(sub, parentBudget, spent = emptyUsage()) {
|
|
256
260
|
if (!parentBudget)
|
|
257
261
|
return sub;
|
|
258
262
|
const child = sub.budget;
|
|
263
|
+
const remainingUSD = parentBudget.maxUSD === undefined ? Infinity : Math.max(0, parentBudget.maxUSD - spent.cost);
|
|
264
|
+
const remainingTokens = parentBudget.maxTokens === undefined
|
|
265
|
+
? Infinity
|
|
266
|
+
: Math.max(0, parentBudget.maxTokens - (spent.input + spent.output));
|
|
259
267
|
const clamped = {
|
|
260
|
-
maxUSD: Math.min(child?.maxUSD ?? Infinity,
|
|
261
|
-
maxTokens: Math.min(child?.maxTokens ?? Infinity,
|
|
268
|
+
maxUSD: Math.min(child?.maxUSD ?? Infinity, remainingUSD),
|
|
269
|
+
maxTokens: Math.min(child?.maxTokens ?? Infinity, remainingTokens),
|
|
262
270
|
};
|
|
263
271
|
// Drop Infinity dimensions (no cap on that axis).
|
|
264
272
|
const budget = {};
|
|
@@ -344,6 +352,37 @@ function traceEmit(deps, event) {
|
|
|
344
352
|
/* trace is best-effort; never run-breaking */
|
|
345
353
|
}
|
|
346
354
|
}
|
|
355
|
+
/** Emit a `decision` event (S1: full decision coverage for fold/replay). Fail-open. */
|
|
356
|
+
function traceDecision(deps, state, phaseId, decision) {
|
|
357
|
+
traceEmit(deps, {
|
|
358
|
+
ts: Date.now(),
|
|
359
|
+
runId: state.runId,
|
|
360
|
+
phaseId,
|
|
361
|
+
kind: "decision",
|
|
362
|
+
decision,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
/** Emit gate decision as gate-score when scores present, else gate-verdict. */
|
|
366
|
+
function traceGateDecision(deps, state, phaseId, gate, judgeOutput) {
|
|
367
|
+
if (gate.scores) {
|
|
368
|
+
traceDecision(deps, state, phaseId, {
|
|
369
|
+
type: "gate-score",
|
|
370
|
+
target: "",
|
|
371
|
+
results: gate.scores.results,
|
|
372
|
+
combined: gate.scores.combined,
|
|
373
|
+
threshold: gate.scores.threshold,
|
|
374
|
+
verdict: gate.verdict,
|
|
375
|
+
judgeOutput,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
traceDecision(deps, state, phaseId, {
|
|
380
|
+
type: "gate-verdict",
|
|
381
|
+
value: gate.verdict,
|
|
382
|
+
reason: gate.reason,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
347
386
|
/** Fail-open trace flush at phase-end. */
|
|
348
387
|
function traceFlush(deps, phaseId) {
|
|
349
388
|
try {
|
|
@@ -355,7 +394,7 @@ function traceFlush(deps, phaseId) {
|
|
|
355
394
|
}
|
|
356
395
|
/** Emit a `decision: unreplayable` marker for a phase whose inputs the trace
|
|
357
396
|
* cannot fully capture (Shared Context Tree, inner sub-flows, context files,
|
|
358
|
-
* unobservable interpolation deps).
|
|
397
|
+
* unobservable interpolation deps). Offline replay marks such phases
|
|
359
398
|
* `needs-live-rerun` instead of silently reusing a recorded output.
|
|
360
399
|
* Single-phase analog of `hasUnobservedDependencies`. Fail-open. */
|
|
361
400
|
function emitUnreplayableMarker(deps, state, phase) {
|
|
@@ -371,7 +410,7 @@ function emitUnreplayableMarker(deps, state, phase) {
|
|
|
371
410
|
function unreplayableReason(state, phase) {
|
|
372
411
|
if (phase.shareContext === true || state.def.contextSharing === true)
|
|
373
412
|
return "context-sharing";
|
|
374
|
-
if (phase.type === "flow")
|
|
413
|
+
if (phase.type === "flow" || phase.type === "expand")
|
|
375
414
|
return "inner-flow";
|
|
376
415
|
if (phase.context && phase.context.length > 0)
|
|
377
416
|
return "context-files";
|
|
@@ -406,7 +445,7 @@ function liveSink(state, phaseId, emitProgress) {
|
|
|
406
445
|
*/
|
|
407
446
|
const CONTEXT_MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
408
447
|
const MAX_TOTAL_CONTEXT_CHARS = 200_000;
|
|
409
|
-
async function resolvePhaseContext(phase, ctx) {
|
|
448
|
+
async function resolvePhaseContext(phase, ctx, cwd, boundary) {
|
|
410
449
|
const entries = phase.context;
|
|
411
450
|
if (!entries || entries.length === 0)
|
|
412
451
|
return "";
|
|
@@ -445,20 +484,29 @@ async function resolvePhaseContext(phase, ctx) {
|
|
|
445
484
|
const blocks = [];
|
|
446
485
|
for (const p of filtered) {
|
|
447
486
|
try {
|
|
448
|
-
const abs = path.resolve(p);
|
|
449
|
-
|
|
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);
|
|
450
496
|
if (!stat.isFile())
|
|
451
497
|
continue;
|
|
452
498
|
if (stat.size > CONTEXT_MAX_FILE_BYTES)
|
|
453
499
|
continue;
|
|
454
|
-
const content = fs.readFileSync(
|
|
500
|
+
const content = fs.readFileSync(canonical, "utf-8");
|
|
455
501
|
const truncated = content.length > limit
|
|
456
502
|
? content.slice(0, limit) + `\n... [truncated ${content.length - limit} chars]`
|
|
457
503
|
: content;
|
|
458
504
|
const ext = path.extname(p).slice(1) || "txt";
|
|
459
505
|
blocks.push(`## File: ${p}\n\n\`\`\`${ext}\n${truncated}\n\`\`\``);
|
|
460
506
|
}
|
|
461
|
-
catch {
|
|
507
|
+
catch (error) {
|
|
508
|
+
if (error instanceof Error && error.message.startsWith("TF_CWD_BOUNDARY_ESCAPE:"))
|
|
509
|
+
throw error;
|
|
462
510
|
console.warn(`[taskflow] Skipped unreadable context file: ${p}`);
|
|
463
511
|
}
|
|
464
512
|
}
|
|
@@ -469,6 +517,16 @@ async function resolvePhaseContext(phase, ctx) {
|
|
|
469
517
|
}
|
|
470
518
|
return result;
|
|
471
519
|
}
|
|
520
|
+
function spawnedOverBudget(state, local) {
|
|
521
|
+
const budget = state.def.budget;
|
|
522
|
+
if (!budget)
|
|
523
|
+
return false;
|
|
524
|
+
return overBudgetCheck({
|
|
525
|
+
maxUSD: budget.maxUSD,
|
|
526
|
+
maxTokens: budget.maxTokens,
|
|
527
|
+
usages: [...Object.values(state.phases).map((p) => p.usage ?? emptyUsage()), local],
|
|
528
|
+
}).over;
|
|
529
|
+
}
|
|
472
530
|
/**
|
|
473
531
|
* Run an inline sub-flow queued via `ctx_spawn({subflow})`. Reuses the SAME
|
|
474
532
|
* validation + execution machinery as a `flow{def}` phase (normalizeInlineDef →
|
|
@@ -490,19 +548,85 @@ async function resolvePhaseContext(phase, ctx) {
|
|
|
490
548
|
* two isolation-leak bugs in the 0.0.23 review).
|
|
491
549
|
*/
|
|
492
550
|
function resolveEffCwd(deps, phase) {
|
|
493
|
-
|
|
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);
|
|
494
558
|
}
|
|
495
|
-
|
|
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;
|
|
617
|
+
}
|
|
618
|
+
async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, deps, state, localSpawnUsage) {
|
|
496
619
|
const stack = deps._stack ?? [];
|
|
497
620
|
const inlineDepth = stack.filter((s) => s.startsWith("def:")).length;
|
|
498
621
|
if (inlineDepth >= MAX_DYNAMIC_NESTING) {
|
|
499
|
-
|
|
622
|
+
const error = `spawned subflow rejected: nesting exceeded MAX_DYNAMIC_NESTING (${MAX_DYNAMIC_NESTING})`;
|
|
623
|
+
return { output: `(${error})`, usage: emptyUsage(), failed: true, error };
|
|
500
624
|
}
|
|
501
625
|
const wrapped = normalizeInlineDef(subflowSpec, childNodeId);
|
|
502
626
|
if (!wrapped)
|
|
503
|
-
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" };
|
|
504
628
|
if (wrapped.phases.length === 0)
|
|
505
|
-
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 };
|
|
506
630
|
// Inner phases without their own agent inherit the assignment's defaultAgent.
|
|
507
631
|
if (defaultAgent) {
|
|
508
632
|
for (const p of wrapped.phases)
|
|
@@ -512,14 +636,26 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
|
|
|
512
636
|
const spawnCwd = resolveEffCwd(deps, phase);
|
|
513
637
|
const dynCwd = spawnCwd;
|
|
514
638
|
const v = validateTaskflow(wrapped, { dynamic: true, cwd: dynCwd });
|
|
515
|
-
if (!v.ok)
|
|
516
|
-
|
|
639
|
+
if (!v.ok) {
|
|
640
|
+
const error = `spawned subflow failed validation: ${v.errors.join("; ")}`;
|
|
641
|
+
return { output: `(${error})`, usage: emptyUsage(), failed: true, error };
|
|
642
|
+
}
|
|
517
643
|
const ver = verifyTaskflow({ name: wrapped.name, phases: wrapped.phases, budget: wrapped.budget, concurrency: wrapped.concurrency });
|
|
518
644
|
if (!ver.ok) {
|
|
519
645
|
const errs = ver.issues.filter((i) => i.severity === "error").map((i) => i.message);
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
646
|
+
const error = `spawned subflow failed verification: ${errs.join("; ")}`;
|
|
647
|
+
return { output: `(${error})`, usage: emptyUsage(), failed: true, error };
|
|
648
|
+
}
|
|
649
|
+
// The generated sub-flow gets only what remains after both already-folded
|
|
650
|
+
// parent spend and siblings/ancestors in this still-running spawn batch. USD
|
|
651
|
+
// and tokens are clamped independently by clampSubFlowBudget. Like the main
|
|
652
|
+
// runtime, this is an atomic-call ceiling: one call may cross the cap, then no
|
|
653
|
+
// subsequent call is admitted.
|
|
654
|
+
const parentAndBatchSpent = aggregateUsage([
|
|
655
|
+
...Object.values(state.phases).map((p) => p.usage ?? emptyUsage()),
|
|
656
|
+
localSpawnUsage,
|
|
657
|
+
]);
|
|
658
|
+
const subDef = clampSubFlowBudget(wrapped, state.def.budget, parentAndBatchSpent);
|
|
523
659
|
const subState = {
|
|
524
660
|
runId: newRunId(subDef.name),
|
|
525
661
|
flowName: subDef.name,
|
|
@@ -535,6 +671,8 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
|
|
|
535
671
|
const subResult = await executeTaskflow(subState, {
|
|
536
672
|
...deps,
|
|
537
673
|
cwd: dynCwd,
|
|
674
|
+
_cacheCwdIdentity: phase.cwd !== undefined || deps._cacheCwdIdentity !== undefined ? dynCwd : undefined,
|
|
675
|
+
_dynamic: true,
|
|
538
676
|
// The parent phase's isolated workspace (if any) applies only to the
|
|
539
677
|
// parent — each spawned sub-phase resolves its own cwd. Clear the
|
|
540
678
|
// override so the whole subflow doesn't inherit the parent's dir
|
|
@@ -550,22 +688,29 @@ async function runInlineSubflow(subflowSpec, defaultAgent, childNodeId, phase, d
|
|
|
550
688
|
// Sum every sub-phase's usage so the parent's budget guard sees spawn spend
|
|
551
689
|
// (verdict Issue 2).
|
|
552
690
|
const usage = aggregateUsage(Object.values(subResult.state.phases).map((p) => p.usage ?? emptyUsage()));
|
|
553
|
-
return {
|
|
691
|
+
return {
|
|
692
|
+
output: subResult.finalOutput ?? "",
|
|
693
|
+
usage,
|
|
694
|
+
failed: !subResult.ok,
|
|
695
|
+
...(!subResult.ok ? { error: sanitizeErrorMessage(subResult.finalOutput || "spawned subflow failed") } : {}),
|
|
696
|
+
};
|
|
554
697
|
}
|
|
555
698
|
catch (e) {
|
|
556
|
-
|
|
699
|
+
const error = sanitizeErrorMessage(e instanceof Error ? e.message : String(e));
|
|
700
|
+
return { output: `(spawned subflow failed: ${error})`, usage: emptyUsage(), failed: true, error };
|
|
557
701
|
}
|
|
558
702
|
}
|
|
559
|
-
async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps, state, run) {
|
|
703
|
+
async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps, state, run, ledger = { usage: emptyUsage() }) {
|
|
560
704
|
const capped = assignments.slice(0, MAX_DYNAMIC_MAP_ITEMS);
|
|
561
705
|
const lines = [];
|
|
562
706
|
const usages = [];
|
|
707
|
+
const errors = [];
|
|
563
708
|
// Effective cwd for flat spawned tasks: honour a workspace override and never
|
|
564
709
|
// pass a reserved keyword through to the runner.
|
|
565
710
|
const spawnCwd = resolveEffCwd(deps, phase);
|
|
566
711
|
let idx = 0;
|
|
567
712
|
for (const a of capped) {
|
|
568
|
-
if (deps.signal?.aborted ||
|
|
713
|
+
if (deps.signal?.aborted || spawnedOverBudget(state, ledger.usage))
|
|
569
714
|
break;
|
|
570
715
|
idx++;
|
|
571
716
|
const childNodeId = `${parentNodeId}--c${idx}`.replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
@@ -575,42 +720,92 @@ async function runSpawnedChildren(assignments, ctxDir, parentNodeId, phase, deps
|
|
|
575
720
|
let out = "";
|
|
576
721
|
try {
|
|
577
722
|
if (isSubflow) {
|
|
578
|
-
const sub = await runInlineSubflow(a.subflow, a.defaultAgent ?? phase.agent, childNodeId, phase, deps, state);
|
|
723
|
+
const sub = await runInlineSubflow(a.subflow, a.defaultAgent ?? phase.agent, childNodeId, phase, deps, state, ledger.usage);
|
|
579
724
|
out = sub.output;
|
|
580
725
|
usages.push(sub.usage);
|
|
581
|
-
|
|
726
|
+
ledger.usage = aggregateUsage([ledger.usage, sub.usage]);
|
|
727
|
+
if (sub.failed)
|
|
728
|
+
errors.push(sub.error ?? `spawned subflow ${childNodeId} failed`);
|
|
729
|
+
setNodeStatus(ctxDir, childNodeId, sub.failed ? "failed" : "done");
|
|
582
730
|
}
|
|
583
731
|
else {
|
|
584
|
-
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();
|
|
585
757
|
out = r.output ?? "";
|
|
586
|
-
if (r
|
|
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
|
+
}
|
|
764
|
+
if (r.usage) {
|
|
587
765
|
usages.push(r.usage);
|
|
766
|
+
ledger.usage = aggregateUsage([ledger.usage, r.usage]);
|
|
767
|
+
}
|
|
588
768
|
setNodeStatus(ctxDir, childNodeId, isFailed(r) ? "failed" : "done");
|
|
589
769
|
// A child may itself have queued spawns — recurse (depth-capped by the tool).
|
|
590
770
|
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);
|
|
771
|
+
if (grand.length > 0 && !deps.signal?.aborted && !spawnedOverBudget(state, ledger.usage)) {
|
|
772
|
+
const rec = await runSpawnedChildren(grand, ctxDir, childNodeId, phase, deps, state, run, ledger);
|
|
593
773
|
if (rec.reports)
|
|
594
774
|
out += rec.reports;
|
|
595
775
|
usages.push(rec.usage);
|
|
776
|
+
errors.push(...rec.errors);
|
|
596
777
|
}
|
|
597
778
|
}
|
|
598
779
|
}
|
|
599
780
|
catch (e) {
|
|
600
781
|
setNodeStatus(ctxDir, childNodeId, "failed");
|
|
601
|
-
|
|
782
|
+
const detail = sanitizeErrorMessage(e instanceof Error ? e.message : String(e));
|
|
783
|
+
errors.push(detail);
|
|
784
|
+
out = `(spawned child failed: ${detail})`;
|
|
602
785
|
}
|
|
603
786
|
lines.push(`### spawned child ${idx} (${agentName})\n${out}`);
|
|
604
787
|
}
|
|
605
788
|
const usage = aggregateUsage(usages);
|
|
606
789
|
if (lines.length === 0)
|
|
607
|
-
return { reports: undefined, usage };
|
|
608
|
-
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
|
+
};
|
|
609
797
|
}
|
|
610
798
|
async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth = 0, opts) {
|
|
611
799
|
// Trace: phase-start (fail-open). Record whether this phase is replayable
|
|
612
|
-
// up front so
|
|
613
|
-
traceEmit(deps, {
|
|
800
|
+
// up front so replay can short-circuit it without guessing from output.
|
|
801
|
+
traceEmit(deps, {
|
|
802
|
+
ts: Date.now(),
|
|
803
|
+
runId: state.runId,
|
|
804
|
+
phaseId: phase.id,
|
|
805
|
+
kind: "phase-start",
|
|
806
|
+
dependencies: dependenciesOf(phase),
|
|
807
|
+
optional: phase.optional === true,
|
|
808
|
+
});
|
|
614
809
|
if (deps.trace)
|
|
615
810
|
emitUnreplayableMarker(deps, state, phase);
|
|
616
811
|
let result;
|
|
@@ -630,6 +825,13 @@ async function executePhase(phase, state, deps, prior, emitProgress, _retryDepth
|
|
|
630
825
|
}
|
|
631
826
|
if (threw)
|
|
632
827
|
return result; // unreachable; satisfies TS
|
|
828
|
+
// S1: cache-hit decision (within-run or cross-run) for fold/replay.
|
|
829
|
+
if (result.cacheHit) {
|
|
830
|
+
traceDecision(deps, state, phase.id, {
|
|
831
|
+
type: "cache-hit",
|
|
832
|
+
scope: result.cacheHit === "cross-run" ? "cross-run" : "run-only",
|
|
833
|
+
});
|
|
834
|
+
}
|
|
633
835
|
// Trace: phase-end with the real status, then flush buffered events.
|
|
634
836
|
traceEmit(deps, {
|
|
635
837
|
ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "phase-end",
|
|
@@ -644,6 +846,8 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
|
|
|
644
846
|
// every type branch inside executePhaseInner is covered. A skipped phase ran
|
|
645
847
|
// nothing — no side effect to record.
|
|
646
848
|
const stamp = (ps) => {
|
|
849
|
+
if (phase.optional === true)
|
|
850
|
+
ps.optional = true;
|
|
647
851
|
if (phase.idempotent === false && ps.status !== "skipped") {
|
|
648
852
|
ps.sideEffect = true;
|
|
649
853
|
// Resume double-fire warning (issue #20): a non-idempotent phase is never
|
|
@@ -657,9 +861,163 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
|
|
|
657
861
|
}
|
|
658
862
|
return ps;
|
|
659
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
|
+
}
|
|
660
1018
|
// Non-keyword cwd (or none): no workspace lifecycle — run directly.
|
|
661
1019
|
if (!isWorkspaceKeyword(phase.cwd)) {
|
|
662
|
-
return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth,
|
|
1020
|
+
return stamp(await executePhaseInner(phase, state, deps, prior, emitProgress, _retryDepth, innerOpts));
|
|
663
1021
|
}
|
|
664
1022
|
let ws;
|
|
665
1023
|
try {
|
|
@@ -673,9 +1031,9 @@ async function executePhaseImpl(phase, state, deps, prior, emitProgress, _retryD
|
|
|
673
1031
|
catch {
|
|
674
1032
|
ws = undefined; // fail-open: run in the base cwd
|
|
675
1033
|
}
|
|
676
|
-
const innerDeps = ws ? { ...deps, _cwdOverride: ws.dir } : deps;
|
|
1034
|
+
const innerDeps = ws ? { ...deps, _cwdOverride: ws.dir, _cacheCwdIdentity: ws.dir } : deps;
|
|
677
1035
|
try {
|
|
678
|
-
const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth,
|
|
1036
|
+
const ps = await executePhaseInner(phase, state, innerDeps, prior, emitProgress, _retryDepth, innerOpts);
|
|
679
1037
|
if (ws && (ws.kind !== "inherited" || ws.note)) {
|
|
680
1038
|
const tag = ws.kind === "inherited" ? "workspace" : `workspace:${ws.kind}`;
|
|
681
1039
|
const msg = ws.note ? `${tag} — ${ws.note}` : `${tag} at ${ws.dir}`;
|
|
@@ -725,7 +1083,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
725
1083
|
// M3 observed-readSet: collect every upstream ref this phase resolves, so we
|
|
726
1084
|
// can record what its result ACTUALLY depended on (not just its declared
|
|
727
1085
|
// dependsOn). Shared by every interpolation in this phase (task / when / …).
|
|
728
|
-
const readRefs = [];
|
|
1086
|
+
const readRefs = [...(opts?.whenReadRefs ?? [])];
|
|
729
1087
|
const onRead = (ref) => {
|
|
730
1088
|
readRefs.push(ref);
|
|
731
1089
|
};
|
|
@@ -734,8 +1092,14 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
734
1092
|
// dependencies. Evaluate them inside executePhaseInner so every upstream
|
|
735
1093
|
// interpolation is captured by the shared onRead hook, not silently dropped
|
|
736
1094
|
// by a separate out-of-band context.
|
|
737
|
-
if (phase.when !== undefined) {
|
|
738
|
-
|
|
1095
|
+
if (phase.when !== undefined && opts?.whenPrechecked !== true) {
|
|
1096
|
+
const whenResult = evaluateCondition(phase.when, ctx);
|
|
1097
|
+
traceDecision(deps, state, phase.id, {
|
|
1098
|
+
type: "when-guard",
|
|
1099
|
+
expression: phase.when,
|
|
1100
|
+
result: whenResult,
|
|
1101
|
+
});
|
|
1102
|
+
if (!whenResult) {
|
|
739
1103
|
return {
|
|
740
1104
|
id: phase.id,
|
|
741
1105
|
status: "skipped",
|
|
@@ -746,7 +1110,10 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
746
1110
|
};
|
|
747
1111
|
}
|
|
748
1112
|
}
|
|
749
|
-
|
|
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);
|
|
750
1117
|
// Resolve this phase's cache policy once. Default scope is "run-only" (the
|
|
751
1118
|
// historical within-run resume behavior). Only "cross-run" phases resolve a
|
|
752
1119
|
// fingerprint and consult the persistent store.
|
|
@@ -755,7 +1122,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
755
1122
|
// each run (schema already rejects explicit cross-run, but the default-scope
|
|
756
1123
|
// path must also be blocked). If flowDefHash failed, cross-run is unsafe
|
|
757
1124
|
// 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"]);
|
|
1125
|
+
const CROSS_RUN_BLOCKED_TYPES = new Set(["gate", "approval", "loop", "tournament", "script", "race", "expand"]);
|
|
759
1126
|
if (cacheScope === "cross-run" && CROSS_RUN_BLOCKED_TYPES.has(type)) {
|
|
760
1127
|
cacheScope = "run-only";
|
|
761
1128
|
}
|
|
@@ -770,6 +1137,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
770
1137
|
if (phase.idempotent === false) {
|
|
771
1138
|
cacheScope = "off";
|
|
772
1139
|
}
|
|
1140
|
+
if (deps._disableCache) {
|
|
1141
|
+
cacheScope = "off";
|
|
1142
|
+
}
|
|
773
1143
|
const cc = {
|
|
774
1144
|
scope: cacheScope,
|
|
775
1145
|
ttlMs: phase.cache?.ttl ? (parseTtlMs(phase.cache.ttl) ?? undefined) : undefined,
|
|
@@ -785,17 +1155,36 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
785
1155
|
thinking: phase.thinking,
|
|
786
1156
|
tools: phase.tools,
|
|
787
1157
|
preRead,
|
|
1158
|
+
agentScope: state.def.agentScope,
|
|
1159
|
+
contextSharing: state.def.contextSharing === true,
|
|
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();
|
|
788
1187
|
};
|
|
789
|
-
const baseRun = (agentName, task, onLive, ctxNodeId, signal) => run(effCwd, deps.agents, agentName, task, {
|
|
790
|
-
model: phase.model,
|
|
791
|
-
thinking: phase.thinking,
|
|
792
|
-
tools: phase.tools,
|
|
793
|
-
cwd: effCwd,
|
|
794
|
-
signal: signal ?? deps.signal,
|
|
795
|
-
onLive,
|
|
796
|
-
ctxDir: ctxDir,
|
|
797
|
-
nodeId: ctxDir ? ctxNodeId : undefined,
|
|
798
|
-
}, deps.globalThinking);
|
|
799
1188
|
// Wrap each subagent call in the phase's retry policy. Usage is summed across
|
|
800
1189
|
// attempts; the attempt count rides along on the result for the TUI.
|
|
801
1190
|
//
|
|
@@ -813,44 +1202,90 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
813
1202
|
const phaseTimeoutMs = type !== "script" && typeof phase.timeout === "number" && Number.isFinite(phase.timeout) && phase.timeout >= 1000
|
|
814
1203
|
? phase.timeout
|
|
815
1204
|
: undefined;
|
|
816
|
-
const runOne = async (agentName, task, onLive, ctxNodeId, check
|
|
1205
|
+
const runOne = async (agentName, task, onLive, ctxNodeId, check,
|
|
1206
|
+
/** Extra abort (e.g. race branch cancelLosers) — chained with run + phase timeout. */
|
|
1207
|
+
extraSignal) => {
|
|
817
1208
|
const explicitMax = Math.max(1, 1 + Math.max(0, Math.floor(retry?.max ?? 0)));
|
|
818
1209
|
// Allow enough attempts to cover whichever policy applies on a given attempt.
|
|
819
1210
|
const maxAttempts = Math.max(explicitMax, 1 + DEFAULT_TRANSIENT_RETRIES);
|
|
820
1211
|
const usages = [];
|
|
821
1212
|
let last;
|
|
822
1213
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
823
|
-
if (deps.signal?.aborted)
|
|
1214
|
+
if (deps.signal?.aborted || extraSignal?.aborted)
|
|
824
1215
|
break;
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
// retried (retrying a call that just burned its full cap would double-spend).
|
|
1216
|
+
// AbortController chains: run signal + optional extra (race cancel) + phase timeout.
|
|
1217
|
+
// Deterministic: a timed-out call is never retried (would double-spend).
|
|
828
1218
|
let timedOut = false;
|
|
1219
|
+
let terminalCommitted = false;
|
|
829
1220
|
let timer;
|
|
830
|
-
let
|
|
1221
|
+
let forceReturnTimer;
|
|
1222
|
+
const removers = [];
|
|
831
1223
|
let callSignal;
|
|
832
|
-
|
|
1224
|
+
let timeoutController;
|
|
1225
|
+
if (phaseTimeoutMs || extraSignal) {
|
|
833
1226
|
const ac = new AbortController();
|
|
1227
|
+
timeoutController = ac;
|
|
834
1228
|
callSignal = ac.signal;
|
|
835
|
-
if (deps.signal?.aborted)
|
|
1229
|
+
if (deps.signal?.aborted || extraSignal?.aborted)
|
|
836
1230
|
ac.abort();
|
|
837
|
-
else
|
|
838
|
-
|
|
839
|
-
|
|
1231
|
+
else {
|
|
1232
|
+
if (deps.signal) {
|
|
1233
|
+
const fn = () => ac.abort();
|
|
1234
|
+
deps.signal.addEventListener("abort", fn, { once: true });
|
|
1235
|
+
removers.push(() => deps.signal?.removeEventListener("abort", fn));
|
|
1236
|
+
}
|
|
1237
|
+
if (extraSignal) {
|
|
1238
|
+
const fn = () => ac.abort();
|
|
1239
|
+
extraSignal.addEventListener("abort", fn, { once: true });
|
|
1240
|
+
removers.push(() => extraSignal.removeEventListener("abort", fn));
|
|
1241
|
+
}
|
|
840
1242
|
}
|
|
841
|
-
timer = setTimeout(() => {
|
|
842
|
-
timedOut = true;
|
|
843
|
-
ac.abort();
|
|
844
|
-
}, phaseTimeoutMs);
|
|
845
1243
|
}
|
|
846
1244
|
try {
|
|
847
|
-
|
|
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);
|
|
1255
|
+
if (phaseTimeoutMs && timeoutController) {
|
|
1256
|
+
const timeoutFallback = new Promise((resolve) => {
|
|
1257
|
+
timer = setTimeout(() => {
|
|
1258
|
+
if (terminalCommitted)
|
|
1259
|
+
return;
|
|
1260
|
+
timedOut = true;
|
|
1261
|
+
timeoutController?.abort();
|
|
1262
|
+
forceReturnTimer = setTimeout(() => resolve({
|
|
1263
|
+
agent: agentName,
|
|
1264
|
+
task,
|
|
1265
|
+
exitCode: 1,
|
|
1266
|
+
output: "",
|
|
1267
|
+
stderr: "",
|
|
1268
|
+
usage: emptyUsage(),
|
|
1269
|
+
stopReason: "error",
|
|
1270
|
+
errorMessage: `Phase runner did not stop within ${PHASE_TIMEOUT_ABORT_GRACE_MS}ms after abort`,
|
|
1271
|
+
phaseTimeout: true,
|
|
1272
|
+
completionSource: "phase-timeout",
|
|
1273
|
+
}), PHASE_TIMEOUT_ABORT_GRACE_MS);
|
|
1274
|
+
}, phaseTimeoutMs);
|
|
1275
|
+
});
|
|
1276
|
+
last = await Promise.race([invocation, timeoutFallback]);
|
|
1277
|
+
}
|
|
1278
|
+
else {
|
|
1279
|
+
last = await invocation;
|
|
1280
|
+
}
|
|
848
1281
|
}
|
|
849
1282
|
finally {
|
|
850
1283
|
if (timer)
|
|
851
1284
|
clearTimeout(timer);
|
|
852
|
-
if (
|
|
853
|
-
|
|
1285
|
+
if (forceReturnTimer)
|
|
1286
|
+
clearTimeout(forceReturnTimer);
|
|
1287
|
+
for (const r of removers)
|
|
1288
|
+
r();
|
|
854
1289
|
}
|
|
855
1290
|
if (timedOut) {
|
|
856
1291
|
// Reclassify the abort as a phase timeout: a distinct, deterministic
|
|
@@ -862,8 +1297,20 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
862
1297
|
stopReason: "error",
|
|
863
1298
|
errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`,
|
|
864
1299
|
phaseTimeout: true,
|
|
1300
|
+
completionSource: "phase-timeout",
|
|
865
1301
|
};
|
|
866
1302
|
usages.push(last.usage);
|
|
1303
|
+
traceEmit(deps, {
|
|
1304
|
+
ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "subagent-call",
|
|
1305
|
+
input: { agent: agentName, model: phase.model, task, preRead, nodePath: ctxNodeId ?? phase.id, attempt },
|
|
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
|
+
},
|
|
1312
|
+
});
|
|
1313
|
+
traceFlush(deps, phase.id);
|
|
867
1314
|
break;
|
|
868
1315
|
}
|
|
869
1316
|
usages.push(last.usage);
|
|
@@ -886,11 +1333,39 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
886
1333
|
const liveRetry = state.phases[phase.id];
|
|
887
1334
|
if (liveRetry)
|
|
888
1335
|
liveRetry.usage = aggregateUsage(usages);
|
|
1336
|
+
// Persist every attempt, not only the final aggregate. This is required
|
|
1337
|
+
// for honest replay/cost accounting when a transient or explicit retry
|
|
1338
|
+
// succeeds after earlier spend.
|
|
1339
|
+
traceEmit(deps, {
|
|
1340
|
+
ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "subagent-call",
|
|
1341
|
+
input: { agent: agentName, model: phase.model, task, preRead, nodePath: ctxNodeId ?? phase.id, attempt },
|
|
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
|
+
},
|
|
1348
|
+
});
|
|
1349
|
+
traceFlush(deps, phase.id);
|
|
889
1350
|
if (!isFailed(last))
|
|
890
1351
|
break;
|
|
891
|
-
// Stop retrying on abort or
|
|
892
|
-
if (deps.signal?.aborted || overBudget(state).over)
|
|
1352
|
+
// Stop retrying on abort (run-level or race cancel) or once over budget.
|
|
1353
|
+
if (deps.signal?.aborted || extraSignal?.aborted || overBudget(state).over)
|
|
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
|
+
}
|
|
893
1367
|
break;
|
|
1368
|
+
}
|
|
894
1369
|
// Decide whether THIS failure warrants another attempt. Explicit retry
|
|
895
1370
|
// policy covers all failures up to its cap; the transient fallback covers
|
|
896
1371
|
// only retryable provider errors. A non-transient failure with no explicit
|
|
@@ -919,8 +1394,29 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
919
1394
|
// backoff.
|
|
920
1395
|
const factor = retry ? (retry.factor ?? 1) : DEFAULT_TRANSIENT_FACTOR;
|
|
921
1396
|
const wait = Math.min(60000, Math.round(baseMs * factor ** attempt));
|
|
922
|
-
if (wait > 0)
|
|
923
|
-
|
|
1397
|
+
if (wait > 0) {
|
|
1398
|
+
// Honor run abort and/or race-branch cancel during backoff.
|
|
1399
|
+
if (deps.signal && extraSignal) {
|
|
1400
|
+
const ac = new AbortController();
|
|
1401
|
+
const ab = () => ac.abort();
|
|
1402
|
+
if (deps.signal.aborted || extraSignal.aborted)
|
|
1403
|
+
ac.abort();
|
|
1404
|
+
else {
|
|
1405
|
+
deps.signal.addEventListener("abort", ab, { once: true });
|
|
1406
|
+
extraSignal.addEventListener("abort", ab, { once: true });
|
|
1407
|
+
}
|
|
1408
|
+
try {
|
|
1409
|
+
await delay(wait, ac.signal);
|
|
1410
|
+
}
|
|
1411
|
+
finally {
|
|
1412
|
+
deps.signal.removeEventListener("abort", ab);
|
|
1413
|
+
extraSignal.removeEventListener("abort", ab);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
else {
|
|
1417
|
+
await delay(wait, extraSignal ?? deps.signal);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
924
1420
|
}
|
|
925
1421
|
// Aborted before any attempt ran → return a clean aborted result (no crash).
|
|
926
1422
|
if (!last) {
|
|
@@ -939,29 +1435,6 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
939
1435
|
if (usages.length > 1)
|
|
940
1436
|
last.usage = aggregateUsage(usages);
|
|
941
1437
|
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
1438
|
return last;
|
|
966
1439
|
};
|
|
967
1440
|
const parseJson = phase.output === "json";
|
|
@@ -1000,7 +1473,11 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1000
1473
|
emitProgress();
|
|
1001
1474
|
};
|
|
1002
1475
|
refresh();
|
|
1003
|
-
|
|
1476
|
+
// Usage is only authoritative after a call reports it. Serial admission for
|
|
1477
|
+
// budgeted fan-out prevents N siblings from all observing the same remaining
|
|
1478
|
+
// allowance and overshooting it concurrently.
|
|
1479
|
+
const admissionConcurrency = state.def.budget ? 1 : concurrency;
|
|
1480
|
+
return mapWithConcurrencyLimit(items, admissionConcurrency, async (it, idx) => {
|
|
1004
1481
|
// Budget guard: stop spawning new fan-out items once the run is over budget.
|
|
1005
1482
|
if (overBudget(state).over) {
|
|
1006
1483
|
done++;
|
|
@@ -1090,6 +1567,11 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1090
1567
|
const child = await runSpawnedChildren(spawned, ctxDir, itemNid, phase, deps, state, run);
|
|
1091
1568
|
if (child.reports)
|
|
1092
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
|
+
}
|
|
1093
1575
|
if (child.usage) {
|
|
1094
1576
|
r.usage = aggregateUsage([r.usage ?? emptyUsage(), child.usage]);
|
|
1095
1577
|
liveUsages[idx] = r.usage;
|
|
@@ -1227,6 +1709,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1227
1709
|
};
|
|
1228
1710
|
if (readRefs.length)
|
|
1229
1711
|
ps.reads = readRefsToReads(readRefs, state);
|
|
1712
|
+
traceGateDecision(deps, state, phase.id, ps.gate, undefined);
|
|
1230
1713
|
return ps;
|
|
1231
1714
|
}
|
|
1232
1715
|
const report = targetResolved
|
|
@@ -1259,6 +1742,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1259
1742
|
const verdict = final.passed ? "pass" : "block";
|
|
1260
1743
|
ps.gate = { verdict, reason: judged.reason, scores: { results, combined: final.combined, threshold } };
|
|
1261
1744
|
ps.json = scoreResultJSON(results, final.combined, verdict, threshold, { score: judged.score, reason: judged.reason });
|
|
1745
|
+
traceGateDecision(deps, state, phase.id, ps.gate, r.output);
|
|
1262
1746
|
}
|
|
1263
1747
|
if (readRefs.length)
|
|
1264
1748
|
ps.reads = readRefsToReads(readRefs, state);
|
|
@@ -1332,7 +1816,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1332
1816
|
break;
|
|
1333
1817
|
attempt++;
|
|
1334
1818
|
if (_retryDepth < MAX_RETRY_DEPTH) {
|
|
1335
|
-
const
|
|
1819
|
+
const depsForUpstream = opts?.upstreamDeps ?? deps;
|
|
1336
1820
|
for (const depId of phase.dependsOn ?? []) {
|
|
1337
1821
|
const d = state.def.phases.find((p) => p.id === depId);
|
|
1338
1822
|
if (!d)
|
|
@@ -1394,13 +1878,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1394
1878
|
ps.warnings = [...(ps.warnings ?? []), refWarning];
|
|
1395
1879
|
if (type === "gate" && ps.status === "done") {
|
|
1396
1880
|
ps.gate = parseGateVerdict(r.output);
|
|
1397
|
-
// Trace:
|
|
1398
|
-
// re-adjudicates this against a changed threshold.
|
|
1881
|
+
// Trace: gate decision (fail-open). Replay re-adjudicates thresholds.
|
|
1399
1882
|
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
|
-
});
|
|
1883
|
+
traceGateDecision(deps, state, phase.id, ps.gate);
|
|
1404
1884
|
}
|
|
1405
1885
|
// Shared Context Tree: register this node, mark its terminal status, and
|
|
1406
1886
|
// pick up any ctx_spawn intents the subagent queued. The spawned child
|
|
@@ -1415,6 +1895,10 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1415
1895
|
const child = await runSpawnedChildren(spawned, ctxDir, nid, phase, deps, state, run);
|
|
1416
1896
|
if (child.reports)
|
|
1417
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
|
+
}
|
|
1418
1902
|
// Fold spawned spend into this phase's usage so the run-wide budget
|
|
1419
1903
|
// guard accounts for it (verdict Issue 2).
|
|
1420
1904
|
ps.usage = aggregateUsage([ps.usage ?? emptyUsage(), child.usage]);
|
|
@@ -1443,7 +1927,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1443
1927
|
// NOTE: we intentionally pass the gate's `prior` (not the dep's own
|
|
1444
1928
|
// completed state) so the dep does NOT cache-hit and actually
|
|
1445
1929
|
// RE-RUNS — re-running upstream is the whole point of onBlock:retry.
|
|
1446
|
-
const
|
|
1930
|
+
const depsForUpstream = opts?.upstreamDeps ?? deps;
|
|
1447
1931
|
for (const depId of phase.dependsOn ?? []) {
|
|
1448
1932
|
const d = state.def.phases.find((p) => p.id === depId);
|
|
1449
1933
|
if (!d)
|
|
@@ -1470,9 +1954,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1470
1954
|
recordCache(cc, ps);
|
|
1471
1955
|
return ps;
|
|
1472
1956
|
}
|
|
1473
|
-
// script — zero-token shell
|
|
1474
|
-
// interpolated `input` to stdin, captures stdout as the phase output.
|
|
1957
|
+
// script — zero-token shell (spawn/timeout/size caps in runtime/phases/script.ts)
|
|
1475
1958
|
if (type === "script") {
|
|
1959
|
+
const { runScriptCommand, scriptResultToPhaseState, scriptSpawnErrorToPhaseState } = await import("./runtime/phases/script.js");
|
|
1476
1960
|
const cmd = phase.run;
|
|
1477
1961
|
if (!cmd) {
|
|
1478
1962
|
return {
|
|
@@ -1483,19 +1967,16 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1483
1967
|
usage: emptyUsage(),
|
|
1484
1968
|
};
|
|
1485
1969
|
}
|
|
1486
|
-
// Interpolate the command.
|
|
1487
1970
|
// Array form: interpolate each element (safe — schema rejects placeholders in string form).
|
|
1488
1971
|
// String form: skip interpolation — schema already guarantees no {placeholders}.
|
|
1489
1972
|
const interpRun = Array.isArray(cmd)
|
|
1490
1973
|
? cmd.map((s) => interpolate(s, ctx))
|
|
1491
1974
|
: [{ text: cmd, missing: [] }];
|
|
1492
|
-
// Warn unresolved references.
|
|
1493
1975
|
for (const r of interpRun) {
|
|
1494
1976
|
if (r.missing.length)
|
|
1495
1977
|
warnUnresolvedRefs(phase.id, r.missing);
|
|
1496
1978
|
}
|
|
1497
1979
|
const interpRunText = interpRun.map((r) => r.text);
|
|
1498
|
-
// Interpolate stdin input if provided.
|
|
1499
1980
|
const stdinInterp = phase.input !== undefined ? interpolate(phase.input, ctx) : undefined;
|
|
1500
1981
|
if (stdinInterp?.missing.length)
|
|
1501
1982
|
warnUnresolvedRefs(phase.id, stdinInterp.missing);
|
|
@@ -1505,123 +1986,39 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1505
1986
|
const cached = cachedPhase(cc, ck);
|
|
1506
1987
|
if (cached)
|
|
1507
1988
|
return cached;
|
|
1508
|
-
const
|
|
1509
|
-
const
|
|
1510
|
-
const SIGKILL_GRACE_MS = 5_000; // grace period after SIGTERM before SIGKILL
|
|
1989
|
+
const SCRIPT_TIMEOUT_MS = phase.timeout ?? 60_000;
|
|
1990
|
+
const reads = readRefs.length ? readRefsToReads(readRefs, state) : undefined;
|
|
1511
1991
|
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
|
-
}
|
|
1992
|
+
const invoke = () => runScriptCommand({
|
|
1993
|
+
interpRunText,
|
|
1994
|
+
arrayForm: Array.isArray(cmd),
|
|
1995
|
+
cwd: effCwd,
|
|
1996
|
+
signal: deps.signal,
|
|
1997
|
+
stdinInput,
|
|
1998
|
+
timeoutMs: SCRIPT_TIMEOUT_MS,
|
|
1573
1999
|
});
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
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(),
|
|
2000
|
+
const result = deps._workspaceBinding
|
|
2001
|
+
? await deps._workspaceBinding.runScript({ unitId: phase.id, signal: deps.signal, invoke })
|
|
2002
|
+
: await invoke();
|
|
2003
|
+
const ps = scriptResultToPhaseState(phase, result, {
|
|
1599
2004
|
inputHash,
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
2005
|
+
timeoutMs: SCRIPT_TIMEOUT_MS,
|
|
2006
|
+
reads,
|
|
2007
|
+
});
|
|
2008
|
+
// Non-zero exit: cache (deterministic). Timeout/spawn: don't cache (transient).
|
|
2009
|
+
if (ps.status === "done" || (ps.status === "failed" && !ps.timedOut)) {
|
|
2010
|
+
recordCache(cc, ps);
|
|
2011
|
+
}
|
|
1605
2012
|
return ps;
|
|
1606
2013
|
}
|
|
1607
2014
|
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;
|
|
2015
|
+
// Spawn errors intentionally NOT cached — re-execute on resume/retry.
|
|
2016
|
+
return scriptSpawnErrorToPhaseState(phase.id, err, { inputHash, reads });
|
|
1622
2017
|
}
|
|
1623
2018
|
}
|
|
2019
|
+
// parallel — all branches; merge via shared mergePhaseState (phases/parallel.ts)
|
|
1624
2020
|
if (type === "parallel") {
|
|
2021
|
+
const { executeParallelBranches } = await import("./runtime/phases/parallel.js");
|
|
1625
2022
|
const branches = (phase.branches ?? []).map((b) => {
|
|
1626
2023
|
const r = interpolate(b.task, ctx);
|
|
1627
2024
|
return {
|
|
@@ -1634,10 +2031,36 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1634
2031
|
const cached = cachedPhase(cc, ck);
|
|
1635
2032
|
if (cached)
|
|
1636
2033
|
return cached;
|
|
1637
|
-
const
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
2034
|
+
const ps = await executeParallelBranches(phase, branches, runFanout, mergePhaseState, {
|
|
2035
|
+
inputHash,
|
|
2036
|
+
parseJson,
|
|
2037
|
+
reads: readRefs.length ? readRefsToReads(readRefs, state) : undefined,
|
|
2038
|
+
});
|
|
2039
|
+
recordCache(cc, ps);
|
|
2040
|
+
return ps;
|
|
2041
|
+
}
|
|
2042
|
+
// Horizon B: race — implementation lives in runtime/phases/race.ts
|
|
2043
|
+
if (type === "race") {
|
|
2044
|
+
const { executeRaceBranches } = await import("./runtime/phases/race.js");
|
|
2045
|
+
const branches = (phase.branches ?? []).map((b) => {
|
|
2046
|
+
const r = interpolate(b.task, ctx);
|
|
2047
|
+
return {
|
|
2048
|
+
agent: resolveAgent(b.agent ?? phase.agent, deps, state),
|
|
2049
|
+
task: preRead + r.text,
|
|
2050
|
+
};
|
|
2051
|
+
});
|
|
2052
|
+
const ck = cacheKeys(cc, [phase.id, "race", phase.model ?? "", JSON.stringify(branches)]);
|
|
2053
|
+
const inputHash = ck.key;
|
|
2054
|
+
const cached = cachedPhase(cc, ck);
|
|
2055
|
+
if (cached)
|
|
2056
|
+
return cached;
|
|
2057
|
+
const raceRunOne = (agent, task, branchSignal) => runOne(agent, task, undefined, undefined, undefined, branchSignal);
|
|
2058
|
+
const ps = await executeRaceBranches(phase, branches, raceRunOne, isFailed, {
|
|
2059
|
+
inputHash,
|
|
2060
|
+
parseJson,
|
|
2061
|
+
readRefs: readRefs.length ? readRefsToReads(readRefs, state) : undefined,
|
|
2062
|
+
parentSignal: deps.signal,
|
|
2063
|
+
});
|
|
1641
2064
|
recordCache(cc, ps);
|
|
1642
2065
|
return ps;
|
|
1643
2066
|
}
|
|
@@ -1735,7 +2158,9 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1735
2158
|
recordCache(cc, ps);
|
|
1736
2159
|
return ps;
|
|
1737
2160
|
}
|
|
2161
|
+
// approval — HITL pause (decision → PhaseState in runtime/phases/approval.ts)
|
|
1738
2162
|
if (type === "approval") {
|
|
2163
|
+
const { approvalDecisionToPhaseState } = await import("./runtime/phases/approval.js");
|
|
1739
2164
|
const readRefs = [];
|
|
1740
2165
|
const ctx = buildInterpolationContext(state, previousOutput, undefined, (ref) => readRefs.push(ref));
|
|
1741
2166
|
const message = interpolate(phase.task ?? "Approve to continue?", ctx).text;
|
|
@@ -1744,48 +2169,51 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1744
2169
|
const cached = cachedPhase(cc, ck);
|
|
1745
2170
|
if (cached)
|
|
1746
2171
|
return cached;
|
|
1747
|
-
|
|
1748
|
-
//
|
|
1749
|
-
// let unreviewed work ship. Detached/CI runs must not bypass approval gates.
|
|
2172
|
+
const reads = readRefsToReads(readRefs, state);
|
|
2173
|
+
// Non-interactive (headless/CI/detached): auto-REJECT — safety boundary, never bypass.
|
|
1750
2174
|
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(),
|
|
2175
|
+
return approvalDecisionToPhaseState(phase.id, { decision: "reject" }, {
|
|
1758
2176
|
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" };
|
|
2177
|
+
reads,
|
|
2178
|
+
auto: true,
|
|
2179
|
+
});
|
|
1778
2180
|
}
|
|
1779
|
-
|
|
2181
|
+
const decision = await deps.requestApproval({
|
|
2182
|
+
phaseId: phase.id,
|
|
2183
|
+
message,
|
|
2184
|
+
upstream: previousOutput,
|
|
2185
|
+
});
|
|
2186
|
+
return approvalDecisionToPhaseState(phase.id, decision, { inputHash, reads });
|
|
1780
2187
|
}
|
|
1781
|
-
if (type === "flow") {
|
|
2188
|
+
if (type === "flow" || type === "expand") {
|
|
1782
2189
|
const readRefs = [];
|
|
1783
2190
|
const ctx = buildInterpolationContext(state, previousOutput, undefined, (ref) => readRefs.push(ref));
|
|
1784
|
-
|
|
2191
|
+
// expand always requires `def`; flow may use `use` or `def`.
|
|
2192
|
+
const hasDef = type === "expand" ? phase.def !== undefined : phase.def !== undefined;
|
|
1785
2193
|
const stack = deps._stack ?? [];
|
|
2194
|
+
const { resolveExpandMode, resolveMaxNodes, prefixGraftFragment, promoteGraftPhases } = await import("./runtime/phases/expand.js");
|
|
2195
|
+
const expandMode = type === "expand" ? resolveExpandMode(phase) : "nested";
|
|
2196
|
+
const maxNodes = type === "expand" ? resolveMaxNodes(phase, MAX_DYNAMIC_PHASES) : 50;
|
|
2197
|
+
if (type === "expand" && expandMode === "graft") {
|
|
2198
|
+
// Rerun replacement semantics: prior promoted children belong to the old
|
|
2199
|
+
// fragment and must disappear before ANY new resolution path. This is
|
|
2200
|
+
// intentionally before parse/validation/empty/sub-flow failure returns so
|
|
2201
|
+
// stale children and their usage cannot survive a failed or empty v2 plan.
|
|
2202
|
+
const declaredIds = new Set(state.def.phases.map((p) => p.id));
|
|
2203
|
+
for (const oldId of Object.keys(prior?.promotedPhases ?? {})) {
|
|
2204
|
+
// Definition evolution may promote an old dynamic id into a real
|
|
2205
|
+
// authored parent phase. That declared phase owns its state now and must
|
|
2206
|
+
// never be deleted by stale graft metadata.
|
|
2207
|
+
if (!declaredIds.has(oldId))
|
|
2208
|
+
delete state.phases[oldId];
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
1786
2211
|
let subDef;
|
|
1787
2212
|
let name;
|
|
1788
2213
|
let recursionKey; // identity used for cache key + recursion guard
|
|
2214
|
+
if (type === "expand" && !hasDef) {
|
|
2215
|
+
return failPhase(phase.id, `expand phase '${phase.id}' requires 'def'`);
|
|
2216
|
+
}
|
|
1789
2217
|
if (hasDef) {
|
|
1790
2218
|
// --- Inline `def`: resolve at runtime, validate, fail-OPEN on any error. ---
|
|
1791
2219
|
// Fail-open contract: a bad def NEVER aborts the run. The phase resolves
|
|
@@ -1822,7 +2250,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1822
2250
|
parsed = rawDef;
|
|
1823
2251
|
}
|
|
1824
2252
|
// Accept a full Taskflow, a bare phases array, or {phases:[...]}; wrap the latter two.
|
|
1825
|
-
|
|
2253
|
+
let wrapped = normalizeInlineDef(parsed, phase.id);
|
|
1826
2254
|
if (!wrapped) {
|
|
1827
2255
|
return defFailOpen("inline def is not a Taskflow, phases array, or {phases:[...]}");
|
|
1828
2256
|
}
|
|
@@ -1835,11 +2263,20 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1835
2263
|
output: "",
|
|
1836
2264
|
json: parseJson ? safeParse("") : undefined,
|
|
1837
2265
|
usage: emptyUsage(),
|
|
1838
|
-
inputHash: hashInput(phase.id, "flow-def-empty"),
|
|
2266
|
+
inputHash: hashInput(phase.id, type === "expand" ? "expand-def-empty" : "flow-def-empty"),
|
|
1839
2267
|
reads: readRefsToReads(readRefs, state),
|
|
1840
2268
|
endedAt: Date.now(),
|
|
1841
2269
|
};
|
|
1842
2270
|
}
|
|
2271
|
+
// expand: cap fragment size + prefix ids for graft (helpers in phases/expand.ts).
|
|
2272
|
+
if (type === "expand") {
|
|
2273
|
+
if (wrapped.phases.length > maxNodes) {
|
|
2274
|
+
return defFailOpen(`expand fragment has ${wrapped.phases.length} phases (maxNodes=${maxNodes})`);
|
|
2275
|
+
}
|
|
2276
|
+
if (expandMode === "graft") {
|
|
2277
|
+
wrapped = prefixGraftFragment(wrapped, phase.id);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
1843
2280
|
// Validate with `dynamic` hardening (breadth caps + cwd containment) since
|
|
1844
2281
|
// this content is LLM-authored / untrusted. cwd anchors containment checks.
|
|
1845
2282
|
const dynCwd = effCwd;
|
|
@@ -1879,23 +2316,46 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1879
2316
|
// Resolve sub-flow args (interpolate string values), then apply declared defaults.
|
|
1880
2317
|
const provided = {};
|
|
1881
2318
|
for (const [k, v] of Object.entries(phase.with ?? {})) {
|
|
1882
|
-
provided[k] =
|
|
2319
|
+
provided[k] = interpolateValue(v, ctx);
|
|
1883
2320
|
}
|
|
1884
2321
|
const subArgs = resolveArgs(subDef, provided);
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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;
|
|
2335
|
+
// Every sub-flow cache identity includes the resolved definition. A saved
|
|
2336
|
+
// flow's name alone is insufficient: its contents can change without the
|
|
2337
|
+
// parent definition moving.
|
|
2338
|
+
const flowIdentity = `${hasDef ? "def" : "flow"}:${name}:${JSON.stringify(subDef)}`;
|
|
2339
|
+
const ck = cacheKeys(flowCc, [phase.id, flowIdentity, preRead, JSON.stringify(subArgs)]);
|
|
1890
2340
|
const inputHash = ck.key;
|
|
1891
|
-
const cached = cachedPhase(
|
|
1892
|
-
if (cached)
|
|
2341
|
+
const cached = cachedPhase(flowCc, ck);
|
|
2342
|
+
if (cached) {
|
|
2343
|
+
if (type === "expand" && expandMode === "graft" && cached.promotedPhases) {
|
|
2344
|
+
const promo = promoteGraftPhases(state, cached.promotedPhases);
|
|
2345
|
+
if (promo.promotedIds.length > 0) {
|
|
2346
|
+
cached.promotedPhases = Object.fromEntries(promo.promotedIds.map((id) => [id, { ...cached.promotedPhases[id] }]));
|
|
2347
|
+
}
|
|
2348
|
+
else {
|
|
2349
|
+
delete cached.promotedPhases;
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
1893
2352
|
return cached;
|
|
2353
|
+
}
|
|
1894
2354
|
const live = state.phases[phase.id];
|
|
1895
|
-
//
|
|
1896
|
-
//
|
|
1897
|
-
|
|
1898
|
-
const subDefEffective = subDef
|
|
2355
|
+
// A nested flow receives only the parent's remaining allowance, then its
|
|
2356
|
+
// own cap (if any) may tighten each dimension independently.
|
|
2357
|
+
const parentSpent = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage()));
|
|
2358
|
+
const subDefEffective = clampSubFlowBudget(subDef, state.def.budget, parentSpent);
|
|
1899
2359
|
const subState = {
|
|
1900
2360
|
runId: newRunId(subDef.name),
|
|
1901
2361
|
flowName: subDef.name,
|
|
@@ -1913,10 +2373,15 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1913
2373
|
const subRunTask = (cwd, agents, agentName, subTask, opts, globalThinking) => baseRunTask(cwd, agents, agentName, preRead + subTask, opts, globalThinking);
|
|
1914
2374
|
const subResult = await executeTaskflow(subState, {
|
|
1915
2375
|
...deps,
|
|
2376
|
+
// A trace file is scoped to one runId. The parent flow phase carries an
|
|
2377
|
+
// unreplayable marker; nested events must not be mixed into that file.
|
|
2378
|
+
trace: undefined,
|
|
1916
2379
|
// Override deps.cwd with the flow phase's own cwd so that sub-flow
|
|
1917
2380
|
// phases without an explicit cwd derive their subagents from the
|
|
1918
2381
|
// flow's cwd (not the caller's cwd).
|
|
1919
2382
|
cwd: effCwd,
|
|
2383
|
+
_cacheCwdIdentity: phase.cwd !== undefined || deps._cacheCwdIdentity !== undefined ? effCwd : undefined,
|
|
2384
|
+
_dynamic: hasDef || deps._dynamic === true ? true : undefined,
|
|
1920
2385
|
// The workspace override applies only to THIS flow phase, not to the
|
|
1921
2386
|
// nested sub-phases (each resolves its own cwd). Clear it so the child
|
|
1922
2387
|
// phases don't all inherit this phase's isolated dir as an override.
|
|
@@ -1945,12 +2410,31 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1945
2410
|
},
|
|
1946
2411
|
});
|
|
1947
2412
|
const sp = Object.values(subState.phases);
|
|
2413
|
+
const nestedFailure = sp.find((nested) => nested.status === "failed")?.error;
|
|
2414
|
+
// expand graft promote — pure helper (see runtime/phases/expand.ts)
|
|
2415
|
+
const warnings = [];
|
|
2416
|
+
let graftPromotedIds = [];
|
|
2417
|
+
if (type === "expand" && expandMode === "graft" && subResult.ok) {
|
|
2418
|
+
const promo = promoteGraftPhases(state, subState.phases);
|
|
2419
|
+
warnings.push(...promo.warnings);
|
|
2420
|
+
graftPromotedIds = promo.promotedIds;
|
|
2421
|
+
}
|
|
2422
|
+
// Graft accounting is ownership-based. Successfully promoted children carry
|
|
2423
|
+
// their own usage in parent state; children skipped on id collision remain
|
|
2424
|
+
// owned by the expand phase and their residual usage must stay here. This
|
|
2425
|
+
// yields: all promoted => 0, all collision => all child usage, mixed => only
|
|
2426
|
+
// collision residual (no loss and no double count).
|
|
2427
|
+
const phaseUsage = type === "expand" && expandMode === "graft" && subResult.ok
|
|
2428
|
+
? aggregateUsage(Object.entries(subState.phases)
|
|
2429
|
+
.filter(([id]) => !graftPromotedIds.includes(id))
|
|
2430
|
+
.map(([, ps]) => ps.usage ?? emptyUsage()))
|
|
2431
|
+
: subResult.totalUsage;
|
|
1948
2432
|
const flowPs = {
|
|
1949
2433
|
id: phase.id,
|
|
1950
2434
|
status: subResult.ok ? "done" : "failed",
|
|
1951
2435
|
output: subResult.finalOutput,
|
|
1952
2436
|
json: parseJson ? safeParse(subResult.finalOutput) : undefined,
|
|
1953
|
-
usage:
|
|
2437
|
+
usage: phaseUsage,
|
|
1954
2438
|
// B-F015: include failed in `done` so the renderer's
|
|
1955
2439
|
// `done - failed` formula gives the success count (matches the
|
|
1956
2440
|
// map/parallel runner's overlapping-counter convention).
|
|
@@ -1960,12 +2444,18 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1960
2444
|
running: 0,
|
|
1961
2445
|
failed: sp.filter((p) => p.status === "failed").length,
|
|
1962
2446
|
},
|
|
1963
|
-
error: subResult.ok
|
|
2447
|
+
error: subResult.ok
|
|
2448
|
+
? undefined
|
|
2449
|
+
: `sub-flow '${name}' ${subResult.state.status}${nestedFailure ? `: ${nestedFailure}` : ""}`,
|
|
1964
2450
|
inputHash,
|
|
1965
2451
|
reads: readRefsToReads(readRefs, state),
|
|
1966
2452
|
endedAt: Date.now(),
|
|
2453
|
+
...(warnings.length ? { warnings } : {}),
|
|
2454
|
+
...(type === "expand" && expandMode === "graft" && subResult.ok && graftPromotedIds.length > 0
|
|
2455
|
+
? { promotedPhases: Object.fromEntries(graftPromotedIds.map((id) => [id, { ...subState.phases[id] }])) }
|
|
2456
|
+
: {}),
|
|
1967
2457
|
};
|
|
1968
|
-
recordCache(
|
|
2458
|
+
recordCache(flowCc, flowPs);
|
|
1969
2459
|
return flowPs;
|
|
1970
2460
|
}
|
|
1971
2461
|
// loop-until-done: run the body repeatedly until `until` is truthy, the output
|
|
@@ -2208,6 +2698,12 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
2208
2698
|
}
|
|
2209
2699
|
// Only one competitor survived → no contest; it wins by default (skip judge).
|
|
2210
2700
|
if (ok.length === 1) {
|
|
2701
|
+
const w = ranIdx(ok[0]);
|
|
2702
|
+
traceDecision(deps, state, phase.id, {
|
|
2703
|
+
type: "tournament-winner",
|
|
2704
|
+
value: w,
|
|
2705
|
+
reason: "only surviving variant",
|
|
2706
|
+
});
|
|
2211
2707
|
return {
|
|
2212
2708
|
id: phase.id,
|
|
2213
2709
|
status: "done",
|
|
@@ -2216,7 +2712,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
2216
2712
|
usage: variantUsage,
|
|
2217
2713
|
model: ok[0].model,
|
|
2218
2714
|
budgetTruncated: budgetSkipCount > 0 || undefined,
|
|
2219
|
-
tournament: { variants: competitors.length, winner:
|
|
2715
|
+
tournament: { variants: competitors.length, winner: w, mode, reason: "only surviving variant" },
|
|
2220
2716
|
inputHash,
|
|
2221
2717
|
reads: readRefsToReads(readRefs, state),
|
|
2222
2718
|
endedAt: Date.now(),
|
|
@@ -2278,6 +2774,11 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
2278
2774
|
const chosen = winnerIneligible ? ok[0] : winnerResult;
|
|
2279
2775
|
const winnerIdx = ranIdx(chosen);
|
|
2280
2776
|
const output = mode === "aggregate" ? judgeRes.output : chosen.output;
|
|
2777
|
+
traceDecision(deps, state, phase.id, {
|
|
2778
|
+
type: "tournament-winner",
|
|
2779
|
+
value: winnerIdx,
|
|
2780
|
+
reason,
|
|
2781
|
+
});
|
|
2281
2782
|
return {
|
|
2282
2783
|
id: phase.id,
|
|
2283
2784
|
status: "done",
|
|
@@ -2334,9 +2835,24 @@ function lastCompletedOutput(state, phase) {
|
|
|
2334
2835
|
}
|
|
2335
2836
|
return undefined;
|
|
2336
2837
|
}
|
|
2838
|
+
/** Stable cache identity for the fully resolved agent pool. File paths are
|
|
2839
|
+
* excluded: content/config, not installation location, determines output. */
|
|
2840
|
+
export function agentDefinitionsIdentity(agents) {
|
|
2841
|
+
return JSON.stringify(agents
|
|
2842
|
+
.map((a) => ({
|
|
2843
|
+
name: a.name,
|
|
2844
|
+
description: a.description,
|
|
2845
|
+
systemPrompt: a.systemPrompt,
|
|
2846
|
+
model: a.model ?? "",
|
|
2847
|
+
thinking: a.thinking ?? "",
|
|
2848
|
+
tools: [...(a.tools ?? [])].sort(),
|
|
2849
|
+
source: a.source,
|
|
2850
|
+
}))
|
|
2851
|
+
.sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)));
|
|
2852
|
+
}
|
|
2337
2853
|
/** Fold the phase fingerprint into the base hash parts to form the cache keys.
|
|
2338
2854
|
*
|
|
2339
|
-
|
|
2855
|
+
* Four keys are derived for tooling/backward compatibility (see
|
|
2340
2856
|
* docs/internal/cache-migration.md):
|
|
2341
2857
|
* - `key` : `v3:phasefp:<subfp>` — the current write key (per-phase
|
|
2342
2858
|
* structural sub-fingerprint; falls back to the whole-flow hash when
|
|
@@ -2344,9 +2860,9 @@ function lastCompletedOutput(state, phase) {
|
|
|
2344
2860
|
* - `v2Key` : `v2:flowdef:<flowDefHash>` — pre-M6 whole-flow key.
|
|
2345
2861
|
* - `bareKey` : bare `flowdef:<flowDefHash>` (unversioned) — pre-H1 entries.
|
|
2346
2862
|
* - `legacyKey`: the flowdef line omitted — pre-flowDefHash entries.
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2863
|
+
* `cachedPhase` consults the first three safe tiers READ-ONLY on a miss;
|
|
2864
|
+
* `legacyKey` is never read because it has no structural identity.
|
|
2865
|
+
* `recordCache` writes only `key`. */
|
|
2350
2866
|
export function cacheKeys(cc, baseParts) {
|
|
2351
2867
|
// Fold the full cache identity into the hash: flow name (prevents collisions
|
|
2352
2868
|
// across different flows that share a phase.id + task + model), the per-phase
|
|
@@ -2357,6 +2873,10 @@ export function cacheKeys(cc, baseParts) {
|
|
|
2357
2873
|
`think:${cc.thinking ?? ""}`,
|
|
2358
2874
|
`tools:${JSON.stringify(cc.tools ?? [])}`,
|
|
2359
2875
|
`ctx:${cc.preRead ?? ""}`,
|
|
2876
|
+
`agent-scope:${cc.agentScope ?? "user"}`,
|
|
2877
|
+
`context-sharing:${cc.contextSharing === true ? "1" : "0"}`,
|
|
2878
|
+
`agents:${cc.agentDefinitions ?? ""}`,
|
|
2879
|
+
...(cc.executionCwd ? [`cwd:${cc.executionCwd}`] : []),
|
|
2360
2880
|
];
|
|
2361
2881
|
const fold = (parts) => cc.fingerprint ? hashInput(...parts, cc.fingerprint) : hashInput(...parts);
|
|
2362
2882
|
// Per-phase sub-fingerprint; falls back to the whole-flow hash when absent
|
|
@@ -2375,13 +2895,13 @@ export function cacheKeys(cc, baseParts) {
|
|
|
2375
2895
|
* - "off": never reuse (even within-run).
|
|
2376
2896
|
* - "run-only": within-run resume only (historical behavior).
|
|
2377
2897
|
* - "cross-run": within-run first, then the persistent cross-run store.
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
* The cross-run read is
|
|
2898
|
+
* On a cross-run hit, usage is zeroed and `cacheHit` records the source.
|
|
2899
|
+
*
|
|
2900
|
+
* The cross-run read is three-tier and READ-ONLY for fallback keys: it tries
|
|
2381
2901
|
* `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
|
|
2902
|
+
* `v2:flowdef:`), then `keys.bareKey` (pre-H1 bare `flowdef:`). The older
|
|
2903
|
+
* no-flowdef key is deliberately unsafe and ignored.
|
|
2904
|
+
* A hit on any safe tier is restored as a cache hit; we do NOT write-through (no
|
|
2385
2905
|
* re-store under the new key) so the cache size stays stable and the legacy
|
|
2386
2906
|
* entry ages out naturally. See docs/internal/cache-migration.md.
|
|
2387
2907
|
*/
|
|
@@ -2397,9 +2917,13 @@ function cachedPhase(cc, keys) {
|
|
|
2397
2917
|
if (cc.prior && cc.prior.status === "done" && cc.prior.inputHash === keys.key) {
|
|
2398
2918
|
return { ...cc.prior, status: "done", cacheHit: "run-only" };
|
|
2399
2919
|
}
|
|
2400
|
-
// 2. cross-run memoization (opt-in) —
|
|
2920
|
+
// 2. cross-run memoization (opt-in) — three safe read-only tiers.
|
|
2401
2921
|
if (cc.scope === "cross-run") {
|
|
2402
|
-
|
|
2922
|
+
// The pre-flow-definition legacy key is intentionally NOT read: it omits
|
|
2923
|
+
// all structural identity and can return stale output after any semantic
|
|
2924
|
+
// flow change. v2/bare remain safe because they include today's definition
|
|
2925
|
+
// hash; old entries whose historical hash omitted new fields simply miss.
|
|
2926
|
+
for (const k of [keys.key, keys.v2Key, keys.bareKey]) {
|
|
2403
2927
|
const e = cc.store.get(k, cc.ttlMs);
|
|
2404
2928
|
if (!e)
|
|
2405
2929
|
continue;
|
|
@@ -2484,12 +3008,8 @@ function defaultAgent(deps) {
|
|
|
2484
3008
|
* JSON verdict object whose value is non-blocking (e.g. `{"verdict":"No issues
|
|
2485
3009
|
* found"}`) is an *explicit* pass, not ambiguity, and still resolves to pass.
|
|
2486
3010
|
*/
|
|
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
|
-
}
|
|
3011
|
+
// `parseGateVerdict` / `parseTournamentWinner` live in `deterministic.ts`
|
|
3012
|
+
// (pure seam for replay + event kernel). Re-exported via the barrel.
|
|
2493
3013
|
/**
|
|
2494
3014
|
* If a gate phase relies on free-text verdict parsing (no `output:"json"` +
|
|
2495
3015
|
* `expect` contract) and its task does not already demand a `VERDICT:` marker,
|
|
@@ -2513,30 +3033,7 @@ function appendGateFormatSuffix(task, phase) {
|
|
|
2513
3033
|
`End your response with exactly one line in this exact form (no Markdown, no bold, no extra words):\n` +
|
|
2514
3034
|
`VERDICT: PASS\nor\nVERDICT: BLOCK`);
|
|
2515
3035
|
}
|
|
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
|
-
}
|
|
3036
|
+
/* parseTournamentWinner lives in deterministic.ts (shared with event kernel). */
|
|
2540
3037
|
/**
|
|
2541
3038
|
* Best-effort invocation of the user-provided `persist` + `onProgress` callbacks.
|
|
2542
3039
|
*
|
|
@@ -2615,9 +3112,27 @@ export async function recomputeTaskflow(state, deps, seeds,
|
|
|
2615
3112
|
// Fail-safe default: a real recompute overwrites the run and spends tokens.
|
|
2616
3113
|
// The tool/command wrappers can explicitly opt into dryRun:false.
|
|
2617
3114
|
opts = { dryRun: true }) {
|
|
3115
|
+
deps = snapshotFlowLoader(deps);
|
|
2618
3116
|
// Never mutate the caller's RunState in-place. Recompute is a speculative
|
|
2619
3117
|
// replay; only the caller decides whether to persist the new state.
|
|
2620
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
|
+
}
|
|
2621
3136
|
const reads = readMapOf(newState.phases);
|
|
2622
3137
|
// M2: derive the declared read-map fresh from the def so the frontier uses
|
|
2623
3138
|
// the UNION (observed ∪ declared). Derived here (not read from the persisted
|
|
@@ -2729,10 +3244,23 @@ opts = { dryRun: true }) {
|
|
|
2729
3244
|
const changedUpstreams = depsFor(id).filter((u) => outputMoved.has(u));
|
|
2730
3245
|
try {
|
|
2731
3246
|
const ps = await executePhase(phase, newState, deps, newState.phases[id], noop, 0, execOpts);
|
|
3247
|
+
if (ps.status === "failed") {
|
|
3248
|
+
// Recompute is speculative. Preserve the last known-good row for the
|
|
3249
|
+
// failed phase and every downstream phase, then stop. Mark the report
|
|
3250
|
+
// aborted so callers cannot persist a partially refreshed graph.
|
|
3251
|
+
rerun.push(id);
|
|
3252
|
+
decisions.push({
|
|
3253
|
+
phaseId: id,
|
|
3254
|
+
outcome: "failed",
|
|
3255
|
+
reason: ps.error ?? "re-execution returned a failed phase",
|
|
3256
|
+
});
|
|
3257
|
+
aborted = true;
|
|
3258
|
+
break;
|
|
3259
|
+
}
|
|
2732
3260
|
newState.phases[id] = ps;
|
|
2733
3261
|
// A phase counts as "rerun" if it was a forced seed OR its result moved;
|
|
2734
3262
|
// otherwise it hit its cache (inputHash unchanged) → early cutoff.
|
|
2735
|
-
if (isSeed || ps.inputHash !== before) {
|
|
3263
|
+
if (isSeed || !ps.cacheHit || ps.inputHash !== before) {
|
|
2736
3264
|
rerun.push(id);
|
|
2737
3265
|
outputMoved.add(id);
|
|
2738
3266
|
decisions.push(isSeed
|
|
@@ -2756,11 +3284,16 @@ opts = { dryRun: true }) {
|
|
|
2756
3284
|
});
|
|
2757
3285
|
}
|
|
2758
3286
|
}
|
|
2759
|
-
catch {
|
|
3287
|
+
catch (error) {
|
|
2760
3288
|
// A failing recompute phase is recorded as rerun (it was attempted).
|
|
2761
3289
|
rerun.push(id);
|
|
2762
|
-
|
|
2763
|
-
|
|
3290
|
+
decisions.push({
|
|
3291
|
+
phaseId: id,
|
|
3292
|
+
outcome: "failed",
|
|
3293
|
+
reason: `re-execution threw: ${error instanceof Error ? error.message : String(error)}`,
|
|
3294
|
+
});
|
|
3295
|
+
aborted = true;
|
|
3296
|
+
break;
|
|
2764
3297
|
}
|
|
2765
3298
|
}
|
|
2766
3299
|
// Frontier-external phases were never touched — record them as reused.
|
|
@@ -2783,8 +3316,150 @@ opts = { dryRun: true }) {
|
|
|
2783
3316
|
};
|
|
2784
3317
|
}
|
|
2785
3318
|
export async function executeTaskflow(state, deps) {
|
|
3319
|
+
deps = snapshotFlowLoader(deps);
|
|
2786
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
|
+
}
|
|
3419
|
+
const runnerUsageAccounting = deps.runTask
|
|
3420
|
+
?.usageAccounting;
|
|
3421
|
+
if (!deps.usageAccounting && runnerUsageAccounting) {
|
|
3422
|
+
// Preserve a runner-advertised capability across the wrapper functions used
|
|
3423
|
+
// by nested flow/context execution; those wrappers would otherwise erase a
|
|
3424
|
+
// property attached to the original runTask function.
|
|
3425
|
+
deps = { ...deps, usageAccounting: runnerUsageAccounting };
|
|
3426
|
+
}
|
|
2787
3427
|
try {
|
|
3428
|
+
if (deps.usageAccounting === "unavailable" && def.budget) {
|
|
3429
|
+
throw new Error(`Usage accounting is unavailable for this host; refusing budgeted flow '${def.name}' because its token/USD ceiling cannot be enforced`);
|
|
3430
|
+
}
|
|
3431
|
+
if (deps.usageAccounting === "tokens-only" && def.budget?.maxUSD !== undefined) {
|
|
3432
|
+
throw new Error("This host reports tokens but not cost, so budget.maxUSD cannot be enforced. " +
|
|
3433
|
+
"Use budget.maxTokens or a host with cost accounting.");
|
|
3434
|
+
}
|
|
3435
|
+
// S2 strangler (default OFF): all phase kinds may use the event kernel when enabled.
|
|
3436
|
+
const { eventKernelEnabled, canUseEventKernel, runEventKernel } = await import("./exec/driver.js");
|
|
3437
|
+
// Existing phase state requires the imperative cache/inputHash machinery to
|
|
3438
|
+
// validate definition and idempotency before reuse. The event kernel does
|
|
3439
|
+
// not yet persist compatible input hashes, so it must never blindly trust a
|
|
3440
|
+
// prior `done` row.
|
|
3441
|
+
const hasPriorState = Object.keys(state.phases).length > 0;
|
|
3442
|
+
if (eventKernelEnabled(deps) && deps._cwdBoundary === undefined && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) {
|
|
3443
|
+
if (!deps.runTask) {
|
|
3444
|
+
throw new Error("event kernel requires RuntimeDeps.runTask");
|
|
3445
|
+
}
|
|
3446
|
+
return await runEventKernel(state, {
|
|
3447
|
+
cwd: deps.cwd,
|
|
3448
|
+
agents: deps.agents,
|
|
3449
|
+
runTask: deps.runTask,
|
|
3450
|
+
signal: deps.signal,
|
|
3451
|
+
globalThinking: deps.globalThinking,
|
|
3452
|
+
usageAccounting: deps.usageAccounting,
|
|
3453
|
+
trace: deps.trace,
|
|
3454
|
+
persist: deps.persist,
|
|
3455
|
+
onProgress: deps.onProgress,
|
|
3456
|
+
eventKernel: deps.eventKernel,
|
|
3457
|
+
requestApproval: deps.requestApproval,
|
|
3458
|
+
loadFlow: deps.loadFlow,
|
|
3459
|
+
_stack: deps._stack,
|
|
3460
|
+
_dynamic: deps._dynamic,
|
|
3461
|
+
});
|
|
3462
|
+
}
|
|
2788
3463
|
return await runTaskflowLayers(state, deps);
|
|
2789
3464
|
}
|
|
2790
3465
|
catch (e) {
|
|
@@ -2806,6 +3481,31 @@ export async function executeTaskflow(state, deps) {
|
|
|
2806
3481
|
}
|
|
2807
3482
|
async function runTaskflowLayers(state, deps) {
|
|
2808
3483
|
const def = state.def;
|
|
3484
|
+
// Ownership migration must happen before ANY phase in the new definition is
|
|
3485
|
+
// scheduled. Definition evolution may remove/rename ordinary phases as well
|
|
3486
|
+
// as graft children; neither their terminal failure nor their usage may leak
|
|
3487
|
+
// into the new run. Dynamic promoted state is intentionally cleared here too
|
|
3488
|
+
// and is restored only by its owning expand phase (from its current result or
|
|
3489
|
+
// cache). An id newly promoted to a real authored phase is preserved because
|
|
3490
|
+
// it is present in `declaredPhaseIds` and the scheduler will validate/rerun it.
|
|
3491
|
+
// Cleaning inside the expand phase would be too late: an unrelated authored
|
|
3492
|
+
// phase may already have been scheduled and stale usage already counted.
|
|
3493
|
+
const declaredPhaseIds = new Set(def.phases.map((p) => p.id));
|
|
3494
|
+
// A pre-seeded state may intentionally supply an external dependency (for
|
|
3495
|
+
// example an embedding host injects `src` and the definition starts at a map
|
|
3496
|
+
// that depends on it). Those ids are part of the new definition's dependency
|
|
3497
|
+
// contract even though they have no executable Phase row, so preserve them.
|
|
3498
|
+
const externalDependencyIds = new Set(def.phases.flatMap((phase) => dependenciesOf(phase)).filter((id) => !declaredPhaseIds.has(id)));
|
|
3499
|
+
for (const oldId of Object.keys(state.phases)) {
|
|
3500
|
+
if (!declaredPhaseIds.has(oldId) && !externalDependencyIds.has(oldId))
|
|
3501
|
+
delete state.phases[oldId];
|
|
3502
|
+
}
|
|
3503
|
+
for (const previous of Object.values(state.phases)) {
|
|
3504
|
+
for (const oldId of Object.keys(previous.promotedPhases ?? {})) {
|
|
3505
|
+
if (!declaredPhaseIds.has(oldId))
|
|
3506
|
+
delete state.phases[oldId];
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
2809
3509
|
const layers = topoLayers(def.phases);
|
|
2810
3510
|
// Content-fingerprint the desugared definition ONCE per run and fold it into
|
|
2811
3511
|
// every phase's cache key (overstory hash algorithm; see ./flowir/hash.ts).
|
|
@@ -2819,16 +3519,20 @@ async function runTaskflowLayers(state, deps) {
|
|
|
2819
3519
|
// plane) is persisted for audit/provenance. The declared plane is also
|
|
2820
3520
|
// derived fresh from `def` in recompute (so old runs get union semantics
|
|
2821
3521
|
// 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
|
-
}
|
|
3522
|
+
try {
|
|
3523
|
+
const ir = await compileTaskflowToIR(def);
|
|
3524
|
+
const nextHash = ir.hash ?? "failed";
|
|
3525
|
+
if (state.flowDefHash !== nextHash) {
|
|
3526
|
+
state.flowDefHash = nextHash;
|
|
3527
|
+
state.phaseFingerprints = undefined;
|
|
2830
3528
|
}
|
|
2831
|
-
|
|
3529
|
+
state.declaredDeps = ir.meta.declaredDeps;
|
|
3530
|
+
if (ir.errors.length) {
|
|
3531
|
+
console.warn(`[taskflow] IR compile errors for '${def.name}': ${ir.errors.map((e) => e.message).join("; ")}`);
|
|
3532
|
+
}
|
|
3533
|
+
}
|
|
3534
|
+
catch (e) {
|
|
3535
|
+
if (state.flowDefHash === undefined) {
|
|
2832
3536
|
// Fail-safe: warn loudly rather than silently degrading to the legacy
|
|
2833
3537
|
// flowName-only key, which would reopen the cross-flow collision hole.
|
|
2834
3538
|
console.warn(`[taskflow] flowDefHash failed for '${def.name}': ${e instanceof Error ? e.message : String(e)}. ` +
|
|
@@ -2875,7 +3579,11 @@ async function runTaskflowLayers(state, deps) {
|
|
|
2875
3579
|
break;
|
|
2876
3580
|
}
|
|
2877
3581
|
// Phases within a layer have no inter-dependencies → run concurrently.
|
|
2878
|
-
|
|
3582
|
+
// A usage report arrives only after a subagent call. With a declared hard
|
|
3583
|
+
// budget, concurrent layer admission would let every sibling observe the
|
|
3584
|
+
// same remaining allowance. Serialize admission so no additional call can
|
|
3585
|
+
// begin after a previous call has exhausted the cap.
|
|
3586
|
+
const layerConcurrency = def.budget ? 1 : Math.max(1, def.concurrency ?? 8);
|
|
2879
3587
|
await mapWithConcurrencyLimit(layer, layerConcurrency, async (phase) => {
|
|
2880
3588
|
// Snapshot prior state BEFORE marking running, so resume cache checks work.
|
|
2881
3589
|
const prior = state.phases[phase.id];
|
|
@@ -2900,8 +3608,27 @@ async function runTaskflowLayers(state, deps) {
|
|
|
2900
3608
|
else if (!depsSatisfied)
|
|
2901
3609
|
skipReason = join === "any" ? "All dependencies failed or were skipped" : "Upstream dependency not satisfied";
|
|
2902
3610
|
if (skipReason) {
|
|
2903
|
-
if (skipReason.startsWith("Budget exceeded"))
|
|
3611
|
+
if (skipReason.startsWith("Budget exceeded")) {
|
|
2904
3612
|
budgetBlocked = true;
|
|
3613
|
+
// S1: budget-hit decision so fold/replay can re-tally under new caps.
|
|
3614
|
+
traceDecision(deps, state, phase.id, {
|
|
3615
|
+
type: "budget-hit",
|
|
3616
|
+
value: budgetReason || "budget",
|
|
3617
|
+
reason: skipReason,
|
|
3618
|
+
});
|
|
3619
|
+
// executePhase already flushed its phase-end batch. Flush this
|
|
3620
|
+
// post-completion decision too so FileTraceSink cannot strand it.
|
|
3621
|
+
traceFlush(deps, phase.id);
|
|
3622
|
+
}
|
|
3623
|
+
// Synthetic phase-start/end so fold sees a complete phase lifecycle.
|
|
3624
|
+
traceEmit(deps, {
|
|
3625
|
+
ts: Date.now(),
|
|
3626
|
+
runId: state.runId,
|
|
3627
|
+
phaseId: phase.id,
|
|
3628
|
+
kind: "phase-start",
|
|
3629
|
+
dependencies: dependenciesOf(phase),
|
|
3630
|
+
optional: phase.optional === true,
|
|
3631
|
+
});
|
|
2905
3632
|
state.phases[phase.id] = {
|
|
2906
3633
|
id: phase.id,
|
|
2907
3634
|
status: "skipped",
|
|
@@ -2909,6 +3636,15 @@ async function runTaskflowLayers(state, deps) {
|
|
|
2909
3636
|
endedAt: Date.now(),
|
|
2910
3637
|
usage: emptyUsage(),
|
|
2911
3638
|
};
|
|
3639
|
+
traceEmit(deps, {
|
|
3640
|
+
ts: Date.now(),
|
|
3641
|
+
runId: state.runId,
|
|
3642
|
+
phaseId: phase.id,
|
|
3643
|
+
kind: "phase-end",
|
|
3644
|
+
status: "skipped",
|
|
3645
|
+
error: skipReason,
|
|
3646
|
+
});
|
|
3647
|
+
traceFlush(deps, phase.id);
|
|
2912
3648
|
safeEmit(deps, state);
|
|
2913
3649
|
return;
|
|
2914
3650
|
}
|
|
@@ -2952,11 +3688,29 @@ async function runTaskflowLayers(state, deps) {
|
|
|
2952
3688
|
// acceptable: budgetBlocked prevents cascading into subsequent layers.
|
|
2953
3689
|
const ob = overBudget(state);
|
|
2954
3690
|
if (ob.over) {
|
|
3691
|
+
if (!budgetBlocked) {
|
|
3692
|
+
// First time we detect the ceiling after a phase completes.
|
|
3693
|
+
traceDecision(deps, state, phase.id, {
|
|
3694
|
+
type: "budget-hit",
|
|
3695
|
+
value: "budget",
|
|
3696
|
+
reason: ob.reason,
|
|
3697
|
+
});
|
|
3698
|
+
traceFlush(deps, phase.id);
|
|
3699
|
+
}
|
|
2955
3700
|
budgetBlocked = true;
|
|
2956
3701
|
budgetReason = ob.reason;
|
|
2957
3702
|
}
|
|
2958
3703
|
safeEmit(deps, state);
|
|
2959
3704
|
});
|
|
3705
|
+
// The signal can flip while a layer is in flight. Checking only at the
|
|
3706
|
+
// beginning of the next layer lets an abort during the final layer fall
|
|
3707
|
+
// through to `completed` (notably when a non-cooperative race branch later
|
|
3708
|
+
// reports success). Cancellation is terminal for this invocation: preserve
|
|
3709
|
+
// the phase evidence gathered above, but classify the run as resumable.
|
|
3710
|
+
if (deps.signal?.aborted) {
|
|
3711
|
+
aborted = true;
|
|
3712
|
+
break;
|
|
3713
|
+
}
|
|
2960
3714
|
}
|
|
2961
3715
|
const fp = finalPhase(def.phases);
|
|
2962
3716
|
let finalState = state.phases[fp.id];
|
|
@@ -2968,7 +3722,7 @@ async function runTaskflowLayers(state, deps) {
|
|
|
2968
3722
|
finalState = doneInOrder[doneInOrder.length - 1];
|
|
2969
3723
|
}
|
|
2970
3724
|
// 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);
|
|
3725
|
+
const anyFailed = Object.entries(state.phases).some(([id, p]) => p.status === "failed" && !byId.get(id)?.optional && !p.optional);
|
|
2972
3726
|
state.status = aborted
|
|
2973
3727
|
? "paused"
|
|
2974
3728
|
: gateBlocked || budgetBlocked
|