taskflow-core 0.1.3 → 0.1.4
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 +858 -0
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +5 -1
- package/dist/compile.js.map +1 -1
- package/dist/contract.d.ts +39 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +150 -0
- package/dist/contract.js.map +1 -0
- package/dist/host/runner-types.d.ts +3 -0
- package/dist/host/runner-types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/peek.d.ts +39 -0
- package/dist/peek.d.ts.map +1 -0
- package/dist/peek.js +119 -0
- package/dist/peek.js.map +1 -0
- package/dist/runner-core.d.ts.map +1 -1
- package/dist/runner-core.js +3 -0
- package/dist/runner-core.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +237 -8
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts +10 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +64 -2
- package/dist/schema.js.map +1 -1
- package/dist/store.d.ts +4 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/verify.d.ts +1 -1
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +59 -0
- package/dist/verify.js.map +1 -1
- package/package.json +2 -2
package/dist/runtime.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import * as fs from "node:fs";
|
|
14
14
|
import { coerceArray, evaluateCondition, interpolate, safeParse, tryEvaluateCondition } from "./interpolate.js";
|
|
15
|
+
import { contractViolations } from "./contract.js";
|
|
15
16
|
import { isFailed, isTransientError, mapWithConcurrencyLimit } from "./runner-core.js";
|
|
16
17
|
/** Default runner used when no host injected one: fail loudly rather than
|
|
17
18
|
* silently spawn anything (core is host-neutral and cannot spawn pi/codex). */
|
|
@@ -100,6 +101,7 @@ function resultToPhaseState(id, r, inputHash, parseJson) {
|
|
|
100
101
|
usage: r.usage,
|
|
101
102
|
model: r.model,
|
|
102
103
|
attempts: attempts > 1 ? attempts : undefined,
|
|
104
|
+
timedOut: r.phaseTimeout || undefined,
|
|
103
105
|
error: failed ? r.errorMessage || r.stderr || r.output : undefined,
|
|
104
106
|
inputHash,
|
|
105
107
|
endedAt: Date.now(),
|
|
@@ -314,6 +316,7 @@ function mergePhaseState(id, results, inputHash, parseJson) {
|
|
|
314
316
|
usage,
|
|
315
317
|
model,
|
|
316
318
|
attempts: attempts > results.length ? attempts : undefined,
|
|
319
|
+
timedOut: ran.some((r) => r.phaseTimeout) || undefined,
|
|
317
320
|
budgetTruncated: budgetSkips.length > 0 || undefined,
|
|
318
321
|
subProgress: { done: ran.length, total: results.length, running: 0, failed: failedCount },
|
|
319
322
|
error: errors.length ? errors.join("; ") : undefined,
|
|
@@ -648,7 +651,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
648
651
|
// each run (schema already rejects explicit cross-run, but the default-scope
|
|
649
652
|
// path must also be blocked). If flowDefHash failed, cross-run is unsafe
|
|
650
653
|
// because the key degrades to flowName-only and reopens cross-flow collisions.
|
|
651
|
-
const CROSS_RUN_BLOCKED_TYPES = new Set(["gate", "approval", "loop", "tournament"]);
|
|
654
|
+
const CROSS_RUN_BLOCKED_TYPES = new Set(["gate", "approval", "loop", "tournament", "script"]);
|
|
652
655
|
if (cacheScope === "cross-run" && CROSS_RUN_BLOCKED_TYPES.has(type)) {
|
|
653
656
|
cacheScope = "run-only";
|
|
654
657
|
}
|
|
@@ -671,12 +674,12 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
671
674
|
tools: phase.tools,
|
|
672
675
|
preRead,
|
|
673
676
|
};
|
|
674
|
-
const baseRun = (agentName, task, onLive, ctxNodeId) => run(effCwd, deps.agents, agentName, task, {
|
|
677
|
+
const baseRun = (agentName, task, onLive, ctxNodeId, signal) => run(effCwd, deps.agents, agentName, task, {
|
|
675
678
|
model: phase.model,
|
|
676
679
|
thinking: phase.thinking,
|
|
677
680
|
tools: phase.tools,
|
|
678
681
|
cwd: effCwd,
|
|
679
|
-
signal: deps.signal,
|
|
682
|
+
signal: signal ?? deps.signal,
|
|
680
683
|
onLive,
|
|
681
684
|
ctxDir: ctxDir,
|
|
682
685
|
nodeId: ctxDir ? ctxNodeId : undefined,
|
|
@@ -693,7 +696,12 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
693
696
|
const DEFAULT_TRANSIENT_RETRIES = 3;
|
|
694
697
|
const DEFAULT_TRANSIENT_BACKOFF_MS = 2000;
|
|
695
698
|
const DEFAULT_TRANSIENT_FACTOR = 2;
|
|
696
|
-
|
|
699
|
+
// Per-phase timeout: caps EACH subagent call of an agent-running phase.
|
|
700
|
+
// (script phases enforce their own child-process timeout — see the script branch.)
|
|
701
|
+
const phaseTimeoutMs = type !== "script" && typeof phase.timeout === "number" && Number.isFinite(phase.timeout) && phase.timeout >= 1000
|
|
702
|
+
? phase.timeout
|
|
703
|
+
: undefined;
|
|
704
|
+
const runOne = async (agentName, task, onLive, ctxNodeId, check) => {
|
|
697
705
|
const explicitMax = Math.max(1, 1 + Math.max(0, Math.floor(retry?.max ?? 0)));
|
|
698
706
|
// Allow enough attempts to cover whichever policy applies on a given attempt.
|
|
699
707
|
const maxAttempts = Math.max(explicitMax, 1 + DEFAULT_TRANSIENT_RETRIES);
|
|
@@ -702,8 +710,65 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
702
710
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
703
711
|
if (deps.signal?.aborted)
|
|
704
712
|
break;
|
|
705
|
-
|
|
713
|
+
// Phase timeout — an AbortController chained to the run's signal that also
|
|
714
|
+
// fires after `phase.timeout` ms. Deterministic: a timed-out call is never
|
|
715
|
+
// retried (retrying a call that just burned its full cap would double-spend).
|
|
716
|
+
let timedOut = false;
|
|
717
|
+
let timer;
|
|
718
|
+
let onParentAbort;
|
|
719
|
+
let callSignal;
|
|
720
|
+
if (phaseTimeoutMs) {
|
|
721
|
+
const ac = new AbortController();
|
|
722
|
+
callSignal = ac.signal;
|
|
723
|
+
if (deps.signal?.aborted)
|
|
724
|
+
ac.abort();
|
|
725
|
+
else if (deps.signal) {
|
|
726
|
+
onParentAbort = () => ac.abort();
|
|
727
|
+
deps.signal.addEventListener("abort", onParentAbort, { once: true });
|
|
728
|
+
}
|
|
729
|
+
timer = setTimeout(() => {
|
|
730
|
+
timedOut = true;
|
|
731
|
+
ac.abort();
|
|
732
|
+
}, phaseTimeoutMs);
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
last = await baseRun(agentName, task, onLive, ctxNodeId, callSignal);
|
|
736
|
+
}
|
|
737
|
+
finally {
|
|
738
|
+
if (timer)
|
|
739
|
+
clearTimeout(timer);
|
|
740
|
+
if (onParentAbort)
|
|
741
|
+
deps.signal?.removeEventListener("abort", onParentAbort);
|
|
742
|
+
}
|
|
743
|
+
if (timedOut) {
|
|
744
|
+
// Reclassify the abort as a phase timeout: a distinct, deterministic
|
|
745
|
+
// failure (stopReason "error" keeps it in the failed bucket; the
|
|
746
|
+
// phaseTimeout marker excludes it from transient retry).
|
|
747
|
+
last = {
|
|
748
|
+
...last,
|
|
749
|
+
exitCode: last.exitCode === 0 ? 1 : last.exitCode,
|
|
750
|
+
stopReason: "error",
|
|
751
|
+
errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`,
|
|
752
|
+
phaseTimeout: true,
|
|
753
|
+
};
|
|
754
|
+
usages.push(last.usage);
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
706
757
|
usages.push(last.usage);
|
|
758
|
+
// Output contract (`expect`): a successful attempt whose JSON output
|
|
759
|
+
// violates the declared contract is a failure — eligible for the phase's
|
|
760
|
+
// explicit retry policy (never the transient fallback).
|
|
761
|
+
if (check && !isFailed(last)) {
|
|
762
|
+
const violations = check(last);
|
|
763
|
+
if (violations.length > 0) {
|
|
764
|
+
last = {
|
|
765
|
+
...last,
|
|
766
|
+
exitCode: last.exitCode === 0 ? 1 : last.exitCode,
|
|
767
|
+
stopReason: "error",
|
|
768
|
+
errorMessage: `Output contract violated:\n- ${violations.join("\n- ")}`,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
}
|
|
707
772
|
// B6: aggregate and surface cumulative usage before the retry decision,
|
|
708
773
|
// so the TUI / budget guard see the in-flight spend on every attempt.
|
|
709
774
|
const liveRetry = state.phases[phase.id];
|
|
@@ -759,6 +824,17 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
759
824
|
return last;
|
|
760
825
|
};
|
|
761
826
|
const parseJson = phase.output === "json";
|
|
827
|
+
// Output contract (`expect`): validates the parsed JSON output of a finished
|
|
828
|
+
// subagent call. Wired into `runOne` so a violation counts as a failed attempt
|
|
829
|
+
// (retryable under the phase's explicit `retry` policy).
|
|
830
|
+
const contractCheck = phase.expect !== undefined && parseJson
|
|
831
|
+
? (r) => {
|
|
832
|
+
const parsed = safeParse(r.output);
|
|
833
|
+
if (parsed === undefined)
|
|
834
|
+
return ["$: output is not valid JSON (contract could not be checked)"];
|
|
835
|
+
return contractViolations(parsed, phase.expect);
|
|
836
|
+
}
|
|
837
|
+
: undefined;
|
|
762
838
|
// Runs a list of sub-tasks with live fan-out progress + aggregate live usage/activity.
|
|
763
839
|
// `perItem` (map only) enables per-item cross-run caching: each item is looked
|
|
764
840
|
// up in the cache before spawning a subagent, and a successful fresh item is
|
|
@@ -959,7 +1035,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
959
1035
|
const cached = cachedPhase(cc, ck);
|
|
960
1036
|
if (cached)
|
|
961
1037
|
return cached;
|
|
962
|
-
const r = await runOne(agentName, fullTask, liveSink(state, phase.id, emitProgress), nodeIdFor());
|
|
1038
|
+
const r = await runOne(agentName, fullTask, liveSink(state, phase.id, emitProgress), nodeIdFor(), contractCheck);
|
|
963
1039
|
const ps = resultToPhaseState(phase.id, r, inputHash, parseJson);
|
|
964
1040
|
if (readRefs.length)
|
|
965
1041
|
ps.reads = readRefsToReads(readRefs, state);
|
|
@@ -1021,7 +1097,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1021
1097
|
const retryText = interpolate(phase.task ?? "", retryCtx).text;
|
|
1022
1098
|
const retryTask = preRead + retryText;
|
|
1023
1099
|
const retryIH = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", retryTask]).key;
|
|
1024
|
-
const retryR = await runOne(agentName, retryTask, liveSink(state, phase.id, emitProgress));
|
|
1100
|
+
const retryR = await runOne(agentName, retryTask, liveSink(state, phase.id, emitProgress), undefined, contractCheck);
|
|
1025
1101
|
gatePs = resultToPhaseState(phase.id, retryR, retryIH, parseJson);
|
|
1026
1102
|
if (gatePs.status === "done")
|
|
1027
1103
|
gatePs.gate = parseGateVerdict(retryR.output);
|
|
@@ -1035,6 +1111,157 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1035
1111
|
recordCache(cc, ps);
|
|
1036
1112
|
return ps;
|
|
1037
1113
|
}
|
|
1114
|
+
// script — zero-token shell command. Spawns a child process, pipes
|
|
1115
|
+
// interpolated `input` to stdin, captures stdout as the phase output.
|
|
1116
|
+
if (type === "script") {
|
|
1117
|
+
const cmd = phase.run;
|
|
1118
|
+
if (!cmd) {
|
|
1119
|
+
return {
|
|
1120
|
+
id: phase.id,
|
|
1121
|
+
status: "failed",
|
|
1122
|
+
error: "script phase requires 'run'",
|
|
1123
|
+
endedAt: Date.now(),
|
|
1124
|
+
usage: emptyUsage(),
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
// Interpolate the command.
|
|
1128
|
+
// Array form: interpolate each element (safe — schema rejects placeholders in string form).
|
|
1129
|
+
// String form: skip interpolation — schema already guarantees no {placeholders}.
|
|
1130
|
+
const interpRun = Array.isArray(cmd)
|
|
1131
|
+
? cmd.map((s) => interpolate(s, ctx))
|
|
1132
|
+
: [{ text: cmd, missing: [] }];
|
|
1133
|
+
// Warn unresolved references.
|
|
1134
|
+
for (const r of interpRun) {
|
|
1135
|
+
if (r.missing.length)
|
|
1136
|
+
warnUnresolvedRefs(phase.id, r.missing);
|
|
1137
|
+
}
|
|
1138
|
+
const interpRunText = interpRun.map((r) => r.text);
|
|
1139
|
+
// Interpolate stdin input if provided.
|
|
1140
|
+
const stdinInterp = phase.input !== undefined ? interpolate(phase.input, ctx) : undefined;
|
|
1141
|
+
if (stdinInterp?.missing.length)
|
|
1142
|
+
warnUnresolvedRefs(phase.id, stdinInterp.missing);
|
|
1143
|
+
const stdinInput = stdinInterp?.text;
|
|
1144
|
+
const ck = cacheKeys(cc, [phase.id, JSON.stringify(interpRunText), stdinInput ?? ""]);
|
|
1145
|
+
const inputHash = ck.key;
|
|
1146
|
+
const cached = cachedPhase(cc, ck);
|
|
1147
|
+
if (cached)
|
|
1148
|
+
return cached;
|
|
1149
|
+
const MAX_STDOUT = 1_048_576; // 1 MB cap
|
|
1150
|
+
const SCRIPT_TIMEOUT_MS = (phase.timeout ?? 60_000); // default 60 s, configurable via phase.timeout
|
|
1151
|
+
const SIGKILL_GRACE_MS = 5_000; // grace period after SIGTERM before SIGKILL
|
|
1152
|
+
try {
|
|
1153
|
+
const { spawn } = await import("node:child_process");
|
|
1154
|
+
const result = await new Promise((resolve, reject) => {
|
|
1155
|
+
// Array command → direct spawn (safe); string → shell (rejected at validation if it has placeholders).
|
|
1156
|
+
const child = Array.isArray(cmd)
|
|
1157
|
+
? spawn(interpRunText[0], interpRunText.slice(1), {
|
|
1158
|
+
cwd: effCwd,
|
|
1159
|
+
env: process.env,
|
|
1160
|
+
shell: false,
|
|
1161
|
+
signal: deps.signal,
|
|
1162
|
+
})
|
|
1163
|
+
: spawn(interpRunText[0], [], {
|
|
1164
|
+
cwd: effCwd,
|
|
1165
|
+
env: process.env,
|
|
1166
|
+
shell: true,
|
|
1167
|
+
signal: deps.signal,
|
|
1168
|
+
});
|
|
1169
|
+
let stdout = "";
|
|
1170
|
+
let stderr = "";
|
|
1171
|
+
let stdoutOversize = false;
|
|
1172
|
+
let timedOut = false;
|
|
1173
|
+
child.stdout?.on("data", (d) => {
|
|
1174
|
+
if (stdout.length < MAX_STDOUT) {
|
|
1175
|
+
const need = MAX_STDOUT - stdout.length;
|
|
1176
|
+
stdout += d.toString().slice(0, need);
|
|
1177
|
+
if (stdout.length >= MAX_STDOUT)
|
|
1178
|
+
stdoutOversize = true;
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
child.stderr?.on("data", (d) => {
|
|
1182
|
+
if (stderr.length < 500) {
|
|
1183
|
+
stderr += d.toString().slice(0, 500 - stderr.length);
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
// Timeout guard: SIGTERM first, then SIGKILL after grace period.
|
|
1187
|
+
let sigkillTimer;
|
|
1188
|
+
const timer = setTimeout(() => {
|
|
1189
|
+
timedOut = true;
|
|
1190
|
+
child.kill("SIGTERM");
|
|
1191
|
+
// SIGKILL fallback if process ignores SIGTERM.
|
|
1192
|
+
sigkillTimer = setTimeout(() => {
|
|
1193
|
+
try {
|
|
1194
|
+
child.kill("SIGKILL");
|
|
1195
|
+
}
|
|
1196
|
+
catch { /* already dead */ }
|
|
1197
|
+
}, SIGKILL_GRACE_MS);
|
|
1198
|
+
}, SCRIPT_TIMEOUT_MS);
|
|
1199
|
+
child.on("error", (err) => {
|
|
1200
|
+
clearTimeout(timer);
|
|
1201
|
+
clearTimeout(sigkillTimer);
|
|
1202
|
+
reject(err);
|
|
1203
|
+
});
|
|
1204
|
+
child.on("close", (code) => {
|
|
1205
|
+
clearTimeout(timer);
|
|
1206
|
+
clearTimeout(sigkillTimer);
|
|
1207
|
+
resolve({ stdout, stderr, code, stdoutOversize, timedOut });
|
|
1208
|
+
});
|
|
1209
|
+
if (stdinInput !== undefined) {
|
|
1210
|
+
child.stdin?.on("error", () => { }); // swallow EPIPE when child closes stdin early
|
|
1211
|
+
child.stdin?.write(stdinInput);
|
|
1212
|
+
child.stdin?.end();
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
if (result.code !== 0 || result.timedOut) {
|
|
1216
|
+
const ps = {
|
|
1217
|
+
id: phase.id,
|
|
1218
|
+
status: "failed",
|
|
1219
|
+
output: result.stdout,
|
|
1220
|
+
error: result.timedOut
|
|
1221
|
+
? `Script timed out after ${SCRIPT_TIMEOUT_MS}ms`
|
|
1222
|
+
: `Script exited with code ${result.code}${result.stderr ? ": " + result.stderr.slice(0, 500) : ""}${result.stdoutOversize ? " [stdout truncated at 1 MB]" : ""}`,
|
|
1223
|
+
timedOut: result.timedOut || undefined,
|
|
1224
|
+
usage: emptyUsage(),
|
|
1225
|
+
inputHash,
|
|
1226
|
+
endedAt: Date.now(),
|
|
1227
|
+
};
|
|
1228
|
+
if (readRefs.length)
|
|
1229
|
+
ps.reads = readRefsToReads(readRefs, state);
|
|
1230
|
+
// Non-zero exit: cache (deterministic failure). Timeout: don't cache (transient, like spawn errors).
|
|
1231
|
+
if (!result.timedOut)
|
|
1232
|
+
recordCache(cc, ps);
|
|
1233
|
+
return ps;
|
|
1234
|
+
}
|
|
1235
|
+
const ps = {
|
|
1236
|
+
id: phase.id,
|
|
1237
|
+
status: "done",
|
|
1238
|
+
output: result.stdout.trimEnd() + (result.stdoutOversize ? "\n[stdout truncated at 1 MB]" : ""),
|
|
1239
|
+
usage: emptyUsage(),
|
|
1240
|
+
inputHash,
|
|
1241
|
+
endedAt: Date.now(),
|
|
1242
|
+
};
|
|
1243
|
+
if (readRefs.length)
|
|
1244
|
+
ps.reads = readRefsToReads(readRefs, state);
|
|
1245
|
+
recordCache(cc, ps);
|
|
1246
|
+
return ps;
|
|
1247
|
+
}
|
|
1248
|
+
catch (err) {
|
|
1249
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1250
|
+
// Note: intentionally NOT cached — spawn errors (ENOENT, permission denied, etc.)
|
|
1251
|
+
// are treated as transient. The phase will re-execute on resume/retry.
|
|
1252
|
+
const ps = {
|
|
1253
|
+
id: phase.id,
|
|
1254
|
+
status: "failed",
|
|
1255
|
+
error: `Script error: ${msg}`,
|
|
1256
|
+
usage: emptyUsage(),
|
|
1257
|
+
inputHash,
|
|
1258
|
+
endedAt: Date.now(),
|
|
1259
|
+
};
|
|
1260
|
+
if (readRefs.length)
|
|
1261
|
+
ps.reads = readRefsToReads(readRefs, state);
|
|
1262
|
+
return ps;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1038
1265
|
if (type === "parallel") {
|
|
1039
1266
|
const branches = (phase.branches ?? []).map((b) => {
|
|
1040
1267
|
const r = interpolate(b.task, ctx);
|
|
@@ -1416,7 +1643,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1416
1643
|
loop: { iteration: i, lastOutput, maxIterations: maxIters },
|
|
1417
1644
|
}, (ref) => readRefs.push(ref));
|
|
1418
1645
|
const body = preRead + interpolate(phase.task ?? "", bodyCtx).text;
|
|
1419
|
-
const r = await runOne(agentName, body, liveSink(state, phase.id, emitProgress));
|
|
1646
|
+
const r = await runOne(agentName, body, liveSink(state, phase.id, emitProgress), undefined, contractCheck);
|
|
1420
1647
|
usages.push(r.usage);
|
|
1421
1648
|
if (isFailed(r)) {
|
|
1422
1649
|
failedResult = r;
|
|
@@ -1457,6 +1684,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1457
1684
|
status: "failed",
|
|
1458
1685
|
output: lastOutput || undefined,
|
|
1459
1686
|
usage: aggUsage,
|
|
1687
|
+
timedOut: failedResult?.phaseTimeout || undefined,
|
|
1460
1688
|
error: failedResult?.errorMessage || failedResult?.stderr || (stop === "aborted" ? "Aborted" : `loop '${phase.id}' iteration ${iterations} failed`),
|
|
1461
1689
|
loop: { iterations, stop },
|
|
1462
1690
|
warnings: loopWarnings.length ? loopWarnings : undefined,
|
|
@@ -1518,6 +1746,7 @@ async function executePhaseInner(phase, state, deps, prior, emitProgress, _retry
|
|
|
1518
1746
|
status: "failed",
|
|
1519
1747
|
usage: variantUsage,
|
|
1520
1748
|
error: `tournament '${phase.id}': all ${competitors.length} variants failed`,
|
|
1749
|
+
timedOut: ran.some((r) => r.phaseTimeout) || undefined,
|
|
1521
1750
|
budgetTruncated: budgetSkipCount > 0 || undefined,
|
|
1522
1751
|
tournament: { variants: competitors.length, winner: 0, mode },
|
|
1523
1752
|
inputHash,
|