@ferris1225/pi-subagents 0.18.0 → 0.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Focused sub-agent delegation for pi: explore / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
- import { Text, truncateToWidth } from "@earendil-works/pi-tui";
16
+ import { Text } from "@earendil-works/pi-tui";
17
17
  import { Type } from "typebox";
18
18
  import { discoverAgents, type AgentConfig } from "./agents.ts";
19
19
  import { BackgroundTaskQueue } from "./background.ts";
@@ -43,7 +43,20 @@ import {
43
43
  type UsageStats,
44
44
  } from "./spawn.ts";
45
45
  import { buildFixTaskBrief, buildReReviewBrief, shouldTriggerFixLoop } from "./fixloop.ts";
46
- import { formatTaskSummary, formatToolActivity, monitor, statusColor, statusIcon, statusLabel, type RunChainMeta } from "./monitor.ts";
46
+ import {
47
+ activityStateLabel,
48
+ compactModelRef,
49
+ deriveActivityState,
50
+ formatElapsed,
51
+ formatTaskSummary,
52
+ formatToolActivity,
53
+ formatUsageCompact,
54
+ monitor,
55
+ rightAlign,
56
+ statusIcon,
57
+ statusLabel,
58
+ type RunChainMeta,
59
+ } from "./monitor.ts";
47
60
 
48
61
  const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
49
62
 
@@ -136,6 +149,7 @@ function formatUsage(usage: UsageStats): string {
136
149
  if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
137
150
  if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
138
151
  if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
152
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
139
153
  if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
140
154
  return parts.join(" ");
141
155
  }
@@ -148,7 +162,10 @@ function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd
148
162
  const fallbackNote = result.modelFallbackFrom
149
163
  ? ` (model fell back from ${result.modelFallbackFrom} to ${result.model ?? "main-window model"})`
150
164
  : "";
151
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
165
+ const retryNote = result.startupRetries
166
+ ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
167
+ : "";
168
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${retryNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
152
169
  if (truncated) {
153
170
  // The full text lives on disk so the main agent can read it on demand.
154
171
  lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, cwd)})`);
@@ -284,29 +301,30 @@ export default function (pi: ExtensionAPI): void {
284
301
  };
285
302
 
286
303
  // Live sub-agent activity → concise one-line status ("thinking",
287
- // "read src/index.ts", ...), never a raw args blob. Reviewer runs started
288
- // by the main agent defer finishing so the queue task can decide between
289
- // delivering the review and starting an auto-fix chain: a triggered chain
290
- // keeps the parent row in the widget (annotated) until it completes and
291
- // suppresses the premature "done" notification.
292
- const makeLiveHandler = (runId: number, deferFinish = false) => (e: SubagentLiveEvent): void => {
304
+ // "read src/index.ts", ...), never a raw args blob. The live handler only
305
+ // updates widget status; finishing (removeRun + notify) is owned by the
306
+ // queue task / launchInLoop. That keeps a startup retry which fires a
307
+ // transient "failed" status before relaunching from ripping the row out
308
+ // early, and lets the queue task decide between delivering a reviewer's
309
+ // result and starting an auto-fix chain (a triggered chain keeps the
310
+ // parent row annotated until it completes).
311
+ const makeLiveHandler = (runId: number) => (e: SubagentLiveEvent): void => {
293
312
  switch (e.kind) {
294
313
  case "status":
295
- if (e.status === "done" || e.status === "failed") {
296
- // Deferred runs only update the widget; the queue task finishes
297
- // them once it knows whether an auto-fix chain will follow.
298
- if (deferFinish) monitor.setStatus(runId, e.status);
299
- else finishRun(runId, e.status);
300
- } else monitor.setStatus(runId, e.status);
314
+ // Only update the widget status here. Finishing (removeRun + notify) is
315
+ // owned by the queue task / launchInLoop so that a startup retry — which
316
+ // fires a transient "failed" status before relaunching the child — never
317
+ // rips the row out from under the retry or emits a premature "✗" toast.
318
+ monitor.setStatus(runId, e.status);
301
319
  break;
302
320
  case "usage":
303
321
  monitor.setUsage(runId, e.usage, e.model);
304
322
  break;
305
323
  case "tool_start":
306
- monitor.setActivity(runId, formatToolActivity(e.toolName, e.args));
324
+ monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args));
307
325
  break;
308
326
  case "tool_end":
309
- if (e.isError) monitor.setActivity(runId, `✗ ${e.toolName} failed`);
327
+ monitor.recordToolEnd(runId, e.toolName, e.isError);
310
328
  break;
311
329
  case "thinking":
312
330
  monitor.setActivity(runId, "thinking");
@@ -522,7 +540,7 @@ export default function (pi: ExtensionAPI): void {
522
540
  const runId = monitor.addRun(agent.name, task, agent.model, thinkingLevel);
523
541
  // Only a main-agent-dispatched reviewer can trigger an auto-fix chain, so
524
542
  // only its finish is deferred to the queue task (see startFixLoop).
525
- const onLive = makeLiveHandler(runId, agent.name === "reviewer");
543
+ const onLive = makeLiveHandler(runId);
526
544
 
527
545
  backgroundQueue.enqueue(
528
546
  async (backgroundSignal) => {
@@ -553,7 +571,7 @@ export default function (pi: ExtensionAPI): void {
553
571
  errorMessage,
554
572
  dispatchFailed: true,
555
573
  };
556
- // The dedicated 派发失败 notification below replaces the generic
574
+ // The dedicated dispatch-failure notification below replaces the generic
557
575
  // failure toast for dispatch crashes, so finish silently here.
558
576
  finishRun(runId, "failed", { silent: true });
559
577
  }
@@ -578,7 +596,7 @@ export default function (pi: ExtensionAPI): void {
578
596
  }
579
597
  const failed = isFailedResult(result);
580
598
  // Model-level failures and dispatch crashes get their own dedicated
581
- // 派发失败 notification below, so finishRun's generic failure toast is
599
+ // dispatch-failure notification below, so finishRun's generic failure toast is
582
600
  // silenced for them (computed before finishRun for that reason).
583
601
  const modelLevel = failed && isModelLevelFailure(result);
584
602
  const dispatchFailed = result.dispatchFailed === true;
@@ -761,20 +779,47 @@ export default function (pi: ExtensionAPI): void {
761
779
  render(width: number): string[] {
762
780
  const runs = monitor.getRuns();
763
781
  if (runs.length === 0) return [];
782
+ const now = Date.now();
764
783
  const lines: string[] = [];
765
784
  for (const r of runs) {
766
785
  const icon = statusIcon(r.status, theme);
767
- const label = theme.fg(statusColor(r.status), statusLabel(r.status));
768
- // Chain-internal runs (auto-fix worker/reviewer) indent under their
769
- // parent reviewer; summarize() already carries the relationLabel.
786
+ // Chain-internal runs (auto-fix worker/reviewer) indent under their parent
787
+ // reviewer. For those, the relationLabel ("fix round 1") is more
788
+ // distinguishing than the repeated worker/reviewer name.
770
789
  const head = r.groupId ? theme.fg("dim", " ↳ ") : " ";
771
- const note = r.annotation ? theme.fg("dim", ` · ${r.annotation}`) : "";
772
- lines.push(truncateToWidth(`${head}${icon} #${r.id} ${monitor.summarize(r)} · ${label}${note}`, width, ""));
773
- if (r.status === "queued" || r.status === "running") {
774
- lines.push(truncateToWidth(theme.fg("dim", ` task: ${formatTaskSummary(r.task, Math.max(20, width - 11))}`), width, ""));
790
+ const name = r.groupId ? (r.relationLabel ?? r.agent) : r.agent;
791
+ // Header row stays short: icon, run id, agent name the task summary
792
+ // (keys-only fragments: paths, symbols, quoted phrases) gets its own
793
+ // line below with a full width budget, so parallel runs of the SAME
794
+ // agent are still distinguishable at a glance.
795
+ const title = formatTaskSummary(r.task, Math.max(16, Math.floor(width * 0.65)), true);
796
+ const left = `${head}${icon} ${theme.fg("dim", `#${r.id}`)} ${theme.bold(name)}`;
797
+
798
+ // Right side: compact model (no provider prefix), token usage (in/out +
799
+ // cache read/write), tool count, elapsed, and the soft activity-state
800
+ // annotation (idle / long-running). Always visible — rightAlign clips
801
+ // the title on overflow, never this.
802
+ const model = compactModelRef(r.model);
803
+ const usage = formatUsageCompact(r.usage);
804
+ const tools = r.toolCount ? `${r.toolCount} tool${r.toolCount === 1 ? "" : "s"}` : "";
805
+ const elapsed = formatElapsed(r, now);
806
+ const metaParts = [model, usage, tools, elapsed].filter(Boolean);
807
+ // Running is conveyed by the icon + elapsed; spell out the label only for
808
+ // the other states (ready / done / stopped) so they are unambiguous.
809
+ if (r.status !== "running") metaParts.push(statusLabel(r.status));
810
+ const state = deriveActivityState(r, now);
811
+ const stateNote = state ? ` · ${activityStateLabel(state)}` : "";
812
+ const note = r.annotation ? ` · ${r.annotation}` : "";
813
+ const right = theme.fg("dim", `${metaParts.join(" · ")}${stateNote}${note}`);
814
+ lines.push(rightAlign(left, right, width));
815
+
816
+ // Task summary sits one indent below the header, on its own line.
817
+ lines.push(rightAlign(`${head} ${theme.fg("accent", title)}`, "", width));
818
+
819
+ // Current activity sits one indent below, only while the run is active.
820
+ if (r.activity && (r.status === "running" || r.status === "queued")) {
821
+ lines.push(rightAlign(`${head} ${theme.fg("dim", r.activity)}`, "", width));
775
822
  }
776
- // Activity sits one indent level below the agent name.
777
- if (r.activity) lines.push(truncateToWidth(theme.fg("dim", ` ${r.activity}`), width, ""));
778
823
  }
779
824
  return lines;
780
825
  },
package/src/monitor.ts CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { stripVTControlCharacters } from "node:util";
14
14
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
- import { visibleWidth } from "@earendil-works/pi-tui";
15
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
16
16
  import type { UsageStats } from "./spawn.ts";
17
17
 
18
18
  // ---------------------------------------------------------------------------
@@ -21,6 +21,20 @@ import type { UsageStats } from "./spawn.ts";
21
21
 
22
22
  export type RunStatus = "queued" | "running" | "done" | "failed";
23
23
 
24
+ /** Soft state-awareness signals, complementary to the hard idle-kill: a run may
25
+ * be alive (stdout streaming) yet "stuck thinking" (no tool running for a while),
26
+ * or simply taking a long time. Both are surfaced as widget annotations so the
27
+ * user can tell a healthy busy run from one that needs a nudge. */
28
+ export type ActivityState = "needs_attention" | "active_long_running";
29
+
30
+ /** A run with no tool running and no activity for this long is "needs attention"
31
+ * (the model may be stuck between turns). Below the idle-kill threshold so the
32
+ * soft signal always fires before the hard kill. */
33
+ export const NEEDS_ATTENTION_AFTER_MS = 60_000;
34
+ /** A run whose total elapsed time exceeds this is "long-running": still active
35
+ * but worth flagging so the user can decide whether to wait or steer. */
36
+ export const ACTIVE_LONG_RUNNING_AFTER_MS = 240_000;
37
+
24
38
  export interface RunView {
25
39
  id: number;
26
40
  agent: string;
@@ -32,6 +46,14 @@ export interface RunView {
32
46
  usage: UsageStats;
33
47
  /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
34
48
  activity?: string;
49
+ /** Total tool calls started by the run so far (a progress signal). */
50
+ toolCount?: number;
51
+ /** Tool currently executing (set on tool_start, cleared on tool_end). When set,
52
+ * the run is NOT idle for needs-attention purposes. */
53
+ currentTool?: string;
54
+ /** Epoch ms of the last live activity (tool, usage, status). Used to derive
55
+ * the needs-attention state: no tool running AND now - lastActivityAt > threshold. */
56
+ lastActivityAt?: number;
35
57
  /** Epoch ms when the run started executing (set on first "running" status). */
36
58
  startedAt?: number;
37
59
  /** Epoch ms when the run finished (set on "done"/"failed"). */
@@ -212,6 +234,7 @@ export function formatUsageCompact(usage: UsageStats): string {
212
234
  if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
213
235
  if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
214
236
  if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
237
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
215
238
  if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
216
239
  return parts.join(" ");
217
240
  }
@@ -233,6 +256,47 @@ export function formatElapsed(run: RunView, now: number = Date.now()): string {
233
256
  return formatDuration(end - run.startedAt);
234
257
  }
235
258
 
259
+ /** Strip the provider prefix from a "provider/model-id" reference for compact
260
+ * widget display ("anthropic/claude-sonnet-4" → "claude-sonnet-4"). A bare id is
261
+ * left unchanged. */
262
+ export function compactModelRef(model: string | undefined): string {
263
+ if (!model) return "";
264
+ const slash = model.lastIndexOf("/");
265
+ return slash >= 0 ? model.slice(slash + 1) : model;
266
+ }
267
+
268
+ /** Human-readable label for a soft activity-state annotation. */
269
+ export function activityStateLabel(state: ActivityState): string {
270
+ return state === "needs_attention" ? "idle" : "long-running";
271
+ }
272
+
273
+ /** Derive the soft activity state of a run at render time: needs_attention
274
+ * (no tool running, idle past the threshold) takes priority over
275
+ * active_long_running (total elapsed past its threshold). Both are suppressed
276
+ * for non-running runs. */
277
+ export function deriveActivityState(run: RunView, now: number = Date.now()): ActivityState | undefined {
278
+ if (run.status !== "running") return undefined;
279
+ if (!run.currentTool) {
280
+ const since = run.lastActivityAt ?? run.startedAt ?? now;
281
+ if (now - since >= NEEDS_ATTENTION_AFTER_MS) return "needs_attention";
282
+ }
283
+ if (run.startedAt !== undefined && now - run.startedAt >= ACTIVE_LONG_RUNNING_AFTER_MS) {
284
+ return "active_long_running";
285
+ }
286
+ return undefined;
287
+ }
288
+
289
+ /** Left/right split a widget line so the right side (status, elapsed) is always
290
+ * visible and the left side (title) clips on overflow instead of pushing it off.
291
+ * `left`/`right` may carry ANSI styling; widths are measured display-column-wise. */
292
+ export function rightAlign(left: string, right: string, width: number): string {
293
+ const rightWidth = visibleWidth(right);
294
+ const leftMax = Math.max(0, width - rightWidth - 1);
295
+ const leftClipped = truncateToWidth(left, leftMax);
296
+ const gap = Math.max(1, width - visibleWidth(leftClipped) - rightWidth);
297
+ return truncateToWidth(`${leftClipped}${" ".repeat(gap)}${right}`, width);
298
+ }
299
+
236
300
  /** Max length of the argument target inside a formatted activity line. */
237
301
  export const ACTIVITY_TARGET_MAX = 60;
238
302
 
@@ -337,6 +401,7 @@ export class MonitorStore {
337
401
  // A model-fallback retry after a failed attempt restarts the clock; a
338
402
  // stale endedAt would freeze the elapsed display at the first attempt.
339
403
  if (run.endedAt !== undefined) run.endedAt = undefined;
404
+ run.lastActivityAt = Date.now();
340
405
  } else if ((status === "done" || status === "failed") && run.endedAt === undefined) {
341
406
  run.endedAt = Date.now();
342
407
  }
@@ -347,6 +412,7 @@ export class MonitorStore {
347
412
  if (!run) return;
348
413
  run.usage = { ...usage };
349
414
  if (model) run.model = model;
415
+ run.lastActivityAt = Date.now();
350
416
  this.notify();
351
417
  }
352
418
 
@@ -355,6 +421,31 @@ export class MonitorStore {
355
421
  const run = this.find(id);
356
422
  if (!run) return;
357
423
  run.activity = text;
424
+ run.lastActivityAt = Date.now();
425
+ this.notify();
426
+ }
427
+
428
+ /** Record a tool starting: counts it, marks it current, and updates activity.
429
+ * A running tool means the run is NOT idle, so needs-attention is suppressed
430
+ * while it stays current. */
431
+ recordToolStart(id: number, toolName: string, activity: string): void {
432
+ const run = this.find(id);
433
+ if (!run) return;
434
+ run.toolCount = (run.toolCount ?? 0) + 1;
435
+ run.currentTool = toolName;
436
+ run.activity = activity;
437
+ run.lastActivityAt = Date.now();
438
+ this.notify();
439
+ }
440
+
441
+ /** Record a tool ending: clears the current-tool marker (so the run becomes
442
+ * eligible for needs-attention again) and notes the failure in activity. */
443
+ recordToolEnd(id: number, toolName: string, isError: boolean): void {
444
+ const run = this.find(id);
445
+ if (!run) return;
446
+ run.currentTool = undefined;
447
+ run.lastActivityAt = Date.now();
448
+ if (isError) run.activity = `✗ ${toolName} failed`;
358
449
  this.notify();
359
450
  }
360
451
 
package/src/spawn.ts CHANGED
@@ -32,6 +32,15 @@ export const SUBAGENT_KILL_GRACE_MS = 5_000;
32
32
  * (idleTimeoutSec); this constant is only a fallback for tests. */
33
33
  export const SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS = 0;
34
34
 
35
+ /** Backoff schedule for retrying a child that exited before any model or tool
36
+ * activity — the signature of a concurrent pi startup race, where several
37
+ * sub-agents contending for pi's startup lock lose and exit with nothing on
38
+ * stdout. Bounded and short so persistent launch failures are not amplified
39
+ * while the startup lock clears. */
40
+ export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500] as const;
41
+ /** A genuine startup race fails well before a model request can complete. */
42
+ export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
43
+
35
44
  export interface UsageStats {
36
45
  input: number;
37
46
  output: number;
@@ -61,6 +70,10 @@ export interface SingleResult {
61
70
  * temp-file/fs errors, delivery bugs) instead of being produced by the agent
62
71
  * process. A dispatch failure is never a model-level failure. */
63
72
  dispatchFailed?: boolean;
73
+ /** How many times the run was relaunched after a silent, zero-activity startup
74
+ * exit (a concurrent pi startup race) before it produced a result. Set only when
75
+ * the run actually recovered after retrying, so callers can surface it. */
76
+ startupRetries?: number;
64
77
  }
65
78
 
66
79
  export interface SubagentDetails {
@@ -177,6 +190,73 @@ export function isModelLevelFailure(result: SingleResult): boolean {
177
190
  return result.messages.length > 0 || result.stderr.trim().length > 0;
178
191
  }
179
192
 
193
+ /**
194
+ * True when a failed run produced NO model, tool, output, or usage activity
195
+ * within the startup window — the signature of a concurrent pi startup race,
196
+ * where the child lost pi's startup lock and exited before doing anything.
197
+ * Such a run is safe to relaunch: nothing was mutated and no provider call
198
+ * completed, so retrying cannot duplicate work.
199
+ *
200
+ * Fails closed: any final output, assistant message, usage, stderr, structured
201
+ * error message, idle-timeout, abort, dispatch crash, or run that outlived the
202
+ * startup window disqualifies the run from retry (it either did real work or
203
+ * carries a real error that belongs to model fallback / normal failure
204
+ * delivery instead). Only a clean, SILENT, fast, zero-activity exit retries.
205
+ */
206
+ export function isRetryableStartupFailure(result: SingleResult, durationMs: number): boolean {
207
+ if (result.exitCode === 0) return false;
208
+ if (result.stopReason === "aborted") return false;
209
+ if (result.dispatchFailed) return false;
210
+ if (result.errorMessage?.includes("idle timeout")) return false;
211
+ if (getFinalOutput(result.messages)) return false;
212
+ if (result.messages.length > 0) return false;
213
+ const usage = result.usage;
214
+ if (usage.turns || usage.input || usage.output || usage.cacheRead || usage.cacheWrite || usage.cost) return false;
215
+ if (durationMs > MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS) return false;
216
+ // Any stderr or structured error could be a real provider/config error (auth,
217
+ // bad model id, quota, ...) that must not be amplified by retry. A silent
218
+ // zero-activity exit — no stdout, no stderr, no error message — is the race.
219
+ if (result.stderr.trim().length > 0) return false;
220
+ if (result.errorMessage && result.errorMessage.trim().length > 0) return false;
221
+ return true;
222
+ }
223
+
224
+ /** Error surfaced when every startup-retry attempt still exited with no
225
+ * activity. Tells the main agent the dispatch never reached a model and what to
226
+ * do (retry, or lower maxConcurrency). */
227
+ export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
228
+ return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child exited before any model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or temporarily lower maxConcurrency in /subagents-setup.`;
229
+ }
230
+
231
+ /** Wait out a startup-retry backoff. Resolves false immediately (do not retry)
232
+ * when the signal is or becomes aborted during the wait, so cancellation never
233
+ * delays delivering the last result. The timer is unref'd so it cannot keep the
234
+ * event loop alive on shutdown. */
235
+ export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
236
+ if (delayMs <= 0) return !signal?.aborted;
237
+ if (!signal) {
238
+ return new Promise<boolean>((resolve) => {
239
+ const timer = setTimeout(() => resolve(true), delayMs);
240
+ if (typeof timer.unref === "function") timer.unref();
241
+ });
242
+ }
243
+ if (signal.aborted) return false;
244
+ return new Promise<boolean>((resolve) => {
245
+ let settled = false;
246
+ const finish = (shouldRetry: boolean): void => {
247
+ if (settled) return;
248
+ settled = true;
249
+ clearTimeout(timer);
250
+ signal.removeEventListener("abort", onAbort);
251
+ resolve(shouldRetry);
252
+ };
253
+ const onAbort = (): void => finish(false);
254
+ const timer = setTimeout(() => finish(true), delayMs);
255
+ if (typeof timer.unref === "function") timer.unref();
256
+ signal.addEventListener("abort", onAbort, { once: true });
257
+ });
258
+ }
259
+
180
260
  export function getResultOutput(result: SingleResult): string {
181
261
  if (isFailedResult(result)) {
182
262
  const error = result.errorMessage || result.stderr;
@@ -253,6 +333,10 @@ export interface RunSingleOptions {
253
333
  /** Idle timeout in ms: terminate the child if its stdout produces no activity
254
334
  * for this duration. 0 (the default) disables the idle watchdog. */
255
335
  idleTimeoutMs?: number;
336
+ /** Startup-retry backoff schedule (ms) for silent, zero-activity child exits
337
+ * (a concurrent pi startup race). Defaults to SUBAGENT_STARTUP_RETRY_DELAYS_MS;
338
+ * pass a shorter array in tests to keep them fast. */
339
+ startupRetryDelaysMs?: readonly number[];
256
340
  signal?: AbortSignal;
257
341
  onLive?: (e: SubagentLiveEvent) => void;
258
342
  makeDetails: (results: SingleResult[]) => SubagentDetails;
@@ -548,22 +632,68 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
548
632
  }
549
633
 
550
634
  /**
551
- * Run one agent; when the configured model fails at the provider level before
552
- * producing any output (see isModelLevelFailure), retry once with the main
553
- * window's current model. The retried result is returned with `modelFallbackFrom`
554
- * set so callers can surface the degradation. The fallback is per-run only and
555
- * never persisted: a transient provider hiccup must not silently downgrade the
556
- * configured agent model.
635
+ * Run one agent with two layers of resilience against transient dispatch failures:
636
+ *
637
+ * 1. Startup retry (inner loop): a concurrent pi startup race can make the child
638
+ * exit before any model/tool activity. Relaunch with backoff so the startup
639
+ * lock clears. The SAME model is retried the race is in the host, not the
640
+ * model and only a clean, silent, zero-activity exit qualifies (see
641
+ * isRetryableStartupFailure), so retrying can never duplicate real work.
642
+ * 2. Model fallback (outer): when the provider rejects the configured model
643
+ * before producing output (see isModelLevelFailure), retry once with the main
644
+ * window's current model. The fallback gets its own startup-retry loop, since
645
+ * a startup race can hit any relaunch regardless of model.
646
+ *
647
+ * The fallback is per-run only and never persisted: a transient provider hiccup
648
+ * must not silently downgrade the configured agent model.
557
649
  */
558
650
  export async function runSingleAgentWithModelFallback(
559
651
  options: RunSingleOptions,
560
652
  fallbackModelRef?: string,
561
653
  ): Promise<SingleResult> {
562
- const result = await runSingleAgent(options);
563
654
  const agent = options.agent;
564
655
  const launchedRef = agent?.model;
656
+ const delays = options.startupRetryDelaysMs ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
657
+
658
+ const runWithStartupRetry = async (opts: RunSingleOptions): Promise<SingleResult> => {
659
+ let lastResult: SingleResult;
660
+ let retries = 0;
661
+ for (let attempt = 0; ; attempt++) {
662
+ const start = Date.now();
663
+ lastResult = await runSingleAgent(opts);
664
+ const durationMs = Date.now() - start;
665
+ if (!isRetryableStartupFailure(lastResult, durationMs)) {
666
+ if (retries > 0 && !isFailedResult(lastResult)) lastResult.startupRetries = retries;
667
+ return lastResult;
668
+ }
669
+ const delay = delays[attempt];
670
+ if (delay === undefined) {
671
+ // Exhausted: the agent never reached a model. Surface the concurrency-race
672
+ // cause as a dispatch-level failure (no model was ever reached, so this
673
+ // must NOT trigger model fallback) so the main agent can retry or lower
674
+ // maxConcurrency.
675
+ lastResult.errorMessage = formatStartupRetryExhaustedError(
676
+ lastResult.model ?? opts.agent?.model ?? "default",
677
+ attempt + 1,
678
+ );
679
+ lastResult.stopReason ??= "error";
680
+ lastResult.dispatchFailed = true;
681
+ return lastResult;
682
+ }
683
+ // Flip the live status back to running so the widget does not flash a
684
+ // false "failed" while we wait out the backoff and relaunch the child.
685
+ try {
686
+ opts.onLive?.({ kind: "status", status: "running" });
687
+ } catch { /* never throw from event handling */ }
688
+ const shouldRetry = await waitForStartupRetry(delay, opts.signal);
689
+ if (!shouldRetry) return lastResult;
690
+ retries++;
691
+ }
692
+ };
693
+
694
+ const result = await runWithStartupRetry(options);
565
695
  if (!agent || !launchedRef || !fallbackModelRef || launchedRef === fallbackModelRef) return result;
566
696
  if (!isModelLevelFailure(result)) return result;
567
- const retried = await runSingleAgent({ ...options, agent: { ...agent, model: fallbackModelRef } });
697
+ const retried = await runWithStartupRetry({ ...options, agent: { ...agent, model: fallbackModelRef } });
568
698
  return { ...retried, modelFallbackFrom: launchedRef };
569
699
  }