@agwab/pi-workflow 0.2.1 → 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 (70) hide show
  1. package/dist/compiler.js +6 -8
  2. package/dist/dynamic-decision.d.ts +0 -1
  3. package/dist/dynamic-decision.js +0 -7
  4. package/dist/dynamic-profiles.d.ts +0 -1
  5. package/dist/dynamic-profiles.js +0 -3
  6. package/dist/engine-run-graph.d.ts +1 -0
  7. package/dist/engine-run-graph.js +142 -2
  8. package/dist/engine.d.ts +5 -0
  9. package/dist/engine.js +112 -27
  10. package/dist/extension.d.ts +2 -1
  11. package/dist/extension.js +27 -6
  12. package/dist/index.d.ts +3 -3
  13. package/dist/index.js +2 -1
  14. package/dist/store.js +55 -11
  15. package/dist/subagent-backend.js +155 -29
  16. package/dist/types.d.ts +6 -0
  17. package/dist/workflow-runtime.js +10 -1
  18. package/dist/workflow-view.js +3 -1
  19. package/dist/workflow-web-source-extension.js +167 -48
  20. package/dist/workflow-web-source.d.ts +2 -1
  21. package/dist/workflow-web-source.js +84 -19
  22. package/node_modules/@agwab/pi-subagent/README.md +3 -3
  23. package/node_modules/@agwab/pi-subagent/api.mjs +1 -0
  24. package/node_modules/@agwab/pi-subagent/docs/usage.md +63 -12
  25. package/node_modules/@agwab/pi-subagent/package.json +2 -2
  26. package/node_modules/@agwab/pi-subagent/src/api.ts +54 -1
  27. package/node_modules/@agwab/pi-subagent/src/artifacts/registry.ts +9 -4
  28. package/node_modules/@agwab/pi-subagent/src/artifacts/result.ts +8 -0
  29. package/node_modules/@agwab/pi-subagent/src/core/constants.ts +9 -0
  30. package/node_modules/@agwab/pi-subagent/src/core/validation.ts +21 -0
  31. package/node_modules/@agwab/pi-subagent/src/index.ts +995 -573
  32. package/node_modules/@agwab/pi-subagent/src/orchestrate/async.ts +279 -156
  33. package/node_modules/@agwab/pi-subagent/src/orchestrate/interrupt.ts +165 -89
  34. package/node_modules/@agwab/pi-subagent/src/orchestrate/reconcile.ts +111 -65
  35. package/node_modules/@agwab/pi-subagent/src/orchestrate/run-ref.ts +219 -0
  36. package/node_modules/@agwab/pi-subagent/src/orchestrate/run.ts +88 -8
  37. package/node_modules/@agwab/pi-subagent/src/orchestrate/status.ts +614 -298
  38. package/node_modules/@agwab/pi-subagent/src/panel.ts +1352 -560
  39. package/node_modules/@agwab/pi-subagent/src/runners/headless-model.ts +53 -5
  40. package/node_modules/@agwab/pi-subagent/src/runners/tmux.ts +13 -6
  41. package/package.json +2 -2
  42. package/src/compiler.ts +14 -9
  43. package/src/dynamic-decision.ts +0 -11
  44. package/src/dynamic-profiles.ts +0 -4
  45. package/src/engine-run-graph.ts +185 -2
  46. package/src/engine.ts +145 -24
  47. package/src/extension.ts +33 -4
  48. package/src/index.ts +3 -1
  49. package/src/store.ts +74 -11
  50. package/src/subagent-backend.ts +201 -28
  51. package/src/types.ts +6 -0
  52. package/src/workflow-runtime.ts +18 -2
  53. package/src/workflow-view.ts +2 -1
  54. package/src/workflow-web-source-extension.ts +621 -228
  55. package/src/workflow-web-source.ts +118 -28
  56. package/workflows/deep-research/helpers/claim-evidence-gate.mjs +56 -16
  57. package/workflows/deep-research/helpers/final-audit-packet.mjs +1 -4
  58. package/workflows/deep-research/helpers/normalize-input-packet.mjs +1 -1
  59. package/workflows/deep-research/helpers/render-executive.mjs +8 -21
  60. package/workflows/deep-research/helpers/sanitize-verification-candidates.mjs +89 -15
  61. package/workflows/deep-research/schemas/deep-research-executive-render-control.schema.json +0 -1
  62. package/workflows/deep-research/schemas/deep-research-verify-claims-control.schema.json +4 -1
  63. package/workflows/impact-review/spec.json +3 -3
  64. package/workflows/spec-review/helpers/spec-review-pipeline.mjs +1 -8
  65. package/dist/dynamic-loader.d.ts +0 -25
  66. package/dist/dynamic-loader.js +0 -13
  67. package/src/dynamic-loader.ts +0 -49
  68. package/workflows/impact-review/schemas/docs-release-impact-control.schema.json +0 -42
  69. package/workflows/impact-review/schemas/security-performance-impact-control.schema.json +0 -42
  70. package/workflows/impact-review/schemas/state-data-impact-control.schema.json +0 -42
@@ -79,6 +79,13 @@ export interface PiJsonParseResult {
79
79
  metadata: Partial<ResultMetadata>;
80
80
  }
81
81
 
82
+ export interface ContextLengthResolution {
83
+ rawContextLengthExceeded: boolean;
84
+ contextLengthExceeded: boolean;
85
+ contextOverflowRecovered: boolean;
86
+ recoveredStreamErrors: string[];
87
+ }
88
+
82
89
  const CONTEXT_LENGTH_ERROR_PATTERN =
83
90
  /\bcontext[_ -]?length[_ -]?exceeded\b|\bcontext[_ -]?window[_ -]?(?:exceeded|overflow|exhausted)\b|\b(?:maximum|max)[_ -]?context[_ -]?length\b|\btoo many tokens\b|\b(?:prompt|input|request)[^\n]{0,80}\btoo large\b|\bcontext_length_exceeded\b/i;
84
91
 
@@ -94,6 +101,32 @@ export function detectContextLengthExceeded(signals: {
94
101
  return CONTEXT_LENGTH_ERROR_PATTERN.test(text);
95
102
  }
96
103
 
104
+ export function resolveContextLengthState(
105
+ parsed: PiJsonParseResult,
106
+ rawContextLengthExceeded: boolean,
107
+ ): ContextLengthResolution {
108
+ const contextOverflowRecovered =
109
+ rawContextLengthExceeded && finalAssistantSucceeded(parsed);
110
+ return {
111
+ rawContextLengthExceeded,
112
+ contextLengthExceeded:
113
+ rawContextLengthExceeded && !contextOverflowRecovered,
114
+ contextOverflowRecovered,
115
+ recoveredStreamErrors: contextOverflowRecovered
116
+ ? parsed.errors.filter((error) =>
117
+ detectContextLengthExceeded({ errors: [error] }),
118
+ )
119
+ : [],
120
+ };
121
+ }
122
+
123
+ function finalAssistantSucceeded(parsed: PiJsonParseResult): boolean {
124
+ return (
125
+ parsed.finalAssistantText.length > 0 &&
126
+ parsed.metadata.stopReason !== "error"
127
+ );
128
+ }
129
+
97
130
  function normalizeTimeoutMs(timeoutMs: number | undefined): number | undefined {
98
131
  if (timeoutMs === undefined) return undefined;
99
132
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
@@ -390,18 +423,29 @@ export function resolvePiJsonOutcome(
390
423
 
391
424
  export function resultMetadataFromParse(
392
425
  parsed: PiJsonParseResult,
393
- contextLengthExceeded: boolean,
426
+ contextLength: ContextLengthResolution,
394
427
  outcome: ProcessOutcome,
395
428
  ): Partial<ResultMetadata> {
396
429
  return {
397
430
  ...parsed.metadata,
398
- contextLengthExceeded,
431
+ contextLengthExceeded: contextLength.contextLengthExceeded,
432
+ ...(contextLength.contextOverflowRecovered
433
+ ? { contextOverflowRecovered: true }
434
+ : {}),
399
435
  ...(parsed.errors.length === 0
400
436
  ? {}
401
437
  : { streamErrors: parsed.errors.slice(0, MAX_METADATA_ERRORS) }),
402
438
  ...(outcome.status === "completed" && parsed.errors.length > 0
403
439
  ? { nonFatalStreamErrors: parsed.errors.slice(0, MAX_METADATA_ERRORS) }
404
440
  : {}),
441
+ ...(contextLength.recoveredStreamErrors.length === 0
442
+ ? {}
443
+ : {
444
+ recoveredStreamErrors: contextLength.recoveredStreamErrors.slice(
445
+ 0,
446
+ MAX_METADATA_ERRORS,
447
+ ),
448
+ }),
405
449
  ...(parsed.parseErrors.length === 0
406
450
  ? {}
407
451
  : { parseErrors: parsed.parseErrors.slice(0, MAX_METADATA_ERRORS) }),
@@ -774,14 +818,18 @@ export async function runHeadlessModel(
774
818
  stderrText,
775
819
  stderrContextLengthExceeded,
776
820
  } = processResult;
777
- const contextLengthExceeded =
821
+ const rawContextLengthExceeded =
778
822
  stderrContextLengthExceeded ||
779
823
  detectContextLengthExceeded({ stderrText, errors: parsed.errors });
824
+ const contextLength = resolveContextLengthState(
825
+ parsed,
826
+ rawContextLengthExceeded,
827
+ );
780
828
 
781
829
  const outcome = resolvePiJsonOutcome(
782
830
  processOutcome,
783
831
  parsed,
784
- contextLengthExceeded,
832
+ contextLength.contextLengthExceeded,
785
833
  );
786
834
 
787
835
  const completedAt = new Date();
@@ -811,7 +859,7 @@ export async function runHeadlessModel(
811
859
  artifacts,
812
860
  correlationId: options.correlationId,
813
861
  metadata: {
814
- ...resultMetadataFromParse(parsed, contextLengthExceeded, outcome),
862
+ ...resultMetadataFromParse(parsed, contextLength, outcome),
815
863
  ...sessionMetadata,
816
864
  ...(options.parentSessionId === undefined
817
865
  ? {}
@@ -20,6 +20,7 @@ import {
20
20
  detectContextLengthExceeded,
21
21
  parsePiJsonFile,
22
22
  parsePiJsonLines,
23
+ resolveContextLengthState,
23
24
  resolvePiJsonOutcome,
24
25
  resultMetadataFromParse,
25
26
  resultSessionMetadata,
@@ -157,9 +158,7 @@ function workerScript(
157
158
  return `import { spawn } from "node:child_process";\nimport { appendFileSync, closeSync, openSync, writeFileSync } from "node:fs";\nconst argv = ${JSON.stringify(argv)};\nconst cwd = ${JSON.stringify(cwd)};\nconst eventPath = ${JSON.stringify(eventPath)};\nconst stderrPath = ${JSON.stringify(stderrPath)};\nconst metaPath = ${JSON.stringify(metaPath)};\nconst messageUpdatePattern = /"type"\\s*:\\s*"message_update"/;\nconst maxStdoutLogLineChars = 64 * 1024 * 1024;\ncloseSync(openSync(eventPath, "w"));\ncloseSync(openSync(stderrPath, "w"));\nlet settled = false;\nlet stdoutBuffer = "";\nlet discardingOversizedLine = false;\nlet omittedMessageUpdates = 0;\nlet omittedMessageUpdateBytes = 0;\nlet omittedOversizedLines = 0;\nlet omittedOversizedBytes = 0;\nfunction writeStdoutLine(line) {\n if (messageUpdatePattern.test(line)) {\n omittedMessageUpdates += 1;\n omittedMessageUpdateBytes += Buffer.byteLength(line, "utf8");\n return;\n }\n appendFileSync(eventPath, line);\n process.stdout.write(line);\n}\nfunction handleStdoutChunk(chunk) {\n let text = chunk.toString("utf8");\n while (text.length > 0) {\n if (discardingOversizedLine) {\n const newline = text.indexOf("\\n");\n omittedOversizedBytes += Buffer.byteLength(newline < 0 ? text : text.slice(0, newline + 1), "utf8");\n if (newline < 0) return;\n discardingOversizedLine = false;\n text = text.slice(newline + 1);\n continue;\n }\n const newline = text.indexOf("\\n");\n const segment = newline < 0 ? text : text.slice(0, newline + 1);\n stdoutBuffer += segment;\n text = newline < 0 ? "" : text.slice(newline + 1);\n if (stdoutBuffer.length > maxStdoutLogLineChars) {\n omittedOversizedLines += 1;\n omittedOversizedBytes += Buffer.byteLength(stdoutBuffer, "utf8");\n stdoutBuffer = "";\n discardingOversizedLine = newline < 0;\n continue;\n }\n if (newline >= 0) {\n writeStdoutLine(stdoutBuffer);\n stdoutBuffer = "";\n }\n }\n}\nfunction finishStdoutFilter() {\n if (!discardingOversizedLine && stdoutBuffer.length > 0) writeStdoutLine(stdoutBuffer);\n stdoutBuffer = "";\n if (omittedMessageUpdates > 0 || omittedOversizedLines > 0) {\n appendFileSync(eventPath, JSON.stringify({ type: "pi-subagent.stdout_filter", omitted: { messageUpdateEvents: omittedMessageUpdates, messageUpdateBytes: omittedMessageUpdateBytes, oversizedLines: omittedOversizedLines, oversizedBytes: omittedOversizedBytes }, reason: "cumulative message_update snapshots are omitted from durable stdout artifacts; final assistant text is stored in output.log" }) + "\\n");\n }\n}\nfunction writeMeta(meta) {\n if (settled) return;\n settled = true;\n finishStdoutFilter();\n writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\\n");\n}\nconst env = { ...process.env };\ndelete env.TMUX;\nconst child = spawn(argv[0], argv.slice(1), { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], env });\nchild.stdout?.on("data", handleStdoutChunk);\nchild.stderr?.on("data", (chunk) => { appendFileSync(stderrPath, chunk); process.stderr.write(chunk); });\nchild.on("error", () => { writeMeta({ status: "failed", failureKind: "spawn", exitCode: null, signal: null }); });\nchild.on("close", (exitCode, signal) => {\n const failureKind = exitCode === 0 ? null : "exit";\n writeMeta({ status: failureKind === null ? "completed" : "failed", failureKind, exitCode, signal });\n});\n`;
158
159
  }
159
160
 
160
- async function runTmuxProcess(
161
- options: RunTmuxProcessOptions,
162
- ): Promise<{
161
+ async function runTmuxProcess(options: RunTmuxProcessOptions): Promise<{
163
162
  result: TmuxRunResult | null;
164
163
  store: Awaited<ReturnType<typeof createAttemptArtifactStore>>;
165
164
  cwd: string;
@@ -439,11 +438,19 @@ export async function runTmuxModel(
439
438
  parsePiJsonLines(""),
440
439
  );
441
440
  await unlink(result.eventPath).catch(() => undefined);
442
- const contextLengthExceeded = detectContextLengthExceeded({
441
+ const rawContextLengthExceeded = detectContextLengthExceeded({
443
442
  stderrText,
444
443
  errors: parsed.errors,
445
444
  });
446
- const meta = resolvePiJsonOutcome(result.meta, parsed, contextLengthExceeded);
445
+ const contextLength = resolveContextLengthState(
446
+ parsed,
447
+ rawContextLengthExceeded,
448
+ );
449
+ const meta = resolvePiJsonOutcome(
450
+ result.meta,
451
+ parsed,
452
+ contextLength.contextLengthExceeded,
453
+ );
447
454
 
448
455
  const outputRef = await store.writeTextArtifact(
449
456
  "output",
@@ -464,7 +471,7 @@ export async function runTmuxModel(
464
471
  tmux: result.tmux,
465
472
  correlationId: options.correlationId,
466
473
  metadata: {
467
- ...resultMetadataFromParse(parsed, contextLengthExceeded, meta),
474
+ ...resultMetadataFromParse(parsed, contextLength, meta),
468
475
  ...sessionMetadata,
469
476
  ...(options.parentSessionId === undefined
470
477
  ? {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agwab/pi-workflow",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Workflow orchestration for Pi subagents.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -76,7 +76,7 @@
76
76
  "node": ">=22.19.0"
77
77
  },
78
78
  "dependencies": {
79
- "@agwab/pi-subagent": "^0.3.6",
79
+ "@agwab/pi-subagent": "^0.4.0",
80
80
  "pi-web-access": "^0.10.7",
81
81
  "typebox": "^1.1.39"
82
82
  },
package/src/compiler.ts CHANGED
@@ -3,6 +3,7 @@ import { dirname, resolve } from "node:path";
3
3
 
4
4
  import { loadAgentByName } from "./agents.js";
5
5
  import { DYNAMIC_OUTPUT_PROFILES } from "./dynamic-profiles.js";
6
+ import { compileRole } from "./roles.js";
6
7
  import {
7
8
  classifyToolCapability,
8
9
  effectiveToolClassification,
@@ -663,15 +664,19 @@ async function compileArtifactGraphPlan(
663
664
  return defaultAgent;
664
665
  };
665
666
  const roleEntries = Object.entries(spec.roles ?? {});
666
- const roles = roleEntries.map(([name, role]: [string, any]) => ({
667
- name,
668
- fromAgent: role.fromAgent,
669
- content: role.prompt ?? "",
670
- maxChars: role.maxChars ?? 8000,
671
- truncated: false,
672
- includedSections: [],
673
- excludedSections: [],
674
- }));
667
+ const roles = await Promise.all(
668
+ roleEntries.map(async ([name, role]: [string, any]) => {
669
+ const sourceAgent = role.fromAgent
670
+ ? await loadWorkflowAgent(
671
+ role.fromAgent,
672
+ options.cwd,
673
+ agentCache,
674
+ `$.roles.${name}.fromAgent`,
675
+ )
676
+ : undefined;
677
+ return compileRole(name, role, sourceAgent);
678
+ }),
679
+ );
675
680
  const roleText = roles.length
676
681
  ? `# Role Context\n\n${roles.map((r) => `## Role: ${r.name}\n${r.content}`).join("\n\n")}`
677
682
  : "";
@@ -283,17 +283,6 @@ export function validateDynamicDecision(
283
283
  };
284
284
  }
285
285
 
286
- export function assertValidDynamicDecision(
287
- value: unknown,
288
- context: DynamicDecisionValidationContext = {},
289
- ): NormalizedDynamicDecision {
290
- const result = validateDynamicDecision(value, context);
291
- if (!result.ok || !result.decision) {
292
- throw new Error(`invalid dynamic decision: ${result.errors.join("; ")}`);
293
- }
294
- return result.decision;
295
- }
296
-
297
286
  export function hashDynamicDecision(value: unknown): string {
298
287
  return createHash("sha256")
299
288
  .update(stableStringify(toJsonNormalizedValue(value)))
@@ -44,7 +44,3 @@ export function isTerminalDynamicOutputProfile(
44
44
  ): value is (typeof DYNAMIC_TERMINAL_OUTPUT_PROFILES)[number] {
45
45
  return typeof value === "string" && TERMINAL_OUTPUT_PROFILE_SET.has(value);
46
46
  }
47
-
48
- export function dynamicOutputProfileValues(): string[] {
49
- return [...DYNAMIC_OUTPUT_PROFILES];
50
- }
@@ -127,6 +127,135 @@ export function reconcileDynamicGeneratedRunRecords(
127
127
  return changed;
128
128
  }
129
129
 
130
+ export function reconcileForeachGeneratedRunRecords(
131
+ cwd: string,
132
+ run: WorkflowRunRecord,
133
+ compiledFlow: CompiledWorkflow,
134
+ ): boolean {
135
+ let changed = false;
136
+ const compiledSpecIds = new Set(
137
+ compiledFlow.tasks.map((task) => compiledTaskSpecId(task)),
138
+ );
139
+ const placeholderToGeneratedSpecIds = new Map<string, string[]>();
140
+
141
+ for (const compiledTask of compiledFlow.tasks) {
142
+ const specId = compiledTaskSpecId(compiledTask);
143
+ const placeholderSpecId = foreachGeneratedPlaceholderSpecId(
144
+ compiledTask,
145
+ compiledFlow,
146
+ specId,
147
+ );
148
+ if (!placeholderSpecId) continue;
149
+ if (
150
+ compiledTask.foreachGenerated?.placeholderSpecId !== placeholderSpecId
151
+ ) {
152
+ compiledTask.foreachGenerated = { placeholderSpecId };
153
+ changed = true;
154
+ }
155
+ const generated =
156
+ placeholderToGeneratedSpecIds.get(placeholderSpecId) ?? [];
157
+ generated.push(specId);
158
+ placeholderToGeneratedSpecIds.set(placeholderSpecId, generated);
159
+ }
160
+
161
+ if (placeholderToGeneratedSpecIds.size === 0) return changed;
162
+
163
+ const filteredRunTasks: WorkflowTaskRunRecord[] = [];
164
+ const seenGeneratedSpecIds = new Set<string>();
165
+ for (const task of run.tasks) {
166
+ const generatedSpecIds = placeholderToGeneratedSpecIds.get(task.specId);
167
+ let placeholderSpecId = foreachGeneratedPlaceholderSpecId(
168
+ task,
169
+ compiledFlow,
170
+ task.specId,
171
+ );
172
+ if (generatedSpecIds && !placeholderSpecId) {
173
+ if (generatedSpecIds.includes(task.specId)) {
174
+ placeholderSpecId = task.specId;
175
+ task.foreachGenerated = { placeholderSpecId };
176
+ changed = true;
177
+ } else {
178
+ changed = true;
179
+ continue;
180
+ }
181
+ }
182
+ if (placeholderSpecId && !compiledSpecIds.has(task.specId)) {
183
+ changed = true;
184
+ continue;
185
+ }
186
+ if (placeholderSpecId && seenGeneratedSpecIds.has(task.specId)) {
187
+ changed = true;
188
+ continue;
189
+ }
190
+ if (placeholderSpecId) {
191
+ seenGeneratedSpecIds.add(task.specId);
192
+ if (task.foreachGenerated?.placeholderSpecId !== placeholderSpecId) {
193
+ task.foreachGenerated = { placeholderSpecId };
194
+ changed = true;
195
+ }
196
+ }
197
+ filteredRunTasks.push(task);
198
+ }
199
+
200
+ const runTaskBySpecId = new Map<string, WorkflowTaskRunRecord>();
201
+ for (const task of filteredRunTasks) {
202
+ if (!runTaskBySpecId.has(task.specId))
203
+ runTaskBySpecId.set(task.specId, task);
204
+ }
205
+
206
+ const reordered: WorkflowTaskRunRecord[] = [];
207
+ const usedSpecIds = new Set<string>();
208
+ let nextIndex = nextTaskRecordIndex({ ...run, tasks: filteredRunTasks });
209
+ for (const compiledTask of compiledFlow.tasks) {
210
+ const specId = compiledTaskSpecId(compiledTask);
211
+ const existing = runTaskBySpecId.get(specId);
212
+ if (existing) {
213
+ const placeholderSpecId =
214
+ compiledTask.foreachGenerated?.placeholderSpecId;
215
+ if (
216
+ placeholderSpecId &&
217
+ existing.foreachGenerated?.placeholderSpecId !== placeholderSpecId
218
+ ) {
219
+ existing.foreachGenerated = { placeholderSpecId };
220
+ changed = true;
221
+ }
222
+ reordered.push(existing);
223
+ usedSpecIds.add(specId);
224
+ continue;
225
+ }
226
+ if (!compiledTask.foreachGenerated) continue;
227
+ const created = createTaskRunRecord(
228
+ cwd,
229
+ run.runId,
230
+ compiledTask,
231
+ nextIndex,
232
+ );
233
+ nextIndex += 1;
234
+ reordered.push(created);
235
+ usedSpecIds.add(specId);
236
+ changed = true;
237
+ }
238
+
239
+ for (const task of filteredRunTasks) {
240
+ if (!usedSpecIds.has(task.specId)) reordered.push(task);
241
+ }
242
+
243
+ if (!sameTaskRecordOrder(run.tasks, reordered)) changed = true;
244
+ for (const task of reordered) {
245
+ if (!task.dependsOn) continue;
246
+ const replaced = replaceForeachGeneratedDependencies(
247
+ task.dependsOn,
248
+ placeholderToGeneratedSpecIds,
249
+ );
250
+ if (!sameStringList(task.dependsOn, replaced)) {
251
+ task.dependsOn = replaced;
252
+ changed = true;
253
+ }
254
+ }
255
+ if (changed) run.tasks = reordered;
256
+ return changed;
257
+ }
258
+
130
259
  export function assertRunTaskPositionalAlignment(
131
260
  run: WorkflowRunRecord,
132
261
  compiledFlow: CompiledWorkflow,
@@ -209,6 +338,54 @@ export function compiledTaskSpecId(task: CompiledTask): string {
209
338
  return typeof specId === "string" && specId.trim() !== "" ? specId : task.id;
210
339
  }
211
340
 
341
+ function foreachGeneratedPlaceholderSpecId(
342
+ task: CompiledTask | WorkflowTaskRunRecord,
343
+ compiledFlow: CompiledWorkflow,
344
+ specId: string,
345
+ ): string | undefined {
346
+ const explicit = task.foreachGenerated?.placeholderSpecId;
347
+ if (typeof explicit === "string" && explicit.trim() !== "") return explicit;
348
+ if ((task as CompiledTask).foreach) return undefined;
349
+ if (task.kind !== "foreach" || !task.stageId) return undefined;
350
+ const placeholderSpecId = foreachPlaceholderSpecId(
351
+ compiledFlow,
352
+ task.stageId,
353
+ );
354
+ if (!placeholderSpecId || specId === placeholderSpecId) return undefined;
355
+ return placeholderSpecId;
356
+ }
357
+
358
+ function foreachPlaceholderSpecId(
359
+ compiledFlow: CompiledWorkflow,
360
+ stageId: string,
361
+ ): string | undefined {
362
+ const stage = ((compiledFlow as any).stages ?? []).find(
363
+ (candidate: any) => candidate?.id === stageId,
364
+ );
365
+ if (stage?.type !== "foreach") return undefined;
366
+ return `${stageId}.item`;
367
+ }
368
+
369
+ function replaceForeachGeneratedDependencies(
370
+ dependsOn: string[],
371
+ placeholderToGeneratedSpecIds: Map<string, string[]>,
372
+ ): string[] {
373
+ const replaced: string[] = [];
374
+ for (const dep of dependsOn) {
375
+ const generatedSpecIds = placeholderToGeneratedSpecIds.get(dep);
376
+ if (generatedSpecIds) replaced.push(...generatedSpecIds);
377
+ else replaced.push(dep);
378
+ }
379
+ return [...new Set(replaced)];
380
+ }
381
+
382
+ function sameStringList(left: string[], right: string[]): boolean {
383
+ return (
384
+ left.length === right.length &&
385
+ left.every((value, index) => value === right[index])
386
+ );
387
+ }
388
+
212
389
  function isLoopGeneratedCompiledTask(
213
390
  task: CompiledTask,
214
391
  loopIds: Set<string>,
@@ -330,7 +507,7 @@ export function buildForeachGeneratedTasks(
330
507
  const itemText = formatForeachItem(item);
331
508
  const instructions = template.foreach!.prompt.replace(
332
509
  /\$\{item\}/g,
333
- itemText,
510
+ escapeReplacementText(itemText),
334
511
  );
335
512
  const compiledPrompt = [
336
513
  template.foreach!.injectRuntimeTask && runtimeTask
@@ -352,6 +529,7 @@ export function buildForeachGeneratedTasks(
352
529
  compiledPrompt,
353
530
  dependsOn: [...(template.dependsOn ?? [])],
354
531
  foreach: undefined,
532
+ foreachGenerated: { placeholderSpecId: template.id },
355
533
  } as CompiledTask);
356
534
  }
357
535
  return { tasks };
@@ -382,6 +560,10 @@ function formatForeachItem(item: unknown): string {
382
560
  return typeof item === "string" ? item : JSON.stringify(item);
383
561
  }
384
562
 
563
+ function escapeReplacementText(value: string): string {
564
+ return value.replace(/\$/g, "$$$$");
565
+ }
566
+
385
567
  export function sourceStageIdsForFrom(from: unknown): string[] {
386
568
  if (Array.isArray(from))
387
569
  return from.filter((item): item is string => typeof item === "string");
@@ -453,7 +635,8 @@ export function markDagDependentsSkipped(
453
635
  return (
454
636
  status === "failed" ||
455
637
  status === "interrupted" ||
456
- status === "skipped"
638
+ status === "skipped" ||
639
+ status === "blocked"
457
640
  );
458
641
  });
459
642
  if (!failedDep) continue;