@ferris1225/pi-subagents 2.0.1 → 2.0.3
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/README.md +17 -11
- package/package.json +1 -1
- package/src/dispatch.ts +2 -1
- package/src/format.ts +5 -1
- package/src/rpc-run.ts +119 -54
- package/src/spawn.ts +13 -1
package/README.md
CHANGED
|
@@ -88,9 +88,10 @@ index.
|
|
|
88
88
|
- **Direct fallback with real thinking capabilities** — each agent has at most
|
|
89
89
|
one selected model. An unavailable selection, rate limit, invalid key, quota,
|
|
90
90
|
missing model, or provider failure hands directly to the current main model.
|
|
91
|
-
A child-only provider adapter forces request retries to zero
|
|
92
|
-
|
|
93
|
-
settings. Auto thinking clamps the
|
|
91
|
+
A child-only provider adapter forces inner request retries to zero; transient
|
|
92
|
+
stream drops still use Pi's outer turn retry, and only a settled model-level
|
|
93
|
+
failure hands off, without changing user settings. Auto thinking clamps the
|
|
94
|
+
agent preference to the
|
|
94
95
|
effective model's real `thinkingLevelMap`; manual setup shows only levels that
|
|
95
96
|
model supports.
|
|
96
97
|
- **Resumes, retargets, and forks preserve context** — every run is session-backed.
|
|
@@ -331,14 +332,19 @@ available catalog is skipped. Any model-level runtime failure — rate limit,
|
|
|
331
332
|
quota, invalid key/auth, missing model, provider error, or idle model stream —
|
|
332
333
|
hands directly to current main, including stream errors that retain partial text.
|
|
333
334
|
A child-only Pi extension wraps the selected provider's registered API stream
|
|
334
|
-
with `maxRetries: 0
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
335
|
+
with `maxRetries: 0` so a deterministic auth/quota miss fails fast. Transient
|
|
336
|
+
stream drops such as xAI `terminated` still use Pi's outer turn retry — the
|
|
337
|
+
parent does not `abort_retry` them — and only a settled model-level failure
|
|
338
|
+
hands off to current main. This uses supported extension/RPC surfaces in Node
|
|
339
|
+
and standalone/Bun builds, never rewrites global or project settings, and does
|
|
340
|
+
not alter descendant tool environments. Tool/test failures stay on the same
|
|
341
|
+
model because they are task failures, not model availability failures. A child is
|
|
342
|
+
probed with RPC `get_state` before the first prompt so the 30s command ACK clock
|
|
343
|
+
does not include process boot. Only a zero-activity startup miss can retry — a
|
|
344
|
+
silent fast exit, a `get_state` handshake timeout, or an initial prompt ACK
|
|
345
|
+
timeout before any agent/turn/stream/tool activity. Those transport misses are
|
|
346
|
+
not model-level failures and do not hand the task to the main window. An accepted
|
|
347
|
+
prompt or any activity forbids replay.
|
|
342
348
|
|
|
343
349
|
Auto thinking starts from the Agent's declared preference (`low` for `explore`,
|
|
344
350
|
`high` for the other built-ins) and uses Pi's capability map to clamp it to the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"description": "Controllable background sub-agent threads for pi: specialized roles, capability-aware thinking, direct main-model fallback, auto-fix chains, and Git worktree isolation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/dispatch.ts
CHANGED
|
@@ -1483,7 +1483,8 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
1483
1483
|
triggerTurn: completionTriggersTurn(result, runConfig.notifyOnReviewPass),
|
|
1484
1484
|
};
|
|
1485
1485
|
if (modelLevel) {
|
|
1486
|
-
|
|
1486
|
+
const detail = result.errorMessage?.trim() || "model unavailable or broken";
|
|
1487
|
+
runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${detail} — task handed to the main window`, "error");
|
|
1487
1488
|
} else if (dispatchFailed) {
|
|
1488
1489
|
runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${result.errorMessage ?? "dispatch crashed"}`, "error");
|
|
1489
1490
|
}
|
package/src/format.ts
CHANGED
|
@@ -155,11 +155,15 @@ export function formatCompletionBlock(
|
|
|
155
155
|
* fresh (which would re-scan everything). */
|
|
156
156
|
export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
|
|
157
157
|
const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
|
|
158
|
+
const detail = result.errorMessage?.trim();
|
|
159
|
+
const cause = detail
|
|
160
|
+
? `its model/provider call failed (${detail})`
|
|
161
|
+
: "its model was unavailable or failed (or the run stalled)";
|
|
158
162
|
const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
|
|
159
163
|
const recovery = sessionPreserved
|
|
160
164
|
? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
|
|
161
165
|
: ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
|
|
162
|
-
return `The sub-agent could not complete this task:
|
|
166
|
+
return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
|
|
163
167
|
}
|
|
164
168
|
|
|
165
169
|
/** Resolve a run-id request to actual ids: an exact numeric match always wins
|
package/src/rpc-run.ts
CHANGED
|
@@ -20,9 +20,16 @@ import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
|
|
|
20
20
|
|
|
21
21
|
export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
|
|
22
22
|
export const SUBAGENT_KILL_GRACE_MS = 5_000;
|
|
23
|
+
/** ACK budget after the child is known to be reading RPC. */
|
|
23
24
|
export const RPC_COMMAND_TIMEOUT_MS = 30_000;
|
|
25
|
+
/** Time allowed for the child to boot and answer get_state. */
|
|
26
|
+
export const RPC_READY_TIMEOUT_MS = 60_000;
|
|
24
27
|
export const RPC_ABORT_SETTLE_TIMEOUT_MS = 5_000;
|
|
25
28
|
|
|
29
|
+
export function isRpcCommandTimeoutError(message?: string): boolean {
|
|
30
|
+
return typeof message === "string" && message.includes("Timed out waiting for RPC response");
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
/** Prevent RPC prompt expansion when a control objective itself starts with
|
|
27
34
|
* slash (for example `/subagents-setup`). The original text stays verbatim
|
|
28
35
|
* below a non-command prefix and therefore always starts a model turn. */
|
|
@@ -59,6 +66,9 @@ export interface RpcSingleResult {
|
|
|
59
66
|
* model execution. This remains main-model handoff eligible even when an
|
|
60
67
|
* earlier, aborted objective left assistant text in the session. */
|
|
61
68
|
rpcPromptRejected?: boolean;
|
|
69
|
+
/** Handshake or initial prompt ACK never came back. This is a startup/
|
|
70
|
+
* transport miss, not a model/provider failure. */
|
|
71
|
+
rpcStartupFailed?: boolean;
|
|
62
72
|
/** The child accepted a prompt; startup retries must never duplicate it. */
|
|
63
73
|
rpcPromptAccepted?: boolean;
|
|
64
74
|
/** Pi emitted agent/turn/model/tool activity for this attempt. */
|
|
@@ -408,7 +418,7 @@ interface RpcResponse {
|
|
|
408
418
|
interface PendingRequest {
|
|
409
419
|
resolve: (response: RpcResponse) => void;
|
|
410
420
|
reject: (error: Error) => void;
|
|
411
|
-
timer
|
|
421
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
412
422
|
}
|
|
413
423
|
|
|
414
424
|
interface Deferred<T> {
|
|
@@ -442,6 +452,8 @@ export interface RunRpcAttemptOptions {
|
|
|
442
452
|
onLive?: (event: SubagentLiveEvent) => void;
|
|
443
453
|
env?: NodeJS.ProcessEnv;
|
|
444
454
|
control?: RpcRunControl;
|
|
455
|
+
rpcReadyTimeoutMs?: number;
|
|
456
|
+
rpcCommandTimeoutMs?: number;
|
|
445
457
|
}
|
|
446
458
|
|
|
447
459
|
/** Run one persistent RPC child until a stable `agent_settled` or control action. */
|
|
@@ -549,7 +561,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
549
561
|
|
|
550
562
|
const rejectPending = (error: Error): void => {
|
|
551
563
|
for (const request of pendingRequests.values()) {
|
|
552
|
-
clearTimeout(request.timer);
|
|
564
|
+
if (request.timer) clearTimeout(request.timer);
|
|
553
565
|
request.reject(error);
|
|
554
566
|
}
|
|
555
567
|
pendingRequests.clear();
|
|
@@ -589,32 +601,46 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
589
601
|
}
|
|
590
602
|
};
|
|
591
603
|
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
throw new Error("Subagent RPC stdin is not writable.");
|
|
595
|
-
}
|
|
596
|
-
// JSON strings may contain U+2028/U+2029. Only the final ASCII LF frames a
|
|
597
|
-
// record; never use a generic line reader on the receiving side.
|
|
598
|
-
proc.stdin.write(`${JSON.stringify(value)}\n`, "utf8");
|
|
599
|
-
};
|
|
604
|
+
const readyTimeoutMs = options.rpcReadyTimeoutMs ?? RPC_READY_TIMEOUT_MS;
|
|
605
|
+
const commandTimeoutMs = options.rpcCommandTimeoutMs ?? RPC_COMMAND_TIMEOUT_MS;
|
|
600
606
|
|
|
601
|
-
const
|
|
607
|
+
const writeLine = (value: object): Promise<void> =>
|
|
608
|
+
new Promise((resolve, reject) => {
|
|
609
|
+
if (!proc.stdin || proc.stdin.destroyed || !proc.stdin.writable) {
|
|
610
|
+
reject(new Error("Subagent RPC stdin is not writable."));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
// JSON strings may contain U+2028/U+2029. Only the final ASCII LF frames a
|
|
614
|
+
// record; never use a generic line reader on the receiving side.
|
|
615
|
+
proc.stdin.write(`${JSON.stringify(value)}\n`, "utf8", (error) => {
|
|
616
|
+
if (error) reject(error);
|
|
617
|
+
else resolve();
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const send = async (command: Record<string, unknown>, timeoutMs = commandTimeoutMs): Promise<RpcResponse> => {
|
|
602
622
|
if (finished || closed) throw new Error("Subagent RPC process is no longer active.");
|
|
603
623
|
const id = `req_${++requestId}`;
|
|
624
|
+
const payload = { ...command, id };
|
|
604
625
|
return new Promise<RpcResponse>((resolve, reject) => {
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
626
|
+
const pending: PendingRequest = { resolve, reject };
|
|
627
|
+
pendingRequests.set(id, pending);
|
|
628
|
+
void writeLine(payload).then(
|
|
629
|
+
() => {
|
|
630
|
+
if (!pendingRequests.has(id)) return;
|
|
631
|
+
pending.timer = setTimeout(() => {
|
|
632
|
+
pendingRequests.delete(id);
|
|
633
|
+
reject(new Error(`Timed out waiting for RPC response to ${String(command.type)}.`));
|
|
634
|
+
}, timeoutMs);
|
|
635
|
+
if (typeof pending.timer.unref === "function") pending.timer.unref();
|
|
636
|
+
},
|
|
637
|
+
(error) => {
|
|
638
|
+
if (!pendingRequests.has(id)) return;
|
|
639
|
+
pendingRequests.delete(id);
|
|
640
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
641
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
642
|
+
},
|
|
643
|
+
);
|
|
618
644
|
}).then((response) => {
|
|
619
645
|
if (!response.success) throw new Error(response.error || `RPC ${response.command} failed.`);
|
|
620
646
|
return response;
|
|
@@ -708,7 +734,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
708
734
|
result.exitCode = 1;
|
|
709
735
|
result.stopReason = "error";
|
|
710
736
|
result.errorMessage = `Replacement prompt was rejected: ${promptError.message}`;
|
|
711
|
-
result.rpcPromptRejected = true;
|
|
737
|
+
if (!isRpcCommandTimeoutError(promptError.message)) result.rpcPromptRejected = true;
|
|
712
738
|
finish();
|
|
713
739
|
terminate();
|
|
714
740
|
if (!closed) await processClosed.promise;
|
|
@@ -716,18 +742,41 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
716
742
|
}
|
|
717
743
|
},
|
|
718
744
|
async park(): Promise<void> {
|
|
745
|
+
const markParked = (): void => {
|
|
746
|
+
result.parked = true;
|
|
747
|
+
result.exitCode = 0;
|
|
748
|
+
result.stopReason = undefined;
|
|
749
|
+
result.errorMessage = undefined;
|
|
750
|
+
result.rpcStartupFailed = undefined;
|
|
751
|
+
result.rpcPromptRejected = undefined;
|
|
752
|
+
};
|
|
719
753
|
if (finished) {
|
|
720
754
|
if (!closed) await processClosed.promise;
|
|
755
|
+
if (result.parked) return;
|
|
756
|
+
// Handshake/startup already tore the child down. Convert a pre-prompt
|
|
757
|
+
// settlement into a park instead of throwing past the control tool.
|
|
758
|
+
if (!result.rpcPromptAccepted) {
|
|
759
|
+
markParked();
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
721
762
|
throw new Error("Thread already settled before it could be parked.");
|
|
722
763
|
}
|
|
723
764
|
setAttemptPhase("interrupting");
|
|
765
|
+
if (!initialPromptResolved) {
|
|
766
|
+
const parked = new Error("Run was parked before its initial prompt.");
|
|
767
|
+
resolveInitialPrompt(false, parked);
|
|
768
|
+
rejectPending(parked);
|
|
769
|
+
markParked();
|
|
770
|
+
setAttemptPhase("parked");
|
|
771
|
+
finish();
|
|
772
|
+
terminate();
|
|
773
|
+
if (!closed) await processClosed.promise;
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
724
776
|
const accepted = await abortAcceptedPrompt();
|
|
725
777
|
if (!accepted && !closed) await processClosed.promise;
|
|
726
778
|
if (finished && accepted) throw new Error("Thread exited while parking.");
|
|
727
|
-
|
|
728
|
-
result.exitCode = 0;
|
|
729
|
-
result.stopReason = undefined;
|
|
730
|
-
result.errorMessage = undefined;
|
|
779
|
+
markParked();
|
|
731
780
|
setAttemptPhase("parked");
|
|
732
781
|
finish();
|
|
733
782
|
terminate();
|
|
@@ -739,6 +788,11 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
739
788
|
return;
|
|
740
789
|
}
|
|
741
790
|
setAttemptPhase("interrupting");
|
|
791
|
+
if (!initialPromptResolved) {
|
|
792
|
+
const stopped = new Error(reason);
|
|
793
|
+
resolveInitialPrompt(false, stopped);
|
|
794
|
+
rejectPending(stopped);
|
|
795
|
+
}
|
|
742
796
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
743
797
|
const timeout = new Promise<boolean>((resolve) => {
|
|
744
798
|
timer = setTimeout(() => resolve(false), RPC_ABORT_SETTLE_TIMEOUT_MS);
|
|
@@ -813,12 +867,11 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
813
867
|
result.rpcActivity = true;
|
|
814
868
|
}
|
|
815
869
|
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
}
|
|
870
|
+
// Let Pi's outer turn retry run. Grok/xAI long streams commonly drop with
|
|
871
|
+
// a retryable `terminated` mid-turn; aborting that retry was misread as
|
|
872
|
+
// "model unavailable" and handed a still-working model back to the parent.
|
|
873
|
+
// After retries exhaust, dispatch still classifies a settled model-level
|
|
874
|
+
// failure and hands off.
|
|
822
875
|
|
|
823
876
|
// Child RPC mode exposes extension dialogs. Sub-agents are non-interactive:
|
|
824
877
|
// cancel blocking dialogs so an unrelated child extension cannot deadlock.
|
|
@@ -827,11 +880,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
827
880
|
typeof event.id === "string" &&
|
|
828
881
|
["select", "confirm", "input", "editor"].includes(event.method)
|
|
829
882
|
) {
|
|
830
|
-
|
|
831
|
-
writeLine({ type: "extension_ui_response", id: event.id, cancelled: true });
|
|
832
|
-
} catch {
|
|
833
|
-
/* process failure is handled by close/error */
|
|
834
|
-
}
|
|
883
|
+
void writeLine({ type: "extension_ui_response", id: event.id, cancelled: true }).catch(() => undefined);
|
|
835
884
|
return;
|
|
836
885
|
}
|
|
837
886
|
|
|
@@ -1020,20 +1069,36 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
1020
1069
|
resolveInitialPrompt(false, new Error("Run was stopped before its initial prompt."));
|
|
1021
1070
|
await attemptControl.stop();
|
|
1022
1071
|
} else {
|
|
1023
|
-
|
|
1024
|
-
(
|
|
1025
|
-
(
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
},
|
|
1036
|
-
)
|
|
1072
|
+
const failBeforePrompt = (error: Error, startup: boolean): void => {
|
|
1073
|
+
resolveInitialPrompt(false, error);
|
|
1074
|
+
if (finished) return;
|
|
1075
|
+
result.exitCode = 1;
|
|
1076
|
+
result.stopReason = "error";
|
|
1077
|
+
result.errorMessage = error.message;
|
|
1078
|
+
if (startup) result.rpcStartupFailed = true;
|
|
1079
|
+
else result.rpcPromptRejected = true;
|
|
1080
|
+
finish();
|
|
1081
|
+
terminate();
|
|
1082
|
+
};
|
|
1083
|
+
try {
|
|
1084
|
+
await send({ type: "get_state" }, readyTimeoutMs);
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
const handshakeError = error instanceof Error ? error : new Error(String(error));
|
|
1087
|
+
if (!control?.isParkRequested() && !control?.isStopRequested()) {
|
|
1088
|
+
failBeforePrompt(handshakeError, true);
|
|
1089
|
+
} else {
|
|
1090
|
+
resolveInitialPrompt(false, handshakeError);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (!finished && !initialPromptResolved && !control?.isParkRequested() && !control?.isStopRequested()) {
|
|
1094
|
+
void send({ type: "prompt", message: asPlainTextRpcPrompt(options.prompt) }).then(
|
|
1095
|
+
() => resolveInitialPrompt(true),
|
|
1096
|
+
(error) => {
|
|
1097
|
+
const promptError = error instanceof Error ? error : new Error(String(error));
|
|
1098
|
+
failBeforePrompt(promptError, isRpcCommandTimeoutError(promptError.message));
|
|
1099
|
+
},
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1037
1102
|
}
|
|
1038
1103
|
await outcome.promise;
|
|
1039
1104
|
terminate();
|
package/src/spawn.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
emptyUsage,
|
|
23
23
|
extractToolErrorText,
|
|
24
24
|
getPiInvocation,
|
|
25
|
+
isRpcCommandTimeoutError,
|
|
25
26
|
RpcRunControl,
|
|
26
27
|
runRpcAgentAttempt,
|
|
27
28
|
sessionExists,
|
|
@@ -37,6 +38,7 @@ export {
|
|
|
37
38
|
DEPTH_ENV_VAR,
|
|
38
39
|
extractToolErrorText,
|
|
39
40
|
getPiInvocation,
|
|
41
|
+
isRpcCommandTimeoutError,
|
|
40
42
|
RpcRunControl,
|
|
41
43
|
sessionExists,
|
|
42
44
|
SUBAGENT_KILL_GRACE_MS,
|
|
@@ -201,6 +203,8 @@ export function isModelLevelFailure(result: SingleResult): boolean {
|
|
|
201
203
|
if (!isFailedResult(result)) return false;
|
|
202
204
|
if (result.stopReason === "aborted") return false;
|
|
203
205
|
if (result.dispatchFailed) return false;
|
|
206
|
+
if (result.rpcStartupFailed) return false;
|
|
207
|
+
if (isRpcCommandTimeoutError(result.errorMessage)) return false;
|
|
204
208
|
if (result.integrationStatus === "retained") return false;
|
|
205
209
|
if (result.errorMessage?.includes("idle timeout")) return true;
|
|
206
210
|
if (result.rpcPromptRejected) return true;
|
|
@@ -217,6 +221,9 @@ export function isModelLevelFailure(result: SingleResult): boolean {
|
|
|
217
221
|
}
|
|
218
222
|
|
|
219
223
|
if ((result.failedTools?.length ?? 0) > 0) return false;
|
|
224
|
+
// No accepted prompt, no activity, and no assistant turn means the provider
|
|
225
|
+
// was never reached. Stderr or an exit error here is a startup/transport miss.
|
|
226
|
+
if (!result.rpcPromptAccepted && !result.rpcActivity) return false;
|
|
220
227
|
return Boolean(
|
|
221
228
|
result.rpcPromptAccepted ||
|
|
222
229
|
result.rpcActivity ||
|
|
@@ -235,6 +242,7 @@ export function isRetryableStartupFailure(result: SingleResult, durationMs: numb
|
|
|
235
242
|
if (result.messages.length > 0) return false;
|
|
236
243
|
const usage = result.usage;
|
|
237
244
|
if (usage.turns || usage.input || usage.output || usage.cacheRead || usage.cacheWrite || usage.cost) return false;
|
|
245
|
+
if (result.rpcStartupFailed) return true;
|
|
238
246
|
if (durationMs > MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS) return false;
|
|
239
247
|
if (result.stderr.trim().length > 0) return false;
|
|
240
248
|
if (result.errorMessage && result.errorMessage.trim().length > 0) return false;
|
|
@@ -242,7 +250,7 @@ export function isRetryableStartupFailure(result: SingleResult, durationMs: numb
|
|
|
242
250
|
}
|
|
243
251
|
|
|
244
252
|
export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
|
|
245
|
-
return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child
|
|
253
|
+
return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child never accepted its initial RPC prompt or produced 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.`;
|
|
246
254
|
}
|
|
247
255
|
|
|
248
256
|
export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
|
|
@@ -326,6 +334,8 @@ export interface RunSingleOptions {
|
|
|
326
334
|
env?: NodeJS.ProcessEnv;
|
|
327
335
|
/** Stable logical-generation controller shared across retry attempts. */
|
|
328
336
|
control?: RpcRunControl;
|
|
337
|
+
rpcReadyTimeoutMs?: number;
|
|
338
|
+
rpcCommandTimeoutMs?: number;
|
|
329
339
|
}
|
|
330
340
|
|
|
331
341
|
function controlledDisposition(options: RunSingleOptions, base?: SingleResult): SingleResult | undefined {
|
|
@@ -391,6 +401,8 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
391
401
|
onLive: options.onLive,
|
|
392
402
|
env: options.env,
|
|
393
403
|
control,
|
|
404
|
+
rpcReadyTimeoutMs: options.rpcReadyTimeoutMs,
|
|
405
|
+
rpcCommandTimeoutMs: options.rpcCommandTimeoutMs,
|
|
394
406
|
});
|
|
395
407
|
result.task = control?.getObjective() ?? result.task;
|
|
396
408
|
return result;
|