@agwab/pi-workflow 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +2 -0
  2. package/dist/compiler.d.ts +4 -6
  3. package/dist/compiler.js +70 -39
  4. package/dist/dynamic-decision.d.ts +0 -1
  5. package/dist/dynamic-decision.js +0 -7
  6. package/dist/dynamic-generated-task-runtime.d.ts +2 -0
  7. package/dist/dynamic-generated-task-runtime.js +21 -8
  8. package/dist/dynamic-profiles.d.ts +0 -1
  9. package/dist/dynamic-profiles.js +0 -3
  10. package/dist/engine-run-graph.d.ts +1 -0
  11. package/dist/engine-run-graph.js +142 -2
  12. package/dist/engine.d.ts +10 -6
  13. package/dist/engine.js +146 -77
  14. package/dist/extension.d.ts +2 -1
  15. package/dist/extension.js +38 -15
  16. package/dist/index.d.ts +3 -3
  17. package/dist/index.js +2 -1
  18. package/dist/store.d.ts +3 -1
  19. package/dist/store.js +189 -49
  20. package/dist/subagent-backend.d.ts +4 -0
  21. package/dist/subagent-backend.js +281 -31
  22. package/dist/types.d.ts +9 -1
  23. package/dist/workflow-runtime.d.ts +2 -0
  24. package/dist/workflow-runtime.js +40 -1
  25. package/dist/workflow-view.js +3 -1
  26. package/dist/workflow-web-source-extension.js +167 -48
  27. package/dist/workflow-web-source.d.ts +2 -1
  28. package/dist/workflow-web-source.js +84 -19
  29. package/docs/usage.md +11 -0
  30. package/node_modules/@agwab/pi-subagent/README.md +3 -3
  31. package/node_modules/@agwab/pi-subagent/api.mjs +1 -0
  32. package/node_modules/@agwab/pi-subagent/docs/usage.md +63 -12
  33. package/node_modules/@agwab/pi-subagent/package.json +2 -2
  34. package/node_modules/@agwab/pi-subagent/src/api.ts +54 -1
  35. package/node_modules/@agwab/pi-subagent/src/artifacts/registry.ts +9 -4
  36. package/node_modules/@agwab/pi-subagent/src/artifacts/result.ts +8 -0
  37. package/node_modules/@agwab/pi-subagent/src/core/constants.ts +9 -0
  38. package/node_modules/@agwab/pi-subagent/src/core/validation.ts +21 -0
  39. package/node_modules/@agwab/pi-subagent/src/index.ts +995 -573
  40. package/node_modules/@agwab/pi-subagent/src/orchestrate/async.ts +279 -156
  41. package/node_modules/@agwab/pi-subagent/src/orchestrate/interrupt.ts +165 -89
  42. package/node_modules/@agwab/pi-subagent/src/orchestrate/reconcile.ts +111 -65
  43. package/node_modules/@agwab/pi-subagent/src/orchestrate/run-ref.ts +219 -0
  44. package/node_modules/@agwab/pi-subagent/src/orchestrate/run.ts +88 -8
  45. package/node_modules/@agwab/pi-subagent/src/orchestrate/status.ts +614 -298
  46. package/node_modules/@agwab/pi-subagent/src/panel.ts +1352 -560
  47. package/node_modules/@agwab/pi-subagent/src/runners/headless-model.ts +53 -5
  48. package/node_modules/@agwab/pi-subagent/src/runners/tmux.ts +13 -6
  49. package/package.json +2 -2
  50. package/src/compiler.ts +127 -66
  51. package/src/dynamic-decision.ts +0 -11
  52. package/src/dynamic-generated-task-runtime.ts +47 -12
  53. package/src/dynamic-profiles.ts +0 -4
  54. package/src/engine-run-graph.ts +185 -2
  55. package/src/engine.ts +192 -107
  56. package/src/extension.ts +50 -17
  57. package/src/index.ts +3 -1
  58. package/src/store.ts +253 -55
  59. package/src/subagent-backend.ts +369 -32
  60. package/src/types.ts +13 -1
  61. package/src/workflow-runtime.ts +53 -2
  62. package/src/workflow-view.ts +2 -1
  63. package/src/workflow-web-source-extension.ts +621 -228
  64. package/src/workflow-web-source.ts +118 -28
  65. package/workflows/deep-research/helpers/claim-evidence-gate.mjs +56 -16
  66. package/workflows/deep-research/helpers/final-audit-packet.mjs +1 -4
  67. package/workflows/deep-research/helpers/normalize-input-packet.mjs +1 -1
  68. package/workflows/deep-research/helpers/render-executive.mjs +8 -21
  69. package/workflows/deep-research/helpers/sanitize-verification-candidates.mjs +89 -15
  70. package/workflows/deep-research/schemas/deep-research-executive-render-control.schema.json +0 -1
  71. package/workflows/deep-research/schemas/deep-research-verify-claims-control.schema.json +4 -1
  72. package/workflows/impact-review/spec.json +3 -3
  73. package/workflows/spec-review/helpers/spec-review-pipeline.mjs +1 -8
  74. package/dist/dynamic-loader.d.ts +0 -25
  75. package/dist/dynamic-loader.js +0 -13
  76. package/src/dynamic-loader.ts +0 -49
  77. package/workflows/impact-review/schemas/docs-release-impact-control.schema.json +0 -42
  78. package/workflows/impact-review/schemas/security-performance-impact-control.schema.json +0 -42
  79. package/workflows/impact-review/schemas/state-data-impact-control.schema.json +0 -42
@@ -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,15 @@ 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 PARENT_SUBAGENT_CWD_ENV = "PI_WORKFLOW_PARENT_SUBAGENT_CWD";
61
+ const PARENT_SUBAGENT_RUNS_DIR_ENV = "PI_WORKFLOW_PARENT_SUBAGENT_RUNS_DIR";
62
+ const PARENT_SUBAGENT_RUN_ID_ENV = "PI_WORKFLOW_PARENT_SUBAGENT_RUN_ID";
63
+ const PARENT_SUBAGENT_ATTEMPT_ID_ENV = "PI_WORKFLOW_PARENT_SUBAGENT_ATTEMPT_ID";
64
+ const DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS = 3_000;
65
+ const STALE_LAUNCH_CLAIM_GRACE_MS = 30_000;
66
+ const MIN_TRANSIENT_RETRY_JITTER_MS = 1_000;
67
+ const MAX_TRANSIENT_RETRY_JITTER_MS = 5_000;
58
68
  const MODULE_PATH = fileURLToPath(import.meta.url);
59
69
  const MODULE_DIR = dirname(MODULE_PATH);
60
70
  const BUNDLED_PI_WEB_ACCESS_EXTENSION = bundledNodeModulePath(
@@ -156,8 +166,31 @@ interface SubagentApi {
156
166
  ): Promise<SubagentRunStatusSnapshot | null>;
157
167
  interruptSubagent(options: Record<string, unknown>): Promise<unknown>;
158
168
  reconcileSubagentRun(options: Record<string, unknown>): Promise<unknown>;
169
+ recordSubagentChildEvent?(
170
+ options: Record<string, unknown>,
171
+ ): Promise<unknown>;
172
+ }
173
+
174
+ type ParentSubagentChildEvent =
175
+ | "started"
176
+ | "completed"
177
+ | "failed"
178
+ | "cancelled";
179
+
180
+ interface ParentSubagentRef {
181
+ cwd: string;
182
+ runsDir: string;
183
+ runId: string;
184
+ attemptId?: string;
159
185
  }
160
186
 
187
+ const GENERIC_TASK_STATUS_DETAILS = new Set([
188
+ "completed",
189
+ "failed",
190
+ "interrupted",
191
+ "running",
192
+ ]);
193
+
161
194
  const subagentApiSpecifier = "@agwab/pi-subagent/api";
162
195
  let cachedSubagentApi: Promise<SubagentApi> | undefined;
163
196
  let injectedSubagentApi: SubagentApi | undefined;
@@ -175,12 +208,186 @@ async function loadSubagentApi(): Promise<SubagentApi> {
175
208
  return cachedSubagentApi;
176
209
  }
177
210
 
211
+ function nonEmptyEnv(
212
+ env: Record<string, string | undefined>,
213
+ key: string,
214
+ ): string | undefined {
215
+ const value = env[key]?.trim();
216
+ return value ? value : undefined;
217
+ }
218
+
219
+ function parentSubagentRefFromEnv(
220
+ env: Record<string, string | undefined> = process.env,
221
+ ): ParentSubagentRef | undefined {
222
+ const cwd = nonEmptyEnv(env, PARENT_SUBAGENT_CWD_ENV);
223
+ const runsDir = nonEmptyEnv(env, PARENT_SUBAGENT_RUNS_DIR_ENV);
224
+ const runId = nonEmptyEnv(env, PARENT_SUBAGENT_RUN_ID_ENV);
225
+ if (!cwd || !runsDir || !runId) return undefined;
226
+ const attemptId = nonEmptyEnv(env, PARENT_SUBAGENT_ATTEMPT_ID_ENV);
227
+ return { cwd, runsDir, runId, ...(attemptId ? { attemptId } : {}) };
228
+ }
229
+
230
+ function terminalChildEventForTaskStatus(
231
+ status: WorkflowTaskRunRecord["status"],
232
+ ): ParentSubagentChildEvent | undefined {
233
+ if (status === "completed") return "completed";
234
+ if (status === "failed") return "failed";
235
+ if (status === "interrupted") return "cancelled";
236
+ return undefined;
237
+ }
238
+
239
+ async function recordParentSubagentChildEvent(options: {
240
+ event: ParentSubagentChildEvent;
241
+ childRunId: string;
242
+ run: WorkflowRunRecord;
243
+ task: WorkflowTaskRunRecord;
244
+ failureKind?: string | null;
245
+ message?: string;
246
+ }): Promise<void> {
247
+ const parent = parentSubagentRefFromEnv();
248
+ if (!parent) return;
249
+ const api = await loadSubagentApi().catch(() => undefined);
250
+ if (!api?.recordSubagentChildEvent) return;
251
+ await api
252
+ .recordSubagentChildEvent({
253
+ ...parent,
254
+ event: options.event,
255
+ childRunId: options.childRunId,
256
+ workflowRunId: options.run.runId,
257
+ childTaskId: options.task.taskId,
258
+ ...(options.failureKind === undefined
259
+ ? {}
260
+ : { failureKind: options.failureKind }),
261
+ ...(options.message === undefined ? {} : { message: options.message }),
262
+ })
263
+ .catch(() => undefined);
264
+ }
265
+
266
+ async function recordTerminalParentSubagentChildEvent(
267
+ run: WorkflowRunRecord,
268
+ task: WorkflowTaskRunRecord,
269
+ snapshot: SubagentRunStatusSnapshot,
270
+ ): Promise<void> {
271
+ const event = terminalChildEventForTaskStatus(task.status);
272
+ if (!event) return;
273
+ const taskFailureKind =
274
+ task.statusDetail && !GENERIC_TASK_STATUS_DETAILS.has(task.statusDetail)
275
+ ? task.statusDetail
276
+ : undefined;
277
+ await recordParentSubagentChildEvent({
278
+ event,
279
+ childRunId: snapshot.runId,
280
+ run,
281
+ task,
282
+ failureKind:
283
+ event === "completed"
284
+ ? undefined
285
+ : (snapshot.failureKind ?? taskFailureKind ?? task.statusDetail),
286
+ message: task.lastMessage,
287
+ });
288
+ }
289
+
290
+ let launchSlotReleaseDelayMs = DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS;
291
+ let transientRetryJitterForTests: (() => number) | undefined;
292
+ const launchWaitQueue: Array<() => void> = [];
293
+ let activeLaunchSlots = 0;
294
+
295
+ function resolveMaxConcurrentLaunches(): number {
296
+ const override = Number.parseInt(
297
+ process.env[MAX_CONCURRENT_LAUNCHES_ENV] ?? "",
298
+ 10,
299
+ );
300
+ if (Number.isFinite(override)) return Math.max(1, Math.floor(override));
301
+ return Math.max(2, Math.floor(availableParallelism() / 2));
302
+ }
303
+
304
+ function isLaunchGateSaturated(): boolean {
305
+ return activeLaunchSlots >= resolveMaxConcurrentLaunches();
306
+ }
307
+
308
+ async function acquireLaunchSlot(): Promise<() => void> {
309
+ if (!isLaunchGateSaturated()) {
310
+ activeLaunchSlots += 1;
311
+ return releaseLaunchSlot;
312
+ }
313
+ await new Promise<void>((resolveWait) => launchWaitQueue.push(resolveWait));
314
+ return releaseLaunchSlot;
315
+ }
316
+
317
+ function releaseLaunchSlot(): void {
318
+ const next = launchWaitQueue.shift();
319
+ if (next) {
320
+ // Transfer the occupied slot directly to the queued launcher.
321
+ next();
322
+ return;
323
+ }
324
+ activeLaunchSlots = Math.max(0, activeLaunchSlots - 1);
325
+ }
326
+
327
+ function releaseLaunchSlotAfterDelay(
328
+ delayMs: number,
329
+ release: () => void,
330
+ ): void {
331
+ if (delayMs <= 0) {
332
+ release();
333
+ return;
334
+ }
335
+ setTimeout(release, delayMs);
336
+ }
337
+
338
+ async function runWithLaunchSlot<T>(action: () => Promise<T>): Promise<T> {
339
+ const release = await acquireLaunchSlot();
340
+ let holdAfterReturn = false;
341
+ try {
342
+ const result = await action();
343
+ holdAfterReturn = true;
344
+ return result;
345
+ } finally {
346
+ releaseLaunchSlotAfterDelay(
347
+ holdAfterReturn ? launchSlotReleaseDelayMs : 0,
348
+ release,
349
+ );
350
+ }
351
+ }
352
+
353
+ function transientRetryJitterMs(): number {
354
+ if (transientRetryJitterForTests) return transientRetryJitterForTests();
355
+ return (
356
+ MIN_TRANSIENT_RETRY_JITTER_MS +
357
+ Math.floor(
358
+ Math.random() *
359
+ (MAX_TRANSIENT_RETRY_JITTER_MS - MIN_TRANSIENT_RETRY_JITTER_MS + 1),
360
+ )
361
+ );
362
+ }
363
+
364
+ function sleep(ms: number): Promise<void> {
365
+ return new Promise((resolve) => setTimeout(resolve, ms));
366
+ }
367
+
368
+ export function setSubagentLaunchControlsForTests(options?: {
369
+ releaseDelayMs?: number;
370
+ retryJitterMs?: number | (() => number);
371
+ }): void {
372
+ launchSlotReleaseDelayMs =
373
+ options?.releaseDelayMs === undefined
374
+ ? DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS
375
+ : Math.max(0, Math.floor(options.releaseDelayMs));
376
+ transientRetryJitterForTests =
377
+ options?.retryJitterMs === undefined
378
+ ? undefined
379
+ : typeof options.retryJitterMs === "function"
380
+ ? options.retryJitterMs
381
+ : () => Math.max(0, Math.floor(options.retryJitterMs as number));
382
+ activeLaunchSlots = 0;
383
+ while (launchWaitQueue.length > 0) launchWaitQueue.shift()?.();
384
+ }
385
+
178
386
  export async function cleanupSubagentRun(
179
387
  _cwd: string,
180
388
  run: WorkflowRunRecord,
181
389
  ): Promise<void> {
182
390
  for (const task of run.tasks) {
183
- if (isTerminalTaskStatus(task.status)) continue;
184
391
  const handle = getSubagentHandle(task);
185
392
  if (!handle) continue;
186
393
  const api = await loadSubagentApi();
@@ -212,6 +419,14 @@ export async function launchSubagentTask(
212
419
  };
213
420
  }
214
421
 
422
+ if ((task.launchRetry?.attempts ?? 0) > 0) {
423
+ const jitterMs = transientRetryJitterMs();
424
+ task.statusDetail = "retry_model_failure";
425
+ task.lastMessage = `waiting ${jitterMs}ms before retrying transient-model launch`;
426
+ await writeRunRecord(cwd, run);
427
+ if (jitterMs > 0) await sleep(jitterMs);
428
+ }
429
+
215
430
  const systemPromptFile = fromProjectPath(cwd, task.files.systemPrompt);
216
431
  const taskPromptFile = fromProjectPath(cwd, task.files.taskPrompt);
217
432
  const outputFile = fromProjectPath(cwd, task.files.output);
@@ -267,7 +482,11 @@ export async function launchSubagentTask(
267
482
  };
268
483
  subagentOptions.extensions = extensions;
269
484
  if (captureToolCallsEnabled()) subagentOptions.captureToolCalls = true;
270
- launched = await api.runSubagent(subagentOptions);
485
+ if (isLaunchGateSaturated()) {
486
+ task.lastMessage = `waiting for pi-subagent launch slot (${resolveMaxConcurrentLaunches()} max)`;
487
+ await writeRunRecord(cwd, run).catch(() => undefined);
488
+ }
489
+ launched = await runWithLaunchSlot(() => api.runSubagent(subagentOptions));
271
490
  } catch (error) {
272
491
  task.status = "pending";
273
492
  task.statusDetail = "pending";
@@ -295,6 +514,13 @@ export async function launchSubagentTask(
295
514
  task.statusDetail = "running";
296
515
  task.lastMessage = "launched via pi-subagent/headless";
297
516
  await writeRunRecord(cwd, run).catch(() => undefined);
517
+ await recordParentSubagentChildEvent({
518
+ event: "started",
519
+ childRunId: launched.runId,
520
+ run,
521
+ task,
522
+ message: task.lastMessage,
523
+ });
298
524
  return { kind: "launched" };
299
525
  }
300
526
 
@@ -326,8 +552,13 @@ export async function refreshRunFromSubagentArtifacts(
326
552
  }
327
553
  }
328
554
  if (!handle) {
555
+ if (isStaleLaunchClaim(task)) {
556
+ resetStaleLaunchClaim(task);
557
+ changed = true;
558
+ continue;
559
+ }
329
560
  if (isTaskTimedOut(task)) {
330
- markTaskTimedOut(task);
561
+ markSubagentTaskTimedOut(task);
331
562
  changed = true;
332
563
  }
333
564
  continue;
@@ -352,16 +583,8 @@ export async function refreshRunFromSubagentArtifacts(
352
583
 
353
584
  if (snapshot === null) {
354
585
  if (isTaskTimedOut(task)) {
355
- await api
356
- .interruptSubagent({
357
- cwd: handle.cwd,
358
- runsDir: handle.runsDir,
359
- runId: handle.runId,
360
- attemptId: handle.attemptId,
361
- reason: "workflow timeout",
362
- })
363
- .catch(() => undefined);
364
- markTaskTimedOut(task);
586
+ await interruptTimedOutSubagent(api, handle);
587
+ markSubagentTaskTimedOut(task);
365
588
  changed = true;
366
589
  }
367
590
  continue;
@@ -378,16 +601,8 @@ export async function refreshRunFromSubagentArtifacts(
378
601
  ? `pi-subagent heartbeat ${activeAttempt.heartbeatAt}`
379
602
  : "pi-subagent running";
380
603
  if (isTaskTimedOut(task)) {
381
- await api
382
- .interruptSubagent({
383
- cwd: handle.cwd,
384
- runsDir: handle.runsDir,
385
- runId: handle.runId,
386
- attemptId: handle.attemptId,
387
- reason: "workflow timeout",
388
- })
389
- .catch(() => undefined);
390
- markTaskTimedOut(task);
604
+ await interruptTimedOutSubagent(api, handle);
605
+ markSubagentTaskTimedOut(task);
391
606
  changed = true;
392
607
  }
393
608
  continue;
@@ -401,6 +616,48 @@ export async function refreshRunFromSubagentArtifacts(
401
616
  return run;
402
617
  }
403
618
 
619
+ async function interruptTimedOutSubagent(
620
+ api: Awaited<ReturnType<typeof loadSubagentApi>>,
621
+ handle: NonNullable<WorkflowTaskRunRecord["backendHandle"]>,
622
+ ): Promise<void> {
623
+ await api
624
+ .interruptSubagent({
625
+ cwd: handle.cwd,
626
+ runsDir: handle.runsDir,
627
+ runId: handle.runId,
628
+ attemptId: handle.attemptId,
629
+ reason: "workflow timeout",
630
+ })
631
+ .catch(() => undefined);
632
+ }
633
+
634
+ function markSubagentTaskTimedOut(task: WorkflowTaskRunRecord): void {
635
+ markTaskTimedOut(task);
636
+ task.backendHandle = undefined;
637
+ task.backendTaskId = task.taskId;
638
+ task.pid = undefined;
639
+ }
640
+
641
+ function isStaleLaunchClaim(task: WorkflowTaskRunRecord): boolean {
642
+ if (task.statusDetail !== "launching" || !task.startedAt) return false;
643
+ const startedAtMs = Date.parse(task.startedAt);
644
+ return (
645
+ Number.isFinite(startedAtMs) &&
646
+ Date.now() - startedAtMs > STALE_LAUNCH_CLAIM_GRACE_MS
647
+ );
648
+ }
649
+
650
+ function resetStaleLaunchClaim(task: WorkflowTaskRunRecord): void {
651
+ task.status = "pending";
652
+ task.statusDetail = "pending";
653
+ task.startedAt = undefined;
654
+ task.backendHandle = undefined;
655
+ task.backendFiles = undefined;
656
+ task.backendTaskId = task.taskId;
657
+ task.pid = undefined;
658
+ task.lastMessage = "stale pi-subagent launch claim reset";
659
+ }
660
+
404
661
  async function materializeTerminalSubagentResult(
405
662
  cwd: string,
406
663
  run: WorkflowRunRecord,
@@ -432,12 +689,29 @@ async function materializeTerminalSubagentResult(
432
689
  artifactRoot,
433
690
  );
434
691
  const outputText = await readFile(outputFile, "utf8").catch(() => "");
692
+ const stderrText = await readFile(stderrFile, "utf8").catch(() => "");
435
693
  const outputBytes = Buffer.byteLength(outputText, "utf8");
436
- const statusInfo = workflowStatusFromSubagent(
694
+ let statusInfo = workflowStatusFromSubagent(
437
695
  snapshot,
438
696
  subagentResult,
439
697
  outputBytes,
440
698
  );
699
+ const deterministicBootFailure = classifyDeterministicBootFailure({
700
+ statusInfo,
701
+ stderrText,
702
+ outputBytes,
703
+ contextLengthExceeded: Boolean(
704
+ (subagentResult?.metadata as any)?.contextLengthExceeded ??
705
+ snapshot.metadata?.contextLengthExceeded,
706
+ ),
707
+ });
708
+ if (deterministicBootFailure) {
709
+ statusInfo = {
710
+ status: "failed",
711
+ failureKind: "deterministic_boot",
712
+ errorMessage: deterministicBootFailure,
713
+ };
714
+ }
441
715
  const completedAt =
442
716
  typeof subagentResult?.completedAt === "string"
443
717
  ? subagentResult.completedAt
@@ -462,7 +736,7 @@ async function materializeTerminalSubagentResult(
462
736
  snapshot.metadata?.contextLengthExceeded,
463
737
  );
464
738
  if (task.artifactGraph?.enabled && statusInfo.status === "completed") {
465
- return await materializeTerminalArtifactGraphResult(cwd, run, task, {
739
+ const changed = await materializeTerminalArtifactGraphResult(cwd, run, task, {
466
740
  outputFile,
467
741
  stderrFile,
468
742
  resultFile,
@@ -471,6 +745,8 @@ async function materializeTerminalSubagentResult(
471
745
  exitCode,
472
746
  subagentResult,
473
747
  });
748
+ await recordTerminalParentSubagentChildEvent(run, task, snapshot);
749
+ return changed;
474
750
  }
475
751
  if (
476
752
  shouldAttemptArtifactGraphSalvage({
@@ -484,7 +760,7 @@ async function materializeTerminalSubagentResult(
484
760
  snapshot,
485
761
  })
486
762
  ) {
487
- return await materializeTerminalArtifactGraphResult(cwd, run, task, {
763
+ const changed = await materializeTerminalArtifactGraphResult(cwd, run, task, {
488
764
  outputFile,
489
765
  stderrFile,
490
766
  resultFile,
@@ -498,6 +774,8 @@ async function materializeTerminalSubagentResult(
498
774
  subagentFailureKind: snapshot.failureKind,
499
775
  },
500
776
  });
777
+ await recordTerminalParentSubagentChildEvent(run, task, snapshot);
778
+ return changed;
501
779
  }
502
780
  const workflowResult = {
503
781
  status: statusInfo.status,
@@ -533,10 +811,12 @@ async function materializeTerminalSubagentResult(
533
811
  ),
534
812
  workflowResult,
535
813
  );
536
- return retryOrFailTransientSubagentFailure(task, {
814
+ const changed = retryOrFailTransientSubagentFailure(task, {
537
815
  reason: statusInfo.failureKind ?? "model",
538
816
  message: errorMessage ?? "pi-subagent run failed before producing output",
539
817
  });
818
+ await recordTerminalParentSubagentChildEvent(run, task, snapshot);
819
+ return changed;
540
820
  }
541
821
  await writeJson(resultFile, workflowResult);
542
822
 
@@ -551,6 +831,7 @@ async function materializeTerminalSubagentResult(
551
831
  delete task.backendHandle;
552
832
  delete task.backendFiles;
553
833
  }
834
+ await recordTerminalParentSubagentChildEvent(run, task, snapshot);
554
835
  return changed;
555
836
  }
556
837
 
@@ -1005,6 +1286,36 @@ function failArtifactGraphTask(
1005
1286
  return true;
1006
1287
  }
1007
1288
 
1289
+ function classifyDeterministicBootFailure(options: {
1290
+ statusInfo: {
1291
+ status: WorkflowTaskRunRecord["status"];
1292
+ failureKind?: string;
1293
+ errorMessage?: string;
1294
+ };
1295
+ stderrText: string;
1296
+ outputBytes: number;
1297
+ contextLengthExceeded: boolean;
1298
+ }): string | undefined {
1299
+ if (
1300
+ options.statusInfo.status !== "failed" ||
1301
+ options.statusInfo.failureKind !== "model" ||
1302
+ options.outputBytes !== 0 ||
1303
+ options.contextLengthExceeded
1304
+ ) {
1305
+ return undefined;
1306
+ }
1307
+ const text = options.stderrText;
1308
+ const deterministicPattern =
1309
+ /(Failed to load extension|Cannot find module|(?:failed to load|invalid|missing) (?:workflow )?config(?:uration)?|config(?:uration)? (?:error|failed|invalid))/i;
1310
+ if (!deterministicPattern.test(text)) return undefined;
1311
+ const excerpt =
1312
+ text
1313
+ .split(/\r?\n/)
1314
+ .map((line) => line.trim())
1315
+ .find((line) => deterministicPattern.test(line)) ?? text.trim();
1316
+ return `deterministic-boot failure: ${excerpt.slice(0, 500)}`;
1317
+ }
1318
+
1008
1319
  function shouldRetryTransientModelFailure(
1009
1320
  statusInfo: {
1010
1321
  status: WorkflowTaskRunRecord["status"];
@@ -1056,14 +1367,14 @@ function retryOrFailTransientSubagentFailure(
1056
1367
  if (!exhausted) {
1057
1368
  task.status = "pending";
1058
1369
  task.statusDetail = "retry_model_failure";
1059
- task.lastMessage = `${options.message}; retrying transient model failure (${attempt}/${maxAttempts})`;
1370
+ task.lastMessage = `${options.message}; retrying transient-model failure (${attempt}/${maxAttempts})`;
1060
1371
  return true;
1061
1372
  }
1062
1373
  task.status = "failed";
1063
1374
  task.statusDetail = task.launchRetry.reason ?? "model_exhausted";
1064
1375
  task.exitCode = 1;
1065
1376
  task.completedAt = nowIso();
1066
- task.lastMessage = `${options.message}; transient model failure retries exhausted (${maxAttempts})`;
1377
+ task.lastMessage = `${options.message}; transient-model failure retries exhausted (${maxAttempts})`;
1067
1378
  return true;
1068
1379
  }
1069
1380
 
@@ -1317,7 +1628,10 @@ async function workflowTaskExtensions(
1317
1628
  },
1318
1629
  });
1319
1630
  const capturedProviderExtensions = new Set(
1320
- workflowWebSourceProviderExtensions(tools, compiledTask.runtime.toolProviders),
1631
+ workflowWebSourceProviderExtensions(
1632
+ tools,
1633
+ compiledTask.runtime.toolProviders,
1634
+ ),
1321
1635
  );
1322
1636
  extensions = uniqueStrings([
1323
1637
  ...extensions.filter(
@@ -1510,6 +1824,7 @@ async function recoverSubagentHandle(
1510
1824
  const runsDir = subagentRunsDir(run, task);
1511
1825
  const absoluteRunsDir = resolve(task.cwd, runsDir);
1512
1826
  const expectedCorrelationId = `${run.runId}:${task.taskId}`;
1827
+ const claimStartedAtMs = timestampMs(task.startedAt);
1513
1828
  const entries = await readdir(absoluteRunsDir, { withFileTypes: true }).catch(
1514
1829
  () => [],
1515
1830
  );
@@ -1524,6 +1839,7 @@ async function recoverSubagentHandle(
1524
1839
  join(absoluteRunsDir, entry.name, "run.json"),
1525
1840
  );
1526
1841
  if (!record || record.correlationId !== expectedCorrelationId) continue;
1842
+ if (isPreClaimSubagentRecord(record, claimStartedAtMs)) continue;
1527
1843
  const attemptId =
1528
1844
  record.activeAttemptId ??
1529
1845
  record.latestAttemptId ??
@@ -1550,6 +1866,20 @@ async function recoverSubagentHandle(
1550
1866
  return candidates[0]?.handle;
1551
1867
  }
1552
1868
 
1869
+ function isPreClaimSubagentRecord(
1870
+ record: SubagentRunRecordLike,
1871
+ claimStartedAtMs: number | undefined,
1872
+ ): boolean {
1873
+ if (claimStartedAtMs === undefined) return false;
1874
+ const recordStartedAtMs =
1875
+ timestampMs(record.startedAt) ??
1876
+ timestampMs(record.attempts?.[0]?.startedAt) ??
1877
+ timestampMs(record.updatedAt);
1878
+ return (
1879
+ recordStartedAtMs !== undefined && recordStartedAtMs < claimStartedAtMs
1880
+ );
1881
+ }
1882
+
1553
1883
  function timestampMs(value: string | undefined): number | undefined {
1554
1884
  if (value === undefined) return undefined;
1555
1885
  const time = Date.parse(value);
@@ -1610,7 +1940,14 @@ function subagentSessionId(
1610
1940
  task: WorkflowTaskRunRecord,
1611
1941
  ): string | undefined {
1612
1942
  if (!task.artifactGraph?.enabled) return undefined;
1613
- return task.outputRetry?.sessionId ?? baseSubagentSessionId(run, task);
1943
+ const baseSessionId = baseSubagentSessionId(run, task);
1944
+ if (task.outputRetry?.sessionId) return task.outputRetry.sessionId;
1945
+ const launchAttempt = task.launchRetry?.attempts ?? 0;
1946
+ if (launchAttempt > 0)
1947
+ return `${baseSessionId}:launch-retry-${launchAttempt}`;
1948
+ const resumeAttempt = task.resumeEvents?.length ?? 0;
1949
+ if (resumeAttempt > 0) return `${baseSessionId}:resume-${resumeAttempt}`;
1950
+ return baseSessionId;
1614
1951
  }
1615
1952
 
1616
1953
  function baseSubagentSessionId(
@@ -1673,7 +2010,7 @@ function buildSystemPrompt(task: CompiledTask): string {
1673
2010
  enabledTools.includes("workflow_web_source_read")
1674
2011
  ? "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
2012
  : !enabledTools.includes("get_search_content") &&
1676
- (enabledTools.includes("web_search") ||
2013
+ (enabledTools.includes("web_search") ||
1677
2014
  enabledTools.includes("fetch_content"))
1678
2015
  ? "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
2016
  : 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 {
@@ -536,6 +542,9 @@ export interface CompiledTask {
536
542
  branchId?: string;
537
543
  outputProfile?: string;
538
544
  };
545
+ foreachGenerated?: {
546
+ placeholderSpecId: string;
547
+ };
539
548
  loopChild?: CompiledLoopChildTaskRef;
540
549
  loopPlaceholder?: {
541
550
  loopId: string;
@@ -628,6 +637,9 @@ export interface WorkflowTaskRunRecord {
628
637
  branchId?: string;
629
638
  outputProfile?: string;
630
639
  };
640
+ foreachGenerated?: {
641
+ placeholderSpecId: string;
642
+ };
631
643
  launchRetry?: {
632
644
  attempts: number;
633
645
  maxAttempts?: number;
@@ -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;
@@ -310,9 +345,25 @@ export function readSimpleJsonPath(value: unknown, path: string): unknown {
310
345
  const parts = path.slice(2).split(".").filter(Boolean);
311
346
  let current = value as any;
312
347
  for (const part of parts) {
313
- if (current === null || typeof current !== "object" || !(part in current))
314
- return undefined;
348
+ if (!canReadJsonPathPart(current, part)) return undefined;
315
349
  current = current[part];
316
350
  }
317
351
  return current;
318
352
  }
353
+
354
+ function canReadJsonPathPart(
355
+ value: unknown,
356
+ part: string,
357
+ ): value is Record<string, unknown> {
358
+ return (
359
+ isSafeJsonPathPart(part) && isRecord(value) && Object.hasOwn(value, part)
360
+ );
361
+ }
362
+
363
+ function isSafeJsonPathPart(part: string): boolean {
364
+ return part !== "__proto__" && part !== "prototype" && part !== "constructor";
365
+ }
366
+
367
+ function isRecord(value: unknown): value is Record<string, unknown> {
368
+ return typeof value === "object" && value !== null;
369
+ }