@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
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { discoverAgents, loadAgentByName, parseAgentMarkdown, } from "./agents.js";
2
- export { formatLogs, formatRunDetails, formatRunStatus, formatStatus, refreshRun, resumeRun, resumeSupervisors, runDynamicTask, runWorkflow, runWorkflowSpec, waitForRun, } from "./engine.js";
3
- export type { ResumeRunSummary } from "./engine.js";
2
+ export { formatLogs, formatRunDetails, formatRunStatus, formatStatus, refreshRun, resumeRun, resumeSupervisors, runDynamicTask, stopRun, runWorkflow, runWorkflowSpec, waitForRun, } from "./engine.js";
3
+ export type { ResumeRunSummary, StopRunSummary } from "./engine.js";
4
4
  export { listWorkflows, resolveWorkflowRef } from "./workflow-specs.js";
5
5
  export type { ResolvedWorkflowSpecRef, WorkflowSpecRecord, } from "./workflow-specs.js";
6
6
  export { compileRole, extractMarkdownSections } from "./roles.js";
@@ -11,4 +11,4 @@ export { WorkflowValidationError } from "./types.js";
11
11
  export { runDynamicDecisionLoop } from "./dynamic-decision-loop.js";
12
12
  export type { DynamicDecisionLoopControllerContext, DynamicDecisionLoopResult, DynamicDecisionLoopRunResult, RunDynamicDecisionLoopOptions, } from "./dynamic-decision-loop.js";
13
13
  export declare const WORKFLOW_COMMAND = "workflow";
14
- export declare const WORKFLOW_HELP = "pi-workflow\n\nUsage:\n /workflow [run-id]\n /workflow help\n /workflow validate <workflow-name-or-path>\n /workflow roles <workflow-name-or-path>\n /workflow agents\n /workflow list\n /workflow run [--model MODEL] [--thinking LEVEL] <workflow-name-or-path> \"<task>\" [--detach]\n /workflow dynamic [--model MODEL] [--thinking LEVEL] \"<task>\" [--detach]\n /workflow status [run-id]\n /workflow show <run-id-or-workflow-name>\n /workflow logs <run-id> [task-id] [lines]\n /workflow wait <run-id> [timeout-ms]\n /workflow resume <run-id>\n\n/workflow opens the read-only workflow board TUI.\n/workflow <run-id> opens the board focused on that run.\n/workflow dynamic starts a spec-less direct dynamic run: no workflow name,\nuser-selected spec, or generated workflow spec is required.\n\nWith --detach, a standalone supervisor process (pi-workflow supervise) keeps\nthe run progressing after this session exits.\n";
14
+ export declare const WORKFLOW_HELP = "pi-workflow\n\nUsage:\n /workflow [run-id]\n /workflow help\n /workflow validate <workflow-name-or-path>\n /workflow roles <workflow-name-or-path>\n /workflow agents\n /workflow list\n /workflow run [--model MODEL] [--thinking LEVEL] <workflow-name-or-path> \"<task>\" [--detach]\n /workflow dynamic [--model MODEL] [--thinking LEVEL] \"<task>\" [--detach]\n /workflow status [run-id]\n /workflow show <run-id-or-workflow-name>\n /workflow logs <run-id> [task-id] [lines]\n /workflow wait <run-id> [timeout-ms]\n /workflow resume <run-id>\n /workflow stop <run-id>\n\n/workflow opens the read-only workflow board TUI.\n/workflow <run-id> opens the board focused on that run.\n/workflow dynamic starts a spec-less direct dynamic run: no workflow name,\nuser-selected spec, or generated workflow spec is required.\n\nWith --detach, a standalone supervisor process (pi-workflow supervise) keeps\nthe run progressing after this session exits.\n";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { discoverAgents, loadAgentByName, parseAgentMarkdown, } from "./agents.js";
2
- export { formatLogs, formatRunDetails, formatRunStatus, formatStatus, refreshRun, resumeRun, resumeSupervisors, runDynamicTask, runWorkflow, runWorkflowSpec, waitForRun, } from "./engine.js";
2
+ export { formatLogs, formatRunDetails, formatRunStatus, formatStatus, refreshRun, resumeRun, resumeSupervisors, runDynamicTask, stopRun, runWorkflow, runWorkflowSpec, waitForRun, } from "./engine.js";
3
3
  export { listWorkflows, resolveWorkflowRef } from "./workflow-specs.js";
4
4
  export { compileRole, extractMarkdownSections } from "./roles.js";
5
5
  export { loadWorkflow, loadWorkflowSpec, parseWorkflow } from "./schema.js";
@@ -23,6 +23,7 @@ Usage:
23
23
  /workflow logs <run-id> [task-id] [lines]
24
24
  /workflow wait <run-id> [timeout-ms]
25
25
  /workflow resume <run-id>
26
+ /workflow stop <run-id>
26
27
 
27
28
  /workflow opens the read-only workflow board TUI.
28
29
  /workflow <run-id> opens the board focused on that run.
package/dist/store.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { cp, mkdir, open, readdir, readFile, realpath, rename, stat, unlink, utimes, writeFile, } from "node:fs/promises";
2
+ import { cp, link, mkdir, open, readdir, readFile, realpath, rename, stat, unlink, utimes, writeFile, } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep, } from "node:path";
4
4
  import { randomBytes } from "node:crypto";
5
5
  import { parseWorkflow } from "./schema.js";
6
6
  import { WORKFLOW_RUN_TYPE, } from "./types.js";
7
7
  const TERMINAL_INDEX_LIMIT = 50;
8
8
  const LEASE_STALE_MS = 30_000;
9
+ const LEASE_ABSOLUTE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
9
10
  const INDEX_LOCK_WAIT_MS = 5_000;
10
11
  const INDEX_LOCK_RETRY_MS = 50;
11
12
  const DEFAULT_INDEX_UPDATE_DEBOUNCE_MS = 500;
@@ -142,34 +143,74 @@ async function reclaimStaleLock(lockFile) {
142
143
  const snapshot = await readLockSnapshot(lockFile);
143
144
  if (!snapshot)
144
145
  return true;
145
- if (Date.now() - snapshot.mtimeMs <= LEASE_STALE_MS)
146
+ if (!isReclaimableLockSnapshot(snapshot))
146
147
  return false;
147
- if (snapshot.pid !== undefined && isProcessAlive(snapshot.pid))
148
+ const reclaimFile = `${lockFile}.reclaim-${process.pid}-${randomBytes(3).toString("hex")}`;
149
+ try {
150
+ await rename(lockFile, reclaimFile);
151
+ }
152
+ catch (error) {
153
+ if (error.code === "ENOENT")
154
+ return true;
148
155
  return false;
149
- const latest = await readLockSnapshot(lockFile);
150
- if (!latest)
156
+ }
157
+ const claimed = await readLockSnapshot(reclaimFile);
158
+ if (!claimed)
151
159
  return true;
152
- if (latest.ownerId !== snapshot.ownerId || latest.pid !== snapshot.pid)
160
+ if (!sameLockOwnerSnapshot(snapshot, claimed)) {
161
+ await restoreReclaimFile(reclaimFile, lockFile);
162
+ return false;
163
+ }
164
+ if (!isReclaimableLockSnapshot(claimed)) {
165
+ await restoreReclaimFile(reclaimFile, lockFile);
153
166
  return false;
154
- if (Date.now() - latest.mtimeMs <= LEASE_STALE_MS)
167
+ }
168
+ await unlink(reclaimFile).catch(() => undefined);
169
+ return true;
170
+ }
171
+ async function restoreReclaimFile(reclaimFile, lockFile) {
172
+ try {
173
+ await link(reclaimFile, lockFile);
174
+ }
175
+ catch (error) {
176
+ if (error.code !== "EEXIST")
177
+ throw error;
178
+ }
179
+ finally {
180
+ await unlink(reclaimFile).catch(() => undefined);
181
+ }
182
+ }
183
+ function isReclaimableLockSnapshot(snapshot) {
184
+ const now = Date.now();
185
+ const leaseStale = now - snapshot.mtimeMs > LEASE_STALE_MS;
186
+ const absoluteStale = now - (snapshot.createdAtMs ?? snapshot.mtimeMs) > LEASE_ABSOLUTE_STALE_MS;
187
+ if (!leaseStale && !absoluteStale)
155
188
  return false;
156
- if (latest.pid !== undefined && isProcessAlive(latest.pid))
189
+ if (snapshot.pid !== undefined &&
190
+ isProcessAlive(snapshot.pid) &&
191
+ !absoluteStale)
157
192
  return false;
158
- await unlink(lockFile).catch(() => undefined);
159
193
  return true;
160
194
  }
195
+ function sameLockOwnerSnapshot(left, right) {
196
+ return (left.ownerId === right.ownerId &&
197
+ left.pid === right.pid &&
198
+ left.createdAtMs === right.createdAtMs);
199
+ }
161
200
  async function readLockSnapshot(lockFile) {
162
201
  try {
163
202
  const [fileStat, text] = await Promise.all([
164
203
  stat(lockFile),
165
204
  readFile(lockFile, "utf8"),
166
205
  ]);
167
- const [ownerId = "", pidText] = text.split(/\r?\n/);
206
+ const [ownerId = "", pidText, createdAtText] = text.split(/\r?\n/);
168
207
  const pid = Number.parseInt(pidText ?? "", 10);
208
+ const createdAtMs = Date.parse(createdAtText ?? "");
169
209
  return {
170
210
  ownerId,
171
211
  pid: Number.isFinite(pid) ? pid : undefined,
172
212
  mtimeMs: fileStat.mtimeMs,
213
+ createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : undefined,
173
214
  };
174
215
  }
175
216
  catch (error) {
@@ -996,8 +1037,10 @@ export function deriveWorkflowStatus(summary) {
996
1037
  return "running";
997
1038
  if (summary.total > 0 && summary.completed === summary.total)
998
1039
  return "completed";
999
- if (summary.failed > 0 || summary.interrupted > 0)
1040
+ if (summary.failed > 0)
1000
1041
  return "failed";
1042
+ if (summary.interrupted > 0)
1043
+ return "interrupted";
1001
1044
  return "interrupted";
1002
1045
  }
1003
1046
  export function isTerminalWorkflowStatus(status) {
@@ -1176,6 +1219,7 @@ export function createTaskRunRecord(cwd, runId, task, index) {
1176
1219
  dependsOn: task.dependsOn,
1177
1220
  artifactGraph: taskArtifactGraph,
1178
1221
  dynamicGenerated: task.dynamicGenerated,
1222
+ foreachGenerated: task.foreachGenerated,
1179
1223
  files,
1180
1224
  lastMessage: blocked ? task.safety.permission.reason : undefined,
1181
1225
  };
@@ -17,7 +17,12 @@ const LEGACY_FETCH_CACHE_ENV = "PI_WORKFLOW_FETCH_CACHE";
17
17
  const DEFAULT_TRANSIENT_MODEL_FAILURE_RETRIES = 5;
18
18
  const DEFAULT_ARTIFACT_OUTPUT_RETRIES = 2;
19
19
  const MAX_CONCURRENT_LAUNCHES_ENV = "PI_WORKFLOW_MAX_CONCURRENT_LAUNCHES";
20
+ const PARENT_SUBAGENT_CWD_ENV = "PI_WORKFLOW_PARENT_SUBAGENT_CWD";
21
+ const PARENT_SUBAGENT_RUNS_DIR_ENV = "PI_WORKFLOW_PARENT_SUBAGENT_RUNS_DIR";
22
+ const PARENT_SUBAGENT_RUN_ID_ENV = "PI_WORKFLOW_PARENT_SUBAGENT_RUN_ID";
23
+ const PARENT_SUBAGENT_ATTEMPT_ID_ENV = "PI_WORKFLOW_PARENT_SUBAGENT_ATTEMPT_ID";
20
24
  const DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS = 3_000;
25
+ const STALE_LAUNCH_CLAIM_GRACE_MS = 30_000;
21
26
  const MIN_TRANSIENT_RETRY_JITTER_MS = 1_000;
22
27
  const MAX_TRANSIENT_RETRY_JITTER_MS = 5_000;
23
28
  const MODULE_PATH = fileURLToPath(import.meta.url);
@@ -39,6 +44,12 @@ function bundledNodeModulePath(packageName, ...parts) {
39
44
  ];
40
45
  return (candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]);
41
46
  }
47
+ const GENERIC_TASK_STATUS_DETAILS = new Set([
48
+ "completed",
49
+ "failed",
50
+ "interrupted",
51
+ "running",
52
+ ]);
42
53
  const subagentApiSpecifier = "@agwab/pi-subagent/api";
43
54
  let cachedSubagentApi;
44
55
  let injectedSubagentApi;
@@ -52,6 +63,67 @@ async function loadSubagentApi() {
52
63
  cachedSubagentApi ??= import(subagentApiSpecifier).then((mod) => mod);
53
64
  return cachedSubagentApi;
54
65
  }
66
+ function nonEmptyEnv(env, key) {
67
+ const value = env[key]?.trim();
68
+ return value ? value : undefined;
69
+ }
70
+ function parentSubagentRefFromEnv(env = process.env) {
71
+ const cwd = nonEmptyEnv(env, PARENT_SUBAGENT_CWD_ENV);
72
+ const runsDir = nonEmptyEnv(env, PARENT_SUBAGENT_RUNS_DIR_ENV);
73
+ const runId = nonEmptyEnv(env, PARENT_SUBAGENT_RUN_ID_ENV);
74
+ if (!cwd || !runsDir || !runId)
75
+ return undefined;
76
+ const attemptId = nonEmptyEnv(env, PARENT_SUBAGENT_ATTEMPT_ID_ENV);
77
+ return { cwd, runsDir, runId, ...(attemptId ? { attemptId } : {}) };
78
+ }
79
+ function terminalChildEventForTaskStatus(status) {
80
+ if (status === "completed")
81
+ return "completed";
82
+ if (status === "failed")
83
+ return "failed";
84
+ if (status === "interrupted")
85
+ return "cancelled";
86
+ return undefined;
87
+ }
88
+ async function recordParentSubagentChildEvent(options) {
89
+ const parent = parentSubagentRefFromEnv();
90
+ if (!parent)
91
+ return;
92
+ const api = await loadSubagentApi().catch(() => undefined);
93
+ if (!api?.recordSubagentChildEvent)
94
+ return;
95
+ await api
96
+ .recordSubagentChildEvent({
97
+ ...parent,
98
+ event: options.event,
99
+ childRunId: options.childRunId,
100
+ workflowRunId: options.run.runId,
101
+ childTaskId: options.task.taskId,
102
+ ...(options.failureKind === undefined
103
+ ? {}
104
+ : { failureKind: options.failureKind }),
105
+ ...(options.message === undefined ? {} : { message: options.message }),
106
+ })
107
+ .catch(() => undefined);
108
+ }
109
+ async function recordTerminalParentSubagentChildEvent(run, task, snapshot) {
110
+ const event = terminalChildEventForTaskStatus(task.status);
111
+ if (!event)
112
+ return;
113
+ const taskFailureKind = task.statusDetail && !GENERIC_TASK_STATUS_DETAILS.has(task.statusDetail)
114
+ ? task.statusDetail
115
+ : undefined;
116
+ await recordParentSubagentChildEvent({
117
+ event,
118
+ childRunId: snapshot.runId,
119
+ run,
120
+ task,
121
+ failureKind: event === "completed"
122
+ ? undefined
123
+ : (snapshot.failureKind ?? taskFailureKind ?? task.statusDetail),
124
+ message: task.lastMessage,
125
+ });
126
+ }
55
127
  let launchSlotReleaseDelayMs = DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS;
56
128
  let transientRetryJitterForTests;
57
129
  const launchWaitQueue = [];
@@ -87,8 +159,7 @@ function releaseLaunchSlotAfterDelay(delayMs, release) {
87
159
  release();
88
160
  return;
89
161
  }
90
- const timer = setTimeout(release, delayMs);
91
- timer.unref?.();
162
+ setTimeout(release, delayMs);
92
163
  }
93
164
  async function runWithLaunchSlot(action) {
94
165
  const release = await acquireLaunchSlot();
@@ -129,8 +200,6 @@ export function setSubagentLaunchControlsForTests(options) {
129
200
  }
130
201
  export async function cleanupSubagentRun(_cwd, run) {
131
202
  for (const task of run.tasks) {
132
- if (isTerminalTaskStatus(task.status))
133
- continue;
134
203
  const handle = getSubagentHandle(task);
135
204
  if (!handle)
136
205
  continue;
@@ -240,6 +309,13 @@ export async function launchSubagentTask(cwd, run, task, compiledTask) {
240
309
  task.statusDetail = "running";
241
310
  task.lastMessage = "launched via pi-subagent/headless";
242
311
  await writeRunRecord(cwd, run).catch(() => undefined);
312
+ await recordParentSubagentChildEvent({
313
+ event: "started",
314
+ childRunId: launched.runId,
315
+ run,
316
+ task,
317
+ message: task.lastMessage,
318
+ });
243
319
  return { kind: "launched" };
244
320
  }
245
321
  export async function refreshRunFromSubagentArtifacts(cwd, run) {
@@ -266,8 +342,13 @@ export async function refreshRunFromSubagentArtifacts(cwd, run) {
266
342
  }
267
343
  }
268
344
  if (!handle) {
345
+ if (isStaleLaunchClaim(task)) {
346
+ resetStaleLaunchClaim(task);
347
+ changed = true;
348
+ continue;
349
+ }
269
350
  if (isTaskTimedOut(task)) {
270
- markTaskTimedOut(task);
351
+ markSubagentTaskTimedOut(task);
271
352
  changed = true;
272
353
  }
273
354
  continue;
@@ -290,16 +371,8 @@ export async function refreshRunFromSubagentArtifacts(cwd, run) {
290
371
  .catch(() => null);
291
372
  if (snapshot === null) {
292
373
  if (isTaskTimedOut(task)) {
293
- await api
294
- .interruptSubagent({
295
- cwd: handle.cwd,
296
- runsDir: handle.runsDir,
297
- runId: handle.runId,
298
- attemptId: handle.attemptId,
299
- reason: "workflow timeout",
300
- })
301
- .catch(() => undefined);
302
- markTaskTimedOut(task);
374
+ await interruptTimedOutSubagent(api, handle);
375
+ markSubagentTaskTimedOut(task);
303
376
  changed = true;
304
377
  }
305
378
  continue;
@@ -312,16 +385,8 @@ export async function refreshRunFromSubagentArtifacts(cwd, run) {
312
385
  ? `pi-subagent heartbeat ${activeAttempt.heartbeatAt}`
313
386
  : "pi-subagent running";
314
387
  if (isTaskTimedOut(task)) {
315
- await api
316
- .interruptSubagent({
317
- cwd: handle.cwd,
318
- runsDir: handle.runsDir,
319
- runId: handle.runId,
320
- attemptId: handle.attemptId,
321
- reason: "workflow timeout",
322
- })
323
- .catch(() => undefined);
324
- markTaskTimedOut(task);
388
+ await interruptTimedOutSubagent(api, handle);
389
+ markSubagentTaskTimedOut(task);
325
390
  changed = true;
326
391
  }
327
392
  continue;
@@ -333,6 +398,40 @@ export async function refreshRunFromSubagentArtifacts(cwd, run) {
333
398
  await writeRunRecord(cwd, run);
334
399
  return run;
335
400
  }
401
+ async function interruptTimedOutSubagent(api, handle) {
402
+ await api
403
+ .interruptSubagent({
404
+ cwd: handle.cwd,
405
+ runsDir: handle.runsDir,
406
+ runId: handle.runId,
407
+ attemptId: handle.attemptId,
408
+ reason: "workflow timeout",
409
+ })
410
+ .catch(() => undefined);
411
+ }
412
+ function markSubagentTaskTimedOut(task) {
413
+ markTaskTimedOut(task);
414
+ task.backendHandle = undefined;
415
+ task.backendTaskId = task.taskId;
416
+ task.pid = undefined;
417
+ }
418
+ function isStaleLaunchClaim(task) {
419
+ if (task.statusDetail !== "launching" || !task.startedAt)
420
+ return false;
421
+ const startedAtMs = Date.parse(task.startedAt);
422
+ return (Number.isFinite(startedAtMs) &&
423
+ Date.now() - startedAtMs > STALE_LAUNCH_CLAIM_GRACE_MS);
424
+ }
425
+ function resetStaleLaunchClaim(task) {
426
+ task.status = "pending";
427
+ task.statusDetail = "pending";
428
+ task.startedAt = undefined;
429
+ task.backendHandle = undefined;
430
+ task.backendFiles = undefined;
431
+ task.backendTaskId = task.taskId;
432
+ task.pid = undefined;
433
+ task.lastMessage = "stale pi-subagent launch claim reset";
434
+ }
336
435
  async function materializeTerminalSubagentResult(cwd, run, task, snapshot) {
337
436
  const outputRef = findLog(snapshot, "output");
338
437
  const stderrRef = findLog(snapshot, "stderr");
@@ -386,7 +485,7 @@ async function materializeTerminalSubagentResult(cwd, run, task, snapshot) {
386
485
  const contextLengthExceeded = Boolean(subagentResult?.metadata?.contextLengthExceeded ??
387
486
  snapshot.metadata?.contextLengthExceeded);
388
487
  if (task.artifactGraph?.enabled && statusInfo.status === "completed") {
389
- return await materializeTerminalArtifactGraphResult(cwd, run, task, {
488
+ const changed = await materializeTerminalArtifactGraphResult(cwd, run, task, {
390
489
  outputFile,
391
490
  stderrFile,
392
491
  resultFile,
@@ -395,6 +494,8 @@ async function materializeTerminalSubagentResult(cwd, run, task, snapshot) {
395
494
  exitCode,
396
495
  subagentResult,
397
496
  });
497
+ await recordTerminalParentSubagentChildEvent(run, task, snapshot);
498
+ return changed;
398
499
  }
399
500
  if (shouldAttemptArtifactGraphSalvage({
400
501
  task,
@@ -406,7 +507,7 @@ async function materializeTerminalSubagentResult(cwd, run, task, snapshot) {
406
507
  subagentResult,
407
508
  snapshot,
408
509
  })) {
409
- return await materializeTerminalArtifactGraphResult(cwd, run, task, {
510
+ const changed = await materializeTerminalArtifactGraphResult(cwd, run, task, {
410
511
  outputFile,
411
512
  stderrFile,
412
513
  resultFile,
@@ -420,6 +521,8 @@ async function materializeTerminalSubagentResult(cwd, run, task, snapshot) {
420
521
  subagentFailureKind: snapshot.failureKind,
421
522
  },
422
523
  });
524
+ await recordTerminalParentSubagentChildEvent(run, task, snapshot);
525
+ return changed;
423
526
  }
424
527
  const workflowResult = {
425
528
  status: statusInfo.status,
@@ -447,10 +550,12 @@ async function materializeTerminalSubagentResult(cwd, run, task, snapshot) {
447
550
  };
448
551
  if (shouldRetryTransientModelFailure(statusInfo, workflowResult, outputBytes)) {
449
552
  await writeJson(transientFailureAttemptPath(resultFile, (task.launchRetry?.attempts ?? 0) + 1), workflowResult);
450
- return retryOrFailTransientSubagentFailure(task, {
553
+ const changed = retryOrFailTransientSubagentFailure(task, {
451
554
  reason: statusInfo.failureKind ?? "model",
452
555
  message: errorMessage ?? "pi-subagent run failed before producing output",
453
556
  });
557
+ await recordTerminalParentSubagentChildEvent(run, task, snapshot);
558
+ return changed;
454
559
  }
455
560
  await writeJson(resultFile, workflowResult);
456
561
  const completedAfterTimeout = resultCompletedAfterTimeout(task, completedAt);
@@ -464,6 +569,7 @@ async function materializeTerminalSubagentResult(cwd, run, task, snapshot) {
464
569
  delete task.backendHandle;
465
570
  delete task.backendFiles;
466
571
  }
572
+ await recordTerminalParentSubagentChildEvent(run, task, snapshot);
467
573
  return changed;
468
574
  }
469
575
  function artifactGraphRetrySession(run, task, subagentResult, attempt) {
@@ -1145,6 +1251,7 @@ async function recoverSubagentHandle(run, task) {
1145
1251
  const runsDir = subagentRunsDir(run, task);
1146
1252
  const absoluteRunsDir = resolve(task.cwd, runsDir);
1147
1253
  const expectedCorrelationId = `${run.runId}:${task.taskId}`;
1254
+ const claimStartedAtMs = timestampMs(task.startedAt);
1148
1255
  const entries = await readdir(absoluteRunsDir, { withFileTypes: true }).catch(() => []);
1149
1256
  const candidates = [];
1150
1257
  for (const entry of entries) {
@@ -1153,6 +1260,8 @@ async function recoverSubagentHandle(run, task) {
1153
1260
  const record = await readJsonLoose(join(absoluteRunsDir, entry.name, "run.json"));
1154
1261
  if (!record || record.correlationId !== expectedCorrelationId)
1155
1262
  continue;
1263
+ if (isPreClaimSubagentRecord(record, claimStartedAtMs))
1264
+ continue;
1156
1265
  const attemptId = record.activeAttemptId ??
1157
1266
  record.latestAttemptId ??
1158
1267
  record.attempts?.at(-1)?.attemptId;
@@ -1170,6 +1279,14 @@ async function recoverSubagentHandle(run, task) {
1170
1279
  candidates.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
1171
1280
  return candidates[0]?.handle;
1172
1281
  }
1282
+ function isPreClaimSubagentRecord(record, claimStartedAtMs) {
1283
+ if (claimStartedAtMs === undefined)
1284
+ return false;
1285
+ const recordStartedAtMs = timestampMs(record.startedAt) ??
1286
+ timestampMs(record.attempts?.[0]?.startedAt) ??
1287
+ timestampMs(record.updatedAt);
1288
+ return (recordStartedAtMs !== undefined && recordStartedAtMs < claimStartedAtMs);
1289
+ }
1173
1290
  function timestampMs(value) {
1174
1291
  if (value === undefined)
1175
1292
  return undefined;
@@ -1213,7 +1330,16 @@ function subagentRunsDir(run, task) {
1213
1330
  function subagentSessionId(run, task) {
1214
1331
  if (!task.artifactGraph?.enabled)
1215
1332
  return undefined;
1216
- return task.outputRetry?.sessionId ?? baseSubagentSessionId(run, task);
1333
+ const baseSessionId = baseSubagentSessionId(run, task);
1334
+ if (task.outputRetry?.sessionId)
1335
+ return task.outputRetry.sessionId;
1336
+ const launchAttempt = task.launchRetry?.attempts ?? 0;
1337
+ if (launchAttempt > 0)
1338
+ return `${baseSessionId}:launch-retry-${launchAttempt}`;
1339
+ const resumeAttempt = task.resumeEvents?.length ?? 0;
1340
+ if (resumeAttempt > 0)
1341
+ return `${baseSessionId}:resume-${resumeAttempt}`;
1342
+ return baseSessionId;
1217
1343
  }
1218
1344
  function baseSubagentSessionId(run, task) {
1219
1345
  return `pi-workflow.${run.runId}.${task.taskId}`.replace(/[^A-Za-z0-9._-]/g, "-");
package/dist/types.d.ts CHANGED
@@ -484,6 +484,9 @@ export interface CompiledTask {
484
484
  branchId?: string;
485
485
  outputProfile?: string;
486
486
  };
487
+ foreachGenerated?: {
488
+ placeholderSpecId: string;
489
+ };
487
490
  loopChild?: CompiledLoopChildTaskRef;
488
491
  loopPlaceholder?: {
489
492
  loopId: string;
@@ -562,6 +565,9 @@ export interface WorkflowTaskRunRecord {
562
565
  branchId?: string;
563
566
  outputProfile?: string;
564
567
  };
568
+ foreachGenerated?: {
569
+ placeholderSpecId: string;
570
+ };
565
571
  launchRetry?: {
566
572
  attempts: number;
567
573
  maxAttempts?: number;
@@ -193,9 +193,18 @@ export function readSimpleJsonPath(value, path) {
193
193
  const parts = path.slice(2).split(".").filter(Boolean);
194
194
  let current = value;
195
195
  for (const part of parts) {
196
- if (current === null || typeof current !== "object" || !(part in current))
196
+ if (!canReadJsonPathPart(current, part))
197
197
  return undefined;
198
198
  current = current[part];
199
199
  }
200
200
  return current;
201
201
  }
202
+ function canReadJsonPathPart(value, part) {
203
+ return (isSafeJsonPathPart(part) && isRecord(value) && Object.hasOwn(value, part));
204
+ }
205
+ function isSafeJsonPathPart(part) {
206
+ return part !== "__proto__" && part !== "prototype" && part !== "constructor";
207
+ }
208
+ function isRecord(value) {
209
+ return typeof value === "object" && value !== null;
210
+ }
@@ -953,12 +953,14 @@ function statusForSummary(summary) {
953
953
  return "running";
954
954
  if (summary.blocked > 0)
955
955
  return "blocked";
956
- if (summary.failed > 0 || summary.interrupted > 0)
956
+ if (summary.failed > 0)
957
957
  return "failed";
958
958
  if (summary.pending > 0)
959
959
  return "pending";
960
960
  if (summary.total > 0 && summary.completed === summary.total)
961
961
  return "completed";
962
+ if (summary.interrupted > 0)
963
+ return "interrupted";
962
964
  return "interrupted";
963
965
  }
964
966
  function taskElapsed(task) {