@ferris1225/pi-subagents 0.18.0 → 0.19.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.19.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,19 @@ 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
+ monitor,
54
+ rightAlign,
55
+ statusIcon,
56
+ statusLabel,
57
+ type RunChainMeta,
58
+ } from "./monitor.ts";
47
59
 
48
60
  const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
49
61
 
@@ -148,7 +160,10 @@ function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd
148
160
  const fallbackNote = result.modelFallbackFrom
149
161
  ? ` (model fell back from ${result.modelFallbackFrom} to ${result.model ?? "main-window model"})`
150
162
  : "";
151
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
163
+ const retryNote = result.startupRetries
164
+ ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
165
+ : "";
166
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${retryNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
152
167
  if (truncated) {
153
168
  // The full text lives on disk so the main agent can read it on demand.
154
169
  lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, cwd)})`);
@@ -284,29 +299,30 @@ export default function (pi: ExtensionAPI): void {
284
299
  };
285
300
 
286
301
  // 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 => {
302
+ // "read src/index.ts", ...), never a raw args blob. The live handler only
303
+ // updates widget status; finishing (removeRun + notify) is owned by the
304
+ // queue task / launchInLoop. That keeps a startup retry which fires a
305
+ // transient "failed" status before relaunching from ripping the row out
306
+ // early, and lets the queue task decide between delivering a reviewer's
307
+ // result and starting an auto-fix chain (a triggered chain keeps the
308
+ // parent row annotated until it completes).
309
+ const makeLiveHandler = (runId: number) => (e: SubagentLiveEvent): void => {
293
310
  switch (e.kind) {
294
311
  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);
312
+ // Only update the widget status here. Finishing (removeRun + notify) is
313
+ // owned by the queue task / launchInLoop so that a startup retry — which
314
+ // fires a transient "failed" status before relaunching the child — never
315
+ // rips the row out from under the retry or emits a premature "✗" toast.
316
+ monitor.setStatus(runId, e.status);
301
317
  break;
302
318
  case "usage":
303
319
  monitor.setUsage(runId, e.usage, e.model);
304
320
  break;
305
321
  case "tool_start":
306
- monitor.setActivity(runId, formatToolActivity(e.toolName, e.args));
322
+ monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args));
307
323
  break;
308
324
  case "tool_end":
309
- if (e.isError) monitor.setActivity(runId, `✗ ${e.toolName} failed`);
325
+ monitor.recordToolEnd(runId, e.toolName, e.isError);
310
326
  break;
311
327
  case "thinking":
312
328
  monitor.setActivity(runId, "thinking");
@@ -522,7 +538,7 @@ export default function (pi: ExtensionAPI): void {
522
538
  const runId = monitor.addRun(agent.name, task, agent.model, thinkingLevel);
523
539
  // Only a main-agent-dispatched reviewer can trigger an auto-fix chain, so
524
540
  // only its finish is deferred to the queue task (see startFixLoop).
525
- const onLive = makeLiveHandler(runId, agent.name === "reviewer");
541
+ const onLive = makeLiveHandler(runId);
526
542
 
527
543
  backgroundQueue.enqueue(
528
544
  async (backgroundSignal) => {
@@ -553,7 +569,7 @@ export default function (pi: ExtensionAPI): void {
553
569
  errorMessage,
554
570
  dispatchFailed: true,
555
571
  };
556
- // The dedicated 派发失败 notification below replaces the generic
572
+ // The dedicated dispatch-failure notification below replaces the generic
557
573
  // failure toast for dispatch crashes, so finish silently here.
558
574
  finishRun(runId, "failed", { silent: true });
559
575
  }
@@ -578,7 +594,7 @@ export default function (pi: ExtensionAPI): void {
578
594
  }
579
595
  const failed = isFailedResult(result);
580
596
  // Model-level failures and dispatch crashes get their own dedicated
581
- // 派发失败 notification below, so finishRun's generic failure toast is
597
+ // dispatch-failure notification below, so finishRun's generic failure toast is
582
598
  // silenced for them (computed before finishRun for that reason).
583
599
  const modelLevel = failed && isModelLevelFailure(result);
584
600
  const dispatchFailed = result.dispatchFailed === true;
@@ -761,20 +777,41 @@ export default function (pi: ExtensionAPI): void {
761
777
  render(width: number): string[] {
762
778
  const runs = monitor.getRuns();
763
779
  if (runs.length === 0) return [];
780
+ const now = Date.now();
764
781
  const lines: string[] = [];
765
782
  for (const r of runs) {
766
783
  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.
784
+ // Chain-internal runs (auto-fix worker/reviewer) indent under their parent
785
+ // reviewer. For those, the relationLabel ("fix round 1") is more
786
+ // distinguishing than the repeated worker/reviewer name.
770
787
  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, ""));
788
+ const name = r.groupId ? (r.relationLabel ?? r.agent) : r.agent;
789
+ // Inline the task's distinguishing key fragments (paths, symbols, quoted
790
+ // phrases) right after the agent name, so parallel runs of the SAME agent
791
+ // are distinguishable at a glance instead of only on a second line.
792
+ const title = formatTaskSummary(r.task, Math.max(16, Math.floor(width * 0.45)), true);
793
+ const left = `${head}${icon} ${theme.fg("dim", `#${r.id}`)} ${theme.bold(name)} ${theme.fg("dim", "›")} ${theme.fg("accent", title)}`;
794
+
795
+ // Right side: compact model (no provider prefix), tool count, elapsed,
796
+ // and the soft activity-state annotation (idle / long-running). Always
797
+ // visible — rightAlign clips the title on overflow, never this.
798
+ const model = compactModelRef(r.model);
799
+ const tools = r.toolCount ? `${r.toolCount} tool${r.toolCount === 1 ? "" : "s"}` : "";
800
+ const elapsed = formatElapsed(r, now);
801
+ const metaParts = [model, tools, elapsed].filter(Boolean);
802
+ // Running is conveyed by the icon + elapsed; spell out the label only for
803
+ // the other states (ready / done / stopped) so they are unambiguous.
804
+ if (r.status !== "running") metaParts.push(statusLabel(r.status));
805
+ const state = deriveActivityState(r, now);
806
+ const stateNote = state ? ` · ${activityStateLabel(state)}` : "";
807
+ const note = r.annotation ? ` · ${r.annotation}` : "";
808
+ const right = theme.fg("dim", `${metaParts.join(" · ")}${stateNote}${note}`);
809
+ lines.push(rightAlign(left, right, width));
810
+
811
+ // Current activity sits one indent below, only while the run is active.
812
+ if (r.activity && (r.status === "running" || r.status === "queued")) {
813
+ lines.push(rightAlign(`${head} ${theme.fg("dim", r.activity)}`, "", width));
775
814
  }
776
- // Activity sits one indent level below the agent name.
777
- if (r.activity) lines.push(truncateToWidth(theme.fg("dim", ` ${r.activity}`), width, ""));
778
815
  }
779
816
  return lines;
780
817
  },
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"). */
@@ -233,6 +255,47 @@ export function formatElapsed(run: RunView, now: number = Date.now()): string {
233
255
  return formatDuration(end - run.startedAt);
234
256
  }
235
257
 
258
+ /** Strip the provider prefix from a "provider/model-id" reference for compact
259
+ * widget display ("anthropic/claude-sonnet-4" → "claude-sonnet-4"). A bare id is
260
+ * left unchanged. */
261
+ export function compactModelRef(model: string | undefined): string {
262
+ if (!model) return "";
263
+ const slash = model.lastIndexOf("/");
264
+ return slash >= 0 ? model.slice(slash + 1) : model;
265
+ }
266
+
267
+ /** Human-readable label for a soft activity-state annotation. */
268
+ export function activityStateLabel(state: ActivityState): string {
269
+ return state === "needs_attention" ? "idle" : "long-running";
270
+ }
271
+
272
+ /** Derive the soft activity state of a run at render time: needs_attention
273
+ * (no tool running, idle past the threshold) takes priority over
274
+ * active_long_running (total elapsed past its threshold). Both are suppressed
275
+ * for non-running runs. */
276
+ export function deriveActivityState(run: RunView, now: number = Date.now()): ActivityState | undefined {
277
+ if (run.status !== "running") return undefined;
278
+ if (!run.currentTool) {
279
+ const since = run.lastActivityAt ?? run.startedAt ?? now;
280
+ if (now - since >= NEEDS_ATTENTION_AFTER_MS) return "needs_attention";
281
+ }
282
+ if (run.startedAt !== undefined && now - run.startedAt >= ACTIVE_LONG_RUNNING_AFTER_MS) {
283
+ return "active_long_running";
284
+ }
285
+ return undefined;
286
+ }
287
+
288
+ /** Left/right split a widget line so the right side (status, elapsed) is always
289
+ * visible and the left side (title) clips on overflow instead of pushing it off.
290
+ * `left`/`right` may carry ANSI styling; widths are measured display-column-wise. */
291
+ export function rightAlign(left: string, right: string, width: number): string {
292
+ const rightWidth = visibleWidth(right);
293
+ const leftMax = Math.max(0, width - rightWidth - 1);
294
+ const leftClipped = truncateToWidth(left, leftMax);
295
+ const gap = Math.max(1, width - visibleWidth(leftClipped) - rightWidth);
296
+ return truncateToWidth(`${leftClipped}${" ".repeat(gap)}${right}`, width);
297
+ }
298
+
236
299
  /** Max length of the argument target inside a formatted activity line. */
237
300
  export const ACTIVITY_TARGET_MAX = 60;
238
301
 
@@ -337,6 +400,7 @@ export class MonitorStore {
337
400
  // A model-fallback retry after a failed attempt restarts the clock; a
338
401
  // stale endedAt would freeze the elapsed display at the first attempt.
339
402
  if (run.endedAt !== undefined) run.endedAt = undefined;
403
+ run.lastActivityAt = Date.now();
340
404
  } else if ((status === "done" || status === "failed") && run.endedAt === undefined) {
341
405
  run.endedAt = Date.now();
342
406
  }
@@ -347,6 +411,7 @@ export class MonitorStore {
347
411
  if (!run) return;
348
412
  run.usage = { ...usage };
349
413
  if (model) run.model = model;
414
+ run.lastActivityAt = Date.now();
350
415
  this.notify();
351
416
  }
352
417
 
@@ -355,6 +420,31 @@ export class MonitorStore {
355
420
  const run = this.find(id);
356
421
  if (!run) return;
357
422
  run.activity = text;
423
+ run.lastActivityAt = Date.now();
424
+ this.notify();
425
+ }
426
+
427
+ /** Record a tool starting: counts it, marks it current, and updates activity.
428
+ * A running tool means the run is NOT idle, so needs-attention is suppressed
429
+ * while it stays current. */
430
+ recordToolStart(id: number, toolName: string, activity: string): void {
431
+ const run = this.find(id);
432
+ if (!run) return;
433
+ run.toolCount = (run.toolCount ?? 0) + 1;
434
+ run.currentTool = toolName;
435
+ run.activity = activity;
436
+ run.lastActivityAt = Date.now();
437
+ this.notify();
438
+ }
439
+
440
+ /** Record a tool ending: clears the current-tool marker (so the run becomes
441
+ * eligible for needs-attention again) and notes the failure in activity. */
442
+ recordToolEnd(id: number, toolName: string, isError: boolean): void {
443
+ const run = this.find(id);
444
+ if (!run) return;
445
+ run.currentTool = undefined;
446
+ run.lastActivityAt = Date.now();
447
+ if (isError) run.activity = `✗ ${toolName} failed`;
358
448
  this.notify();
359
449
  }
360
450
 
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
  }