pi-subagents 0.24.4 → 0.27.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 (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +145 -27
  3. package/package.json +1 -1
  4. package/prompts/parallel-context-build.md +3 -1
  5. package/prompts/parallel-handoff-plan.md +3 -1
  6. package/prompts/review-loop.md +1 -1
  7. package/skills/pi-subagents/SKILL.md +71 -20
  8. package/src/agents/agent-management.ts +57 -15
  9. package/src/agents/agent-serializer.ts +3 -2
  10. package/src/agents/agents.ts +47 -16
  11. package/src/agents/chain-serializer.ts +120 -0
  12. package/src/extension/fanout-child.ts +171 -0
  13. package/src/extension/index.ts +7 -2
  14. package/src/extension/schemas.ts +138 -5
  15. package/src/intercom/result-intercom.ts +108 -0
  16. package/src/runs/background/async-execution.ts +185 -10
  17. package/src/runs/background/async-job-tracker.ts +41 -6
  18. package/src/runs/background/async-resume.ts +28 -15
  19. package/src/runs/background/async-status.ts +71 -31
  20. package/src/runs/background/result-watcher.ts +111 -54
  21. package/src/runs/background/run-id-resolver.ts +83 -0
  22. package/src/runs/background/run-status.ts +89 -4
  23. package/src/runs/background/stale-run-reconciler.ts +46 -1
  24. package/src/runs/background/subagent-runner.ts +648 -42
  25. package/src/runs/foreground/chain-execution.ts +331 -118
  26. package/src/runs/foreground/execution.ts +226 -10
  27. package/src/runs/foreground/subagent-executor.ts +377 -14
  28. package/src/runs/shared/acceptance-contract.ts +291 -0
  29. package/src/runs/shared/acceptance-evaluation.ts +221 -0
  30. package/src/runs/shared/acceptance-finalization.ts +161 -0
  31. package/src/runs/shared/acceptance-reports.ts +127 -0
  32. package/src/runs/shared/acceptance.ts +22 -0
  33. package/src/runs/shared/chain-outputs.ts +101 -0
  34. package/src/runs/shared/completion-guard.ts +26 -3
  35. package/src/runs/shared/dynamic-fanout.ts +293 -0
  36. package/src/runs/shared/nested-events.ts +819 -0
  37. package/src/runs/shared/nested-path.ts +52 -0
  38. package/src/runs/shared/nested-render.ts +115 -0
  39. package/src/runs/shared/parallel-utils.ts +31 -1
  40. package/src/runs/shared/pi-args.ts +73 -5
  41. package/src/runs/shared/structured-output.ts +77 -0
  42. package/src/runs/shared/subagent-prompt-runtime.ts +77 -7
  43. package/src/runs/shared/workflow-graph.ts +206 -0
  44. package/src/shared/formatters.ts +2 -2
  45. package/src/shared/settings.ts +53 -4
  46. package/src/shared/types.ts +345 -0
  47. package/src/slash/slash-commands.ts +41 -3
  48. package/src/tui/render.ts +268 -43
@@ -12,7 +12,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import type { AgentConfig } from "../../agents/agents.ts";
13
13
  import { applyThinkingSuffix } from "../shared/pi-args.ts";
14
14
  import { injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
15
- import { buildChainInstructions, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
15
+ import { buildChainInstructions, isDynamicParallelStep, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
16
16
  import type { RunnerStep } from "../shared/parallel-utils.ts";
17
17
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
18
18
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
@@ -20,10 +20,16 @@ import { resolveChildCwd } from "../../shared/utils.ts";
20
20
  import { buildModelCandidates, resolveModelCandidate, type AvailableModelInfo } from "../shared/model-fallback.ts";
21
21
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
22
22
  import { resolveExpectedWorktreeAgentCwd } from "../shared/worktree.ts";
23
+ import { buildWorkflowGraphSnapshot } from "../shared/workflow-graph.ts";
24
+ import { ChainOutputValidationError, validateChainOutputBindings } from "../shared/chain-outputs.ts";
25
+ import { createStructuredOutputRuntime } from "../shared/structured-output.ts";
26
+ import { resolveEffectiveAcceptance } from "../shared/acceptance.ts";
23
27
  import {
28
+ type AcceptanceInput,
24
29
  type ArtifactConfig,
25
30
  type Details,
26
31
  type MaxOutputConfig,
32
+ type NestedRouteInfo,
27
33
  type ResolvedControlConfig,
28
34
  type SubagentRunMode,
29
35
  ASYNC_DIR,
@@ -33,6 +39,7 @@ import {
33
39
  getAsyncConfigPath,
34
40
  resolveChildMaxSubagentDepth,
35
41
  } from "../../shared/types.ts";
42
+ import { nestedResultsPath, resolveInheritedNestedRouteFromEnv, resolveNestedParentAddressFromEnv, writeNestedEvent } from "../shared/nested-events.ts";
36
43
 
37
44
  const require = createRequire(import.meta.url);
38
45
  const piPackageRoot = resolvePiPackageRoot();
@@ -105,12 +112,15 @@ interface AsyncChainParams {
105
112
  sessionRoot?: string;
106
113
  chainSkills?: string[];
107
114
  sessionFilesByFlatIndex?: (string | undefined)[];
115
+ dynamicFanoutMaxItems?: number;
108
116
  maxSubagentDepth: number;
109
117
  worktreeSetupHook?: string;
110
118
  worktreeSetupHookTimeoutMs?: number;
111
119
  controlConfig?: ResolvedControlConfig;
112
120
  controlIntercomTarget?: string;
113
121
  childIntercomTarget?: (agent: string, index: number) => string | undefined;
122
+ nestedRoute?: NestedRouteInfo;
123
+ acceptance?: AcceptanceInput;
114
124
  }
115
125
 
116
126
  interface AsyncSingleParams {
@@ -136,6 +146,8 @@ interface AsyncSingleParams {
136
146
  controlConfig?: ResolvedControlConfig;
137
147
  controlIntercomTarget?: string;
138
148
  childIntercomTarget?: (agent: string, index: number) => string | undefined;
149
+ nestedRoute?: NestedRouteInfo;
150
+ acceptance?: AcceptanceInput;
139
151
  }
140
152
 
141
153
  interface AsyncExecutionResult {
@@ -236,6 +248,7 @@ export function executeAsyncChain(
236
248
  controlConfig,
237
249
  controlIntercomTarget,
238
250
  childIntercomTarget,
251
+ nestedRoute,
239
252
  } = params;
240
253
  const resultMode = params.resultMode ?? "chain";
241
254
  const chainSkills = params.chainSkills ?? [];
@@ -243,12 +256,25 @@ export function executeAsyncChain(
243
256
  const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
244
257
  const firstStep = chain[0];
245
258
  const originalTask = params.task ?? (firstStep
246
- ? (isParallelStep(firstStep) ? firstStep.parallel[0]?.task : (firstStep as SequentialStep).task)
259
+ ? (isParallelStep(firstStep)
260
+ ? firstStep.parallel[0]?.task
261
+ : isDynamicParallelStep(firstStep)
262
+ ? firstStep.parallel.task
263
+ : (firstStep as SequentialStep).task)
247
264
  : undefined);
265
+ try {
266
+ validateChainOutputBindings(chain, { maxItems: params.dynamicFanoutMaxItems });
267
+ } catch (error) {
268
+ if (error instanceof ChainOutputValidationError) return formatAsyncStartError(resultMode, error.message);
269
+ throw error;
270
+ }
271
+ const workflowGraph = buildWorkflowGraphSnapshot({ runId: id, mode: resultMode, steps: chain });
248
272
 
249
273
  for (const s of chain) {
250
274
  const stepAgents = isParallelStep(s)
251
275
  ? s.parallel.map((t) => t.agent)
276
+ : isDynamicParallelStep(s)
277
+ ? [s.parallel.agent]
252
278
  : [(s as SequentialStep).agent];
253
279
  for (const agentName of stepAgents) {
254
280
  if (!agents.find((x) => x.name === agentName)) {
@@ -261,7 +287,11 @@ export function executeAsyncChain(
261
287
  }
262
288
  }
263
289
 
264
- const asyncDir = path.join(ASYNC_DIR, id);
290
+ const inheritedNestedRoute = resolveInheritedNestedRouteFromEnv();
291
+ const nestedAddress = inheritedNestedRoute ? resolveNestedParentAddressFromEnv() : undefined;
292
+ const asyncDir = inheritedNestedRoute
293
+ ? path.join(TEMP_ROOT_DIR, "nested-subagent-runs", inheritedNestedRoute.rootRunId, id)
294
+ : path.join(ASYNC_DIR, id);
265
295
  try {
266
296
  fs.mkdirSync(asyncDir, { recursive: true });
267
297
  } catch (error) {
@@ -307,13 +337,20 @@ export function executeAsyncChain(
307
337
  const outputPath = resolveSingleOutputPath(behavior.output, ctx.cwd, instructionCwd);
308
338
  const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Async step (${s.agent})`);
309
339
  if (validationError) throw new AsyncStartValidationError(validationError);
310
- const task = injectSingleOutputInstruction(`${readInstructions.prefix}${s.task ?? "{previous}"}${progressInstructions.suffix}`, outputPath);
340
+ let taskTemplate = s.task ?? "{previous}";
341
+ taskTemplate = taskTemplate.replace(/\{task\}/g, originalTask ?? "");
342
+ taskTemplate = taskTemplate.replace(/\{chain_dir\}/g, runnerCwd);
343
+ const task = injectSingleOutputInstruction(`${readInstructions.prefix}${taskTemplate}${progressInstructions.suffix}`, outputPath);
311
344
 
312
345
  const primaryModel = resolveModelCandidate(behavior.model ?? a.model, availableModels, ctx.currentModelProvider);
313
346
  const model = applyThinkingSuffix(primaryModel, a.thinking);
314
347
  return {
315
348
  agent: s.agent,
316
349
  task,
350
+ phase: s.phase,
351
+ label: s.label,
352
+ outputName: s.as,
353
+ structured: Boolean(s.outputSchema),
317
354
  cwd: stepCwd,
318
355
  model,
319
356
  thinking: resolveEffectiveThinking(model, a.thinking),
@@ -333,6 +370,16 @@ export function executeAsyncChain(
333
370
  outputMode: behavior.outputMode,
334
371
  sessionFile,
335
372
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, a.maxSubagentDepth),
373
+ effectiveAcceptance: resolveEffectiveAcceptance({
374
+ explicit: s.acceptance,
375
+ agentName: s.agent,
376
+ task: s.task,
377
+ mode: resultMode,
378
+ async: true,
379
+ dynamic: false,
380
+ }),
381
+ ...(s.outputSchema ? { structuredOutputSchema: s.outputSchema } : {}),
382
+ ...(s.outputSchema ? { structuredOutput: createStructuredOutputRuntime(s.outputSchema, path.join(asyncDir, "structured-output")) } : {}),
336
383
  };
337
384
  };
338
385
 
@@ -373,6 +420,24 @@ export function executeAsyncChain(
373
420
  worktree: s.worktree,
374
421
  };
375
422
  }
423
+ if (isDynamicParallelStep(s)) {
424
+ const agent = agents.find((candidate) => candidate.name === s.parallel.agent)!;
425
+ const behavior = suppressProgressForReadOnlyTask(resolveStepBehavior(agent, buildStepOverrides(s.parallel), chainSkills), s.parallel.task, originalTask);
426
+ const progressPrecreated = behavior.progress;
427
+ if (progressPrecreated) {
428
+ writeInitialProgressFile(runnerCwd);
429
+ progressInstructionCreated = true;
430
+ }
431
+ return {
432
+ expand: s.expand,
433
+ parallel: buildSeqStep(s.parallel as SequentialStep, undefined, undefined, progressPrecreated, behavior),
434
+ collect: s.collect,
435
+ concurrency: s.concurrency,
436
+ failFast: s.failFast,
437
+ phase: s.phase,
438
+ label: s.label,
439
+ };
440
+ }
376
441
  return buildSeqStep(s as SequentialStep, nextSessionFile());
377
442
  });
378
443
  } catch (error) {
@@ -382,6 +447,10 @@ export function executeAsyncChain(
382
447
  let childTargetIndex = 0;
383
448
  const childIntercomTargets = childIntercomTarget ? steps.flatMap((step) => {
384
449
  if ("parallel" in step) {
450
+ if (!Array.isArray(step.parallel)) {
451
+ childTargetIndex++;
452
+ return [undefined];
453
+ }
385
454
  return step.parallel.map((task) => childIntercomTarget(task.agent, childTargetIndex++));
386
455
  }
387
456
  return [childIntercomTarget(step.agent, childTargetIndex++)];
@@ -393,7 +462,7 @@ export function executeAsyncChain(
393
462
  {
394
463
  id,
395
464
  steps,
396
- resultPath: path.join(RESULTS_DIR, `${id}.json`),
465
+ resultPath: inheritedNestedRoute ? nestedResultsPath(inheritedNestedRoute.rootRunId, id) : path.join(RESULTS_DIR, `${id}.json`),
397
466
  cwd: runnerCwd,
398
467
  placeholder: "{previous}",
399
468
  maxOutput,
@@ -411,6 +480,15 @@ export function executeAsyncChain(
411
480
  controlIntercomTarget,
412
481
  childIntercomTargets,
413
482
  resultMode,
483
+ dynamicFanoutMaxItems: params.dynamicFanoutMaxItems,
484
+ workflowGraph,
485
+ nestedRoute: nestedRoute ?? inheritedNestedRoute,
486
+ nestedSelf: inheritedNestedRoute && nestedAddress ? {
487
+ parentRunId: nestedAddress.parentRunId,
488
+ parentStepIndex: nestedAddress.parentStepIndex,
489
+ depth: nestedAddress.depth,
490
+ path: nestedAddress.path,
491
+ } : undefined,
414
492
  },
415
493
  id,
416
494
  runnerCwd,
@@ -428,6 +506,8 @@ export function executeAsyncChain(
428
506
  const firstStep = chain[0];
429
507
  const firstAgents = isParallelStep(firstStep)
430
508
  ? firstStep.parallel.map((t) => t.agent)
509
+ : isDynamicParallelStep(firstStep)
510
+ ? [firstStep.parallel.agent]
431
511
  : [(firstStep as SequentialStep).agent];
432
512
  const parallelGroups: Array<{ start: number; count: number; stepIndex: number }> = [];
433
513
  const flatAgents: string[] = [];
@@ -438,11 +518,49 @@ export function executeAsyncChain(
438
518
  parallelGroups.push({ start: flatStepStart, count: step.parallel.length, stepIndex });
439
519
  flatAgents.push(...step.parallel.map((task) => task.agent));
440
520
  flatStepStart += step.parallel.length;
521
+ } else if (isDynamicParallelStep(step)) {
522
+ parallelGroups.push({ start: flatStepStart, count: 1, stepIndex });
523
+ flatAgents.push(step.parallel.agent);
524
+ flatStepStart++;
441
525
  } else {
442
526
  flatAgents.push((step as SequentialStep).agent);
443
527
  flatStepStart++;
444
528
  }
445
529
  }
530
+ if (inheritedNestedRoute && nestedAddress) {
531
+ const now = Date.now();
532
+ try {
533
+ writeNestedEvent(inheritedNestedRoute, {
534
+ type: "subagent.nested.started",
535
+ ts: now,
536
+ parentRunId: nestedAddress.parentRunId,
537
+ parentStepIndex: nestedAddress.parentStepIndex,
538
+ child: {
539
+ id,
540
+ parentRunId: nestedAddress.parentRunId,
541
+ parentStepIndex: nestedAddress.parentStepIndex,
542
+ depth: nestedAddress.depth,
543
+ path: nestedAddress.path,
544
+ asyncDir,
545
+ pid: spawnResult.pid,
546
+ ownerIntercomTarget: process.env.PI_SUBAGENT_INTERCOM_SESSION_NAME,
547
+ leafIntercomTarget: childIntercomTargets?.[0],
548
+ intercomTarget: childIntercomTargets?.[0],
549
+ ownerState: "live",
550
+ mode: resultMode,
551
+ state: "running",
552
+ agent: firstAgents[0],
553
+ agents: flatAgents,
554
+ chainStepCount: chain.length,
555
+ parallelGroups,
556
+ startedAt: now,
557
+ lastUpdate: now,
558
+ },
559
+ });
560
+ } catch (error) {
561
+ console.error("Failed to emit nested async start event:", error);
562
+ }
563
+ }
446
564
  ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
447
565
  id,
448
566
  pid: spawnResult.pid,
@@ -452,26 +570,30 @@ export function executeAsyncChain(
452
570
  agents: flatAgents,
453
571
  task: isParallelStep(firstStep)
454
572
  ? firstStep.parallel[0]?.task?.slice(0, 50)
573
+ : isDynamicParallelStep(firstStep)
574
+ ? firstStep.parallel.task?.slice(0, 50)
455
575
  : (firstStep as SequentialStep).task?.slice(0, 50),
456
576
  chain: chain.map((s) =>
457
- isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : (s as SequentialStep).agent,
577
+ isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : isDynamicParallelStep(s) ? `expand:${s.parallel.agent}` : (s as SequentialStep).agent,
458
578
  ),
459
579
  chainStepCount: chain.length,
460
580
  parallelGroups,
581
+ workflowGraph,
461
582
  cwd: runnerCwd,
462
583
  asyncDir,
584
+ nestedRoute,
463
585
  });
464
586
  }
465
587
 
466
588
  const chainDesc = chain
467
589
  .map((s) =>
468
- isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : (s as SequentialStep).agent,
590
+ isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : isDynamicParallelStep(s) ? `expand:${s.parallel.agent}` : (s as SequentialStep).agent,
469
591
  )
470
592
  .join(" -> ");
471
593
 
472
594
  return {
473
595
  content: [{ type: "text", text: formatAsyncStartedMessage(`Async ${resultMode}: ${chainDesc} [${id}]`) }],
474
- details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir },
596
+ details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir, workflowGraph },
475
597
  };
476
598
  }
477
599
 
@@ -499,6 +621,7 @@ export function executeAsyncSingle(
499
621
  controlConfig,
500
622
  controlIntercomTarget,
501
623
  childIntercomTarget,
624
+ nestedRoute,
502
625
  } = params;
503
626
  const task = params.task ?? "";
504
627
  const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
@@ -512,7 +635,11 @@ export function executeAsyncSingle(
512
635
  systemPrompt = systemPrompt ? `${systemPrompt}\n\n${injection}` : injection;
513
636
  }
514
637
 
515
- const asyncDir = path.join(ASYNC_DIR, id);
638
+ const inheritedNestedRoute = resolveInheritedNestedRouteFromEnv();
639
+ const nestedAddress = inheritedNestedRoute ? resolveNestedParentAddressFromEnv() : undefined;
640
+ const asyncDir = inheritedNestedRoute
641
+ ? path.join(TEMP_ROOT_DIR, "nested-subagent-runs", inheritedNestedRoute.rootRunId, id)
642
+ : path.join(ASYNC_DIR, id);
516
643
  try {
517
644
  fs.mkdirSync(asyncDir, { recursive: true });
518
645
  } catch (error) {
@@ -562,9 +689,16 @@ export function executeAsyncSingle(
562
689
  outputMode,
563
690
  sessionFile,
564
691
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, agentConfig.maxSubagentDepth),
692
+ effectiveAcceptance: resolveEffectiveAcceptance({
693
+ explicit: params.acceptance,
694
+ agentName: agent,
695
+ task,
696
+ mode: "single",
697
+ async: true,
698
+ }),
565
699
  },
566
700
  ],
567
- resultPath: path.join(RESULTS_DIR, `${id}.json`),
701
+ resultPath: inheritedNestedRoute ? nestedResultsPath(inheritedNestedRoute.rootRunId, id) : path.join(RESULTS_DIR, `${id}.json`),
568
702
  cwd: runnerCwd,
569
703
  placeholder: "{previous}",
570
704
  maxOutput,
@@ -582,6 +716,13 @@ export function executeAsyncSingle(
582
716
  controlIntercomTarget,
583
717
  childIntercomTargets: childIntercomTarget ? [childIntercomTarget(agent, 0)] : undefined,
584
718
  resultMode: "single",
719
+ nestedRoute: nestedRoute ?? inheritedNestedRoute,
720
+ nestedSelf: inheritedNestedRoute && nestedAddress ? {
721
+ parentRunId: nestedAddress.parentRunId,
722
+ parentStepIndex: nestedAddress.parentStepIndex,
723
+ depth: nestedAddress.depth,
724
+ path: nestedAddress.path,
725
+ } : undefined,
585
726
  },
586
727
  id,
587
728
  runnerCwd,
@@ -596,6 +737,39 @@ export function executeAsyncSingle(
596
737
  }
597
738
 
598
739
  if (spawnResult.pid) {
740
+ if (inheritedNestedRoute && nestedAddress) {
741
+ const now = Date.now();
742
+ try {
743
+ writeNestedEvent(inheritedNestedRoute, {
744
+ type: "subagent.nested.started",
745
+ ts: now,
746
+ parentRunId: nestedAddress.parentRunId,
747
+ parentStepIndex: nestedAddress.parentStepIndex,
748
+ child: {
749
+ id,
750
+ parentRunId: nestedAddress.parentRunId,
751
+ parentStepIndex: nestedAddress.parentStepIndex,
752
+ depth: nestedAddress.depth,
753
+ path: nestedAddress.path,
754
+ asyncDir,
755
+ pid: spawnResult.pid,
756
+ ownerIntercomTarget: process.env.PI_SUBAGENT_INTERCOM_SESSION_NAME,
757
+ leafIntercomTarget: childIntercomTarget?.(agent, 0),
758
+ intercomTarget: childIntercomTarget?.(agent, 0),
759
+ ownerState: "live",
760
+ mode: "single",
761
+ state: "running",
762
+ agent,
763
+ agents: [agent],
764
+ chainStepCount: 1,
765
+ startedAt: now,
766
+ lastUpdate: now,
767
+ },
768
+ });
769
+ } catch (error) {
770
+ console.error("Failed to emit nested async start event:", error);
771
+ }
772
+ }
599
773
  ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
600
774
  id,
601
775
  pid: spawnResult.pid,
@@ -605,6 +779,7 @@ export function executeAsyncSingle(
605
779
  task: task?.slice(0, 50),
606
780
  cwd: runnerCwd,
607
781
  asyncDir,
782
+ nestedRoute,
608
783
  });
609
784
  }
610
785
 
@@ -15,7 +15,8 @@ import {
15
15
  } from "../../shared/types.ts";
16
16
  import { readStatus } from "../../shared/utils.ts";
17
17
  import { normalizeParallelGroups } from "./parallel-groups.ts";
18
- import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
18
+ import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
19
+ import { hasLiveNestedDescendants, updateAsyncJobNestedProjection } from "../shared/nested-events.ts";
19
20
 
20
21
  interface AsyncJobTrackerOptions {
21
22
  completionRetentionMs?: number;
@@ -38,9 +39,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
38
39
  renderWidget(ctx, jobs);
39
40
  ctx.ui.requestRender?.();
40
41
  };
41
- const scheduleCleanup = (asyncId: string) => {
42
+ const cancelCleanup = (asyncId: string) => {
42
43
  const existingTimer = state.cleanupTimers.get(asyncId);
43
- if (existingTimer) clearTimeout(existingTimer);
44
+ if (!existingTimer) return;
45
+ clearTimeout(existingTimer);
46
+ state.cleanupTimers.delete(asyncId);
47
+ };
48
+ const scheduleCleanup = (asyncId: string) => {
49
+ cancelCleanup(asyncId);
44
50
  const timer = setTimeout(() => {
45
51
  state.cleanupTimers.delete(asyncId);
46
52
  state.asyncJobs.delete(asyncId);
@@ -121,8 +127,27 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
121
127
  let widgetChanged = false;
122
128
  for (const job of state.asyncJobs.values()) {
123
129
  const widgetStateBefore = widgetRenderKey(job);
130
+ let nestedRefreshFailed = false;
131
+ const refreshNestedProjection = () => {
132
+ try {
133
+ updateAsyncJobNestedProjection(job);
134
+ } catch (error) {
135
+ nestedRefreshFailed = true;
136
+ console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
137
+ }
138
+ };
139
+ const reconcileNestedDescendants = () => {
140
+ try {
141
+ if (job.nestedRoute) reconcileNestedAsyncDescendants(job.nestedRoute, { resultsDir, kill: options.kill, now: options.now });
142
+ } catch (error) {
143
+ nestedRefreshFailed = true;
144
+ console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
145
+ }
146
+ refreshNestedProjection();
147
+ };
124
148
  try {
125
149
  emitNewControlEvents(job);
150
+ reconcileNestedDescendants();
126
151
  const reconciliation = reconcileAsyncRun(job.asyncDir, {
127
152
  resultsDir,
128
153
  kill: options.kill,
@@ -143,6 +168,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
143
168
  if (status) {
144
169
  const previousStatus = job.status;
145
170
  job.status = status.state;
171
+ if (job.status !== "complete" && job.status !== "failed" && job.status !== "paused") cancelCleanup(job.asyncId);
146
172
  job.sessionId = status.sessionId ?? job.sessionId;
147
173
  job.activityState = status.activityState;
148
174
  job.lastActivityAt = status.lastActivityAt ?? job.lastActivityAt;
@@ -169,6 +195,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
169
195
  job.activeParallelGroup = Boolean(activeGroup);
170
196
  job.agents = visibleSteps.map((step) => step.agent);
171
197
  job.steps = visibleSteps;
198
+ refreshNestedProjection();
172
199
  job.stepsTotal = visibleSteps.length;
173
200
  job.runningSteps = visibleSteps.filter((step) => step.status === "running").length;
174
201
  job.completedSteps = visibleSteps.filter((step) => step.status === "complete" || step.status === "completed").length;
@@ -178,7 +205,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
178
205
  job.outputFile = status.outputFile ?? job.outputFile;
179
206
  job.totalTokens = status.totalTokens ?? job.totalTokens;
180
207
  job.sessionFile = status.sessionFile ?? job.sessionFile;
181
- if ((job.status === "complete" || job.status === "failed" || job.status === "paused") && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
208
+ if ((job.status === "complete" || job.status === "failed" || job.status === "paused") && !nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
182
209
  scheduleCleanup(job.asyncId);
183
210
  }
184
211
  if (widgetRenderKey(job) !== widgetStateBefore) widgetChanged = true;
@@ -194,7 +221,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
194
221
  job.status = "failed";
195
222
  job.updatedAt = Date.now();
196
223
  }
197
- if (!state.cleanupTimers.has(job.asyncId)) {
224
+ if (!hasLiveNestedDescendants(job.nestedChildren) && !state.cleanupTimers.has(job.asyncId)) {
198
225
  scheduleCleanup(job.asyncId);
199
226
  }
200
227
  }
@@ -228,6 +255,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
228
255
  agents,
229
256
  chainStepCount: info.chainStepCount,
230
257
  parallelGroups: validParallelGroups,
258
+ nestedRoute: info.nestedRoute,
231
259
  stepsTotal: firstGroupCount ?? agents?.length,
232
260
  hasParallelGroups: validParallelGroups.length > 0,
233
261
  activeParallelGroup: Boolean(firstGroupCount && firstGroupCount > 0),
@@ -245,15 +273,22 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
245
273
  const asyncId = result.id;
246
274
  if (!asyncId) return;
247
275
  const job = state.asyncJobs.get(asyncId);
276
+ let nestedRefreshFailed = false;
248
277
  if (job) {
249
278
  job.status = result.success ? "complete" : "failed";
250
279
  job.updatedAt = Date.now();
251
280
  if (result.asyncDir) job.asyncDir = result.asyncDir;
281
+ try {
282
+ updateAsyncJobNestedProjection(job);
283
+ } catch (error) {
284
+ nestedRefreshFailed = true;
285
+ console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
286
+ }
252
287
  }
253
288
  if (state.lastUiContext) {
254
289
  rerenderWidget(state.lastUiContext);
255
290
  }
256
- scheduleCleanup(asyncId);
291
+ if (!nestedRefreshFailed && !hasLiveNestedDescendants(job?.nestedChildren)) scheduleCleanup(asyncId);
257
292
  };
258
293
 
259
294
  const resetJobs = (ctx?: ExtensionContext) => {
@@ -149,6 +149,29 @@ function exactResultPath(resultsDir: string, runId: string): string | null {
149
149
  return fs.existsSync(resultPath) ? resultPath : null;
150
150
  }
151
151
 
152
+ export function findAsyncRunPrefixMatches(prefix: string, asyncDirRoot: string, resultsDir: string): Array<{ id: string; location: AsyncRunLocation }> {
153
+ const requestedId = assertRunId(prefix, "id");
154
+ if (!requestedId) return [];
155
+ const asyncRoot = path.resolve(asyncDirRoot);
156
+ const resultRoot = path.resolve(resultsDir);
157
+ const matchingIds = [...new Set([
158
+ ...prefixedRunIds(asyncRoot, requestedId),
159
+ ...prefixedRunIds(resultRoot, requestedId, ".json"),
160
+ ])].sort();
161
+ return matchingIds.map((id) => {
162
+ const asyncDir = path.join(asyncRoot, id);
163
+ assertInsideRoot(asyncRoot, asyncDir, "Async run directory");
164
+ return {
165
+ id,
166
+ location: {
167
+ asyncDir: fs.existsSync(asyncDir) ? asyncDir : null,
168
+ resultPath: exactResultPath(resultRoot, id),
169
+ resolvedId: id,
170
+ },
171
+ };
172
+ });
173
+ }
174
+
152
175
  export function resolveAsyncRunLocation(params: AsyncResumeParams, asyncDirRoot: string, resultsDir: string): AsyncRunLocation {
153
176
  const asyncRoot = path.resolve(asyncDirRoot);
154
177
  const resultRoot = path.resolve(resultsDir);
@@ -175,22 +198,12 @@ export function resolveAsyncRunLocation(params: AsyncResumeParams, asyncDirRoot:
175
198
  };
176
199
  }
177
200
 
178
- const matchingIds = [...new Set([
179
- ...prefixedRunIds(asyncRoot, requestedId),
180
- ...prefixedRunIds(resultRoot, requestedId, ".json"),
181
- ])].sort();
182
- if (matchingIds.length === 0) return { asyncDir: null, resultPath: null, resolvedId: requestedId };
183
- if (matchingIds.length > 1) {
184
- throw new Error(`Ambiguous async run id prefix '${requestedId}' matched: ${matchingIds.join(", ")}. Provide a longer id.`);
201
+ const matching = findAsyncRunPrefixMatches(requestedId, asyncRoot, resultRoot);
202
+ if (matching.length === 0) return { asyncDir: null, resultPath: null, resolvedId: requestedId };
203
+ if (matching.length > 1) {
204
+ throw new Error(`Ambiguous async run id prefix '${requestedId}' matched: ${matching.map((match) => match.id).join(", ")}. Provide a longer id.`);
185
205
  }
186
- const resolvedId = matchingIds[0]!;
187
- const asyncDir = path.join(asyncRoot, resolvedId);
188
- assertInsideRoot(asyncRoot, asyncDir, "Async run directory");
189
- return {
190
- asyncDir: fs.existsSync(asyncDir) ? asyncDir : null,
191
- resultPath: exactResultPath(resultRoot, resolvedId),
192
- resolvedId,
193
- };
206
+ return matching[0]!.location;
194
207
  }
195
208
 
196
209
  function resultState(result: AsyncResultFile): AsyncStatus["state"] {