pi-subagents 0.49.0 → 0.50.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 (75) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/agents/oracle.md +7 -5
  3. package/agents/researcher.md +1 -1
  4. package/agents/reviewer.md +2 -2
  5. package/agents/scout.md +1 -1
  6. package/agents/worker.md +1 -1
  7. package/docs/agents.md +2 -0
  8. package/docs/configuration.md +47 -3
  9. package/docs/extension-api.md +36 -0
  10. package/docs/missions.md +1 -1
  11. package/docs/observability.md +16 -0
  12. package/docs/tool-reference.md +19 -2
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
  16. package/skills/pi-subagents/references/execution-controls.md +5 -4
  17. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  18. package/skills/pi-subagents/references/prompting-and-roles.md +31 -15
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +10 -0
  21. package/src/api/external-runs.ts +174 -84
  22. package/src/api/preflight.ts +12 -6
  23. package/src/extension/config.ts +33 -0
  24. package/src/extension/index.ts +48 -5
  25. package/src/extension/public-execution.ts +3 -1
  26. package/src/extension/rpc.ts +5 -1
  27. package/src/extension/schemas.ts +2 -1
  28. package/src/extension/tool-description.ts +4 -2
  29. package/src/inspectors/herdr/actions.ts +3 -7
  30. package/src/inspectors/herdr/project-panes.ts +2 -6
  31. package/src/inspectors/herdr/shell-command.ts +16 -0
  32. package/src/intercom/intercom-bridge.ts +2 -3
  33. package/src/intercom/native-supervisor-channel.ts +19 -42
  34. package/src/missions/goal-driver.ts +3 -1
  35. package/src/runs/background/active-run-index.ts +71 -1
  36. package/src/runs/background/async-execution.ts +49 -7
  37. package/src/runs/background/async-job-tracker.ts +1 -0
  38. package/src/runs/background/async-resume.ts +3 -5
  39. package/src/runs/background/async-status-snapshot.ts +277 -0
  40. package/src/runs/background/async-status.ts +8 -3
  41. package/src/runs/background/chain-root-attachment.ts +2 -2
  42. package/src/runs/background/completion-replay.ts +11 -1
  43. package/src/runs/background/fleet-view.ts +3 -1
  44. package/src/runs/background/result-files.ts +437 -0
  45. package/src/runs/background/result-watcher.ts +188 -41
  46. package/src/runs/background/retained-children.ts +69 -20
  47. package/src/runs/background/run-id-resolver.ts +30 -24
  48. package/src/runs/background/run-status.ts +7 -2
  49. package/src/runs/background/scheduled-runs.ts +54 -28
  50. package/src/runs/background/stale-run-reconciler.ts +27 -13
  51. package/src/runs/background/subagent-runner.ts +294 -32
  52. package/src/runs/background/subagent-wait.ts +2 -0
  53. package/src/runs/background/wait-completions.ts +5 -2
  54. package/src/runs/foreground/async-dismiss-action.ts +2 -1
  55. package/src/runs/foreground/chain-execution.ts +16 -0
  56. package/src/runs/foreground/execution.ts +219 -15
  57. package/src/runs/foreground/subagent-executor.ts +114 -28
  58. package/src/runs/shared/completion-guard.ts +96 -6
  59. package/src/runs/shared/external-cli-runner.ts +4 -0
  60. package/src/runs/shared/model-fallback.ts +16 -2
  61. package/src/runs/shared/nested-events.ts +66 -62
  62. package/src/runs/shared/orca-progress-tabs.ts +375 -0
  63. package/src/runs/shared/parallel-utils.ts +2 -0
  64. package/src/runs/shared/subagent-control.ts +15 -0
  65. package/src/runs/shared/subagent-prompt-runtime.ts +1 -9
  66. package/src/runs/shared/subagent-startup-retry.ts +12 -0
  67. package/src/runs/shared/tool-timeout.ts +93 -0
  68. package/src/shared/types.ts +29 -2
  69. package/src/slash/slash-commands.ts +34 -25
  70. package/src/slash/slash-live-state.ts +3 -0
  71. package/src/tui/fleet-status.ts +160 -45
  72. package/src/tui/fleet.ts +125 -15
  73. package/src/tui/render.ts +41 -10
  74. package/src/workflows/chat-progress.ts +6 -4
  75. package/src/workflows/scripted-workflow.ts +227 -70
@@ -22,12 +22,14 @@ import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-
22
22
  import { resolveWatchPath } from "../../shared/utils.ts";
23
23
  import { recordWaitCompletion } from "./wait-completions.ts";
24
24
  import { MISSION_BINDING_FILE, syncMissionFromAsyncCompletion } from "../../missions/lifecycle.ts";
25
+ import { missionObserverResultCandidateFiles, promotePendingResultFile, removeMissionObserverIndex, removeResultIndex, resultCandidateFilesForSession, resultPayloadPathForIndexedRun, resultPayloadPathForMissionObserverRun, resultPayloadPathForSessionRun, writeAsyncResultFile, writeResultIndexForData } from "./result-files.ts";
25
26
  import type { CompletionNotifier, CompletionNotification } from "./notify.ts";
26
27
 
27
28
  const WATCHER_RESTART_DELAY_MS = 3000;
28
29
  const POLL_INTERVAL_MS = 3000;
29
30
  const HEALTHY_SCAN_INTERVAL_MS = 60_000;
30
31
  const RETRY_DELAY_MS = 100;
32
+ const SLOW_RESULT_SCAN_MS = 500;
31
33
 
32
34
  type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "realpathSync" | "statSync" | "watch">;
33
35
 
@@ -50,6 +52,8 @@ type ResultWatcherDeps = {
50
52
  parseResult?: (raw: string) => ResultFileData;
51
53
  /** External grouped-result transport. Disable when native completion notifications own delivery. */
52
54
  deliverIntercomResults?: boolean;
55
+ /** Coalesces result-file events. Tests can lower this without changing retry timing. */
56
+ coalesceDelayMs?: number;
53
57
  };
54
58
 
55
59
  type ResultFileChild = {
@@ -79,6 +83,7 @@ type ResultFileData = CompletionNotification & {
79
83
  asyncDir?: string;
80
84
  intercomTarget?: string;
81
85
  parallelHandoff?: ParallelHandoffReference;
86
+ notificationDeliveredAt?: unknown;
82
87
  };
83
88
 
84
89
  type ResultFileIdentity = {
@@ -87,6 +92,12 @@ type ResultFileIdentity = {
87
92
  asyncDir?: string;
88
93
  };
89
94
 
95
+ interface ResultScanStats {
96
+ files: number;
97
+ scheduled: number;
98
+ startedAt: number;
99
+ }
100
+
90
101
  function jsonStringProperty(raw: string, property: string): string | undefined {
91
102
  const matches = raw.matchAll(new RegExp(`"${property}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`, "g"));
92
103
  let encoded: string | undefined;
@@ -134,6 +145,16 @@ function shouldPoll(error: unknown): boolean {
134
145
  return code === "EMFILE" || code === "ENOSPC";
135
146
  }
136
147
 
148
+ function hasDeliveredNotification(data: ResultFileData): boolean {
149
+ return typeof data.notificationDeliveredAt === "number" && Number.isFinite(data.notificationDeliveredAt);
150
+ }
151
+
152
+ function markDeliveredNotification(resultPath: string, data: ResultFileData, runId: string, now: number): ResultFileData {
153
+ const marked = { ...data, runId, notificationDeliveredAt: now };
154
+ writeAsyncResultFile(resultPath, marked);
155
+ return marked;
156
+ }
157
+
137
158
  /**
138
159
  * Watches persisted async results for the session currently owned by this
139
160
  * runtime. `stopResultWatcher()` revokes ownership before closing resources,
@@ -177,16 +198,55 @@ export function createResultWatcher(
177
198
  state.resultFileCoalescer.schedule(file, delayMs);
178
199
  };
179
200
 
180
- const inspectResult = (file: string): ResultFileIdentity | undefined => {
181
- const resultPath = path.join(resultsDir, file);
201
+ const publicResultPath = (file: string): string => path.join(resultsDir, file);
202
+ const publicResultFileExists = (file: string): boolean => {
203
+ try {
204
+ return fsApi.statSync(publicResultPath(file)).isFile();
205
+ } catch (error) {
206
+ if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${publicResultPath(file)}':`, error);
207
+ return false;
208
+ }
209
+ };
210
+ const resultPayloadPath = (file: string, observed?: ReadonlySet<string>): string | undefined => {
211
+ if (file !== path.basename(file) || !file.endsWith(".json")) return undefined;
212
+ const runId = file.replace(/\.json$/i, "");
213
+ const sessionResult = state.currentSessionId ? resultPayloadPathForSessionRun(resultsDir, state.currentSessionId, runId) : undefined;
214
+ if (sessionResult) return sessionResult;
215
+ const observerResult = resultPayloadPathForMissionObserverRun(resultsDir, runId);
216
+ if (observerResult) return observerResult;
217
+ if (observed?.has(runId)) {
218
+ const indexedResult = resultPayloadPathForIndexedRun(resultsDir, runId);
219
+ if (indexedResult) return indexedResult;
220
+ }
221
+ return publicResultFileExists(file) ? publicResultPath(file) : undefined;
222
+ };
223
+ const resultSignature = (file: string, observed?: ReadonlySet<string>): string | undefined => {
224
+ const resultPath = resultPayloadPath(file, observed);
225
+ if (!resultPath) {
226
+ identityCache.delete(file);
227
+ return undefined;
228
+ }
182
229
  try {
183
230
  const stat = fsApi.statSync(resultPath);
184
- const signature = `${stat.size}:${stat.mtimeMs}`;
231
+ if (!stat.isFile()) return undefined;
232
+ return `${resultPath}:${stat.size}:${stat.mtimeMs}`;
233
+ } catch (error) {
234
+ identityCache.delete(file);
235
+ if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${resultPath}':`, error);
236
+ return undefined;
237
+ }
238
+ };
239
+ const inspectResult = (file: string, knownSignature?: string, observed?: ReadonlySet<string>): { identity: ResultFileIdentity; signature: string } | undefined => {
240
+ const resultPath = resultPayloadPath(file, observed);
241
+ if (!resultPath) return undefined;
242
+ try {
243
+ const signature = knownSignature ?? resultSignature(file, observed);
244
+ if (!signature) return undefined;
185
245
  const cached = identityCache.get(file);
186
- if (cached?.signature === signature) return cached.identity;
246
+ if (cached?.signature === signature) return { identity: cached.identity, signature };
187
247
  const identity = resultFileIdentity(fsApi.readFileSync(resultPath, "utf-8"), file);
188
248
  identityCache.set(file, { signature, identity });
189
- return identity;
249
+ return { identity, signature };
190
250
  } catch (error) {
191
251
  identityCache.delete(file);
192
252
  if (!isNotFound(error)) console.error(`Failed to inspect subagent result file '${resultPath}':`, error);
@@ -203,9 +263,10 @@ export function createResultWatcher(
203
263
  }
204
264
  };
205
265
 
206
- const shouldProcessResult = (file: string, observed?: ReadonlySet<string>): boolean => {
207
- const identity = inspectResult(file);
208
- if (!identity) return false;
266
+ const shouldProcessResult = (file: string, observed?: ReadonlySet<string>, knownSignature?: string): boolean => {
267
+ const inspected = inspectResult(file, knownSignature, observed);
268
+ if (!inspected) return false;
269
+ const { identity } = inspected;
209
270
  // Missing identity stays on the normal parser path so malformed or legacy
210
271
  // files keep their existing diagnostics and compatibility behavior.
211
272
  if (!identity.sessionId) return true;
@@ -215,32 +276,80 @@ export function createResultWatcher(
215
276
  return Boolean(deps.observeCompletion && !deps.observedCompletionRunIds);
216
277
  };
217
278
 
279
+ const removeDeliveredResult = (file: string, sessionId: string, runId: string, toolCallId: string | undefined): boolean => {
280
+ try {
281
+ if (publicResultFileExists(file)) fsApi.unlinkSync(publicResultPath(file));
282
+ identityCache.delete(file);
283
+ removeResultIndex(resultsDir, sessionId, runId, toolCallId);
284
+ return true;
285
+ } catch (error) {
286
+ if (!isNotFound(error)) {
287
+ console.error(`Failed to remove delivered subagent result '${publicResultPath(file)}'; will retry:`, error);
288
+ return false;
289
+ }
290
+ return true;
291
+ }
292
+ };
218
293
  const handleResult = async (file: string, triggerTurn: boolean) => {
219
- const resultPath = path.join(resultsDir, file);
220
- if (processing.has(file) || !fsApi.existsSync(resultPath)) return;
221
- if (!shouldProcessResult(file)) return;
294
+ if (processing.has(file)) return;
295
+ let observed: ReadonlySet<string> | undefined;
296
+ if (!shouldProcessResult(file)) {
297
+ const runId = file === path.basename(file) && file.endsWith(".json") ? file.replace(/\.json$/i, "") : undefined;
298
+ observed = observedRunIds();
299
+ if (!runId || !observed.has(runId) || !shouldProcessResult(file, observed)) return;
300
+ }
222
301
  processing.add(file);
302
+ let resultPath = publicResultPath(file);
223
303
  try {
224
- const data = parseResult(fsApi.readFileSync(resultPath, "utf-8"));
304
+ const payloadPath = resultPayloadPath(file, observed);
305
+ if (!payloadPath) return;
306
+ resultPath = payloadPath;
307
+ let raw = fsApi.readFileSync(resultPath, "utf-8");
308
+ let identity = resultFileIdentity(raw, file);
309
+ if (identity.sessionId && identity.runId) {
310
+ const pendingState = promotePendingResultFile(resultsDir, identity.sessionId, identity.runId, file);
311
+ if (pendingState === "promoted") {
312
+ identityCache.delete(file);
313
+ resultPath = publicResultPath(file);
314
+ raw = fsApi.readFileSync(resultPath, "utf-8");
315
+ identity = resultFileIdentity(raw, file);
316
+ } else if (pendingState === "pending") {
317
+ const pendingPath = resultPayloadPathForSessionRun(resultsDir, identity.sessionId, identity.runId);
318
+ if (!pendingPath) {
319
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
320
+ return;
321
+ }
322
+ resultPath = pendingPath;
323
+ raw = fsApi.readFileSync(resultPath, "utf-8");
324
+ identity = resultFileIdentity(raw, file);
325
+ }
326
+ }
327
+ let data = parseResult(raw);
225
328
  if (typeof data.sessionId !== "string" || !data.sessionId) return;
329
+ const sessionId = data.sessionId;
226
330
  const runId = data.runId ?? data.id ?? file.replace(/\.json$/i, "");
331
+ const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : undefined;
332
+ let observerSucceeded = true;
227
333
  try {
228
334
  syncMissionFromAsyncCompletion({ ...data, runId });
229
335
  } catch (error) {
336
+ observerSucceeded = false;
230
337
  console.error(`Mission completion sync failed for '${resultPath}':`, error);
231
338
  }
232
339
  try {
233
340
  deps.observeCompletion?.({ ...data, runId });
234
341
  } catch (error) {
342
+ observerSucceeded = false;
235
343
  console.error(`Completion observer failed for '${resultPath}':`, error);
236
344
  }
345
+ if (observerSucceeded) removeMissionObserverIndex(resultsDir, runId);
237
346
  const epoch = deliveryEpoch;
238
- if (!ownsSession(data.sessionId, epoch)) return;
347
+ if (!ownsSession(sessionId, epoch)) return;
239
348
  // Recorded before dedupe and before the unlink below so subagent_wait can
240
349
  // use the in-memory record or its bounded durable replay after cleanup.
241
350
  recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
242
351
  resultsDir,
243
- sessionId: data.sessionId,
352
+ sessionId,
244
353
  });
245
354
  const hasExplicitNestedChildren = data.nestedChildren !== undefined;
246
355
  let nestedChildren = compactNestedResultChildren(sanitizeNestedResultChildren(data.nestedChildren, resultPath, "nestedChildren"));
@@ -255,20 +364,17 @@ export function createResultWatcher(
255
364
  }
256
365
 
257
366
  const completionKey = buildCompletionKey(data, `result:${file}`);
367
+ const alreadyDelivered = hasDeliveredNotification(data);
258
368
  const lastSeenAt = state.completionSeen.get(completionKey);
259
369
  if (lastSeenAt !== undefined && Date.now() - lastSeenAt > completionTtlMs) {
260
370
  state.completionSeen.delete(completionKey);
261
371
  } else if (lastSeenAt !== undefined) {
262
- if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
263
- try {
264
- fsApi.unlinkSync(resultPath);
265
- identityCache.delete(file);
266
- } catch (error) {
267
- if (!isNotFound(error)) {
268
- console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
269
- scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
270
- }
372
+ if (!observerSucceeded) {
373
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
374
+ return;
271
375
  }
376
+ if (!ownsSession(sessionId, epoch)) return;
377
+ if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
272
378
  return;
273
379
  }
274
380
 
@@ -316,6 +422,17 @@ export function createResultWatcher(
316
422
  };
317
423
  }), nestedChildren);
318
424
 
425
+ if (alreadyDelivered) {
426
+ markSeenWithTtl(state.completionSeen, completionKey, Date.now(), completionTtlMs);
427
+ if (!observerSucceeded) {
428
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
429
+ return;
430
+ }
431
+ if (!ownsSession(sessionId, epoch)) return;
432
+ if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
433
+ return;
434
+ }
435
+
319
436
  const intercomTarget = data.intercomTarget?.trim();
320
437
  let intercomDelivered = false;
321
438
  if (deliverIntercomResults && intercomTarget && triggerTurn) {
@@ -332,7 +449,7 @@ export function createResultWatcher(
332
449
  asyncDir: data.asyncDir,
333
450
  ...(data.parallelHandoff ? { parallelHandoff: data.parallelHandoff } : {}),
334
451
  }));
335
- if (!ownsSession(data.sessionId, epoch)) return;
452
+ if (!ownsSession(sessionId, epoch)) return;
336
453
  if (!intercomDelivered) console.error(`Subagent async grouped result intercom delivery was not acknowledged for '${resultPath}'.`);
337
454
  }
338
455
 
@@ -356,11 +473,19 @@ export function createResultWatcher(
356
473
  })) : [],
357
474
  } : {}),
358
475
  });
359
- if (!ownsSession(data.sessionId, epoch)) return;
476
+ if (!ownsSession(sessionId, epoch)) return;
360
477
  if (!accepted) {
361
478
  scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
362
479
  return;
363
480
  }
481
+ try {
482
+ data = markDeliveredNotification(publicResultPath(file), data, runId, Date.now());
483
+ identityCache.delete(file);
484
+ } catch (error) {
485
+ console.error(`Failed to mark subagent result notification delivered for '${resultPath}'; will retry:`, error);
486
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
487
+ return;
488
+ }
364
489
  markSeenWithTtl(state.completionSeen, completionKey, Date.now(), completionTtlMs);
365
490
  try {
366
491
  pi.events.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, {
@@ -385,16 +510,12 @@ export function createResultWatcher(
385
510
  } catch (error) {
386
511
  console.error(`Completion observer failed for '${resultPath}':`, error);
387
512
  }
388
- if (!ownsSession(data.sessionId, epoch) || !fsApi.existsSync(resultPath)) return;
389
- try {
390
- fsApi.unlinkSync(resultPath);
391
- identityCache.delete(file);
392
- } catch (error) {
393
- if (!isNotFound(error)) {
394
- console.error(`Failed to remove delivered subagent result '${resultPath}'; will retry:`, error);
395
- scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
396
- }
513
+ if (!observerSucceeded) {
514
+ scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
515
+ return;
397
516
  }
517
+ if (!ownsSession(sessionId, epoch)) return;
518
+ if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
398
519
  } catch (error) {
399
520
  if (!isNotFound(error)) console.error(`Failed to process subagent result file '${resultPath}':`, error);
400
521
  } finally {
@@ -406,18 +527,39 @@ export function createResultWatcher(
406
527
  const triggerTurn = pendingTriggerTurn.get(file) !== false;
407
528
  pendingTriggerTurn.delete(file);
408
529
  void handleResult(file, triggerTurn);
409
- }, 50);
530
+ }, deps.coalesceDelayMs ?? 50);
410
531
 
532
+ const logScanStats = (stats: ResultScanStats) => {
533
+ const elapsed = Date.now() - stats.startedAt;
534
+ if (elapsed < SLOW_RESULT_SCAN_MS) return;
535
+ console.error(`Subagent result scan inspected ${stats.files} indexed result file(s), scheduled ${stats.scheduled} in ${elapsed}ms (${resultsDir}).`);
536
+ };
537
+ const indexedResultCandidates = (observed: ReadonlySet<string>): string[] => {
538
+ const files = new Set<string>();
539
+ if (state.currentSessionId) {
540
+ for (const file of resultCandidateFilesForSession(resultsDir, state.currentSessionId)) files.add(file);
541
+ }
542
+ for (const runId of state.asyncJobs.keys()) files.add(`${runId}.json`);
543
+ for (const file of missionObserverResultCandidateFiles(resultsDir)) files.add(file);
544
+ for (const runId of observed) files.add(`${runId}.json`);
545
+ return [...files];
546
+ };
411
547
  const primeExistingResults = (options: { triggerTurn?: boolean } = {}) => {
412
548
  try {
413
549
  const triggerTurn = options.triggerTurn !== false;
414
- fsApi.readdirSync(resultsDir)
415
- .filter((f) => f.endsWith(".json"))
416
- .forEach((file) => {
417
- if (shouldProcessResult(file)) scheduleResult(file, triggerTurn);
418
- });
550
+ const stats: ResultScanStats = { files: 0, scheduled: 0, startedAt: Date.now() };
551
+ const observed = observedRunIds();
552
+ for (const file of indexedResultCandidates(observed)) {
553
+ stats.files += 1;
554
+ const signature = resultSignature(file, observed);
555
+ if (!signature) continue;
556
+ if (!shouldProcessResult(file, observed, signature)) continue;
557
+ stats.scheduled += 1;
558
+ scheduleResult(file, triggerTurn);
559
+ }
560
+ logScanStats(stats);
419
561
  } catch (error) {
420
- if (!isNotFound(error)) console.error(`Failed to scan subagent result directory '${resultsDir}':`, error);
562
+ if (!isNotFound(error)) console.error(`Failed to scan subagent result index in '${resultsDir}':`, error);
421
563
  }
422
564
  };
423
565
 
@@ -474,6 +616,11 @@ export function createResultWatcher(
474
616
  const fileName = file.toString();
475
617
  if (fileName.endsWith(".json")) {
476
618
  identityCache.delete(fileName);
619
+ try {
620
+ writeResultIndexForData(path.join(resultsDir, fileName), JSON.parse(fsApi.readFileSync(path.join(resultsDir, fileName), "utf-8")) as Record<string, unknown>);
621
+ } catch {
622
+ // The writer may still be renaming the file; handleResult will retry from the normal result path.
623
+ }
477
624
  scheduleResult(fileName, true);
478
625
  }
479
626
  });
@@ -1,32 +1,68 @@
1
1
  import * as fs from "node:fs";
2
- import { listAsyncRuns } from "./async-status.ts";
2
+ import { listAsyncRuns, type AsyncRunSummary } from "./async-status.ts";
3
+ import { readAsyncRecoveryDescriptor } from "./async-resume.ts";
3
4
  import type { TokenUsage } from "../../shared/types.ts";
4
5
 
5
6
  const MAX_RETAINED_CHILDREN = 10;
6
7
  const MAX_TASK_SUMMARY_LENGTH = 120;
7
8
 
9
+ type RetainedChildState = "complete" | "failed" | "paused" | "stopped";
10
+ type RetainedChildResumability =
11
+ | { state: "resumable"; sessionPath: string }
12
+ | { state: "not-resumable"; reason: string };
13
+
8
14
  export interface RetainedChild {
9
15
  runId: string;
10
16
  parentRunId?: string;
11
17
  workflowKey?: string;
12
- state: "complete" | "failed" | "paused";
18
+ state: RetainedChildState;
13
19
  agent: string;
14
20
  taskSummary: string;
15
21
  completedAt: number;
16
- sessionPath: string;
22
+ resumability: RetainedChildResumability;
23
+ sessionPath?: string;
17
24
  tokenTotals?: TokenUsage;
18
25
  }
19
26
 
20
- function retainedSessionFile(sessionFile: string | undefined): sessionFile is string {
21
- if (!sessionFile?.endsWith(".jsonl")) return false;
27
+ function isRetainedChildState(state: AsyncRunSummary["state"]): state is RetainedChildState {
28
+ return state === "complete" || state === "failed" || state === "paused" || state === "stopped";
29
+ }
30
+
31
+ function isTerminalStepStatus(status: AsyncRunSummary["steps"][number]["status"]): boolean {
32
+ return status === "complete" || status === "completed" || status === "failed" || status === "paused" || status === "stopped";
33
+ }
34
+
35
+ function retainedSessionFile(sessionFile: string | undefined): RetainedChildResumability {
36
+ if (!sessionFile) return { state: "not-resumable", reason: "no persisted session file" };
37
+ if (!sessionFile.endsWith(".jsonl")) return { state: "not-resumable", reason: "persisted session file is not a .jsonl file" };
22
38
  try {
23
39
  const stat = fs.lstatSync(sessionFile);
24
- return stat.isFile() && !stat.isSymbolicLink();
25
- } catch {
26
- return false;
40
+ if (!stat.isFile() || stat.isSymbolicLink()) return { state: "not-resumable", reason: "persisted session file is not a regular file" };
41
+ return { state: "resumable", sessionPath: sessionFile };
42
+ } catch (error) {
43
+ if (typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
44
+ return { state: "not-resumable", reason: `persisted session file is missing: ${sessionFile}` };
45
+ }
46
+ return { state: "not-resumable", reason: `persisted session file could not be inspected: ${error instanceof Error ? error.message : String(error)}` };
27
47
  }
28
48
  }
29
49
 
50
+ function childResumability(run: AsyncRunSummary, step: AsyncRunSummary["steps"][number]): RetainedChildResumability {
51
+ if (run.state === "stopped" || step.status === "stopped") return { state: "not-resumable", reason: "stopped run" };
52
+ if (step.runner?.type === "external-cli") return { state: "not-resumable", reason: "external CLI runner" };
53
+ const session = retainedSessionFile(step.sessionFile ?? run.sessionFile);
54
+ if (session.state === "not-resumable") return session;
55
+ try {
56
+ const recoveryDescriptor = readAsyncRecoveryDescriptor(run.asyncDir);
57
+ if (!recoveryDescriptor) return { state: "not-resumable", reason: "missing recovery descriptor" };
58
+ if (recoveryDescriptor.sourceRunId !== run.id) return { state: "not-resumable", reason: `recovery descriptor belongs to run ${recoveryDescriptor.sourceRunId}` };
59
+ if (recoveryDescriptor.agent !== step.agent) return { state: "not-resumable", reason: `recovery descriptor belongs to agent ${recoveryDescriptor.agent}` };
60
+ } catch (error) {
61
+ return { state: "not-resumable", reason: `invalid recovery descriptor: ${error instanceof Error ? error.message : String(error)}` };
62
+ }
63
+ return session;
64
+ }
65
+
30
66
  function boundedTaskSummary(value: string | undefined): string {
31
67
  const normalized = value?.replace(/\s+/g, " ").trim() ?? "";
32
68
  return normalized.length > MAX_TASK_SUMMARY_LENGTH
@@ -35,15 +71,15 @@ function boundedTaskSummary(value: string | undefined): string {
35
71
  }
36
72
 
37
73
  export function listRetainedChildren(asyncDirRoot: string, sessionId: string): RetainedChild[] {
38
- return listAsyncRuns(asyncDirRoot, { sessionId, states: ["complete", "failed", "paused"], reconcile: false })
74
+ const children = listAsyncRuns(asyncDirRoot, { sessionId, states: ["complete", "failed", "paused", "stopped"], reconcile: false })
39
75
  .flatMap((run) => {
40
76
  if (!run.parentWorkflowRunId || run.steps.length !== 1) return [];
41
- if (run.state !== "complete" && run.state !== "failed" && run.state !== "paused") return [];
77
+ if (!isRetainedChildState(run.state)) return [];
42
78
  const step = run.steps[0]!;
43
- const terminalStep = step.status === "complete" || step.status === "completed" || step.status === "failed" || step.status === "paused";
44
- if (!terminalStep || !retainedSessionFile(step.sessionFile ?? run.sessionFile)) return [];
79
+ if (!isTerminalStepStatus(step.status)) return [];
45
80
  const completedAt = run.endedAt ?? run.lastUpdate;
46
81
  if (completedAt === undefined) return [];
82
+ const resumability = childResumability(run, step);
47
83
  return [{
48
84
  runId: run.id,
49
85
  ...(run.parentWorkflowRunId ? { parentRunId: run.parentWorkflowRunId } : {}),
@@ -52,25 +88,38 @@ export function listRetainedChildren(asyncDirRoot: string, sessionId: string): R
52
88
  agent: step.agent,
53
89
  taskSummary: boundedTaskSummary(step.description),
54
90
  completedAt,
55
- sessionPath: step.sessionFile ?? run.sessionFile!,
91
+ resumability,
92
+ ...(resumability.state === "resumable" ? { sessionPath: resumability.sessionPath } : {}),
56
93
  ...(step.tokens ?? run.totalTokens ? { tokenTotals: step.tokens ?? run.totalTokens } : {}),
57
94
  }];
58
95
  })
59
- .sort((left, right) => right.completedAt - left.completedAt)
60
- .slice(0, MAX_RETAINED_CHILDREN);
96
+ .sort((left, right) => right.completedAt - left.completedAt);
97
+ return children;
61
98
  }
62
99
 
63
100
  export function formatRetainedChildren(children: RetainedChild[]): string {
64
- if (children.length === 0) return "No recoverable retained children in the active parent session.";
101
+ if (children.length === 0) return "No retained workflow children in the active parent session. If a retained-writer challenge is required, launch a same-role fallback challenge and label it as fallback.";
102
+ const retained = children.slice(0, MAX_RETAINED_CHILDREN);
103
+ if (!retained.some((child) => child.resumability.state === "resumable")) {
104
+ const resumable = children.slice(MAX_RETAINED_CHILDREN).find((child) => child.resumability.state === "resumable");
105
+ if (resumable && retained.length === MAX_RETAINED_CHILDREN) retained[MAX_RETAINED_CHILDREN - 1] = resumable;
106
+ }
107
+ const hasResumableChild = retained.some((child) => child.resumability.state === "resumable");
65
108
  return [
66
- `Recoverable retained children (newest first, last ${MAX_RETAINED_CHILDREN}):`,
67
- ...children.flatMap((child) => [
109
+ `Retained workflow children (up to ${MAX_RETAINED_CHILDREN}; newest first, with a resumable child retained when available):`,
110
+ ...retained.flatMap((child) => [
68
111
  `- ${child.runId} | ${child.agent} | ${child.state} | ${new Date(child.completedAt).toISOString()}`,
69
112
  ...(child.parentRunId ? [` workflow: ${child.parentRunId}${child.workflowKey ? ` (${child.workflowKey})` : ""}`] : []),
70
113
  ` task: ${child.taskSummary || "(no task summary)"}`,
71
- ` session: ${child.sessionPath}`,
72
- ` resume: subagent({ action: "resume", id: "${child.runId}", message: "..." })`,
114
+ child.resumability.state === "resumable"
115
+ ? " resumability: resumable"
116
+ : ` resumability: not resumable (${child.resumability.reason})`,
117
+ ...(child.resumability.state === "resumable" ? [
118
+ ` session: ${child.resumability.sessionPath}`,
119
+ ` resume: subagent({ action: "resume", id: "${child.runId}", message: "..." })`,
120
+ ] : []),
73
121
  ...(child.tokenTotals ? [` tokens: input ${child.tokenTotals.input}, output ${child.tokenTotals.output}, total ${child.tokenTotals.total}`] : []),
74
122
  ]),
123
+ ...(hasResumableChild ? [] : ["No resumable retained child is listed. Launch a same-role fallback challenge and label it as fallback."]),
75
124
  ].join("\n");
76
125
  }
@@ -3,6 +3,8 @@ import * as path from "node:path";
3
3
  import { DIRS, type SubagentState } from "../../shared/types.ts";
4
4
  import { readStatus } from "../../shared/utils.ts";
5
5
  import { findAsyncRunPrefixMatches, type AsyncRunLocation } from "./async-resume.ts";
6
+ import { resultFilePath, resultFilesForToolCall } from "./result-files.ts";
7
+ import { readActiveRunToolCallIndex } from "./active-run-index.ts";
6
8
  import { assertSafeNestedId, findNestedRunMatchesById, type NestedRoute, type NestedRunMatch, type NestedRunResolutionScope } from "../shared/nested-events.ts";
7
9
 
8
10
  export type ResolvedSubagentRunId =
@@ -19,7 +21,7 @@ export interface ResolveSubagentRunIdDeps {
19
21
 
20
22
  function exactAsyncLocation(id: string, asyncDirRoot: string, resultsDir: string): AsyncRunLocation | undefined {
21
23
  const asyncDir = path.join(asyncDirRoot, id);
22
- const resultPath = path.join(resultsDir, `${id}.json`);
24
+ const resultPath = resultFilePath(resultsDir, id);
23
25
  if (!fs.existsSync(asyncDir) && !fs.existsSync(resultPath)) return undefined;
24
26
  return {
25
27
  asyncDir: fs.existsSync(asyncDir) ? asyncDir : null,
@@ -52,39 +54,28 @@ function readWorkflowResultIdentity(resultPath: string): WorkflowResultIdentity
52
54
  };
53
55
  }
54
56
 
55
- function directoryEntries(root: string): string[] {
56
- try {
57
- return fs.readdirSync(root);
58
- } catch (error) {
59
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
60
- throw error;
61
- }
62
- }
63
-
64
57
  function resultPathFor(resultsDir: string, runId: string): string | null {
65
- const resultPath = path.join(resultsDir, `${runId}.json`);
58
+ const resultPath = resultFilePath(resultsDir, runId);
66
59
  return fs.existsSync(resultPath) ? resultPath : null;
67
60
  }
68
61
 
69
- function toolCallIdMatches(value: string | undefined, query: string, options: { prefix?: boolean }): boolean {
70
- if (value === undefined) return false;
71
- return options.prefix === true ? value.startsWith(query) : value === query;
62
+ function toolCallIdMatches(value: string | undefined, query: string): boolean {
63
+ return value === query;
72
64
  }
73
65
 
74
- function toolCallIdAsyncLocations(toolCallId: string, asyncDirRoot: string, resultsDir: string, options: { prefix?: boolean } = {}): AsyncRunMatch[] {
66
+ function indexedToolCallIdAsyncLocations(toolCallId: string, asyncDirRoot: string, resultsDir: string): AsyncRunMatch[] {
75
67
  const byId = new Map<string, AsyncRunLocation>();
76
- for (const entry of directoryEntries(asyncDirRoot)) {
68
+ for (const entry of readActiveRunToolCallIndex(asyncDirRoot, toolCallId)) {
77
69
  const asyncDir = path.join(asyncDirRoot, entry);
78
70
  const status = readStatus(asyncDir);
79
- if (!status || !toolCallIdMatches(status.toolCallId, toolCallId, options)) continue;
71
+ if (!status || !toolCallIdMatches(status.toolCallId, toolCallId)) continue;
80
72
  const runId = status.runId || entry;
81
73
  byId.set(runId, { asyncDir, resultPath: resultPathFor(resultsDir, runId), resolvedId: runId });
82
74
  }
83
- for (const entry of directoryEntries(resultsDir)) {
84
- if (!entry.endsWith(".json")) continue;
75
+ for (const entry of resultFilesForToolCall(resultsDir, toolCallId)) {
85
76
  const resultPath = path.join(resultsDir, entry);
86
77
  const identity = readWorkflowResultIdentity(resultPath);
87
- if (!identity || !toolCallIdMatches(identity.toolCallId, toolCallId, options)) continue;
78
+ if (!identity || !toolCallIdMatches(identity.toolCallId, toolCallId)) continue;
88
79
  const runId = identity.runId ?? identity.id ?? entry.slice(0, -".json".length);
89
80
  const asyncDir = path.join(asyncDirRoot, runId);
90
81
  byId.set(runId, { asyncDir: fs.existsSync(asyncDir) ? asyncDir : null, resultPath, resolvedId: runId });
@@ -109,6 +100,22 @@ function hasExactForegroundId(state: SubagentState | undefined, id: string): boo
109
100
  return Boolean(remembered && state.currentSessionId && remembered.sessionId === state.currentSessionId);
110
101
  }
111
102
 
103
+ function exactLiveAsyncToolCallMatch(state: SubagentState | undefined, toolCallId: string, asyncDirRoot: string, resultsDir: string): AsyncRunMatch | undefined {
104
+ if (!state?.asyncJobs) return undefined;
105
+ const matches = [...state.asyncJobs.values()].filter((job) => job.toolCallId === toolCallId);
106
+ if (matches.length > 1) throw new Error(`Subagent tool-call id '${toolCallId}' is ambiguous across async runs. Use the returned asyncId instead.`);
107
+ const match = matches[0];
108
+ if (!match) return undefined;
109
+ return {
110
+ id: match.asyncId,
111
+ location: {
112
+ asyncDir: fs.existsSync(match.asyncDir) ? match.asyncDir : path.join(asyncDirRoot, match.asyncId),
113
+ resultPath: resultPathFor(resultsDir, match.asyncId),
114
+ resolvedId: match.asyncId,
115
+ },
116
+ };
117
+ }
118
+
112
119
  function nestedScopeFromState(state: SubagentState | undefined): NestedRunResolutionScope | undefined {
113
120
  if (!state) return undefined;
114
121
  const routes: NestedRoute[] = [];
@@ -138,7 +145,9 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
138
145
  if (hasExactForegroundId(deps.state, id)) return { kind: "foreground", id };
139
146
  const exactAsync = exactAsyncLocation(id, asyncDirRoot, resultsDir);
140
147
  if (exactAsync) return { kind: "async", id, location: exactAsync };
141
- const exactToolCallIdMatches = toolCallIdAsyncLocations(id, asyncDirRoot, resultsDir);
148
+ const exactLiveToolCallMatch = exactLiveAsyncToolCallMatch(deps.state, id, asyncDirRoot, resultsDir);
149
+ if (exactLiveToolCallMatch) return { kind: "async", id: exactLiveToolCallMatch.id, location: exactLiveToolCallMatch.location };
150
+ const exactToolCallIdMatches = indexedToolCallIdAsyncLocations(id, asyncDirRoot, resultsDir);
142
151
  if (exactToolCallIdMatches.length > 1) throw new Error(`Subagent tool-call id '${id}' is ambiguous across async runs. Use the returned asyncId instead.`);
143
152
  if (exactToolCallIdMatches[0]) return { kind: "async", id: exactToolCallIdMatches[0].id, location: exactToolCallIdMatches[0].location };
144
153
  const exactNested = findNestedRunMatchesById(id, nestedScope ? { scope: nestedScope } : {});
@@ -152,9 +161,6 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
152
161
  for (const match of asyncPrefixMatches(id, asyncDirRoot, resultsDir)) {
153
162
  matches.push({ kind: "async", id: match.id, location: match.location });
154
163
  }
155
- for (const match of toolCallIdAsyncLocations(id, asyncDirRoot, resultsDir, { prefix: true })) {
156
- matches.push({ kind: "async", id: match.id, location: match.location });
157
- }
158
164
  for (const match of findNestedRunMatchesById(id, nestedScope ? { prefix: true, scope: nestedScope } : { prefix: true })) {
159
165
  matches.push({ kind: "nested", id: match.run.id, match });
160
166
  }
@@ -113,15 +113,20 @@ function formatCheckpointGuidance(runId: string | undefined, checkpoint: AsyncSt
113
113
  return `Checkpoint: ${checkpoint.name}${checkpoint.message ? ` — ${checkpoint.message}` : ""}\nApprove: subagent({ action: "approve-checkpoint", id: "${runId}" })\nReject: subagent({ action: "reject-checkpoint", id: "${runId}" })`;
114
114
  }
115
115
 
116
- function formatResumeGuidance(runId: string | undefined, children: Array<{ agent?: unknown; sessionFile?: unknown; runId?: unknown; workflowKey?: unknown }>, fallbackSessionFile?: unknown, options: { stopped?: boolean } = {}): string {
116
+ function formatResumeGuidance(runId: string | undefined, children: Array<{ agent?: unknown; sessionFile?: unknown; runId?: unknown; workflowKey?: unknown; status?: unknown; activityState?: unknown }>, fallbackSessionFile?: unknown, options: { stopped?: boolean } = {}): string {
117
117
  if (options.stopped) return "Resume: unavailable; stopped runs are not resumable. Start a new run instead.";
118
118
  const knownChildren = children
119
119
  .map((child, index) => ({ child, index }))
120
120
  .filter(({ child }) => typeof child.agent === "string");
121
121
  if (!runId || knownChildren.length === 0) return "Resume: unavailable; no child session file was persisted.";
122
122
  const workflowChildren = knownChildren.filter(({ child }) => typeof child.runId === "string" && child.runId.trim() && hasExistingSessionFile(child.sessionFile));
123
+ const supervisorDetachedWorkflowChildren = workflowChildren.filter(({ child }) => child.status === "paused" && child.activityState === "needs_attention");
124
+ const resumableWorkflowChildren = workflowChildren.filter(({ child }) => !(child.status === "paused" && child.activityState === "needs_attention"));
123
125
  if (workflowChildren.length > 0) {
124
- return workflowChildren.map(({ child }) => `Revive workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: subagent({ action: "resume", id: "${child.runId}", message: "..." })`).join("\n");
126
+ return [
127
+ ...supervisorDetachedWorkflowChildren.map(({ child }) => `Recovery workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: reply to the supervisor request first, then wait with subagent_wait({ id: "${child.runId}" }). Use subagent({ action: "status", id: "${child.runId}" }) to recover the result; do not resume or launch a replacement while it remains detached.`),
128
+ ...resumableWorkflowChildren.map(({ child }) => `Revive workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: subagent({ action: "resume", id: "${child.runId}", message: "..." })`),
129
+ ].join("\n");
125
130
  }
126
131
  const singleSessionFile = knownChildren[0]?.child.sessionFile ?? fallbackSessionFile;
127
132
  if (children.length === 1 && knownChildren.length === 1 && hasExistingSessionFile(singleSessionFile)) {