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
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
+ import { releaseActiveRunIndex, updateActiveRunIndex } from "./active-run-index.ts";
4
5
  import { readStatus } from "../../shared/utils.ts";
5
6
  import { DIRS, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode } from "../../shared/types.ts";
6
7
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
@@ -153,7 +154,7 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
153
154
  modelAttempts: child?.modelAttempts ?? step.modelAttempts,
154
155
  };
155
156
  });
156
- return {
157
+ const terminalStatus: AsyncStatus = {
157
158
  ...status,
158
159
  state: repair.state,
159
160
  ...(status.lifecycleArtifactVersion === 3 && (!status.processTerminal || status.processTerminal.state === "pending") ? {
@@ -165,6 +166,8 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
165
166
  endedAt: status.endedAt ?? now,
166
167
  steps,
167
168
  };
169
+ delete terminalStatus.displayDismissedAt;
170
+ return terminalStatus;
168
171
  }
169
172
 
170
173
  function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, now: number): AsyncStatus {
@@ -258,6 +261,7 @@ function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: st
258
261
  const repair = buildFailedRepair(status, asyncDir, now, reason);
259
262
  writeAtomicJson(resultPath, repair.result);
260
263
  writeAtomicJson(path.join(asyncDir, "status.json"), repair.status);
264
+ if (repair.status.processTerminal?.state === "observed") releaseActiveRunIndex(asyncDir);
261
265
  appendJsonlBestEffort(path.join(asyncDir, "events.jsonl"), {
262
266
  type: "subagent.run.repaired_stale",
263
267
  ts: now,
@@ -339,7 +343,6 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
339
343
  if (stepRecord.model !== undefined && typeof stepRecord.model !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].model must be a string.`);
340
344
  if (stepRecord.thinking !== undefined && typeof stepRecord.thinking !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].thinking must be a string.`);
341
345
  }
342
-
343
346
  const runId = effectiveStatus.runId || path.basename(asyncDir);
344
347
  const resultPath = path.join(options.resultsDir ?? DIRS.results, `${runId}.json`);
345
348
  if (fs.existsSync(resultPath)) {
@@ -348,9 +351,13 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
348
351
  : undefined;
349
352
  if (terminalStatus) {
350
353
  writeAtomicJson(path.join(asyncDir, "status.json"), terminalStatus);
354
+ if (terminalStatus.processTerminal?.state === "observed") releaseActiveRunIndex(asyncDir);
351
355
  return { status: terminalStatus, repaired: true, resultPath, message: "Existing async result file was used to repair stale running status." };
352
356
  }
353
- return { status: effectiveStatus, repaired: false, resultPath };
357
+ if (effectiveStatus.displayDismissedAt === undefined) return { status: effectiveStatus, repaired: false, resultPath };
358
+ }
359
+ if (effectiveStatus.displayDismissedAt !== undefined) {
360
+ return { status: null, repaired: false, resultPath };
354
361
  }
355
362
 
356
363
  if (effectiveStatus.state !== "running" || typeof effectiveStatus.pid !== "number") {
@@ -5,6 +5,8 @@ import * as path from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import type { Message } from "@earendil-works/pi-ai";
7
7
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
8
+ import { createFileCoalescer } from "../../shared/file-coalescer.ts";
9
+ import { isActiveAsyncState, updateActiveRunIndex } from "./active-run-index.ts";
8
10
  import { createChildTranscriptWriter, type ChildTranscriptWriter } from "../../shared/child-transcript.ts";
9
11
  import { closeSteerInbox, consumeInterruptRequest, consumeSteerRequests, deliverInterruptRequest, deliverStopRequest, deliverTimeoutRequest, enqueueStepSteer, steerAcksDir, steerCapabilityPath, stepSteerInboxDir, watchAsyncControlInbox, type SteerAck, type SteerCapability, type SteerRequest } from "./control-channel.ts";
10
12
  import { appendJsonl as appendRawJsonl, formatOutputArtifactContent, getArtifactPaths, writeArtifact, writeMetadata } from "../../shared/artifacts.ts";
@@ -23,11 +25,14 @@ import {
23
25
  type LaunchResolvedChildExtensionsV1,
24
26
  type RuntimeAcknowledgedChildExtensionsV1,
25
27
  type ModelAttempt,
28
+ type PiWriterProcessInstanceExitV1,
29
+ type ProcessTreeTerminalV1,
26
30
  type NestedRouteInfo,
27
31
  type NestedRunSummary,
28
32
  type ResolvedControlConfig,
29
33
  type ResolvedTurnBudget,
30
34
  type ResolvedToolBudget,
35
+ type RunFanoutBudgetDescriptor,
31
36
  type SubagentRunMode,
32
37
  type SubagentOutputState,
33
38
  type UsageBudgetConfig,
@@ -66,13 +71,14 @@ import {
66
71
  DEFAULT_GLOBAL_CONCURRENCY_LIMIT,
67
72
  Semaphore,
68
73
  } from "../shared/parallel-utils.ts";
69
- import { applyThinkingSuffix, buildPiArgs, cleanupTempDir, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/pi-args.ts";
74
+ import { applyThinkingSuffix, buildPiArgs, cleanupTempDir, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan, type SubagentTaskDelivery } from "../shared/pi-args.ts";
70
75
  import { readRuntimeAcknowledgedExtensions } from "../shared/runtime-acknowledged-extensions.ts";
71
76
  import { outputEntryFromAsyncResult, resolveOutputReferences } from "../shared/chain-outputs.ts";
72
77
  import { createStructuredOutputRuntime, MISSING_STRUCTURED_OUTPUT_CALL_ERROR, readStructuredOutput } from "../shared/structured-output.ts";
73
78
  import { formatProcessSignalError, isUnexplainedProcessSignal } from "../shared/process-signal.ts";
74
79
  import { readChildToolDiagnosticError } from "../shared/tool-availability.ts";
75
80
  import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelStep, validateDynamicCollection } from "../shared/dynamic-fanout.ts";
81
+ import { claimRunFanoutBatch, getRunFanoutBudgetSnapshot } from "../shared/run-fanout-budget.ts";
76
82
  import { nestedSummaryFromAsyncStatus, projectNestedEvents, resolveNestedAsyncDir, writeNestedEvent } from "../shared/nested-events.ts";
77
83
  import { formatModelAttemptNote, isRetryableModelFailure } from "../shared/model-fallback.ts";
78
84
  import {
@@ -83,9 +89,10 @@ import {
83
89
  waitForSubagentStartupRetry,
84
90
  } from "../shared/subagent-startup-retry.ts";
85
91
  import { markProcessTerminalCandidateLeaseRelease, writeProcessTerminalCandidate, type ProcessTerminalCandidate } from "./process-terminal.ts";
92
+ import { createOwnedProcessTreeController, type OwnedProcessTreeController } from "./owned-process-tree.ts";
86
93
  import { createSteeringStatus, recordSteeringRequest, steeringStatus, terminalSteeringNoticeState, updateSteeringTarget } from "./steering.ts";
87
94
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
88
- import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, getFinalOutput, hasEmptyTerminalAssistantResponse, readStatus } from "../../shared/utils.ts";
95
+ import { PROMPT_REDACTED, detectSubagentError, extractTextFromContent, extractToolArgsPreview, getFinalOutput, hasEmptyTerminalAssistantResponse, readStatus } from "../../shared/utils.ts";
89
96
  import { evaluateCompletionMutationGuard } from "../shared/completion-guard.ts";
90
97
  import {
91
98
  createMutatingFailureState,
@@ -175,6 +182,7 @@ interface SubagentRunConfig {
175
182
  /** Global cap on simultaneously-running subagent tasks within this run. */
176
183
  globalConcurrencyLimit?: number;
177
184
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
185
+ runFanoutBudget?: RunFanoutBudgetDescriptor;
178
186
  launchContractDigest?: string;
179
187
  launchResolvedExtensions?: LaunchResolvedChildExtensionsV1;
180
188
  runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensionsV1;
@@ -230,7 +238,7 @@ interface StepResult {
230
238
  structuredOutputSchemaPath?: string;
231
239
  acceptance?: import("../../shared/types.ts").AcceptanceLedger;
232
240
  watchdog?: import("../../shared/types.ts").ChildWatchdogProgress;
233
- writerProcesses?: Array<{ processInstanceId: string; kind: "pi-writer"; attempt: number; closeObservedAt: number; exitCode: number | null; signal: string | null }>;
241
+ writerProcesses?: PiWriterProcessInstanceExitV1[];
234
242
  writerAttemptCount?: number;
235
243
  runner?: ExternalCliRunnerStatus;
236
244
  externalProcess?: ExternalProcessStatus;
@@ -510,6 +518,7 @@ interface RunPiStreamingResult {
510
518
  processInstanceId: string;
511
519
  processCloseObservedAt?: number;
512
520
  processSignal?: string | null;
521
+ processTree: ProcessTreeTerminalV1;
513
522
  }
514
523
 
515
524
  function runPiStreaming(
@@ -546,7 +555,9 @@ function runPiStreaming(
546
555
  stdio: ["ignore", "pipe", "pipe"],
547
556
  env: spawnEnv,
548
557
  windowsHide: true,
558
+ detached: process.platform !== "win32",
549
559
  });
560
+ let processTreeController: OwnedProcessTreeController | undefined;
550
561
  const stderrTail = createBoundedByteTail();
551
562
  const rawStdoutTail = createBoundedByteTail();
552
563
  const messages: Message[] = [];
@@ -554,6 +565,7 @@ function runPiStreaming(
554
565
  let model: string | undefined;
555
566
  let writerRegistrationError: string | undefined;
556
567
  if (typeof child.pid === "number") {
568
+ processTreeController = createOwnedProcessTreeController(child.pid);
557
569
  try {
558
570
  onWriterProcess?.({ state: "running", pid: child.pid });
559
571
  } catch (writerError) {
@@ -701,7 +713,6 @@ function runPiStreaming(
701
713
  // a lingering stdio holder after `exit`, or a child that never exits.
702
714
  const FINAL_STOP_GRACE_MS = 1000;
703
715
  const HARD_KILL_MS = 3000;
704
- const TIMEOUT_HARD_KILL_MS = 3000;
705
716
  let childExited = false;
706
717
  let forcedTerminationSignal = false;
707
718
  let cleanTerminalAssistantStopReceived = false;
@@ -709,7 +720,6 @@ function runPiStreaming(
709
720
  let finalDrainTimer: NodeJS.Timeout | undefined;
710
721
  let finalHardKillTimer: NodeJS.Timeout | undefined;
711
722
  let watchdogTailTimer: NodeJS.Timeout | undefined;
712
- let timeoutHardKillTimer: NodeJS.Timeout | undefined;
713
723
  let turnBudgetTerminationTimer: NodeJS.Timeout | undefined;
714
724
  let turnBudgetHardKillTimer: NodeJS.Timeout | undefined;
715
725
  let protocolHardKillTimer: NodeJS.Timeout | undefined;
@@ -774,22 +784,16 @@ function runPiStreaming(
774
784
  timedOut = true;
775
785
  interrupted = false;
776
786
  error = timeoutMessage ?? "Subagent timed out.";
777
- trySignalChild(child, "SIGTERM");
778
- timeoutHardKillTimer = setTimeout(() => {
779
- if (!settled) trySignalChild(child, "SIGKILL");
780
- }, TIMEOUT_HARD_KILL_MS);
781
- timeoutHardKillTimer.unref?.();
787
+ if (processTreeController) void processTreeController.terminate();
788
+ else trySignalChild(child, "SIGTERM");
782
789
  });
783
790
  registerStop?.(() => {
784
791
  if (settled || timedOut || stopped) return;
785
792
  stopped = true;
786
793
  interrupted = false;
787
794
  error = stopMessage ?? "Subagent stopped by user.";
788
- trySignalChild(child, "SIGTERM");
789
- timeoutHardKillTimer = setTimeout(() => {
790
- if (!settled) trySignalChild(child, "SIGKILL");
791
- }, TIMEOUT_HARD_KILL_MS);
792
- timeoutHardKillTimer.unref?.();
795
+ if (processTreeController) void processTreeController.terminate();
796
+ else trySignalChild(child, "SIGTERM");
793
797
  });
794
798
  registerTurnBudgetAbort?.((message, state) => {
795
799
  if (settled || timedOut || stopped || turnBudgetExceeded) return;
@@ -818,10 +822,6 @@ function runPiStreaming(
818
822
  finalHardKillTimer = undefined;
819
823
  }
820
824
  clearWatchdogTailTimer();
821
- if (timeoutHardKillTimer) {
822
- clearTimeout(timeoutHardKillTimer);
823
- timeoutHardKillTimer = undefined;
824
- }
825
825
  if (turnBudgetTerminationTimer) {
826
826
  clearTimeout(turnBudgetTerminationTimer);
827
827
  turnBudgetTerminationTimer = undefined;
@@ -883,9 +883,12 @@ function runPiStreaming(
883
883
  childExited = true;
884
884
  clearDrainTimers();
885
885
  });
886
- child.on("close", (exitCode, signal) => {
886
+ child.on("close", async (exitCode, signal) => {
887
887
  settled = true;
888
888
  const processCloseObservedAt = Date.now();
889
+ const processTree = processTreeController
890
+ ? await processTreeController.finishAfterWriterClose()
891
+ : { state: "unknown" as const, reason: "verification-failed" as const, diagnostic: "Writer PID was unavailable." };
889
892
  try {
890
893
  onWriterProcess?.({ state: "none" });
891
894
  } catch {
@@ -937,6 +940,7 @@ function runPiStreaming(
937
940
  processInstanceId,
938
941
  processCloseObservedAt,
939
942
  processSignal: signal,
943
+ processTree,
940
944
  }));
941
945
  });
942
946
 
@@ -959,7 +963,7 @@ function runPiStreaming(
959
963
  const stderr = stderrTail.text();
960
964
  const finalOutput = getFinalOutput(messages) || rawStdoutTail.text().trim();
961
965
  const spawnErrorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
962
- resolve(omitUndefinedProperties({ stderr, exitCode: 1, messages, usage, toolCount, durationMs: Date.now() - startedAt, model, error: stopped ? (stopMessage ?? "Subagent stopped by user.") : timedOut ? (timeoutMessage ?? "Subagent timed out.") : turnBudgetExceeded ? turnBudgetMessage : error ?? assistantError ?? spawnErrorMessage, protocolError, finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage ?? "Subagent stopped by user." : timeoutMessage ?? "Subagent timed out.") : finalOutput, outputState: finalOutput.trim() ? "present" : "absent", timedOut, stopped, turnBudget, turnBudgetExceeded, wrapUpRequested: turnBudget?.outcome === "wrap-up-requested" || turnBudget?.outcome === "termination-deferred" || turnBudgetExceeded || undefined, observedMutationAttempt, structuredOutputToolInvoked, structuredOutputMessageStartIndex, watchdog: childWatchdogState, processInstanceId }));
966
+ resolve(omitUndefinedProperties({ stderr, exitCode: 1, messages, usage, toolCount, durationMs: Date.now() - startedAt, model, error: stopped ? (stopMessage ?? "Subagent stopped by user.") : timedOut ? (timeoutMessage ?? "Subagent timed out.") : turnBudgetExceeded ? turnBudgetMessage : error ?? assistantError ?? spawnErrorMessage, protocolError, finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage ?? "Subagent stopped by user." : timeoutMessage ?? "Subagent timed out.") : finalOutput, outputState: finalOutput.trim() ? "present" : "absent", timedOut, stopped, turnBudget, turnBudgetExceeded, wrapUpRequested: turnBudget?.outcome === "wrap-up-requested" || turnBudget?.outcome === "termination-deferred" || turnBudgetExceeded || undefined, observedMutationAttempt, structuredOutputToolInvoked, structuredOutputMessageStartIndex, watchdog: childWatchdogState, processInstanceId, processTree: { state: "unknown", reason: "verification-failed", diagnostic: spawnErrorMessage } }));
963
967
  });
964
968
  });
965
969
  }
@@ -1103,6 +1107,7 @@ interface SingleStepContext {
1103
1107
  orchestratorIntercomTarget?: string;
1104
1108
  nestedRoute?: NestedRouteInfo;
1105
1109
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
1110
+ runFanoutBudget?: RunFanoutBudgetDescriptor;
1106
1111
  onAttemptStart?: (attempt: { model?: string; thinking?: string }) => void;
1107
1112
  onChildEvent?: (event: ChildEvent) => void;
1108
1113
  onWriterProcess?: (writer: { state: "none" | "spawning" } | { state: "running"; pid: number }) => void;
@@ -1204,7 +1209,7 @@ async function runSingleStep(
1204
1209
  artifactPaths = getArtifactPaths(ctx.artifactsDir, ctx.id, step.agent, index);
1205
1210
  fs.mkdirSync(ctx.artifactsDir, { recursive: true });
1206
1211
  if (ctx.artifactConfig?.includeInput !== false) {
1207
- fs.writeFileSync(artifactPaths.inputPath, `# Task for ${step.agent}\n\n${task}`, "utf-8");
1212
+ fs.writeFileSync(artifactPaths.inputPath, `# Task for ${step.agent}\n\n${PROMPT_REDACTED}; live Prompt Audit only.\n`, "utf-8");
1208
1213
  }
1209
1214
  if (ctx.artifactConfig?.includeTranscript !== false) {
1210
1215
  transcriptWriter = createChildTranscriptWriter({
@@ -1217,7 +1222,7 @@ async function runSingleStep(
1217
1222
  });
1218
1223
  }
1219
1224
  }
1220
- transcriptWriter?.writeInitialUserMessage(task);
1225
+ transcriptWriter?.writeInitialUserMessage(`${PROMPT_REDACTED}; live Prompt Audit only.`);
1221
1226
 
1222
1227
  if (step.runner?.type === "external-cli") {
1223
1228
  const runner: ExternalCliRunnerStatus = {
@@ -1260,7 +1265,7 @@ async function runSingleStep(
1260
1265
  artifactPaths,
1261
1266
  artifactConfig: ctx.artifactConfig,
1262
1267
  output: formatOutputArtifactContent(omitUndefinedProperties({ output: resolvedOutput.fullOutput, error: external.error, metadataPath: ctx.artifactConfig?.includeMetadata === false ? undefined : artifactPaths.metadataPath })),
1263
- metadata: { runId: ctx.id, agent: step.agent, task, runner, externalProcess: external.externalProcess, exitCode: external.exitCode, error: external.error, timestamp: Date.now() },
1268
+ metadata: { runId: ctx.id, agent: step.agent, task: PROMPT_REDACTED, runner, externalProcess: external.externalProcess, exitCode: external.exitCode, error: external.error, timestamp: Date.now() },
1264
1269
  })
1265
1270
  : {};
1266
1271
  return omitUndefinedProperties({
@@ -1290,7 +1295,7 @@ async function runSingleStep(
1290
1295
  let capabilityAudit: import("../shared/capability-ceiling.ts").SubagentCapabilityAudit | undefined;
1291
1296
  let launchResolvedExtensions = step.launchResolvedExtensions;
1292
1297
  const modelAttempts: ModelAttempt[] = [];
1293
- const writerProcesses: Array<{ processInstanceId: string; kind: "pi-writer"; attempt: number; closeObservedAt: number; exitCode: number | null; signal: string | null }> = [];
1298
+ const writerProcesses: PiWriterProcessInstanceExitV1[] = [];
1294
1299
  let writerAttemptCount = 0;
1295
1300
  const attemptNotes: string[] = [];
1296
1301
  const eventsPath = path.join(path.dirname(ctx.outputFile), "events.jsonl");
@@ -1304,6 +1309,9 @@ async function runSingleStep(
1304
1309
 
1305
1310
  let modelIndex = 0;
1306
1311
  let startupAttemptIndex = 0;
1312
+ // Escalated to "file" after an unexplained zero-activity startup failure so
1313
+ // retries keep the task text out of argv (endpoint pre-exec scans may deny it).
1314
+ let taskDeliveryOverride: SubagentTaskDelivery | undefined;
1307
1315
  modelAttemptsLoop: while (modelIndex < candidates.length) {
1308
1316
  if (ctx.timeoutSignal?.aborted || ctx.stopSignal?.aborted || ctx.skipAcceptance?.()) break;
1309
1317
  const candidate = candidates[modelIndex];
@@ -1329,6 +1337,7 @@ async function runSingleStep(
1329
1337
  parentSessionId: step.parentSessionId,
1330
1338
  baseArgs: ["--mode", "json", "-p"],
1331
1339
  task,
1340
+ taskDelivery: taskDeliveryOverride,
1332
1341
  sessionEnabled,
1333
1342
  sessionDir,
1334
1343
  sessionFile: step.sessionFile,
@@ -1354,6 +1363,10 @@ async function runSingleStep(
1354
1363
  parentControlInbox: ctx.nestedRoute?.controlInbox,
1355
1364
  parentRootRunId: ctx.nestedRoute?.rootRunId,
1356
1365
  parentCapabilityToken: ctx.nestedRoute?.capabilityToken,
1366
+ runFanoutBudget: ctx.runFanoutBudget ? {
1367
+ ...ctx.runFanoutBudget,
1368
+ ...(step.runFanoutPath ? { parentPath: `${ctx.runFanoutBudget.parentPath ? `${ctx.runFanoutBudget.parentPath}/` : ""}${step.runFanoutPath}` } : {}),
1369
+ } : undefined,
1357
1370
  steerInboxDir: ctx.steerInboxDir,
1358
1371
  steerCapabilityPath: ctx.steerCapabilityPath,
1359
1372
  steerAckDir: ctx.steerAckDir,
@@ -1427,6 +1440,7 @@ async function runSingleStep(
1427
1440
  closeObservedAt: run.processCloseObservedAt,
1428
1441
  exitCode: run.exitCode,
1429
1442
  signal: run.processSignal ?? null,
1443
+ processTree: run.processTree,
1430
1444
  });
1431
1445
  }
1432
1446
  if (run.turnBudget) turnBudget = run.turnBudget;
@@ -1581,6 +1595,10 @@ async function runSingleStep(
1581
1595
  });
1582
1596
  const shouldRetry = await waitForSubagentStartupRetry(retryDelayMs, [ctx.timeoutSignal, ctx.stopSignal]);
1583
1597
  if (!shouldRetry || ctx.skipAcceptance?.()) break modelAttemptsLoop;
1598
+ if (!taskDeliveryOverride && run.processSignal === "SIGKILL") {
1599
+ taskDeliveryOverride = "file";
1600
+ attemptNotes.push("[startup-retry] retrying with file task delivery to keep the task text out of the child process argv.");
1601
+ }
1584
1602
  attempt.error = retryNote;
1585
1603
  attemptNotes.push(retryNote);
1586
1604
  startupAttemptIndex += 1;
@@ -1698,7 +1716,7 @@ async function runSingleStep(
1698
1716
  metadata: {
1699
1717
  runId: ctx.id,
1700
1718
  agent: step.agent,
1701
- task,
1719
+ task: PROMPT_REDACTED,
1702
1720
  exitCode: effectiveFinalExitCode,
1703
1721
  model: finalResult?.model,
1704
1722
  attemptedModels: attemptedModels.length > 0 ? attemptedModels : undefined,
@@ -2169,6 +2187,7 @@ async function runSubagent(
2169
2187
  ...(config.launchContractDigest ? { launchContractDigest: config.launchContractDigest } : {}),
2170
2188
  ...(config.launchResolvedExtensions ? { launchResolvedExtensions: config.launchResolvedExtensions } : {}),
2171
2189
  ...(config.capabilityCeiling ? { capabilityCeiling: config.capabilityCeiling } : {}),
2190
+ ...(config.runFanoutBudget ? { runFanoutBudget: getRunFanoutBudgetSnapshot(config.runFanoutBudget) } : {}),
2172
2191
  ...(config.parentWorkflowRunId ? { parentWorkflowRunId: config.parentWorkflowRunId } : {}),
2173
2192
  ...(config.workflowKey ? { workflowKey: config.workflowKey } : {}),
2174
2193
  ...(config.runnerProcessInstanceId ? { processTerminal: { version: 1 as const, state: "pending" as const, runId: id, runnerProcessInstanceId: config.runnerProcessInstanceId } } : {}),
@@ -2180,6 +2199,7 @@ async function runSubagent(
2180
2199
 
2181
2200
  fs.mkdirSync(asyncDir, { recursive: true });
2182
2201
  writeAtomicJson(statusPath, statusPayload);
2202
+ updateActiveRunIndex(asyncDir, statusPayload.state);
2183
2203
  let pendingParallelUsageCost: CostSummary = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
2184
2204
  const currentUsageTotals = (): CostSummary => {
2185
2205
  const cost = results.reduce<CostSummary>((sum, result) => ({
@@ -2252,11 +2272,25 @@ async function runSubagent(
2252
2272
  for (const node of graph.nodes) updateNode(node);
2253
2273
  statusPayload.workflowGraph = graph;
2254
2274
  };
2255
- const writeStatusPayload = (): void => {
2275
+ let lastIndexedActiveState = isActiveAsyncState(statusPayload.state);
2276
+ const writeStatusPayloadNow = (): void => {
2256
2277
  refreshWorkflowGraph();
2257
2278
  writeAtomicJson(statusPath, statusPayload);
2279
+ const activeState = isActiveAsyncState(statusPayload.state);
2280
+ if (activeState && activeState !== lastIndexedActiveState) {
2281
+ updateActiveRunIndex(asyncDir, statusPayload.state);
2282
+ }
2283
+ lastIndexedActiveState = activeState;
2258
2284
  emitNestedSelfEvent(statusPayload.state === "running" || statusPayload.state === "queued" ? "subagent.nested.updated" : "subagent.nested.completed");
2259
2285
  };
2286
+ const statusWriteCoalescer = createFileCoalescer(writeStatusPayloadNow, 100);
2287
+ const writeStatusPayload = (immediate = true): void => {
2288
+ if (immediate || statusPayload.state !== "running" || statusPayload.activityState !== undefined) {
2289
+ if (!statusWriteCoalescer.flush(statusPath)) writeStatusPayloadNow();
2290
+ return;
2291
+ }
2292
+ statusWriteCoalescer.schedule(statusPath);
2293
+ };
2260
2294
  const updateExternalProcess = (index: number, process: ExternalProcessStatus): void => {
2261
2295
  requiredStatusStep(statusPayload, index).externalProcess = process;
2262
2296
  statusPayload.lastUpdate = Date.now();
@@ -2783,7 +2817,7 @@ async function runSubagent(
2783
2817
  step.lastActivityAt = now;
2784
2818
  statusPayload.lastActivityAt = now;
2785
2819
  statusPayload.lastUpdate = now;
2786
- writeStatusPayload();
2820
+ writeStatusPayload(false);
2787
2821
  return;
2788
2822
  }
2789
2823
  if (event.type === "tool_execution_start" && event.toolName) {
@@ -2919,7 +2953,7 @@ async function runSubagent(
2919
2953
  statusPayload.lastActivityAt = now;
2920
2954
  statusPayload.lastUpdate = now;
2921
2955
  maybeEmitActiveLongRunning(flatIndex, now);
2922
- writeStatusPayload();
2956
+ writeStatusPayload(false);
2923
2957
  };
2924
2958
  const updateRunnerActivityState = (now: number): boolean => {
2925
2959
  if (!controlConfig.enabled) return false;
@@ -3018,7 +3052,6 @@ async function runSubagent(
3018
3052
  if (stopped || timedOut || interrupted || statusPayload.state !== "running") return;
3019
3053
  stopped = true;
3020
3054
  const now = Date.now();
3021
- statusPayload.state = "stopped";
3022
3055
  statusPayload.stopped = true;
3023
3056
  statusPayload.error = stopMessage;
3024
3057
  currentActivityState = undefined;
@@ -3051,7 +3084,6 @@ async function runSubagent(
3051
3084
  timedOut = true;
3052
3085
  const now = Date.now();
3053
3086
  const message = timeoutMessage ?? "Subagent timed out.";
3054
- statusPayload.state = "failed";
3055
3087
  statusPayload.timedOut = true;
3056
3088
  statusPayload.error = message;
3057
3089
  currentActivityState = undefined;
@@ -3241,6 +3273,9 @@ async function runSubagent(
3241
3273
  throw new DynamicFanoutError(`Dynamic chain step ${stepIndex + 1} materialized ${materialized.parallel.length} items that resolve output to the same path: ${step.parallel.outputPath}. Remove the explicit output path or use an inherited relative agent output so each item can be isolated.`);
3242
3274
  }
3243
3275
  if (materialized.collectedOnEmpty) await validateDynamicCollection(step.collect.outputSchema, materialized.collectedOnEmpty);
3276
+ if (!config.runFanoutBudget) throw new Error("Async runner is missing its run fan-out budget identity.");
3277
+ const runFanoutBudget = claimRunFanoutBatch(config.runFanoutBudget, materialized.parallel.map((_, itemIndex) => `chain[${stepIndex}].expand[${itemIndex}]`));
3278
+ statusPayload.runFanoutBudget = runFanoutBudget;
3244
3279
  } catch (error) {
3245
3280
  const now = Date.now();
3246
3281
  const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
@@ -3347,6 +3382,7 @@ async function runSubagent(
3347
3382
  const materializedTask = step.parallel.namespaceOutputPath ? injectSingleOutputInstruction(taskText, outputPath, step.parallel) : taskText;
3348
3383
  return omitUndefinedProperties({
3349
3384
  ...step.parallel,
3385
+ runFanoutPath: `chain[${stepIndex}].expand[${itemIndex}]`,
3350
3386
  task: materializedTask,
3351
3387
  effectiveAcceptance: resolveEffectiveAcceptance(omitUndefinedProperties({
3352
3388
  explicit: step.parallel.acceptanceInput,
@@ -3506,6 +3542,7 @@ async function runSubagent(
3506
3542
  orchestratorIntercomTarget: config.controlIntercomTarget,
3507
3543
  nestedRoute: config.nestedRoute,
3508
3544
  capabilityCeiling: config.capabilityCeiling,
3545
+ runFanoutBudget: config.runFanoutBudget,
3509
3546
  registerInterrupt: (interrupt) => registerStepInterrupt(fi, interrupt),
3510
3547
  registerTimeout: (interrupt) => registerStepTimeout(fi, interrupt),
3511
3548
  registerStop: (stop) => registerStepStop(fi, stop),
@@ -3888,6 +3925,7 @@ async function runSubagent(
3888
3925
  orchestratorIntercomTarget: config.controlIntercomTarget,
3889
3926
  nestedRoute: config.nestedRoute,
3890
3927
  capabilityCeiling: config.capabilityCeiling,
3928
+ runFanoutBudget: config.runFanoutBudget,
3891
3929
  registerInterrupt: (interrupt) => registerStepInterrupt(fi, interrupt),
3892
3930
  registerTimeout: (interrupt) => registerStepTimeout(fi, interrupt),
3893
3931
  registerStop: (stop) => registerStepStop(fi, stop),
@@ -4177,6 +4215,7 @@ async function runSubagent(
4177
4215
  orchestratorIntercomTarget: config.controlIntercomTarget,
4178
4216
  nestedRoute: config.nestedRoute,
4179
4217
  capabilityCeiling: config.capabilityCeiling,
4218
+ runFanoutBudget: config.runFanoutBudget,
4180
4219
  registerInterrupt: (interrupt) => registerStepInterrupt(flatIndex, interrupt),
4181
4220
  registerTimeout: (interrupt) => registerStepTimeout(flatIndex, interrupt),
4182
4221
  registerStop: (stop) => registerStepStop(flatIndex, stop),
@@ -4421,6 +4460,9 @@ async function runSubagent(
4421
4460
  clearTimeout(timeoutTimer);
4422
4461
  timeoutTimer = undefined;
4423
4462
  }
4463
+ if (!timedOut && !stopped && !interrupted && config.timeoutMs !== undefined && results.some((result) => result.timedOut === true && result.error === timeoutMessage)) {
4464
+ timedOut = true;
4465
+ }
4424
4466
  const signalTerminated = !stopped && !timedOut && !turnBudgetExceeded && !interrupted && results.some((result) => result.exitCode !== 0 && isUnexplainedProcessSignal(omitUndefinedProperties({
4425
4467
  processSignal: result.processSignal,
4426
4468
  interrupted: result.interrupted,
@@ -4480,6 +4522,7 @@ async function runSubagent(
4480
4522
  statusPayload.error = `Step failed: ${failedStep.agent}`;
4481
4523
  }
4482
4524
  }
4525
+ writeStatusPayload();
4483
4526
  try {
4484
4527
  writeAtomicJson(resultPath, {
4485
4528
  lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
@@ -4612,7 +4655,7 @@ async function runSubagent(
4612
4655
  shareError,
4613
4656
  }));
4614
4657
  if (config.runnerProcessInstanceId) {
4615
- const writers: Record<string, Array<{ processInstanceId: string; kind: "pi-writer"; attempt: number; closeObservedAt: number; exitCode: number | null; signal: string | null }>> = {};
4658
+ const writers: Record<string, PiWriterProcessInstanceExitV1[]> = {};
4616
4659
  const expectedWriters: Record<string, number> = {};
4617
4660
  for (const [index, result] of results.entries()) {
4618
4661
  writers[String(index)] = result.writerProcesses ?? [];
@@ -4633,7 +4676,6 @@ async function runSubagent(
4633
4676
  console.error(`Failed to write process-terminal candidate for '${id}':`, error);
4634
4677
  }
4635
4678
  }
4636
- writeStatusPayload();
4637
4679
  }
4638
4680
 
4639
4681
  async function waitForStartupControl(
@@ -0,0 +1,85 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
+ import { writeAtomicJson } from "../../shared/atomic-json.ts";
5
+ import { DIRS, type Details, type SubagentState } from "../../shared/types.ts";
6
+ import { readStatus } from "../../shared/utils.ts";
7
+ import { updateActiveRunIndex } from "../background/active-run-index.ts";
8
+ import { reconcileAsyncRun } from "../background/stale-run-reconciler.ts";
9
+
10
+ export function dismissRecoveredWorkflow(
11
+ state: SubagentState,
12
+ location: { asyncDir: string | null; resolvedId?: string },
13
+ ): AgentToolResult<Details> {
14
+ const asyncDir = location.asyncDir;
15
+ const runId = location.resolvedId ?? (asyncDir ? path.basename(asyncDir) : "requested");
16
+ if (!asyncDir) {
17
+ return {
18
+ content: [{ type: "text", text: `Recovered workflow '${runId}' has no disk status to dismiss.` }],
19
+ isError: true,
20
+ details: { mode: "management", results: [] },
21
+ };
22
+ }
23
+ const status = readStatus(asyncDir);
24
+ if (!status || status.mode !== "workflow") {
25
+ return {
26
+ content: [{ type: "text", text: `Run '${runId}' is not a recovered workflow.` }],
27
+ isError: true,
28
+ details: { mode: "management", results: [] },
29
+ };
30
+ }
31
+ if (!state.currentSessionId || status.sessionId !== state.currentSessionId) {
32
+ return {
33
+ content: [{ type: "text", text: `Recovered workflow '${runId}' was not found in the active session.` }],
34
+ isError: true,
35
+ details: { mode: "management", results: [] },
36
+ };
37
+ }
38
+ if (state.workflowControllers?.has(status.runId) || state.workflowControllers?.has(runId)) {
39
+ return {
40
+ content: [{ type: "text", text: `Workflow '${runId}' still has a live controller and cannot be dismissed.` }],
41
+ isError: true,
42
+ details: { mode: "management", results: [] },
43
+ };
44
+ }
45
+ if (status.state !== "running") {
46
+ return {
47
+ content: [{ type: "text", text: `Recovered workflow '${runId}' is ${status.state}, not running.` }],
48
+ isError: true,
49
+ details: { mode: "management", results: [] },
50
+ };
51
+ }
52
+ let latestStatus = status;
53
+ const resultPath = path.join(DIRS.results, `${status.runId}.json`);
54
+ if (fs.existsSync(resultPath)) {
55
+ const reconciled = reconcileAsyncRun(asyncDir).status;
56
+ if (reconciled && reconciled.state !== "running") {
57
+ return {
58
+ content: [{ type: "text", text: `Recovered workflow '${runId}' is ${reconciled.state}, not running.` }],
59
+ isError: true,
60
+ details: { mode: "management", results: [] },
61
+ };
62
+ }
63
+ if (reconciled) latestStatus = reconciled;
64
+ }
65
+
66
+ const dismissed = { ...latestStatus, displayDismissedAt: Date.now() };
67
+ writeAtomicJson(path.join(asyncDir, "status.json"), dismissed);
68
+ const repaired = reconcileAsyncRun(asyncDir).status;
69
+ if (repaired && repaired.state !== "running") {
70
+ return {
71
+ content: [{ type: "text", text: `Recovered workflow '${runId}' is ${repaired.state}, not running.` }],
72
+ isError: true,
73
+ details: { mode: "management", results: [] },
74
+ };
75
+ }
76
+ updateActiveRunIndex(asyncDir, "complete");
77
+ state.asyncJobs.delete(status.runId);
78
+ state.asyncJobs.delete(runId);
79
+ state.fleetJobs?.delete(status.runId);
80
+ state.fleetJobs?.delete(runId);
81
+ return {
82
+ content: [{ type: "text", text: `Dismissed recovered workflow ${status.runId} from the display. No running work was terminated.` }],
83
+ details: { mode: "management", results: [] },
84
+ };
85
+ }
@@ -219,15 +219,14 @@ export async function steerAsyncRun(input: {
219
219
  const limits = remainingSteeringRecoveryLimits(recoveryTarget.recoveryDescriptor, paused);
220
220
  const revived = await input.recover(limits);
221
221
  if (revived.isError || !revived.details.asyncId) throw new Error(revived.content[0]?.type === "text" ? revived.content[0].text : "Replacement launch failed; source run remains paused.");
222
- const sourceStatus = readStatus(asyncDir);
223
- const targetIndex = input.index ?? sourceStatus?.steering?.recent.find((request) => request.id === requestId)?.targets[0]?.index ?? status.steps?.findIndex((step) => step.status === "running") ?? -1;
224
- if (sourceStatus?.state === "paused" && sourceStatus.steering && targetIndex >= 0) {
225
- updateSteeringTarget(sourceStatus.steering, requestId, targetIndex, "recovered", Date.now(), { replacementRunId: revived.details.asyncId });
226
- const stepSteering = sourceStatus.steps?.[targetIndex]?.steering;
222
+ const targetIndex = input.index ?? paused.steering?.recent.find((request) => request.id === requestId)?.targets[0]?.index ?? status.steps?.findIndex((step) => step.status === "running") ?? -1;
223
+ if (paused.steering && targetIndex >= 0) {
224
+ updateSteeringTarget(paused.steering, requestId, targetIndex, "recovered", Date.now(), { replacementRunId: revived.details.asyncId });
225
+ const stepSteering = paused.steps?.[targetIndex]?.steering;
227
226
  if (stepSteering) updateSteeringTarget(stepSteering, requestId, targetIndex, "recovered", Date.now(), { replacementRunId: revived.details.asyncId });
228
- writeAtomicJson(path.join(asyncDir, "status.json"), sourceStatus);
227
+ writeAtomicJson(path.join(asyncDir, "status.json"), paused);
229
228
  }
230
- const recovered = sourceStatus?.steering ? actionResultFromSteeringStatus(sourceStatus.steering, status.runId, requestId, revived.details.asyncId) : undefined;
229
+ const recovered = paused.steering ? actionResultFromSteeringStatus(paused.steering, status.runId, requestId, revived.details.asyncId) : undefined;
231
230
  appendSteeringNotice("recovered", `Steering recovered for run ${status.runId}; replacement ${revived.details.asyncId} launched.`);
232
231
  return { content: [{ type: "text", text: `Steering recovered for async run ${status.runId}; replacement ${revived.details.asyncId} launched after the source paused.` }], details: { mode: "management", results: [], steering: recovered ?? { requestId, state: "recovered", sourceRunId: status.runId, replacementRunId: revived.details.asyncId, deliveryStatus: "delivered", targets: [{ index: input.index ?? 0, state: "recovered" }] } } };
233
232
  } catch (error) {