@agwab/pi-workflow 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/extension.ts CHANGED
@@ -39,7 +39,10 @@ import {
39
39
  type ThinkingLevel,
40
40
  WorkflowValidationError,
41
41
  } from "./types.js";
42
- import { toWorkflowModelInfo } from "./workflow-runtime.js";
42
+ import {
43
+ toWorkflowModelInfo,
44
+ type WorkflowRuntimeDefaults,
45
+ } from "./workflow-runtime.js";
43
46
 
44
47
  const UNFINISHED_RUN_NOTICE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
45
48
  const UNFINISHED_RUN_NOTICE_MAX_RUNS = 5;
@@ -337,7 +340,8 @@ async function deliverMissedWorkflowFeedback(
337
340
  const run = await readRunRecord(ctx.cwd, summary.runId).catch(
338
341
  () => undefined,
339
342
  );
340
- if (run) await deliverWorkflowFeedback(ctx, api, run).catch(() => undefined);
343
+ if (run)
344
+ await deliverWorkflowFeedback(ctx, api, run).catch(() => undefined);
341
345
  }
342
346
  }
343
347
 
@@ -548,13 +552,13 @@ interface WorkflowRunToolRequest {
548
552
  workflow: string;
549
553
  task: string;
550
554
  detach: boolean;
551
- runtimeDefaults?: { model?: string; thinking?: ThinkingLevel };
555
+ runtimeOverrides?: WorkflowRuntimeDefaults;
552
556
  }
553
557
 
554
558
  interface WorkflowDynamicToolRequest {
555
559
  task: string;
556
560
  detach: boolean;
557
- runtimeDefaults?: { model?: string; thinking?: ThinkingLevel };
561
+ runtimeOverrides?: WorkflowRuntimeDefaults;
558
562
  }
559
563
 
560
564
  function parseWorkflowListToolParams(params: unknown): void {
@@ -602,9 +606,9 @@ function parseWorkflowDynamicToolParams(
602
606
  "workflow_dynamic",
603
607
  )?.trim();
604
608
  const thinking = rawThinking ? parseThinkingLevel(rawThinking) : undefined;
605
- const runtimeDefaults =
609
+ const runtimeOverrides =
606
610
  model || thinking ? { model: model || undefined, thinking } : undefined;
607
- return { task, detach: detachValue === true, runtimeDefaults };
611
+ return { task, detach: detachValue === true, runtimeOverrides };
608
612
  }
609
613
 
610
614
  function stringParam(
@@ -704,8 +708,8 @@ async function startWorkflowRunFromRequest(
704
708
  );
705
709
  const run = await runWorkflowSpec(workflow, ctx.cwd, {
706
710
  task,
707
- runtimeDefaults:
708
- request.runtimeDefaults ?? currentRuntimeDefaults(ctx, api),
711
+ runtimeOverrides: request.runtimeOverrides,
712
+ runtimeDefaults: currentRuntimeDefaults(ctx, api),
709
713
  availableModels: availableWorkflowModels(ctx),
710
714
  dynamicUi: dynamicUiFromContext(ctx),
711
715
  });
@@ -736,8 +740,8 @@ async function startDynamicRunFromRequest(
736
740
  );
737
741
  const run = await runDynamicTask(ctx.cwd, {
738
742
  task,
739
- runtimeDefaults:
740
- request.runtimeDefaults ?? currentRuntimeDefaults(ctx, api),
743
+ runtimeOverrides: request.runtimeOverrides,
744
+ runtimeDefaults: currentRuntimeDefaults(ctx, api),
741
745
  availableModels: availableWorkflowModels(ctx),
742
746
  dynamicUi: dynamicUiFromContext(ctx),
743
747
  });
@@ -1047,7 +1051,7 @@ async function handleWorkflowCommand(
1047
1051
  const specPath =
1048
1052
  parsed.specPath ||
1049
1053
  requireArg(tokens, 1, '/workflow run <workflow-name-or-path> "<task>"');
1050
- const runtimeDefaults =
1054
+ const runtimeOverrides =
1051
1055
  parsed.model || parsed.thinking
1052
1056
  ? { model: parsed.model, thinking: parsed.thinking }
1053
1057
  : undefined;
@@ -1056,7 +1060,7 @@ async function handleWorkflowCommand(
1056
1060
  workflow: specPath,
1057
1061
  task: parsed.task,
1058
1062
  detach: parsed.detach,
1059
- runtimeDefaults,
1063
+ runtimeOverrides,
1060
1064
  },
1061
1065
  ctx,
1062
1066
  api,
@@ -1067,7 +1071,7 @@ async function handleWorkflowCommand(
1067
1071
 
1068
1072
  if (action === "dynamic") {
1069
1073
  const parsed = parseWorkflowDynamicArgs(args);
1070
- const runtimeDefaults =
1074
+ const runtimeOverrides =
1071
1075
  parsed.model || parsed.thinking
1072
1076
  ? { model: parsed.model, thinking: parsed.thinking }
1073
1077
  : undefined;
@@ -1075,7 +1079,7 @@ async function handleWorkflowCommand(
1075
1079
  {
1076
1080
  task: parsed.task,
1077
1081
  detach: parsed.detach,
1078
- runtimeDefaults,
1082
+ runtimeOverrides,
1079
1083
  },
1080
1084
  ctx,
1081
1085
  api,
package/src/store.ts CHANGED
@@ -43,6 +43,12 @@ const TERMINAL_INDEX_LIMIT = 50;
43
43
  const LEASE_STALE_MS = 30_000;
44
44
  const INDEX_LOCK_WAIT_MS = 5_000;
45
45
  const INDEX_LOCK_RETRY_MS = 50;
46
+ const DEFAULT_INDEX_UPDATE_DEBOUNCE_MS = 500;
47
+ let indexUpdateDebounceMs = DEFAULT_INDEX_UPDATE_DEBOUNCE_MS;
48
+ const pendingIndexUpdates = new Map<
49
+ string,
50
+ { cwd: string; runId: string; timer: ReturnType<typeof setTimeout> }
51
+ >();
46
52
  const runLeaseContext = new AsyncLocalStorage<{
47
53
  cwd: string;
48
54
  runId: string;
@@ -350,7 +356,56 @@ export async function writeRunRecord(
350
356
  const derived = deriveRunStatus(run);
351
357
  Object.assign(run, derived);
352
358
  await writeJsonAtomic(workflowRunPath(cwd, run.runId), run);
353
- await updateIndex(cwd).catch(() => undefined);
359
+ scheduleIndexUpdate(cwd, run.runId, {
360
+ immediate: isTerminalWorkflowStatus(run.status),
361
+ });
362
+ }
363
+
364
+ function indexUpdateKey(cwd: string, runId: string): string {
365
+ return `${cwd}\0${runId}`;
366
+ }
367
+
368
+ function scheduleIndexUpdate(
369
+ cwd: string,
370
+ runId: string,
371
+ options: { immediate: boolean },
372
+ ): void {
373
+ const key = indexUpdateKey(cwd, runId);
374
+ const existing = pendingIndexUpdates.get(key);
375
+ if (existing) {
376
+ clearTimeout(existing.timer);
377
+ pendingIndexUpdates.delete(key);
378
+ }
379
+
380
+ const runUpdate = (): void => {
381
+ pendingIndexUpdates.delete(key);
382
+ void updateIndex(cwd, runId).catch(() => undefined);
383
+ };
384
+
385
+ if (options.immediate) {
386
+ runUpdate();
387
+ return;
388
+ }
389
+
390
+ // Pending debounced index writes are intentionally not flushed on process exit:
391
+ // the next explicit index rebuild/read path self-heals from run.json records.
392
+ const timer = setTimeout(runUpdate, indexUpdateDebounceMs);
393
+ timer.unref?.();
394
+ pendingIndexUpdates.set(key, { cwd, runId, timer });
395
+ }
396
+
397
+ export async function flushPendingIndexUpdatesForTests(): Promise<void> {
398
+ const pending = [...pendingIndexUpdates.values()];
399
+ pendingIndexUpdates.clear();
400
+ for (const item of pending) clearTimeout(item.timer);
401
+ await Promise.all(pending.map((item) => updateIndex(item.cwd, item.runId)));
402
+ }
403
+
404
+ export function setIndexUpdateDebounceMsForTests(value?: number): void {
405
+ indexUpdateDebounceMs =
406
+ value === undefined
407
+ ? DEFAULT_INDEX_UPDATE_DEBOUNCE_MS
408
+ : Math.max(0, Math.floor(value));
354
409
  }
355
410
 
356
411
  export async function writeCompiledRunArtifact(
@@ -1088,55 +1143,19 @@ function isRunRecordLike(value: unknown): value is WorkflowRunRecord {
1088
1143
  );
1089
1144
  }
1090
1145
 
1091
- export async function updateIndex(cwd: string): Promise<WorkflowIndexRecord> {
1146
+ export async function updateIndex(
1147
+ cwd: string,
1148
+ changedRunId?: string,
1149
+ ): Promise<WorkflowIndexRecord> {
1092
1150
  const lockFile = join(workflowsRoot(cwd), "index.lock");
1093
1151
  const ownerId = `${process.pid}-${randomBytes(3).toString("hex")}`;
1094
1152
  await ensureDir(workflowsRoot(cwd));
1095
1153
  await acquireLockWithWait(lockFile, ownerId);
1096
1154
 
1097
1155
  try {
1098
- const runs = (await listRunRecords(cwd)).sort((left, right) =>
1099
- right.updatedAt.localeCompare(left.updatedAt),
1100
- );
1101
- const active = runs.filter((run) => !isTerminalWorkflowStatus(run.status));
1102
- const terminal = runs
1103
- .filter((run) => isTerminalWorkflowStatus(run.status))
1104
- .slice(0, TERMINAL_INDEX_LIMIT);
1105
- const selected = [...active, ...terminal].sort((left, right) =>
1106
- right.updatedAt.localeCompare(left.updatedAt),
1107
- );
1108
-
1109
- const index: WorkflowIndexRecord = {
1110
- schemaVersion: 1,
1111
- updatedAt: nowIso(),
1112
- runs: selected.map((run) => ({
1113
- runId: run.runId,
1114
- name: run.name,
1115
- type: run.type,
1116
- artifactGraph: run.artifactGraph,
1117
- status: run.status,
1118
- taskSummary: run.taskSummary,
1119
- createdAt: run.createdAt,
1120
- updatedAt: run.updatedAt,
1121
- parentRunId: run.parentRunId,
1122
- rootRunId: run.rootRunId,
1123
- round: run.round,
1124
- fanout: run.fanout,
1125
- runJson: toProjectPath(cwd, workflowRunPath(cwd, run.runId)),
1126
- tasks: run.tasks.map((task) => ({
1127
- taskId: task.taskId,
1128
- displayName: task.displayName,
1129
- agent: task.agent,
1130
- kind: task.kind,
1131
- stageId: task.stageId,
1132
- backendHandle: task.backendHandle,
1133
- status: task.status,
1134
- statusDetail: task.statusDetail,
1135
- lastMessage: task.lastMessage,
1136
- })),
1137
- })),
1138
- };
1139
-
1156
+ const index = changedRunId
1157
+ ? await updateIndexIncremental(cwd, changedRunId)
1158
+ : await rebuildIndex(cwd);
1140
1159
  await writeJsonAtomic(workflowIndexPath(cwd), index);
1141
1160
  return index;
1142
1161
  } finally {
@@ -1144,6 +1163,122 @@ export async function updateIndex(cwd: string): Promise<WorkflowIndexRecord> {
1144
1163
  }
1145
1164
  }
1146
1165
 
1166
+ type WorkflowIndexRunEntry = WorkflowIndexRecord["runs"][number];
1167
+
1168
+ async function updateIndexIncremental(
1169
+ cwd: string,
1170
+ changedRunId: string,
1171
+ ): Promise<WorkflowIndexRecord> {
1172
+ const existing = await readIndexForIncremental(cwd);
1173
+ if (!existing) return rebuildIndex(cwd);
1174
+
1175
+ let changedRun: WorkflowRunRecord;
1176
+ try {
1177
+ changedRun = await readRunRecord(cwd, changedRunId);
1178
+ } catch {
1179
+ return rebuildIndex(cwd);
1180
+ }
1181
+
1182
+ const changedEntry = buildIndexEntry(cwd, changedRun);
1183
+ const entries = existing.runs
1184
+ .filter((entry) => entry.runId !== changedRun.runId)
1185
+ .concat(changedEntry);
1186
+ return {
1187
+ schemaVersion: 1,
1188
+ updatedAt: nowIso(),
1189
+ runs: selectIndexEntries(entries),
1190
+ };
1191
+ }
1192
+
1193
+ async function readIndexForIncremental(
1194
+ cwd: string,
1195
+ ): Promise<WorkflowIndexRecord | undefined> {
1196
+ let index: WorkflowIndexRecord | undefined;
1197
+ try {
1198
+ index = await readIndex(cwd);
1199
+ } catch {
1200
+ return undefined;
1201
+ }
1202
+ if (!isIndexRecordLike(index)) return undefined;
1203
+ return index;
1204
+ }
1205
+
1206
+ async function rebuildIndex(cwd: string): Promise<WorkflowIndexRecord> {
1207
+ const runs = await listRunRecords(cwd);
1208
+ return {
1209
+ schemaVersion: 1,
1210
+ updatedAt: nowIso(),
1211
+ runs: selectIndexEntries(runs.map((run) => buildIndexEntry(cwd, run))),
1212
+ };
1213
+ }
1214
+
1215
+ function selectIndexEntries(
1216
+ entries: WorkflowIndexRunEntry[],
1217
+ ): WorkflowIndexRunEntry[] {
1218
+ const sorted = [...entries].sort((left, right) =>
1219
+ right.updatedAt.localeCompare(left.updatedAt),
1220
+ );
1221
+ const active = sorted.filter(
1222
+ (entry) => !isTerminalWorkflowStatus(entry.status),
1223
+ );
1224
+ const terminal = sorted
1225
+ .filter((entry) => isTerminalWorkflowStatus(entry.status))
1226
+ .slice(0, TERMINAL_INDEX_LIMIT);
1227
+ return [...active, ...terminal].sort((left, right) =>
1228
+ right.updatedAt.localeCompare(left.updatedAt),
1229
+ );
1230
+ }
1231
+
1232
+ function buildIndexEntry(
1233
+ cwd: string,
1234
+ run: WorkflowRunRecord,
1235
+ ): WorkflowIndexRunEntry {
1236
+ return {
1237
+ runId: run.runId,
1238
+ name: run.name,
1239
+ type: run.type,
1240
+ artifactGraph: run.artifactGraph,
1241
+ status: run.status,
1242
+ taskSummary: run.taskSummary,
1243
+ createdAt: run.createdAt,
1244
+ updatedAt: run.updatedAt,
1245
+ parentRunId: run.parentRunId,
1246
+ rootRunId: run.rootRunId,
1247
+ round: run.round,
1248
+ fanout: run.fanout,
1249
+ runJson: toProjectPath(cwd, workflowRunPath(cwd, run.runId)),
1250
+ tasks: run.tasks.map((task) => ({
1251
+ taskId: task.taskId,
1252
+ displayName: task.displayName,
1253
+ agent: task.agent,
1254
+ kind: task.kind,
1255
+ stageId: task.stageId,
1256
+ backendHandle: task.backendHandle,
1257
+ status: task.status,
1258
+ statusDetail: task.statusDetail,
1259
+ lastMessage: task.lastMessage,
1260
+ })),
1261
+ };
1262
+ }
1263
+
1264
+ function isIndexRecordLike(
1265
+ value: WorkflowIndexRecord | undefined,
1266
+ ): value is WorkflowIndexRecord {
1267
+ return (
1268
+ value?.schemaVersion === 1 &&
1269
+ Array.isArray(value.runs) &&
1270
+ value.runs.every(
1271
+ (entry) =>
1272
+ entry &&
1273
+ typeof entry === "object" &&
1274
+ typeof entry.runId === "string" &&
1275
+ typeof entry.updatedAt === "string" &&
1276
+ typeof entry.status === "string" &&
1277
+ Array.isArray(entry.tasks),
1278
+ )
1279
+ );
1280
+ }
1281
+
1147
1282
  export function deriveRunStatus(run: WorkflowRunRecord): WorkflowRunRecord {
1148
1283
  const next = { ...run, tasks: run.tasks };
1149
1284
  next.taskSummary = summarizeTasks(next.tasks);
@@ -17,6 +17,7 @@ import {
17
17
  resolve,
18
18
  sep,
19
19
  } from "node:path";
20
+ import { availableParallelism } from "node:os";
20
21
  import { fileURLToPath } from "node:url";
21
22
 
22
23
  import type {
@@ -55,6 +56,10 @@ const FETCH_CONTENT_CACHE_ENV = "PI_WORKFLOW_FETCH_CONTENT_CACHE";
55
56
  const LEGACY_FETCH_CACHE_ENV = "PI_WORKFLOW_FETCH_CACHE";
56
57
  const DEFAULT_TRANSIENT_MODEL_FAILURE_RETRIES = 5;
57
58
  const DEFAULT_ARTIFACT_OUTPUT_RETRIES = 2;
59
+ const MAX_CONCURRENT_LAUNCHES_ENV = "PI_WORKFLOW_MAX_CONCURRENT_LAUNCHES";
60
+ const DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS = 3_000;
61
+ const MIN_TRANSIENT_RETRY_JITTER_MS = 1_000;
62
+ const MAX_TRANSIENT_RETRY_JITTER_MS = 5_000;
58
63
  const MODULE_PATH = fileURLToPath(import.meta.url);
59
64
  const MODULE_DIR = dirname(MODULE_PATH);
60
65
  const BUNDLED_PI_WEB_ACCESS_EXTENSION = bundledNodeModulePath(
@@ -175,6 +180,103 @@ async function loadSubagentApi(): Promise<SubagentApi> {
175
180
  return cachedSubagentApi;
176
181
  }
177
182
 
183
+ let launchSlotReleaseDelayMs = DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS;
184
+ let transientRetryJitterForTests: (() => number) | undefined;
185
+ const launchWaitQueue: Array<() => void> = [];
186
+ let activeLaunchSlots = 0;
187
+
188
+ function resolveMaxConcurrentLaunches(): number {
189
+ const override = Number.parseInt(
190
+ process.env[MAX_CONCURRENT_LAUNCHES_ENV] ?? "",
191
+ 10,
192
+ );
193
+ if (Number.isFinite(override)) return Math.max(1, Math.floor(override));
194
+ return Math.max(2, Math.floor(availableParallelism() / 2));
195
+ }
196
+
197
+ function isLaunchGateSaturated(): boolean {
198
+ return activeLaunchSlots >= resolveMaxConcurrentLaunches();
199
+ }
200
+
201
+ async function acquireLaunchSlot(): Promise<() => void> {
202
+ if (!isLaunchGateSaturated()) {
203
+ activeLaunchSlots += 1;
204
+ return releaseLaunchSlot;
205
+ }
206
+ await new Promise<void>((resolveWait) => launchWaitQueue.push(resolveWait));
207
+ return releaseLaunchSlot;
208
+ }
209
+
210
+ function releaseLaunchSlot(): void {
211
+ const next = launchWaitQueue.shift();
212
+ if (next) {
213
+ // Transfer the occupied slot directly to the queued launcher.
214
+ next();
215
+ return;
216
+ }
217
+ activeLaunchSlots = Math.max(0, activeLaunchSlots - 1);
218
+ }
219
+
220
+ function releaseLaunchSlotAfterDelay(
221
+ delayMs: number,
222
+ release: () => void,
223
+ ): void {
224
+ if (delayMs <= 0) {
225
+ release();
226
+ return;
227
+ }
228
+ const timer = setTimeout(release, delayMs);
229
+ timer.unref?.();
230
+ }
231
+
232
+ async function runWithLaunchSlot<T>(action: () => Promise<T>): Promise<T> {
233
+ const release = await acquireLaunchSlot();
234
+ let holdAfterReturn = false;
235
+ try {
236
+ const result = await action();
237
+ holdAfterReturn = true;
238
+ return result;
239
+ } finally {
240
+ releaseLaunchSlotAfterDelay(
241
+ holdAfterReturn ? launchSlotReleaseDelayMs : 0,
242
+ release,
243
+ );
244
+ }
245
+ }
246
+
247
+ function transientRetryJitterMs(): number {
248
+ if (transientRetryJitterForTests) return transientRetryJitterForTests();
249
+ return (
250
+ MIN_TRANSIENT_RETRY_JITTER_MS +
251
+ Math.floor(
252
+ Math.random() *
253
+ (MAX_TRANSIENT_RETRY_JITTER_MS - MIN_TRANSIENT_RETRY_JITTER_MS + 1),
254
+ )
255
+ );
256
+ }
257
+
258
+ function sleep(ms: number): Promise<void> {
259
+ return new Promise((resolve) => setTimeout(resolve, ms));
260
+ }
261
+
262
+ export function setSubagentLaunchControlsForTests(options?: {
263
+ releaseDelayMs?: number;
264
+ retryJitterMs?: number | (() => number);
265
+ }): void {
266
+ launchSlotReleaseDelayMs =
267
+ options?.releaseDelayMs === undefined
268
+ ? DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS
269
+ : Math.max(0, Math.floor(options.releaseDelayMs));
270
+ transientRetryJitterForTests =
271
+ options?.retryJitterMs === undefined
272
+ ? undefined
273
+ : typeof options.retryJitterMs === "function"
274
+ ? options.retryJitterMs
275
+ : () => Math.max(0, Math.floor(options.retryJitterMs as number));
276
+ activeLaunchSlots = 0;
277
+ while (launchWaitQueue.length > 0) launchWaitQueue.shift()?.();
278
+ }
279
+
178
280
  export async function cleanupSubagentRun(
179
281
  _cwd: string,
180
282
  run: WorkflowRunRecord,
@@ -212,6 +314,14 @@ export async function launchSubagentTask(
212
314
  };
213
315
  }
214
316
 
317
+ if ((task.launchRetry?.attempts ?? 0) > 0) {
318
+ const jitterMs = transientRetryJitterMs();
319
+ task.statusDetail = "retry_model_failure";
320
+ task.lastMessage = `waiting ${jitterMs}ms before retrying transient-model launch`;
321
+ await writeRunRecord(cwd, run);
322
+ if (jitterMs > 0) await sleep(jitterMs);
323
+ }
324
+
215
325
  const systemPromptFile = fromProjectPath(cwd, task.files.systemPrompt);
216
326
  const taskPromptFile = fromProjectPath(cwd, task.files.taskPrompt);
217
327
  const outputFile = fromProjectPath(cwd, task.files.output);
@@ -267,7 +377,11 @@ export async function launchSubagentTask(
267
377
  };
268
378
  subagentOptions.extensions = extensions;
269
379
  if (captureToolCallsEnabled()) subagentOptions.captureToolCalls = true;
270
- launched = await api.runSubagent(subagentOptions);
380
+ if (isLaunchGateSaturated()) {
381
+ task.lastMessage = `waiting for pi-subagent launch slot (${resolveMaxConcurrentLaunches()} max)`;
382
+ await writeRunRecord(cwd, run).catch(() => undefined);
383
+ }
384
+ launched = await runWithLaunchSlot(() => api.runSubagent(subagentOptions));
271
385
  } catch (error) {
272
386
  task.status = "pending";
273
387
  task.statusDetail = "pending";
@@ -432,12 +546,29 @@ async function materializeTerminalSubagentResult(
432
546
  artifactRoot,
433
547
  );
434
548
  const outputText = await readFile(outputFile, "utf8").catch(() => "");
549
+ const stderrText = await readFile(stderrFile, "utf8").catch(() => "");
435
550
  const outputBytes = Buffer.byteLength(outputText, "utf8");
436
- const statusInfo = workflowStatusFromSubagent(
551
+ let statusInfo = workflowStatusFromSubagent(
437
552
  snapshot,
438
553
  subagentResult,
439
554
  outputBytes,
440
555
  );
556
+ const deterministicBootFailure = classifyDeterministicBootFailure({
557
+ statusInfo,
558
+ stderrText,
559
+ outputBytes,
560
+ contextLengthExceeded: Boolean(
561
+ (subagentResult?.metadata as any)?.contextLengthExceeded ??
562
+ snapshot.metadata?.contextLengthExceeded,
563
+ ),
564
+ });
565
+ if (deterministicBootFailure) {
566
+ statusInfo = {
567
+ status: "failed",
568
+ failureKind: "deterministic_boot",
569
+ errorMessage: deterministicBootFailure,
570
+ };
571
+ }
441
572
  const completedAt =
442
573
  typeof subagentResult?.completedAt === "string"
443
574
  ? subagentResult.completedAt
@@ -1005,6 +1136,36 @@ function failArtifactGraphTask(
1005
1136
  return true;
1006
1137
  }
1007
1138
 
1139
+ function classifyDeterministicBootFailure(options: {
1140
+ statusInfo: {
1141
+ status: WorkflowTaskRunRecord["status"];
1142
+ failureKind?: string;
1143
+ errorMessage?: string;
1144
+ };
1145
+ stderrText: string;
1146
+ outputBytes: number;
1147
+ contextLengthExceeded: boolean;
1148
+ }): string | undefined {
1149
+ if (
1150
+ options.statusInfo.status !== "failed" ||
1151
+ options.statusInfo.failureKind !== "model" ||
1152
+ options.outputBytes !== 0 ||
1153
+ options.contextLengthExceeded
1154
+ ) {
1155
+ return undefined;
1156
+ }
1157
+ const text = options.stderrText;
1158
+ const deterministicPattern =
1159
+ /(Failed to load extension|Cannot find module|(?:failed to load|invalid|missing) (?:workflow )?config(?:uration)?|config(?:uration)? (?:error|failed|invalid))/i;
1160
+ if (!deterministicPattern.test(text)) return undefined;
1161
+ const excerpt =
1162
+ text
1163
+ .split(/\r?\n/)
1164
+ .map((line) => line.trim())
1165
+ .find((line) => deterministicPattern.test(line)) ?? text.trim();
1166
+ return `deterministic-boot failure: ${excerpt.slice(0, 500)}`;
1167
+ }
1168
+
1008
1169
  function shouldRetryTransientModelFailure(
1009
1170
  statusInfo: {
1010
1171
  status: WorkflowTaskRunRecord["status"];
@@ -1056,14 +1217,14 @@ function retryOrFailTransientSubagentFailure(
1056
1217
  if (!exhausted) {
1057
1218
  task.status = "pending";
1058
1219
  task.statusDetail = "retry_model_failure";
1059
- task.lastMessage = `${options.message}; retrying transient model failure (${attempt}/${maxAttempts})`;
1220
+ task.lastMessage = `${options.message}; retrying transient-model failure (${attempt}/${maxAttempts})`;
1060
1221
  return true;
1061
1222
  }
1062
1223
  task.status = "failed";
1063
1224
  task.statusDetail = task.launchRetry.reason ?? "model_exhausted";
1064
1225
  task.exitCode = 1;
1065
1226
  task.completedAt = nowIso();
1066
- task.lastMessage = `${options.message}; transient model failure retries exhausted (${maxAttempts})`;
1227
+ task.lastMessage = `${options.message}; transient-model failure retries exhausted (${maxAttempts})`;
1067
1228
  return true;
1068
1229
  }
1069
1230
 
@@ -1317,7 +1478,10 @@ async function workflowTaskExtensions(
1317
1478
  },
1318
1479
  });
1319
1480
  const capturedProviderExtensions = new Set(
1320
- workflowWebSourceProviderExtensions(tools, compiledTask.runtime.toolProviders),
1481
+ workflowWebSourceProviderExtensions(
1482
+ tools,
1483
+ compiledTask.runtime.toolProviders,
1484
+ ),
1321
1485
  );
1322
1486
  extensions = uniqueStrings([
1323
1487
  ...extensions.filter(
@@ -1673,7 +1837,7 @@ function buildSystemPrompt(task: CompiledTask): string {
1673
1837
  enabledTools.includes("workflow_web_source_read")
1674
1838
  ? "Workflow web-source tools return compact source cards. Preserve sourceRef values in structured outputs. Use workflow_web_source_read for exact evidence snippets; when several snippets are needed from the same sourceRef, batch them with queries:[...] or reads:[...] instead of making repeated calls. If the exact quote is unknown, pass claim plus 2-6 distinctive terms to harvest a candidate source window and preserve its match metadata. Do not read workflow cache files directly."
1675
1839
  : !enabledTools.includes("get_search_content") &&
1676
- (enabledTools.includes("web_search") ||
1840
+ (enabledTools.includes("web_search") ||
1677
1841
  enabledTools.includes("fetch_content"))
1678
1842
  ? "Full cached search-content hydration is unavailable here. Use web_search/fetch_content results and report evidence gaps instead of broad raw document retrieval."
1679
1843
  : undefined,
package/src/types.ts CHANGED
@@ -1,4 +1,8 @@
1
- import type { WorkflowRuntimeThinkingResolution } from "./workflow-runtime.js";
1
+ import type {
2
+ WorkflowModelInfo,
3
+ WorkflowRuntimeDefaults,
4
+ WorkflowRuntimeThinkingResolution,
5
+ } from "./workflow-runtime.js";
2
6
 
3
7
  export const THINKING_LEVELS = [
4
8
  "off",
@@ -472,6 +476,8 @@ export interface CompiledDynamicWorkflowTask {
472
476
  helpers: Record<string, CompiledDynamicWorkflowHelper>;
473
477
  workflows: Record<string, CompiledDynamicNestedWorkflow>;
474
478
  decisionLoop?: CompiledDynamicDecisionLoop;
479
+ runtimeOverrides?: WorkflowRuntimeDefaults;
480
+ availableModels?: WorkflowModelInfo[];
475
481
  }
476
482
 
477
483
  export interface CompiledArtifactGraphTask {
@@ -46,6 +46,41 @@ export interface ResolveWorkflowRuntimeOptions {
46
46
  prompt?: WorkflowRuntimePrompt;
47
47
  }
48
48
 
49
+ export type WorkflowRuntimeLayer = WorkflowRuntimeDefaults | undefined;
50
+
51
+ export function selectWorkflowRuntime(
52
+ ...layers: WorkflowRuntimeLayer[]
53
+ ): WorkflowRuntimeResolutionInput {
54
+ const modelLayer = layers.find((layer) => modelOf(layer));
55
+ const model = modelOf(modelLayer);
56
+ let thinking: ThinkingLevel | undefined;
57
+ for (const layer of layers) {
58
+ if (!layer) continue;
59
+ if (layer.thinking) {
60
+ thinking = layer.thinking;
61
+ break;
62
+ }
63
+ const layerModel = modelOf(layer);
64
+ const modelThinking = layerModel
65
+ ? splitKnownThinkingSuffix(layerModel).thinking
66
+ : undefined;
67
+ if (modelThinking) {
68
+ thinking = modelThinking;
69
+ break;
70
+ }
71
+ }
72
+ return {
73
+ ...(model ? { model } : {}),
74
+ ...(thinking ? { thinking } : {}),
75
+ };
76
+ }
77
+
78
+ function modelOf(layer: WorkflowRuntimeLayer): string | undefined {
79
+ return typeof layer?.model === "string" && layer.model.trim()
80
+ ? layer.model.trim()
81
+ : undefined;
82
+ }
83
+
49
84
  export function toWorkflowModelInfo(model: {
50
85
  provider: string;
51
86
  id: string;