@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 +1 -1
- package/src/index.ts +66 -29
- package/src/monitor.ts +91 -1
- package/src/spawn.ts +138 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "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
|
|
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 {
|
|
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
|
|
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.
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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.
|
|
322
|
+
monitor.recordToolStart(runId, e.toolName, formatToolActivity(e.toolName, e.args));
|
|
307
323
|
break;
|
|
308
324
|
case "tool_end":
|
|
309
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
768
|
-
//
|
|
769
|
-
//
|
|
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
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
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
|
|
697
|
+
const retried = await runWithStartupRetry({ ...options, agent: { ...agent, model: fallbackModelRef } });
|
|
568
698
|
return { ...retried, modelFallbackFrom: launchedRef };
|
|
569
699
|
}
|