pi-subagents 0.47.0 → 0.48.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 (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +2 -0
  3. package/agents/reviewer.md +3 -4
  4. package/docs/configuration.md +46 -2
  5. package/docs/observability.md +1 -1
  6. package/docs/tool-reference.md +1 -1
  7. package/package.json +1 -1
  8. package/src/agents/agents.ts +9 -3
  9. package/src/extension/config.ts +16 -0
  10. package/src/extension/doctor.ts +40 -0
  11. package/src/extension/index.ts +30 -11
  12. package/src/extension/public-execution.ts +5 -0
  13. package/src/extension/rpc.ts +7 -9
  14. package/src/extension/schemas.ts +4 -4
  15. package/src/intercom/intercom-bridge.ts +4 -1
  16. package/src/intercom/native-supervisor-channel.ts +102 -4
  17. package/src/missions/lifecycle.ts +4 -7
  18. package/src/missions/store.ts +4 -4
  19. package/src/missions/workflow-state.ts +6 -2
  20. package/src/runs/background/active-async-capacity.ts +374 -0
  21. package/src/runs/background/active-run-index.ts +46 -0
  22. package/src/runs/background/async-execution.ts +117 -29
  23. package/src/runs/background/async-job-tracker.ts +279 -134
  24. package/src/runs/background/async-resume.ts +7 -1
  25. package/src/runs/background/async-status.ts +33 -4
  26. package/src/runs/background/chain-append.ts +33 -15
  27. package/src/runs/background/control-channel.ts +55 -17
  28. package/src/runs/background/owned-process-tree.ts +104 -0
  29. package/src/runs/background/process-terminal.ts +17 -3
  30. package/src/runs/background/result-watcher.ts +87 -6
  31. package/src/runs/background/run-status.ts +23 -1
  32. package/src/runs/background/scheduled-runs.ts +23 -0
  33. package/src/runs/background/stale-run-reconciler.ts +10 -3
  34. package/src/runs/background/subagent-runner.ts +75 -33
  35. package/src/runs/foreground/async-dismiss-action.ts +85 -0
  36. package/src/runs/foreground/async-steering-action.ts +6 -7
  37. package/src/runs/foreground/chain-execution.ts +37 -2
  38. package/src/runs/foreground/execution.ts +90 -19
  39. package/src/runs/foreground/foreground-control.ts +12 -0
  40. package/src/runs/foreground/prompt-audit.ts +171 -0
  41. package/src/runs/foreground/subagent-executor.ts +648 -195
  42. package/src/runs/shared/acceptance.ts +13 -4
  43. package/src/runs/shared/llm-intent-arbiter.ts +286 -0
  44. package/src/runs/shared/parallel-utils.ts +2 -0
  45. package/src/runs/shared/pi-args.ts +44 -1
  46. package/src/runs/shared/run-fanout-budget.ts +280 -0
  47. package/src/runs/shared/single-output.ts +4 -2
  48. package/src/runs/shared/subagent-prompt-runtime.ts +24 -5
  49. package/src/runs/shared/task-intent.ts +19 -3
  50. package/src/runs/shared/worktree.ts +17 -5
  51. package/src/shared/artifacts.ts +1 -1
  52. package/src/shared/file-coalescer.ts +9 -0
  53. package/src/shared/types.ts +97 -1
  54. package/src/shared/utils.ts +3 -1
  55. package/src/tui/fleet-status.ts +7 -5
  56. package/src/tui/fleet.ts +225 -12
  57. package/src/workflows/scripted-workflow.ts +26 -6
@@ -21,7 +21,7 @@ import type { ContextMode } from "../shared/context-mode.ts";
21
21
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
22
22
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
23
23
  import { buildAgentMemoryInjection } from "../../agents/agent-memory.ts";
24
- import { PI_CODING_AGENT_PACKAGE_ROOT_ENV, resolveChildCwd } from "../../shared/utils.ts";
24
+ import { PI_CODING_AGENT_PACKAGE_ROOT_ENV, PROMPT_REDACTED, resolveChildCwd } from "../../shared/utils.ts";
25
25
  import { buildModelCandidates, resolveEffectiveSubagentModel, resolveModelCandidate, resolveSubagentModelOverride, type AvailableModelInfo, type ParentModel } from "../shared/model-fallback.ts";
26
26
  import type { ModelScopeConfig } from "../shared/model-scope.ts";
27
27
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
@@ -30,9 +30,11 @@ import { buildWorkflowGraphSnapshot } from "../shared/workflow-graph.ts";
30
30
  import { ChainOutputValidationError, validateChainOutputBindings } from "../shared/chain-outputs.ts";
31
31
  import { createStructuredOutputRuntime } from "../shared/structured-output.ts";
32
32
  import { resolveEffectiveAcceptance, validateAcceptanceInput, validateExecutionAcceptance } from "../shared/acceptance.ts";
33
+ import { createRunFanoutBudget, writeRunFanoutBudgetDescriptor } from "../shared/run-fanout-budget.ts";
33
34
  import {
34
35
  type AcceptanceInput,
35
36
  type AgentContract,
37
+ type AsyncStatus,
36
38
  type ArtifactConfig,
37
39
  type Details,
38
40
  type JsonSchemaObject,
@@ -41,6 +43,7 @@ import {
41
43
  type ResolvedControlConfig,
42
44
  type ResolvedTurnBudget,
43
45
  type ResolvedToolBudget,
46
+ type RunFanoutBudgetDescriptor,
44
47
  type ToolBudgetConfig,
45
48
  type SubagentRunMode,
46
49
  type SteeringRecoveryDescriptor,
@@ -59,6 +62,7 @@ import { usageBudgetState } from "../shared/usage-budget.ts";
59
62
  import type { ImportedAsyncRoot } from "./chain-root-attachment.ts";
60
63
  import type { SessionLeaseRequest } from "../shared/session-lease.ts";
61
64
  import { finalizeProcessTerminal, readProcessTerminal } from "./process-terminal.ts";
65
+ import type { ActiveAsyncCapacityHandle } from "./active-async-capacity.ts";
62
66
  import { SUBAGENT_PROCESS_TERMINAL_EVENT } from "../../shared/types.ts";
63
67
  import { assertAgentAllowedByCapabilityCeiling, decodeSubagentCapabilityCeiling, intersectSubagentCapabilityCeilings, resolveCurrentSubagentCapabilityCeiling, SUBAGENT_CAPABILITY_CEILING_ENV, type ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
64
68
  import { agentDefinitionDigest, launchBindingDigest } from "../../shared/launch-contract.ts";
@@ -171,8 +175,10 @@ interface AsyncChainParams {
171
175
  /** Global cap on simultaneously-running subagent tasks within the async run. */
172
176
  globalConcurrencyLimit?: number;
173
177
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
178
+ runFanoutBudget?: RunFanoutBudgetDescriptor;
174
179
  parentWorkflowRunId?: string;
175
180
  workflowKey?: string;
181
+ activeAsyncCapacity?: ActiveAsyncCapacityHandle;
176
182
  }
177
183
 
178
184
  interface AsyncSingleParams {
@@ -220,8 +226,10 @@ interface AsyncSingleParams {
220
226
  configToolBudget?: ResolvedToolBudget;
221
227
  allowZeroToolBudget?: boolean;
222
228
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
229
+ runFanoutBudget?: RunFanoutBudgetDescriptor;
223
230
  parentWorkflowRunId?: string;
224
231
  workflowKey?: string;
232
+ activeAsyncCapacity?: ActiveAsyncCapacityHandle;
225
233
  }
226
234
 
227
235
  interface AsyncExecutionResult {
@@ -343,7 +351,7 @@ type RunnerStartupState = "ready" | "acknowledged";
343
351
 
344
352
  type RunnerStartupWaitResult =
345
353
  | { ok: true; token: string }
346
- | { ok: false; error: string };
354
+ | { ok: false; error: string; startupDidNotProceed?: boolean };
347
355
 
348
356
  function waitForStartupInterval(delayMs = 20): void {
349
357
  if (RUNNER_STARTUP_WAIT_VIEW) {
@@ -360,14 +368,14 @@ function readRunnerStartup(startupPath: string, expectedState: RunnerStartupStat
360
368
  if (!fs.existsSync(startupPath)) return undefined;
361
369
  try {
362
370
  const payload = JSON.parse(fs.readFileSync(startupPath, "utf-8")) as { state?: unknown; token?: unknown; error?: unknown };
363
- if (payload.state === "error" && typeof payload.error === "string") return { ok: false, error: payload.error };
371
+ if (payload.state === "error" && typeof payload.error === "string") return { ok: false, error: payload.error, startupDidNotProceed: true };
364
372
  if (payload.state !== expectedState) return undefined;
365
373
  if (typeof payload.token !== "string" || (expectedToken !== undefined && payload.token !== expectedToken)) {
366
- return { ok: false, error: `Async runner wrote an invalid ${expectedState} startup handshake: ${startupPath}` };
374
+ return { ok: false, error: `Async runner wrote an invalid ${expectedState} startup handshake: ${startupPath}`, startupDidNotProceed: true };
367
375
  }
368
376
  return { ok: true, token: payload.token };
369
377
  } catch (error) {
370
- return { ok: false, error: `Failed to read async runner startup handshake '${startupPath}': ${error instanceof Error ? error.message : String(error)}` };
378
+ return { ok: false, error: `Failed to read async runner startup handshake '${startupPath}': ${error instanceof Error ? error.message : String(error)}`, startupDidNotProceed: true };
371
379
  }
372
380
  }
373
381
 
@@ -381,7 +389,7 @@ function waitForRunnerStartup(startupPath: string, expectedState: RunnerStartupS
381
389
  }
382
390
  const finalResult = readRunnerStartup(startupPath, expectedState, expectedToken);
383
391
  if (finalResult) return finalResult;
384
- return { ok: false, error: `Timed out after ${timeoutMs}ms waiting for the async runner startup state '${expectedState}'.` };
392
+ return { ok: false, error: `Timed out after ${timeoutMs}ms waiting for the async runner startup state '${expectedState}'.`, startupDidNotProceed: true };
385
393
  }
386
394
 
387
395
  function writeRunnerStartupControl(filePath: string, payload: { action: "ack" | "proceed"; token: string }): void {
@@ -402,20 +410,63 @@ function runnerIsAlive(pid: number): boolean {
402
410
  }
403
411
  }
404
412
 
405
- function terminateRunnerBeforeProceed(pid: number): void {
413
+ function terminateRunnerBeforeProceed(pid: number): boolean {
406
414
  for (const signal of ["SIGTERM", "SIGKILL"] as const) {
407
- if (!runnerIsAlive(pid)) return;
415
+ if (!runnerIsAlive(pid)) return true;
408
416
  try {
409
417
  process.kill(pid, signal);
410
418
  } catch {
411
- if (!runnerIsAlive(pid)) return;
419
+ if (!runnerIsAlive(pid)) return true;
412
420
  }
413
421
  const deadline = Date.now() + 1000;
414
422
  while (runnerIsAlive(pid) && Date.now() < deadline) waitForStartupInterval();
415
423
  }
424
+ return !runnerIsAlive(pid);
416
425
  }
417
426
 
418
- function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal?: (proof: unknown) => void): { pid?: number; error?: string } {
427
+ function persistPreProceedStartupFailure(asyncDir: string, runId: string, runnerProcessInstanceId: string, sessionId: string | undefined, message: string): void {
428
+ const now = Date.now();
429
+ try {
430
+ const statusPath = path.join(asyncDir, "status.json");
431
+ let status: Partial<AsyncStatus> = {};
432
+ try {
433
+ status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as Partial<AsyncStatus>;
434
+ } catch {}
435
+ writePrivateAtomicJson(statusPath, {
436
+ ...status,
437
+ runId,
438
+ ...(sessionId ? { sessionId } : {}),
439
+ state: "failed",
440
+ lastUpdate: now,
441
+ error: message,
442
+ processTerminal: {
443
+ version: 1,
444
+ state: "not-started",
445
+ runId,
446
+ runnerProcessInstanceId,
447
+ },
448
+ });
449
+ writePrivateAtomicJson(path.join(asyncDir, "process-terminal-candidate.json"), {
450
+ version: 1,
451
+ runId,
452
+ runnerProcessInstanceId,
453
+ writers: {},
454
+ expectedWriters: { 0: 0 },
455
+ });
456
+ } catch {
457
+ // Startup failures must still return the original launch error.
458
+ }
459
+ }
460
+
461
+ interface SpawnRunnerResult {
462
+ pid?: number;
463
+ runnerProcessInstanceId?: string;
464
+ error?: string;
465
+ terminationObserved?: boolean;
466
+ startupDidNotProceed?: boolean;
467
+ }
468
+
469
+ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal?: (proof: unknown) => void): SpawnRunnerResult {
419
470
  if (!jitiCliPath) {
420
471
  return { error: "upstream jiti for TypeScript execution could not be found; ensure package dependencies are installed" };
421
472
  }
@@ -436,8 +487,10 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
436
487
  fs.writeFileSync(cfgPath, JSON.stringify(launchConfig));
437
488
  const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
438
489
  const nodeCommand = resolveAsyncRunnerNodeCommand();
439
- const launchForStartup = launchConfig as typeof launchConfig & { asyncDir?: unknown; revivalLease?: unknown };
490
+ const launchForStartup = launchConfig as typeof launchConfig & { asyncDir?: unknown; id?: unknown; sessionId?: unknown; revivalLease?: unknown };
440
491
  const launchAsyncDir = typeof launchForStartup.asyncDir === "string" ? launchForStartup.asyncDir : undefined;
492
+ const launchRunId = typeof launchForStartup.id === "string" ? launchForStartup.id : suffix;
493
+ const launchSessionId = typeof launchForStartup.sessionId === "string" ? launchForStartup.sessionId : undefined;
441
494
  const startupPath = typeof launchForStartup.revivalLease === "object" && launchAsyncDir
442
495
  ? path.join(launchAsyncDir, "runner-startup.json")
443
496
  : undefined;
@@ -526,27 +579,36 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
526
579
  }
527
580
  proc.unref();
528
581
  if (startupPath && startupAckPath && startupProceedPath) {
582
+ const persistStartupFailure = (message: string) => {
583
+ if (launchAsyncDir) persistPreProceedStartupFailure(launchAsyncDir, launchRunId, runnerProcessInstanceId, launchSessionId, message);
584
+ };
529
585
  const ready = waitForRunnerStartup(startupPath, "ready", RUNNER_STARTUP_TIMEOUT_MS);
530
586
  if (ready.ok === false) {
531
- terminateRunnerBeforeProceed(proc.pid);
532
- return { error: ready.error };
587
+ persistStartupFailure(ready.error);
588
+ const terminationObserved = terminateRunnerBeforeProceed(proc.pid);
589
+ return { pid: proc.pid, runnerProcessInstanceId, error: ready.error, terminationObserved, startupDidNotProceed: ready.startupDidNotProceed };
533
590
  }
534
591
  try {
535
592
  writeRunnerStartupControl(startupAckPath, { action: "ack", token: ready.token });
536
593
  } catch (error) {
537
- terminateRunnerBeforeProceed(proc.pid);
538
- return { error: `Failed to acknowledge async runner startup: ${error instanceof Error ? error.message : String(error)}` };
594
+ const message = `Failed to acknowledge async runner startup: ${error instanceof Error ? error.message : String(error)}`;
595
+ persistStartupFailure(message);
596
+ const terminationObserved = terminateRunnerBeforeProceed(proc.pid);
597
+ return { pid: proc.pid, runnerProcessInstanceId, error: message, terminationObserved, startupDidNotProceed: true };
539
598
  }
540
599
  const acknowledged = waitForRunnerStartup(startupPath, "acknowledged", RUNNER_STARTUP_TIMEOUT_MS, ready.token);
541
600
  if (acknowledged.ok === false) {
542
- terminateRunnerBeforeProceed(proc.pid);
543
- return { error: acknowledged.error };
601
+ persistStartupFailure(acknowledged.error);
602
+ const terminationObserved = terminateRunnerBeforeProceed(proc.pid);
603
+ return { pid: proc.pid, runnerProcessInstanceId, error: acknowledged.error, terminationObserved, startupDidNotProceed: acknowledged.startupDidNotProceed };
544
604
  }
545
605
  try {
546
606
  writeRunnerStartupControl(startupProceedPath, { action: "proceed", token: ready.token });
547
607
  } catch (error) {
548
- terminateRunnerBeforeProceed(proc.pid);
549
- return { error: `Failed to authorize async runner startup: ${error instanceof Error ? error.message : String(error)}` };
608
+ const message = `Failed to authorize async runner startup: ${error instanceof Error ? error.message : String(error)}`;
609
+ persistStartupFailure(message);
610
+ const terminationObserved = terminateRunnerBeforeProceed(proc.pid);
611
+ return { pid: proc.pid, runnerProcessInstanceId, error: message, terminationObserved, startupDidNotProceed: true };
550
612
  }
551
613
  try {
552
614
  fs.rmSync(startupPath, { force: true });
@@ -554,7 +616,7 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
554
616
  // Proceed is the commit point; handshake cleanup cannot turn a running revival into a start error.
555
617
  }
556
618
  }
557
- return { pid: proc.pid };
619
+ return { pid: proc.pid, runnerProcessInstanceId };
558
620
  } catch (error) {
559
621
  closeFd(stdoutFd);
560
622
  closeFd(stderrFd);
@@ -648,7 +710,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
648
710
  ...(s.model !== undefined ? { model: s.model } : {}),
649
711
  };
650
712
  };
651
- const buildSeqStep = (s: SequentialStep, sessionFile?: string, behaviorCwd?: string, progressPrecreated = false, resolvedBehavior?: ResolvedStepBehavior, flatIndex?: number, parallelOutputNamespace?: { stepIndex: number; taskIndex?: number }) => {
713
+ const buildSeqStep = (s: SequentialStep, sessionFile?: string, behaviorCwd?: string, progressPrecreated = false, resolvedBehavior?: ResolvedStepBehavior, flatIndex?: number, parallelOutputNamespace?: { stepIndex: number; taskIndex?: number }, runFanoutPath?: string) => {
652
714
  const a = agents.find((x) => x.name === s.agent)!;
653
715
  const externalRunner = a.runner?.type === "external-cli";
654
716
  if (externalRunner) {
@@ -748,6 +810,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
748
810
  parentSessionId: ctx.parentSessionId ?? ctx.currentSessionId,
749
811
  permissionRules,
750
812
  ...(params.capabilityCeiling ? { capabilityCeiling: params.capabilityCeiling } : {}),
813
+ ...(runFanoutPath ? { runFanoutPath } : {}),
751
814
  agent: s.agent,
752
815
  task,
753
816
  ...(a.runner ? { runner: a.runner } : {}),
@@ -839,7 +902,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
839
902
  }
840
903
  }
841
904
  const staticStep = nextFlatStep();
842
- return buildSeqStep({ ...t, agentContract: t.agentContract ?? s.agentContract, gateOn: t.gateOn ?? s.gateOn }, staticStep.sessionFile, behaviorCwd, progressPrecreated, parallelBehaviors[taskIndex], staticStep.index, { stepIndex, taskIndex });
905
+ return buildSeqStep({ ...t, agentContract: t.agentContract ?? s.agentContract, gateOn: t.gateOn ?? s.gateOn }, staticStep.sessionFile, behaviorCwd, progressPrecreated, parallelBehaviors[taskIndex], staticStep.index, { stepIndex, taskIndex }, resultMode === "parallel" ? `tasks[${taskIndex}]` : `chain[${stepIndex}].parallel[${taskIndex}]`);
843
906
  }),
844
907
  concurrency: s.concurrency,
845
908
  failFast: s.failFast,
@@ -884,7 +947,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
884
947
  };
885
948
  }
886
949
  const staticStep = nextFlatStep();
887
- return buildSeqStep(s as SequentialStep, staticStep.sessionFile, undefined, false, undefined, staticStep.index);
950
+ return buildSeqStep(s as SequentialStep, staticStep.sessionFile, undefined, false, undefined, staticStep.index, undefined, `chain[${stepIndex}]`);
888
951
  });
889
952
  const steps = params.attachRoot
890
953
  ? [{
@@ -966,8 +1029,11 @@ export function executeAsyncChain(
966
1029
  const asyncDir = inheritedNestedRoute
967
1030
  ? path.join(TEMP_ROOT_DIR, "nested-subagent-runs", inheritedNestedRoute.rootRunId, id)
968
1031
  : path.join(DIRS.async, id);
1032
+ let runFanoutBudget: RunFanoutBudgetDescriptor;
969
1033
  try {
1034
+ runFanoutBudget = params.runFanoutBudget ?? createRunFanoutBudget(id, 64);
970
1035
  fs.mkdirSync(asyncDir, { recursive: true });
1036
+ writeRunFanoutBudgetDescriptor(asyncDir, runFanoutBudget);
971
1037
  } catch (error) {
972
1038
  const message = error instanceof Error ? error.message : String(error);
973
1039
  return {
@@ -1030,7 +1096,7 @@ export function executeAsyncChain(
1030
1096
  return "agent" in step ? [childIntercomTarget(step.agent, childTargetIndex++)] : [undefined];
1031
1097
  }) : undefined;
1032
1098
 
1033
- let spawnResult: { pid?: number; error?: string } = {};
1099
+ let spawnResult: SpawnRunnerResult = {};
1034
1100
  try {
1035
1101
  spawnResult = spawnRunner(
1036
1102
  {
@@ -1063,6 +1129,7 @@ export function executeAsyncChain(
1063
1129
  timeoutMs: params.timeoutMs,
1064
1130
  deadlineAt,
1065
1131
  globalConcurrencyLimit: params.globalConcurrencyLimit,
1132
+ runFanoutBudget,
1066
1133
  workflowGraph,
1067
1134
  ...(params.parentWorkflowRunId ? { parentWorkflowRunId: params.parentWorkflowRunId } : {}),
1068
1135
  ...(params.workflowKey ? { workflowKey: params.workflowKey } : {}),
@@ -1079,13 +1146,21 @@ export function executeAsyncChain(
1079
1146
  (proof) => ctx.pi.events.emit(SUBAGENT_PROCESS_TERMINAL_EVENT, proof),
1080
1147
  );
1081
1148
  } catch (error) {
1149
+ params.activeAsyncCapacity?.rollback();
1082
1150
  const message = error instanceof Error ? error.message : String(error);
1083
1151
  return formatAsyncStartError(resultMode, `Failed to start async ${resultMode} '${id}': ${message}`);
1084
1152
  }
1085
1153
 
1086
1154
  if (spawnResult.error) {
1155
+ if (!spawnResult.pid || !spawnResult.runnerProcessInstanceId || (spawnResult.startupDidNotProceed && spawnResult.terminationObserved)) params.activeAsyncCapacity?.rollback();
1156
+ else params.activeAsyncCapacity?.markStarted(spawnResult.runnerProcessInstanceId);
1087
1157
  return formatAsyncStartError(resultMode, `Failed to start async ${resultMode} '${id}': ${spawnResult.error}`);
1088
1158
  }
1159
+ if (!spawnResult.pid || !spawnResult.runnerProcessInstanceId) {
1160
+ params.activeAsyncCapacity?.rollback();
1161
+ return formatAsyncStartError(resultMode, `Failed to start async ${resultMode} '${id}': runner identity unavailable`);
1162
+ }
1163
+ params.activeAsyncCapacity?.markStarted(spawnResult.runnerProcessInstanceId);
1089
1164
 
1090
1165
  if (spawnResult.pid) {
1091
1166
  const eventFirstStep = eventChain[0];
@@ -1166,8 +1241,8 @@ export function executeAsyncChain(
1166
1241
  mode: resultMode,
1167
1242
  agent: firstAgents[0],
1168
1243
  agents: flatAgents,
1169
- task: firstTask?.slice(0, 50),
1170
- goal: workflowGoal?.slice(0, 120),
1244
+ task: firstTask?.trim() ? PROMPT_REDACTED : undefined,
1245
+ goal: workflowGoal?.trim() ? PROMPT_REDACTED : undefined,
1171
1246
  chain: eventChain.map((s) =>
1172
1247
  isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : isDynamicParallelStep(s) ? `expand:${s.parallel.agent}` : (s as SequentialStep).agent,
1173
1248
  ),
@@ -1279,8 +1354,11 @@ export function executeAsyncSingle(
1279
1354
  const asyncDir = inheritedNestedRoute
1280
1355
  ? path.join(TEMP_ROOT_DIR, "nested-subagent-runs", inheritedNestedRoute.rootRunId, id)
1281
1356
  : path.join(DIRS.async, id);
1357
+ let runFanoutBudget: RunFanoutBudgetDescriptor;
1282
1358
  try {
1359
+ runFanoutBudget = params.runFanoutBudget ?? createRunFanoutBudget(id, 64);
1283
1360
  fs.mkdirSync(asyncDir, { recursive: true });
1361
+ writeRunFanoutBudgetDescriptor(asyncDir, runFanoutBudget);
1284
1362
  } catch (error) {
1285
1363
  const message = error instanceof Error ? error.message : String(error);
1286
1364
  return {
@@ -1376,6 +1454,7 @@ export function executeAsyncSingle(
1376
1454
  const recoveryDescriptor: SteeringRecoveryDescriptor = {
1377
1455
  version: 1,
1378
1456
  launchContractDigest,
1457
+ runFanoutBudget,
1379
1458
  sourceRunId: id,
1380
1459
  ...(params.agentContract ? { agentContract: params.agentContract } : {}),
1381
1460
  agent,
@@ -1421,7 +1500,7 @@ export function executeAsyncSingle(
1421
1500
  return formatAsyncStartError("single", `Failed to persist async recovery descriptor for '${id}': ${error instanceof Error ? error.message : String(error)}`);
1422
1501
  }
1423
1502
  }
1424
- let spawnResult: { pid?: number; error?: string } = {};
1503
+ let spawnResult: SpawnRunnerResult = {};
1425
1504
  try {
1426
1505
  spawnResult = spawnRunner(
1427
1506
  {
@@ -1494,6 +1573,7 @@ export function executeAsyncSingle(
1494
1573
  resultMode: "single",
1495
1574
  launchContractDigest,
1496
1575
  launchResolvedExtensions,
1576
+ runFanoutBudget,
1497
1577
  ...(params.parentWorkflowRunId ? { parentWorkflowRunId: params.parentWorkflowRunId } : {}),
1498
1578
  ...(params.workflowKey ? { workflowKey: params.workflowKey } : {}),
1499
1579
  ...(params.revivalLease ? { revivalLease: params.revivalLease } : {}),
@@ -1510,13 +1590,21 @@ export function executeAsyncSingle(
1510
1590
  (proof) => ctx.pi.events.emit(SUBAGENT_PROCESS_TERMINAL_EVENT, proof),
1511
1591
  );
1512
1592
  } catch (error) {
1593
+ params.activeAsyncCapacity?.rollback();
1513
1594
  const message = error instanceof Error ? error.message : String(error);
1514
1595
  return formatAsyncStartError("single", `Failed to start async run '${id}': ${message}`);
1515
1596
  }
1516
1597
 
1517
1598
  if (spawnResult.error) {
1599
+ if (!spawnResult.pid || !spawnResult.runnerProcessInstanceId || (spawnResult.startupDidNotProceed && spawnResult.terminationObserved)) params.activeAsyncCapacity?.rollback();
1600
+ else params.activeAsyncCapacity?.markStarted(spawnResult.runnerProcessInstanceId);
1518
1601
  return formatAsyncStartError("single", `Failed to start async run '${id}': ${spawnResult.error}`);
1519
1602
  }
1603
+ if (!spawnResult.pid || !spawnResult.runnerProcessInstanceId) {
1604
+ params.activeAsyncCapacity?.rollback();
1605
+ return formatAsyncStartError("single", `Failed to start async run '${id}': runner identity unavailable`);
1606
+ }
1607
+ params.activeAsyncCapacity?.markStarted(spawnResult.runnerProcessInstanceId);
1520
1608
 
1521
1609
  if (spawnResult.pid) {
1522
1610
  if (inheritedNestedRoute && nestedAddress) {
@@ -1562,8 +1650,8 @@ export function executeAsyncSingle(
1562
1650
  sessionId: ctx.currentSessionId,
1563
1651
  mode: "single",
1564
1652
  agent,
1565
- task: task?.slice(0, 50),
1566
- goal: (params.goal ?? task).slice(0, 120),
1653
+ task: task?.trim() ? PROMPT_REDACTED : undefined,
1654
+ goal: (params.goal ?? task).trim() ? PROMPT_REDACTED : undefined,
1567
1655
  cwd: runnerCwd,
1568
1656
  asyncDir,
1569
1657
  launchContractDigest,