@agwab/pi-workflow 0.2.1 → 0.4.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 (119) hide show
  1. package/README.md +3 -1
  2. package/dist/artifact-graph-runtime.d.ts +1 -1
  3. package/dist/artifact-graph-runtime.js +10 -5
  4. package/dist/artifact-graph-schema.js +127 -5
  5. package/dist/compiler.js +52 -19
  6. package/dist/dynamic-generated-task-runtime.js +3 -1
  7. package/dist/dynamic-profiles.d.ts +1 -1
  8. package/dist/engine-run-graph.d.ts +3 -0
  9. package/dist/engine-run-graph.js +194 -4
  10. package/dist/engine.d.ts +5 -0
  11. package/dist/engine.js +389 -41
  12. package/dist/extension.d.ts +2 -1
  13. package/dist/extension.js +30 -8
  14. package/dist/index.d.ts +11 -3
  15. package/dist/index.js +6 -1
  16. package/dist/prompt-json.d.ts +7 -0
  17. package/dist/prompt-json.js +13 -0
  18. package/dist/roles.d.ts +1 -1
  19. package/dist/roles.js +5 -8
  20. package/dist/store.d.ts +20 -1
  21. package/dist/store.js +139 -35
  22. package/dist/strings.d.ts +11 -0
  23. package/dist/strings.js +24 -0
  24. package/dist/subagent-backend.js +710 -40
  25. package/dist/types.d.ts +107 -1
  26. package/dist/verification-ontology.d.ts +31 -0
  27. package/dist/verification-ontology.js +66 -0
  28. package/dist/workflow-artifact-tool.js +5 -6
  29. package/dist/workflow-artifacts.d.ts +7 -0
  30. package/dist/workflow-artifacts.js +55 -4
  31. package/dist/workflow-fetch-cache-extension.d.ts +1 -0
  32. package/dist/workflow-fetch-cache-extension.js +57 -9
  33. package/dist/workflow-metrics.d.ts +113 -0
  34. package/dist/workflow-metrics.js +272 -0
  35. package/dist/workflow-output-artifacts.js +5 -3
  36. package/dist/workflow-partial-output.d.ts +45 -0
  37. package/dist/workflow-partial-output.js +205 -0
  38. package/dist/workflow-progress-health.js +42 -10
  39. package/dist/workflow-runtime.js +10 -1
  40. package/dist/workflow-view.js +3 -1
  41. package/dist/workflow-web-source-extension.js +194 -52
  42. package/dist/workflow-web-source.d.ts +2 -1
  43. package/dist/workflow-web-source.js +109 -30
  44. package/docs/usage.md +76 -29
  45. package/node_modules/@agwab/pi-subagent/README.md +3 -3
  46. package/node_modules/@agwab/pi-subagent/api.mjs +1 -0
  47. package/node_modules/@agwab/pi-subagent/docs/usage.md +63 -12
  48. package/node_modules/@agwab/pi-subagent/package.json +2 -2
  49. package/node_modules/@agwab/pi-subagent/src/api.ts +54 -1
  50. package/node_modules/@agwab/pi-subagent/src/artifacts/registry.ts +9 -4
  51. package/node_modules/@agwab/pi-subagent/src/artifacts/result.ts +8 -0
  52. package/node_modules/@agwab/pi-subagent/src/core/constants.ts +9 -0
  53. package/node_modules/@agwab/pi-subagent/src/core/validation.ts +21 -0
  54. package/node_modules/@agwab/pi-subagent/src/index.ts +1046 -576
  55. package/node_modules/@agwab/pi-subagent/src/orchestrate/async.ts +279 -156
  56. package/node_modules/@agwab/pi-subagent/src/orchestrate/interrupt.ts +165 -89
  57. package/node_modules/@agwab/pi-subagent/src/orchestrate/reconcile.ts +111 -65
  58. package/node_modules/@agwab/pi-subagent/src/orchestrate/run-ref.ts +219 -0
  59. package/node_modules/@agwab/pi-subagent/src/orchestrate/run.ts +88 -8
  60. package/node_modules/@agwab/pi-subagent/src/orchestrate/status.ts +614 -298
  61. package/node_modules/@agwab/pi-subagent/src/panel.ts +1356 -560
  62. package/node_modules/@agwab/pi-subagent/src/runners/headless-model.ts +53 -5
  63. package/node_modules/@agwab/pi-subagent/src/runners/tmux.ts +13 -6
  64. package/package.json +2 -2
  65. package/skills/workflow-guide/SKILL.md +1 -0
  66. package/src/artifact-graph-runtime.ts +19 -13
  67. package/src/artifact-graph-schema.ts +143 -3
  68. package/src/cli.mjs +52 -0
  69. package/src/compiler.ts +63 -18
  70. package/src/dynamic-generated-task-runtime.ts +3 -1
  71. package/src/dynamic-profiles.ts +1 -1
  72. package/src/engine-run-graph.ts +246 -4
  73. package/src/engine.ts +545 -38
  74. package/src/extension.ts +36 -6
  75. package/src/index.ts +52 -1
  76. package/src/prompt-json.ts +13 -0
  77. package/src/roles.ts +6 -9
  78. package/src/store.ts +194 -42
  79. package/src/strings.ts +38 -0
  80. package/src/subagent-backend.ts +921 -62
  81. package/src/types.ts +116 -2
  82. package/src/verification-ontology.ts +88 -0
  83. package/src/workflow-artifact-tool.ts +5 -7
  84. package/src/workflow-artifacts.ts +83 -3
  85. package/src/workflow-fetch-cache-extension.ts +78 -13
  86. package/src/workflow-metrics.ts +478 -0
  87. package/src/workflow-output-artifacts.ts +5 -3
  88. package/src/workflow-partial-output.ts +299 -0
  89. package/src/workflow-progress-health.ts +47 -15
  90. package/src/workflow-runtime.ts +18 -2
  91. package/src/workflow-view.ts +2 -1
  92. package/src/workflow-web-source-extension.ts +654 -232
  93. package/src/workflow-web-source.ts +153 -39
  94. package/workflows/README.md +7 -25
  95. package/workflows/deep-research/batched-verification.spec.json +253 -0
  96. package/workflows/deep-research/helpers/batch-verification-candidates.mjs +136 -0
  97. package/workflows/deep-research/helpers/claim-evidence-gate.mjs +229 -36
  98. package/workflows/deep-research/helpers/final-audit-packet.mjs +1 -4
  99. package/workflows/deep-research/helpers/normalize-input-packet.mjs +81 -2
  100. package/workflows/deep-research/helpers/render-executive.mjs +40 -26
  101. package/workflows/deep-research/helpers/sanitize-verification-candidates.mjs +89 -15
  102. package/workflows/deep-research/helpers/shadow-select-verification.mjs +229 -0
  103. package/workflows/deep-research/helpers/verification-ontology.mjs +77 -0
  104. package/workflows/deep-research/schemas/deep-research-executive-render-control.schema.json +3 -3
  105. package/workflows/deep-research/schemas/deep-research-research-questions-control.schema.json +38 -0
  106. package/workflows/deep-research/schemas/deep-research-sanitize-claims-control.schema.json +63 -0
  107. package/workflows/deep-research/schemas/deep-research-verify-claims-batch-control.schema.json +47 -0
  108. package/workflows/deep-research/schemas/deep-research-verify-claims-control.schema.json +13 -3
  109. package/workflows/deep-research/spec.json +32 -12
  110. package/workflows/impact-review/spec.json +3 -3
  111. package/workflows/spec-review/helpers/spec-review-pipeline.mjs +1 -8
  112. package/dist/dynamic-loader.d.ts +0 -25
  113. package/dist/dynamic-loader.js +0 -13
  114. package/skills/workflow-guide/scaffolds/dag-required-reads/spec.json.validate.stderr +0 -0
  115. package/skills/workflow-guide/scaffolds/dag-required-reads/spec.json.validate.stdout +0 -13
  116. package/src/dynamic-loader.ts +0 -49
  117. package/workflows/impact-review/schemas/docs-release-impact-control.schema.json +0 -42
  118. package/workflows/impact-review/schemas/security-performance-impact-control.schema.json +0 -42
  119. package/workflows/impact-review/schemas/state-data-impact-control.schema.json +0 -42
package/src/engine.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  compiledWorkflowPath,
12
12
  fromProjectPath,
13
13
  indexSupervisorErrorPath,
14
+ isBlockedTaskResumableForResume,
14
15
  isTerminalWorkflowStatus,
15
16
  isTerminalTaskStatus,
16
17
  listRunRecords,
@@ -33,6 +34,7 @@ import { resolveWorkflowBackend } from "./backend.js";
33
34
  import { ensureManagedWorktree } from "./worktree.js";
34
35
  import { resolveWorkflowHelperRef } from "./workflow-helpers.js";
35
36
  import { buildAvailableToolView } from "./tool-metadata.js";
37
+ import { summarizeWorkflowTelemetry } from "./workflow-artifacts.js";
36
38
  import {
37
39
  workflowBundleFingerprint,
38
40
  workflowBundleSpecPath,
@@ -93,9 +95,12 @@ import {
93
95
  assertRunTaskPositionalAlignment,
94
96
  buildForeachGeneratedTasks,
95
97
  dependenciesReady,
98
+ foreachStreamingEnabled,
99
+ foreachStreamingMinChunk,
96
100
  markDagDependentsSkipped,
97
101
  nextTaskRecordIndex,
98
102
  reconcileDynamicGeneratedRunRecords,
103
+ reconcileForeachGeneratedRunRecords,
99
104
  recoverStaleRunningDynamicControllers,
100
105
  replaceDependencyList,
101
106
  sourceStageIdsForFrom,
@@ -120,6 +125,12 @@ import {
120
125
  DIRECT_DYNAMIC_RUNTIME_VERSION,
121
126
  ensureDirectDynamicRuntimeBundle,
122
127
  } from "./dynamic-runtime-bundle.js";
128
+ import {
129
+ hasFatalPartialOutputIssue,
130
+ readWorkflowPartialOutputLedger,
131
+ writeWorkflowPartialOutputLedgerFromFile,
132
+ type WorkflowPartialOutputItem,
133
+ } from "./workflow-partial-output.js";
123
134
  import {
124
135
  type CompiledDynamicWorkflowTask,
125
136
  type CompiledTask,
@@ -147,6 +158,8 @@ const DYNAMIC_CONTROLLER_ENGINE_INTEGRITY_ERROR_MESSAGE =
147
158
  "incompatible or stale pi-workflow engine: dynamic controller context is missing runDecisionLoop (rebuild dist / reload workflow engine)";
148
159
  const supervisorTimers = new Map<string, ReturnType<typeof setInterval>>();
149
160
  const supervisorRunMtimes = new Map<string, number>();
161
+ const supervisorErrorCounts = new Map<string, number>();
162
+ const MAX_SUPERVISOR_CONSECUTIVE_ERRORS = 3;
150
163
 
151
164
  export interface WorkflowRunOptions {
152
165
  task?: string;
@@ -228,7 +241,7 @@ async function runLoadedWorkflowSpec(
228
241
  const scheduled =
229
242
  (await scheduleRun(cwd, run.runId, compiled, scheduleOptions)) ??
230
243
  (await readRunRecord(cwd, run.runId));
231
- if (scheduled.status === "running")
244
+ if (shouldWatchRun(scheduled))
232
245
  watchRun(cwd, scheduled.runId, scheduleOptions);
233
246
  return scheduled;
234
247
  }
@@ -245,6 +258,22 @@ export async function refreshRun(
245
258
  return refreshed ?? current;
246
259
  }
247
260
 
261
+ function hasActiveSchedulerWork(
262
+ run: Pick<WorkflowRunRecord, "status" | "taskSummary">,
263
+ ): boolean {
264
+ return (
265
+ run.status === "running" ||
266
+ run.taskSummary.running > 0 ||
267
+ run.taskSummary.pending > 0
268
+ );
269
+ }
270
+
271
+ function shouldWatchRun(
272
+ run: Pick<WorkflowRunRecord, "status" | "taskSummary">,
273
+ ): boolean {
274
+ return hasActiveSchedulerWork(run);
275
+ }
276
+
248
277
  export async function waitForRun(
249
278
  cwd: string,
250
279
  runIdOrPrefix: string,
@@ -255,7 +284,7 @@ export async function waitForRun(
255
284
  const deadline = Date.now() + timeout;
256
285
  let run = await refreshRun(cwd, runIdOrPrefix);
257
286
 
258
- while (run.status === "running") {
287
+ while (hasActiveSchedulerWork(run)) {
259
288
  const beforeScheduleRemaining = deadline - Date.now();
260
289
  if (beforeScheduleRemaining <= 0)
261
290
  throw new Error(
@@ -265,7 +294,7 @@ export async function waitForRun(
265
294
  run = await refreshRun(cwd, run.runId);
266
295
  const remaining = deadline - Date.now();
267
296
  if (remaining <= 0) {
268
- if (run.status !== "running") return run;
297
+ if (!hasActiveSchedulerWork(run)) return run;
269
298
  throw new Error(
270
299
  `Flow run still running after ${timeout}ms: ${run.runId}`,
271
300
  );
@@ -282,6 +311,64 @@ export interface ResumeRunSummary {
282
311
  resetTaskIds: string[];
283
312
  }
284
313
 
314
+ export interface StopRunSummary {
315
+ run: WorkflowRunRecord;
316
+ interruptedTaskIds: string[];
317
+ }
318
+
319
+ function assertBlockedRunResumable(run: WorkflowRunRecord): void {
320
+ if (run.status !== "blocked") return;
321
+ const blockers = run.tasks.filter(
322
+ (task) =>
323
+ task.status === "blocked" && !isBlockedTaskResumableForResume(task),
324
+ );
325
+ if (blockers.length === 0) return;
326
+ const details = blockers
327
+ .slice(0, 3)
328
+ .map((task) => `${task.specId} statusDetail=${task.statusDetail}`)
329
+ .join(", ");
330
+ throw new Error(
331
+ `Cannot resume blocked run ${run.runId}: non-resumable blocked task(s): ${details}. Resolve the attention/approval blocker before resuming.`,
332
+ );
333
+ }
334
+
335
+ export async function stopRun(
336
+ cwd: string,
337
+ runIdOrPrefix: string,
338
+ ): Promise<StopRunSummary> {
339
+ const current = await readRunRecord(cwd, runIdOrPrefix);
340
+ const stopped = await withRunLease(cwd, current.runId, async () => {
341
+ const run = await readRunRecord(cwd, current.runId);
342
+ if (isTerminalWorkflowStatus(run.status)) {
343
+ throw new Error(
344
+ `stop requires a non-terminal run; ${run.runId} is ${run.status}`,
345
+ );
346
+ }
347
+ await resolveWorkflowBackend(run)
348
+ .cleanupRun(cwd, run)
349
+ .catch(() => undefined);
350
+ const interruptedTaskIds: string[] = [];
351
+ for (const task of run.tasks) {
352
+ if (
353
+ setTaskTerminal(task, "interrupted", "workflow_stopped", {
354
+ exitCode: 130,
355
+ lastMessage: "Workflow stopped by user request",
356
+ })
357
+ ) {
358
+ interruptedTaskIds.push(task.taskId);
359
+ }
360
+ }
361
+ await writeRunRecord(cwd, run);
362
+ unwatchRun(cwd, run.runId);
363
+ return { run, interruptedTaskIds };
364
+ });
365
+ if (!stopped)
366
+ throw new Error(
367
+ `Could not acquire workflow run lease for ${current.runId}`,
368
+ );
369
+ return stopped;
370
+ }
371
+
285
372
  export async function resumeRun(
286
373
  cwd: string,
287
374
  runIdOrPrefix: string,
@@ -297,6 +384,7 @@ export async function resumeRun(
297
384
  `resume requires a failed, interrupted, or resumable blocked run; ${current.runId} is ${current.status}`,
298
385
  );
299
386
  }
387
+ assertBlockedRunResumable(current);
300
388
  const compiledFlow = await readCompiledWorkflow(cwd, current.runId);
301
389
  const hasLoopTasks =
302
390
  compiledFlow?.tasks.some(
@@ -314,6 +402,10 @@ export async function resumeRun(
314
402
  const resetTaskIds: string[] = [];
315
403
  const updated = await withRunLease(cwd, current.runId, async () => {
316
404
  const run = await readRunRecord(cwd, current.runId);
405
+ assertBlockedRunResumable(run);
406
+ await resolveWorkflowBackend(run)
407
+ .cleanupRun(cwd, run)
408
+ .catch(() => undefined);
317
409
  for (const task of run.tasks) {
318
410
  if (resetTaskForResume(task)) resetTaskIds.push(task.taskId);
319
411
  }
@@ -332,7 +424,7 @@ export async function resumeRun(
332
424
  const scheduled =
333
425
  (await scheduleRun(cwd, current.runId, undefined, options)) ??
334
426
  (await readRunRecord(cwd, current.runId));
335
- if (scheduled.status === "running") watchRun(cwd, scheduled.runId, options);
427
+ if (shouldWatchRun(scheduled)) watchRun(cwd, scheduled.runId, options);
336
428
  return { run: scheduled, resetTaskIds };
337
429
  }
338
430
 
@@ -343,7 +435,7 @@ export async function resumeSupervisors(
343
435
  try {
344
436
  const runs = await listRunRecords(cwd);
345
437
  for (const run of runs) {
346
- if (run.status === "running") {
438
+ if (hasActiveSchedulerWork(run)) {
347
439
  await scheduleRun(cwd, run.runId, undefined, options).catch((error) =>
348
440
  recordSupervisorError(cwd, run.runId, error),
349
441
  );
@@ -358,6 +450,15 @@ export async function resumeSupervisors(
358
450
  }
359
451
  }
360
452
 
453
+ function unwatchRun(cwd: string, runId: string): void {
454
+ const key = `${cwd}\0${runId}`;
455
+ const existing = supervisorTimers.get(key);
456
+ if (existing) clearInterval(existing);
457
+ supervisorTimers.delete(key);
458
+ supervisorRunMtimes.delete(key);
459
+ supervisorErrorCounts.delete(key);
460
+ }
461
+
361
462
  export function watchRun(
362
463
  cwd: string,
363
464
  runId: string,
@@ -375,8 +476,9 @@ export function watchRun(
375
476
  const currentMtime = afterMtime ?? beforeMtime;
376
477
  if (currentMtime !== undefined)
377
478
  supervisorRunMtimes.set(key, currentMtime);
479
+ supervisorErrorCounts.delete(key);
378
480
 
379
- if (refreshed.status === "running") {
481
+ if (hasActiveSchedulerWork(refreshed)) {
380
482
  const unchanged =
381
483
  previousMtime !== undefined &&
382
484
  currentMtime !== undefined &&
@@ -385,12 +487,18 @@ export function watchRun(
385
487
  return;
386
488
  }
387
489
 
388
- const existing = supervisorTimers.get(key);
389
- if (existing) clearInterval(existing);
390
- supervisorTimers.delete(key);
391
- supervisorRunMtimes.delete(key);
490
+ unwatchRun(cwd, runId);
392
491
  })().catch((error) => {
393
- void recordSupervisorError(cwd, runId, error);
492
+ if (isMissingRunError(error)) {
493
+ unwatchRun(cwd, runId);
494
+ return;
495
+ }
496
+ const failures = (supervisorErrorCounts.get(key) ?? 0) + 1;
497
+ supervisorErrorCounts.set(key, failures);
498
+ void recordSupervisorError(cwd, runId, error).finally(() => {
499
+ if (failures >= MAX_SUPERVISOR_CONSECUTIVE_ERRORS)
500
+ unwatchRun(cwd, runId);
501
+ });
394
502
  });
395
503
  }, POLL_INTERVAL_MS);
396
504
 
@@ -405,21 +513,49 @@ async function readRunMtimeMs(
405
513
  try {
406
514
  return (await stat(workflowRunPath(cwd, runId))).mtimeMs;
407
515
  } catch (error) {
408
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
516
+ if (isEnoentError(error)) return undefined;
409
517
  throw error;
410
518
  }
411
519
  }
412
520
 
521
+ function isEnoentError(error: unknown): boolean {
522
+ return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
523
+ }
524
+
525
+ function isMissingRunError(error: unknown): boolean {
526
+ return (
527
+ isEnoentError(error) ||
528
+ (error instanceof Error && /^Flow run not found: /.test(error.message))
529
+ );
530
+ }
531
+
532
+ function assertScheduleLeaseActive(signal?: AbortSignal): void {
533
+ if (!signal?.aborted) return;
534
+ const reason = (signal as AbortSignal & { reason?: unknown }).reason;
535
+ if (reason instanceof Error) throw reason;
536
+ throw new Error(
537
+ reason === undefined
538
+ ? "Lost supervisor lease"
539
+ : `Lost supervisor lease: ${String(reason)}`,
540
+ );
541
+ }
542
+
413
543
  export async function scheduleRun(
414
544
  cwd: string,
415
545
  runId: string,
416
546
  compiled?: CompiledWorkflow,
417
547
  options: WorkflowScheduleOptions = {},
418
548
  ): Promise<WorkflowRunRecord | undefined> {
419
- return withRunLease(cwd, runId, async () => {
549
+ return withRunLease(cwd, runId, async (leaseSignal) => {
550
+ assertScheduleLeaseActive(leaseSignal);
420
551
  let run = await readRunRecord(cwd, runId);
421
552
  run = await resolveWorkflowBackend(run).refreshRun(cwd, run);
422
- if (run.taskSummary.blocked > 0 || isTerminalWorkflowStatus(run.status))
553
+ if (isTerminalWorkflowStatus(run.status)) return run;
554
+ if (
555
+ run.taskSummary.blocked > 0 &&
556
+ run.taskSummary.pending === 0 &&
557
+ run.taskSummary.running === 0
558
+ )
423
559
  return run;
424
560
 
425
561
  const compiledFlow =
@@ -431,7 +567,8 @@ export async function scheduleRun(
431
567
  `unsupported compiled workflow type: ${compiledFlow.type}`,
432
568
  );
433
569
  }
434
- await scheduleDag(cwd, run, compiledFlow, options);
570
+ await scheduleDag(cwd, run, compiledFlow, options, leaseSignal);
571
+ assertScheduleLeaseActive(leaseSignal);
435
572
 
436
573
  run = await readRunRecord(cwd, run.runId);
437
574
  return run;
@@ -444,13 +581,13 @@ export async function formatStatus(cwd: string): Promise<string> {
444
581
  await reconcileIndexedActiveRuns(cwd, cached);
445
582
  const refreshed = (await readIndex(cwd).catch(() => cached)) ?? cached;
446
583
  if (refreshed.runs.length === 0) return "No workflow runs found.";
447
- return formatIndex(refreshed);
584
+ return formatIndex(cwd, refreshed);
448
585
  }
449
586
 
450
587
  await reconcileActiveRuns(cwd);
451
588
  const rebuilt = await updateIndex(cwd).catch(() => readIndex(cwd));
452
589
  if (!rebuilt || rebuilt.runs.length === 0) return "No workflow runs found.";
453
- return formatIndex(rebuilt);
590
+ return formatIndex(cwd, rebuilt);
454
591
  }
455
592
 
456
593
  export async function formatRunDetails(
@@ -502,10 +639,12 @@ export function formatRun(
502
639
  run: WorkflowRunRecord,
503
640
  detail: "summary" | "full" = "summary",
504
641
  ): string {
642
+ const telemetry = summarizeWorkflowTelemetry(run);
505
643
  const lines = [
506
644
  `${run.runId} [${run.status}] type=${run.type} backend=${run.backend.type}/${run.backend.mode}`,
507
645
  `created=${run.createdAt} updated=${run.updatedAt}`,
508
646
  `tasks=${run.taskSummary.completed}/${run.taskSummary.total} completed, running=${run.taskSummary.running}, pending=${run.taskSummary.pending}, blocked=${run.taskSummary.blocked}, failed=${run.taskSummary.failed}, interrupted=${run.taskSummary.interrupted}`,
647
+ `completion=${telemetry.completion.health}, outputRetries=${telemetry.retryCounts.output}, launchRetries=${telemetry.retryCounts.launch}, resumeEvents=${telemetry.resumeCounts.events}, contextLimitFailures=${telemetry.completion.contextLimitFailures}`,
509
648
  ];
510
649
 
511
650
  for (const task of run.tasks) {
@@ -518,7 +657,7 @@ export function formatRun(
518
657
  async function reconcileActiveRuns(cwd: string): Promise<void> {
519
658
  const runs = await listRunRecords(cwd);
520
659
  for (const run of runs) {
521
- if (run.status === "running")
660
+ if (hasActiveSchedulerWork(run))
522
661
  await refreshRun(cwd, run.runId).catch((error) =>
523
662
  recordSupervisorError(cwd, run.runId, error),
524
663
  );
@@ -530,7 +669,7 @@ async function reconcileIndexedActiveRuns(
530
669
  index: WorkflowIndexRecord,
531
670
  ): Promise<void> {
532
671
  for (const run of index.runs) {
533
- if (run.status === "running")
672
+ if (hasActiveSchedulerWork(run))
534
673
  await refreshRun(cwd, run.runId).catch((error) =>
535
674
  recordSupervisorError(cwd, run.runId, error),
536
675
  );
@@ -561,7 +700,9 @@ async function scheduleDag(
561
700
  run: WorkflowRunRecord,
562
701
  compiledFlow: CompiledWorkflow,
563
702
  options: WorkflowScheduleOptions = {},
703
+ leaseSignal?: AbortSignal,
564
704
  ): Promise<void> {
705
+ assertScheduleLeaseActive(leaseSignal);
565
706
  if (compiledFlow.type === WORKFLOW_RUN_TYPE) {
566
707
  const loopReconciled = await reconcileLoopTaskMaterialization(
567
708
  cwd,
@@ -569,6 +710,16 @@ async function scheduleDag(
569
710
  compiledFlow,
570
711
  );
571
712
  if (loopReconciled) return;
713
+ const foreachReconciled = reconcileForeachGeneratedRunRecords(
714
+ cwd,
715
+ run,
716
+ compiledFlow,
717
+ );
718
+ if (foreachReconciled) {
719
+ await writeJsonAtomic(compiledWorkflowPath(cwd, run.runId), compiledFlow);
720
+ await writeRunRecord(cwd, run);
721
+ return;
722
+ }
572
723
  const dynamicReconciled = reconcileDynamicGeneratedRunRecords(
573
724
  cwd,
574
725
  run,
@@ -601,6 +752,7 @@ async function scheduleDag(
601
752
  index < run.tasks.length && running < maxConcurrency;
602
753
  index += 1
603
754
  ) {
755
+ assertScheduleLeaseActive(leaseSignal);
604
756
  const task = run.tasks[index];
605
757
  const compiledTask = compiledFlow.tasks[index];
606
758
  if (!task || !compiledTask || task.status !== "pending") continue;
@@ -632,6 +784,7 @@ async function scheduleDag(
632
784
  compiledTask,
633
785
  );
634
786
  if (changed) return;
787
+ if (foreachStreamingEnabled(compiledTask)) continue;
635
788
  }
636
789
 
637
790
  if (compiledTask.stageMaxConcurrency !== undefined) {
@@ -647,6 +800,7 @@ async function scheduleDag(
647
800
  continue;
648
801
  }
649
802
 
803
+ assertScheduleLeaseActive(leaseSignal);
650
804
  const launched = await launchPendingTaskAt(
651
805
  cwd,
652
806
  run,
@@ -654,7 +808,8 @@ async function scheduleDag(
654
808
  index,
655
809
  options,
656
810
  );
657
- if (launched) running += 1;
811
+ assertScheduleLeaseActive(leaseSignal);
812
+ if (launched && run.tasks[index]?.status === "running") running += 1;
658
813
  }
659
814
  }
660
815
 
@@ -780,12 +935,14 @@ async function materializeForeachTask(
780
935
  const sourceTasks = run.tasks.filter((task) =>
781
936
  sourceStageIds.includes(task.stageId ?? ""),
782
937
  );
938
+ const streaming = foreachStreamingEnabled(template);
783
939
  const extracted = await extractArtifactGraphForeachItems(
784
940
  cwd,
785
941
  {
786
942
  from: template.foreach.from,
787
943
  sourcePolicy: stageSourcePolicy(compiledFlow, template.stageId),
788
944
  maxItems: template.foreach.maxItems,
945
+ streaming,
789
946
  },
790
947
  sourceTasks,
791
948
  );
@@ -813,7 +970,33 @@ async function materializeForeachTask(
813
970
  }
814
971
 
815
972
  const placeholderSpecId = template.id;
973
+ if (streaming) {
974
+ return await materializeStreamingForeachTask({
975
+ cwd,
976
+ run,
977
+ compiledFlow,
978
+ index,
979
+ templateRunTask,
980
+ placeholderSpecId,
981
+ sourceTaskSpecIds: sourceTasks.map((task) => task.specId),
982
+ itemMetas: extracted.itemMetas ?? [],
983
+ generatedTasks: generated.tasks,
984
+ waitingForSources: extracted.waitingForSources ?? false,
985
+ minChunk: foreachStreamingMinChunk(template),
986
+ });
987
+ }
816
988
  const generatedSpecIds = generated.tasks.map((task) => task.id);
989
+ const hasDownstreamDependents = compiledFlow.tasks.some(
990
+ (task, taskIndex) =>
991
+ taskIndex !== index && (task.dependsOn ?? []).includes(placeholderSpecId),
992
+ );
993
+ if (generatedSpecIds.length === 0 && !hasDownstreamDependents) {
994
+ setTaskTerminal(templateRunTask, "completed", "foreach_empty", {
995
+ lastMessage: "foreach produced 0 item(s)",
996
+ });
997
+ await writeRunRecord(cwd, run);
998
+ return true;
999
+ }
817
1000
  compiledFlow.tasks.splice(index, 1, ...generated.tasks);
818
1001
  updateDownstreamDependencies(
819
1002
  compiledFlow,
@@ -840,20 +1023,279 @@ async function materializeForeachTask(
840
1023
  return true;
841
1024
  }
842
1025
 
1026
+ async function materializeStreamingForeachTask(input: {
1027
+ cwd: string;
1028
+ run: WorkflowRunRecord;
1029
+ compiledFlow: CompiledWorkflow;
1030
+ index: number;
1031
+ templateRunTask: WorkflowTaskRunRecord;
1032
+ placeholderSpecId: string;
1033
+ sourceTaskSpecIds: string[];
1034
+ itemMetas: ForeachExtractedItemMeta[];
1035
+ generatedTasks: CompiledTask[];
1036
+ waitingForSources: boolean;
1037
+ minChunk: number;
1038
+ }): Promise<boolean> {
1039
+ const sourceTaskSpecIdSet = new Set(input.sourceTaskSpecIds);
1040
+ const generatedTasksWithItemDeps = input.generatedTasks.map((task, index) => {
1041
+ const itemMeta = input.itemMetas[index];
1042
+ if (!itemMeta) return task;
1043
+ return {
1044
+ ...task,
1045
+ dependsOn: replaceSourceDependenciesWithItemSource(
1046
+ task.dependsOn ?? [],
1047
+ sourceTaskSpecIdSet,
1048
+ itemMeta,
1049
+ {
1050
+ keepPartialSourceDependency:
1051
+ partialGeneratedTaskNeedsCompletedSourceContext(task),
1052
+ },
1053
+ ),
1054
+ foreachGenerated: {
1055
+ ...(task.foreachGenerated ?? {
1056
+ placeholderSpecId: input.placeholderSpecId,
1057
+ }),
1058
+ itemHash: itemMeta.itemHash,
1059
+ itemSourceSpecId: itemMeta.sourceSpecId,
1060
+ itemSourceKind: itemMeta.sourceKind,
1061
+ itemRef: itemMeta.itemRef,
1062
+ },
1063
+ };
1064
+ });
1065
+ const existingGeneratedTasks = input.compiledFlow.tasks.filter(
1066
+ (task) =>
1067
+ task.foreachGenerated?.placeholderSpecId === input.placeholderSpecId,
1068
+ );
1069
+ const existingGeneratedSpecIds = existingGeneratedTasks.map(
1070
+ (task) => task.id,
1071
+ );
1072
+ const existingGeneratedTaskBySpecId = new Map(
1073
+ existingGeneratedTasks.map((task) => [task.id, task]),
1074
+ );
1075
+ for (const task of generatedTasksWithItemDeps) {
1076
+ const existing = existingGeneratedTaskBySpecId.get(task.id);
1077
+ const existingHash = existing?.foreachGenerated?.itemHash;
1078
+ const nextHash = task.foreachGenerated?.itemHash;
1079
+ if (existing && existingHash && nextHash && existingHash !== nextHash) {
1080
+ setTaskTerminal(
1081
+ input.templateRunTask,
1082
+ "blocked",
1083
+ "foreach_expansion_blocked",
1084
+ {
1085
+ lastMessage: `foreach streaming item ${task.id} changed after materialization`,
1086
+ },
1087
+ );
1088
+ await writeRunRecord(input.cwd, input.run);
1089
+ return true;
1090
+ }
1091
+ }
1092
+ const existingGeneratedSpecIdSet = new Set(existingGeneratedSpecIds);
1093
+ const finalGeneratedSpecIdSet = new Set(
1094
+ generatedTasksWithItemDeps.map((task) => task.id),
1095
+ );
1096
+ if (!input.waitingForSources) {
1097
+ const withdrawn = existingGeneratedTasks.find(
1098
+ (task) =>
1099
+ task.foreachGenerated?.itemSourceKind === "partial" &&
1100
+ !finalGeneratedSpecIdSet.has(task.id),
1101
+ );
1102
+ if (withdrawn) {
1103
+ setTaskTerminal(
1104
+ input.templateRunTask,
1105
+ "blocked",
1106
+ "foreach_expansion_blocked",
1107
+ {
1108
+ lastMessage: `foreach streaming item ${withdrawn.id} was published as partial output but is missing from final control`,
1109
+ },
1110
+ );
1111
+ await writeRunRecord(input.cwd, input.run);
1112
+ return true;
1113
+ }
1114
+ }
1115
+ const newGeneratedTasks = generatedTasksWithItemDeps.filter(
1116
+ (task) => !existingGeneratedSpecIdSet.has(task.id),
1117
+ );
1118
+ const allGeneratedSpecIds = [
1119
+ ...existingGeneratedSpecIds,
1120
+ ...newGeneratedTasks.map((task) => task.id),
1121
+ ];
1122
+ const shouldHoldForMinChunk =
1123
+ input.waitingForSources &&
1124
+ newGeneratedTasks.length > 0 &&
1125
+ newGeneratedTasks.length < input.minChunk;
1126
+ if (shouldHoldForMinChunk) return false;
1127
+
1128
+ let changed = false;
1129
+ if (newGeneratedTasks.length > 0) {
1130
+ let compiledInsertIndex = input.index + 1;
1131
+ while (
1132
+ input.compiledFlow.tasks[compiledInsertIndex]?.foreachGenerated
1133
+ ?.placeholderSpecId === input.placeholderSpecId
1134
+ ) {
1135
+ compiledInsertIndex += 1;
1136
+ }
1137
+ input.compiledFlow.tasks.splice(
1138
+ compiledInsertIndex,
1139
+ 0,
1140
+ ...newGeneratedTasks,
1141
+ );
1142
+
1143
+ let runInsertIndex = input.index + 1;
1144
+ while (
1145
+ input.run.tasks[runInsertIndex]?.foreachGenerated?.placeholderSpecId ===
1146
+ input.placeholderSpecId
1147
+ ) {
1148
+ runInsertIndex += 1;
1149
+ }
1150
+ const nextIndex = nextTaskRecordIndex(input.run);
1151
+ const generatedRunTasks = newGeneratedTasks.map((task, offset) =>
1152
+ createTaskRunRecord(input.cwd, input.run.runId, task, nextIndex + offset),
1153
+ );
1154
+ input.run.tasks.splice(runInsertIndex, 0, ...generatedRunTasks);
1155
+ changed = true;
1156
+ }
1157
+
1158
+ const dependencyTargets = [input.placeholderSpecId, ...allGeneratedSpecIds];
1159
+ for (const task of input.compiledFlow.tasks) {
1160
+ if (!task.dependsOn) continue;
1161
+ const replaced = replaceDependencyList(
1162
+ task.dependsOn,
1163
+ input.placeholderSpecId,
1164
+ dependencyTargets,
1165
+ );
1166
+ if (JSON.stringify(task.dependsOn) !== JSON.stringify(replaced)) {
1167
+ task.dependsOn = replaced;
1168
+ changed = true;
1169
+ }
1170
+ }
1171
+ for (const task of input.run.tasks) {
1172
+ if (!task.dependsOn) continue;
1173
+ const replaced = replaceDependencyList(
1174
+ task.dependsOn,
1175
+ input.placeholderSpecId,
1176
+ dependencyTargets,
1177
+ );
1178
+ if (JSON.stringify(task.dependsOn) !== JSON.stringify(replaced)) {
1179
+ task.dependsOn = replaced;
1180
+ changed = true;
1181
+ }
1182
+ }
1183
+
1184
+ if (!input.waitingForSources) {
1185
+ const statusDetail =
1186
+ allGeneratedSpecIds.length === 0
1187
+ ? "foreach_empty"
1188
+ : "foreach_streaming_complete";
1189
+ const lastMessage =
1190
+ allGeneratedSpecIds.length === 0
1191
+ ? "foreach produced 0 item(s)"
1192
+ : `foreach streaming materialized ${allGeneratedSpecIds.length} item(s)`;
1193
+ setTaskTerminal(input.templateRunTask, "completed", statusDetail, {
1194
+ lastMessage,
1195
+ });
1196
+ changed = true;
1197
+ } else if (newGeneratedTasks.length > 0) {
1198
+ input.templateRunTask.statusDetail = "foreach_streaming_waiting";
1199
+ input.templateRunTask.lastMessage = `foreach streaming materialized ${allGeneratedSpecIds.length} item(s); waiting for more source tasks`;
1200
+ changed = true;
1201
+ }
1202
+
1203
+ if (!changed) return false;
1204
+ await writeJsonAtomic(
1205
+ compiledWorkflowPath(input.cwd, input.run.runId),
1206
+ input.compiledFlow,
1207
+ );
1208
+ await writeRunRecord(input.cwd, input.run);
1209
+ return true;
1210
+ }
1211
+
1212
+ function replaceSourceDependenciesWithItemSource(
1213
+ dependsOn: string[],
1214
+ sourceTaskSpecIds: Set<string>,
1215
+ itemMeta: ForeachExtractedItemMeta,
1216
+ options: { keepPartialSourceDependency?: boolean } = {},
1217
+ ): string[] {
1218
+ const replaced: string[] = [];
1219
+ let inserted = false;
1220
+ const shouldReplaceWithSource =
1221
+ itemMeta.sourceKind !== "partial" ||
1222
+ options.keepPartialSourceDependency === true;
1223
+ for (const dep of dependsOn) {
1224
+ if (!sourceTaskSpecIds.has(dep)) {
1225
+ replaced.push(dep);
1226
+ continue;
1227
+ }
1228
+ if (!shouldReplaceWithSource) continue;
1229
+ if (!inserted) {
1230
+ replaced.push(itemMeta.sourceSpecId);
1231
+ inserted = true;
1232
+ }
1233
+ }
1234
+ if (!inserted && shouldReplaceWithSource) {
1235
+ replaced.push(itemMeta.sourceSpecId);
1236
+ }
1237
+ return [...new Set(replaced)];
1238
+ }
1239
+
1240
+ function partialGeneratedTaskNeedsCompletedSourceContext(
1241
+ task: CompiledTask,
1242
+ ): boolean {
1243
+ const artifactGraph = task.artifactGraph;
1244
+ if (artifactGraph?.artifactAccess === "none") return false;
1245
+ return Boolean(
1246
+ artifactGraph?.sourceProjection !== undefined ||
1247
+ (artifactGraph?.requiredReads?.length ?? 0) > 0,
1248
+ );
1249
+ }
1250
+
1251
+ interface ForeachExtractedItemMeta {
1252
+ sourceSpecId: string;
1253
+ sourceKind: "control" | "partial";
1254
+ itemHash: string;
1255
+ itemRef: string;
1256
+ }
1257
+
843
1258
  async function extractArtifactGraphForeachItems(
844
1259
  cwd: string,
845
- stage: { from: unknown; sourcePolicy?: string; maxItems?: number },
1260
+ stage: {
1261
+ from: unknown;
1262
+ sourcePolicy?: string;
1263
+ maxItems?: number;
1264
+ streaming?: boolean;
1265
+ },
846
1266
  sourceTasks: WorkflowTaskRunRecord[],
847
- ): Promise<{ items?: unknown[]; error?: string }> {
1267
+ ): Promise<{
1268
+ items?: unknown[];
1269
+ itemMetas?: ForeachExtractedItemMeta[];
1270
+ error?: string;
1271
+ waitingForSources?: boolean;
1272
+ }> {
848
1273
  const items: unknown[] = [];
1274
+ const itemMetas: ForeachExtractedItemMeta[] = [];
849
1275
  const path = (stage.from as any)?.path;
850
1276
  if (typeof path !== "string" || !path.startsWith("$.")) {
851
1277
  return {
852
1278
  error: "foreach.from.path must be a control JSONPath like $.items",
853
1279
  };
854
1280
  }
1281
+ let waitingForSources = false;
855
1282
  for (const task of sourceTasks) {
856
1283
  if (task.status !== "completed") {
1284
+ if (stage.streaming && !isTerminalTaskStatus(task.status)) {
1285
+ const partial = await extractPartialForeachItems(cwd, task, path);
1286
+ if (partial.error) return { error: partial.error };
1287
+ for (const item of partial.items) {
1288
+ items.push(item.item);
1289
+ itemMetas.push({
1290
+ sourceSpecId: task.specId,
1291
+ sourceKind: "partial",
1292
+ itemHash: item.itemHash,
1293
+ itemRef: `${task.specId}:${item.itemRef}`,
1294
+ });
1295
+ }
1296
+ waitingForSources = true;
1297
+ continue;
1298
+ }
857
1299
  if (stage.sourcePolicy !== "partial")
858
1300
  return { error: `${task.taskId} did not complete` };
859
1301
  continue;
@@ -869,7 +1311,15 @@ async function extractArtifactGraphForeachItems(
869
1311
  }
870
1312
  continue;
871
1313
  }
872
- items.push(...value);
1314
+ for (const [index, item] of value.entries()) {
1315
+ items.push(item);
1316
+ itemMetas.push({
1317
+ sourceSpecId: task.specId,
1318
+ sourceKind: "control",
1319
+ itemHash: hashDynamicRequest(item),
1320
+ itemRef: `${task.specId}:control:${path}[${index}]`,
1321
+ });
1322
+ }
873
1323
  } catch (error) {
874
1324
  if (stage.sourcePolicy !== "partial") {
875
1325
  return {
@@ -883,7 +1333,31 @@ async function extractArtifactGraphForeachItems(
883
1333
  error: `foreach extracted ${items.length} items, exceeding maxItems=${stage.maxItems}`,
884
1334
  };
885
1335
  }
886
- return { items };
1336
+ return { items, itemMetas, waitingForSources };
1337
+ }
1338
+
1339
+ async function extractPartialForeachItems(
1340
+ cwd: string,
1341
+ task: WorkflowTaskRunRecord,
1342
+ path: string,
1343
+ ): Promise<{ items: WorkflowPartialOutputItem[]; error?: string }> {
1344
+ const partialPaths = task.artifactGraph?.output.partial?.paths ?? [];
1345
+ if (!partialPaths.includes(path)) return { items: [] };
1346
+ const taskDir = dirname(fromProjectPath(cwd, task.files.result));
1347
+ let ledger = await readWorkflowPartialOutputLedger(taskDir).catch(
1348
+ () => undefined,
1349
+ );
1350
+ if (!ledger) {
1351
+ ledger = await writeWorkflowPartialOutputLedgerFromFile({
1352
+ taskDir,
1353
+ outputFile: fromProjectPath(cwd, task.files.output),
1354
+ allowedPaths: partialPaths,
1355
+ }).catch(() => undefined);
1356
+ }
1357
+ if (!ledger) return { items: [] };
1358
+ const fatal = hasFatalPartialOutputIssue(ledger);
1359
+ if (fatal) return { items: [], error: fatal.message };
1360
+ return { items: ledger.items.filter((item) => item.path === path) };
887
1361
  }
888
1362
 
889
1363
  async function launchPendingTaskAt(
@@ -906,12 +1380,15 @@ async function launchPendingTaskAt(
906
1380
  return false;
907
1381
  }
908
1382
 
909
- let launchTask = await prepareDagTask(cwd, run, compiledFlow, index);
910
- if (task.outputRetry) {
911
- launchTask = await prepareArtifactGraphRetryTask(cwd, task, launchTask);
912
- }
913
-
1383
+ let launchTask: CompiledWorkflow["tasks"][number] | undefined;
1384
+ let prepareComplete = false;
914
1385
  try {
1386
+ launchTask = await prepareDagTask(cwd, run, compiledFlow, index);
1387
+ if (task.outputRetry) {
1388
+ launchTask = await prepareArtifactGraphRetryTask(cwd, task, launchTask);
1389
+ }
1390
+ prepareComplete = true;
1391
+
915
1392
  if (launchTask.kind === "support") {
916
1393
  return await executeSupportTask(cwd, run, task, launchTask);
917
1394
  }
@@ -939,10 +1416,11 @@ async function launchPendingTaskAt(
939
1416
  if (launch.kind === "fatal") throw new Error(launch.message);
940
1417
  return launch.kind === "launched";
941
1418
  } catch (error) {
942
- const statusDetail =
943
- launchTask.kind === "support"
1419
+ const statusDetail = !prepareComplete
1420
+ ? "prepare_failed"
1421
+ : launchTask?.kind === "support"
944
1422
  ? "support_failed"
945
- : launchTask.safety.requiresWorktree
1423
+ : launchTask?.safety.requiresWorktree
946
1424
  ? "worktree_failed"
947
1425
  : "launch_failed";
948
1426
  setTaskTerminal(task, "failed", statusDetail, {
@@ -2692,14 +3170,17 @@ async function readCompiledWorkflow(
2692
3170
  return readJson<CompiledWorkflow>(compiledWorkflowPath(cwd, runId));
2693
3171
  }
2694
3172
 
2695
- function formatIndex(index: WorkflowIndexRecord): string {
2696
- return index.runs
2697
- .map((run) => {
3173
+ async function formatIndex(
3174
+ cwd: string,
3175
+ index: WorkflowIndexRecord,
3176
+ ): Promise<string> {
3177
+ const blocks = await Promise.all(
3178
+ index.runs.map(async (run) => {
2698
3179
  const lines = [
2699
3180
  `${run.runId} [${run.status}] type=${run.type} updated=${run.updatedAt}`,
2700
3181
  `tasks=${run.taskSummary.completed}/${run.taskSummary.total} completed, running=${run.taskSummary.running}, pending=${run.taskSummary.pending}, blocked=${run.taskSummary.blocked}, failed=${run.taskSummary.failed}, skipped=${run.taskSummary.skipped}, interrupted=${run.taskSummary.interrupted}`,
2701
3182
  ];
2702
- for (const task of run.tasks) {
3183
+ for (const task of await indexTasksForStatus(cwd, run)) {
2703
3184
  const message = task.lastMessage ? ` — ${task.lastMessage}` : "";
2704
3185
  const kind = task.kind && task.kind !== "main" ? ` ${task.kind}` : "";
2705
3186
  lines.push(
@@ -2707,8 +3188,34 @@ function formatIndex(index: WorkflowIndexRecord): string {
2707
3188
  );
2708
3189
  }
2709
3190
  return lines.join("\n");
2710
- })
2711
- .join("\n\n");
3191
+ }),
3192
+ );
3193
+ return blocks.join("\n\n");
3194
+ }
3195
+
3196
+ type WorkflowIndexTaskEntry = NonNullable<
3197
+ WorkflowIndexRecord["runs"][number]["tasks"]
3198
+ >[number];
3199
+
3200
+ async function indexTasksForStatus(
3201
+ cwd: string,
3202
+ run: WorkflowIndexRecord["runs"][number],
3203
+ ): Promise<WorkflowIndexTaskEntry[]> {
3204
+ if (Array.isArray(run.tasks)) return run.tasks;
3205
+ const fullRun = await readRunRecord(cwd, run.runId).catch(() => undefined);
3206
+ return (
3207
+ fullRun?.tasks.map((task) => ({
3208
+ taskId: task.taskId,
3209
+ displayName: task.displayName,
3210
+ agent: task.agent,
3211
+ kind: task.kind,
3212
+ stageId: task.stageId,
3213
+ backendHandle: task.backendHandle,
3214
+ status: task.status,
3215
+ statusDetail: task.statusDetail,
3216
+ lastMessage: task.lastMessage,
3217
+ })) ?? []
3218
+ );
2712
3219
  }
2713
3220
 
2714
3221
  function formatTask(