@ferris1225/pi-subagents 4.1.1 → 4.1.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/src/spawn.ts CHANGED
@@ -49,8 +49,23 @@ export type { SubagentLiveEvent, UsageStats };
49
49
  export const SUBAGENT_THINKING_LEVEL: ThinkingLevel = DEFAULT_THINKING_LEVEL;
50
50
  /** 0 disables the watchdog; dispatch supplies the configured timeout. */
51
51
  export const SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS = 0;
52
- export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500] as const;
52
+ /** Base delays cover Pi's stale-lock window and leave headroom beyond the
53
+ * default four-way launch fan-out. Additive jitter below reduces the chance
54
+ * that contenders retry in the same lockstep waves. */
55
+ export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500, 3000, 6000] as const;
53
56
  export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
57
+ export const MAX_SUBAGENT_STARTUP_RETRY_JITTER_MS = 1000;
58
+
59
+ function normalizeStartupRetryDelay(delayMs: number): number {
60
+ return Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0;
61
+ }
62
+
63
+ export function addStartupRetryJitter(delayMs: number, randomValue = Math.random()): number {
64
+ const baseDelay = Math.floor(normalizeStartupRetryDelay(delayMs));
65
+ if (baseDelay === 0) return 0;
66
+ const boundedRandom = Number.isFinite(randomValue) ? Math.max(0, Math.min(1, randomValue)) : 0;
67
+ return baseDelay + Math.floor(Math.min(baseDelay, MAX_SUBAGENT_STARTUP_RETRY_JITTER_MS) * boundedRandom);
68
+ }
54
69
 
55
70
  export interface SingleResult extends RpcSingleResult {}
56
71
 
@@ -204,10 +219,17 @@ export function isModelLevelFailure(result: SingleResult): boolean {
204
219
  if (result.stopReason === "aborted") return false;
205
220
  if (result.dispatchFailed) return false;
206
221
  if (result.rpcStartupFailed) return false;
222
+ // A negative RPC response proves Pi rejected the prompt before execution. It
223
+ // remains safe to hand off even though dispatch was attempted; local write,
224
+ // close, timeout, and lost-ACK failures never set this explicit flag.
225
+ if (result.rpcPromptRejected) return true;
226
+ // The prompt write completed but its ACK never arrived. With no later activity
227
+ // we cannot know whether Pi started the model or tools, so neither startup
228
+ // retry nor selected→main fallback may replay this objective.
229
+ if (result.rpcPromptDispatched && !result.rpcPromptAccepted && !result.rpcActivity) return false;
207
230
  if (isRpcCommandTimeoutError(result.errorMessage)) return false;
208
231
  if (result.integrationStatus === "retained") return false;
209
232
  if (result.errorMessage?.includes("idle timeout")) return true;
210
- if (result.rpcPromptRejected) return true;
211
233
 
212
234
  // Classification belongs to the final assistant turn, not the whole attempt.
213
235
  // Earlier useful text or failed tool calls are retained session history and
@@ -236,7 +258,7 @@ export function isRetryableStartupFailure(result: SingleResult, durationMs: numb
236
258
  if (result.exitCode === 0) return false;
237
259
  if (result.stopReason === "aborted") return false;
238
260
  if (result.dispatchFailed) return false;
239
- if (result.rpcPromptAccepted || result.rpcActivity) return false;
261
+ if (result.rpcPromptDispatched || result.rpcPromptAccepted || result.rpcActivity) return false;
240
262
  if (result.errorMessage?.includes("idle timeout")) return false;
241
263
  if (getFinalOutput(result.messages)) return false;
242
264
  if (result.messages.length > 0) return false;
@@ -250,14 +272,15 @@ export function isRetryableStartupFailure(result: SingleResult, durationMs: numb
250
272
  }
251
273
 
252
274
  export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
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.`;
275
+ return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no 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.`;
254
276
  }
255
277
 
256
278
  export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
257
- if (delayMs <= 0) return !signal?.aborted;
279
+ const normalizedDelay = normalizeStartupRetryDelay(delayMs);
280
+ if (normalizedDelay === 0) return !signal?.aborted;
258
281
  if (!signal) {
259
282
  return new Promise<boolean>((resolve) => {
260
- const timer = setTimeout(() => resolve(true), delayMs);
283
+ const timer = setTimeout(() => resolve(true), normalizedDelay);
261
284
  if (typeof timer.unref === "function") timer.unref();
262
285
  });
263
286
  }
@@ -272,7 +295,7 @@ export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal)
272
295
  resolve(shouldRetry);
273
296
  };
274
297
  const onAbort = (): void => finish(false);
275
- const timer = setTimeout(() => finish(true), delayMs);
298
+ const timer = setTimeout(() => finish(true), normalizedDelay);
276
299
  if (typeof timer.unref === "function") timer.unref();
277
300
  signal.addEventListener("abort", onAbort, { once: true });
278
301
  });
@@ -283,7 +306,7 @@ async function waitForControlledRetry(
283
306
  signal: AbortSignal | undefined,
284
307
  control: RpcRunControl | undefined,
285
308
  ): Promise<boolean> {
286
- let remaining = delayMs;
309
+ let remaining = normalizeStartupRetryDelay(delayMs);
287
310
  while (remaining > 0) {
288
311
  if (control?.isParkRequested() || control?.isStopRequested()) return false;
289
312
  const slice = Math.min(remaining, 50);
@@ -304,7 +327,7 @@ export function getResultOutput(result: SingleResult): string {
304
327
  }
305
328
 
306
329
  export function buildResumePrompt(task: string, reason: string): string {
307
- return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Original task: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
330
+ return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Current objective: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
308
331
  }
309
332
 
310
333
  export function buildFallbackResumeReason(fromModel?: string): string {
@@ -328,6 +351,9 @@ export interface RunSingleOptions {
328
351
  sessionId?: string;
329
352
  /** Initial RPC prompt. Kept under the old name to limit caller churn. */
330
353
  stdinText?: string;
354
+ /** Refresh parent-derived tools immediately before every startup retry and
355
+ * selected-to-main fallback process is spawned. */
356
+ resolveAgentForAttempt?: (agent: AgentConfig) => AgentConfig;
331
357
  signal?: AbortSignal;
332
358
  onLive?: (event: SubagentLiveEvent) => void;
333
359
  makeDetails: (results: SingleResult[]) => SubagentDetails;
@@ -368,6 +394,15 @@ function controlledDisposition(options: RunSingleOptions, base?: SingleResult):
368
394
  return result;
369
395
  }
370
396
 
397
+ function signalAbortDisposition(options: RunSingleOptions, base: SingleResult): SingleResult | undefined {
398
+ if (!options.signal?.aborted) return undefined;
399
+ base.parked = undefined;
400
+ base.exitCode = 1;
401
+ base.stopReason = "aborted";
402
+ base.errorMessage = "Subagent was aborted";
403
+ return base;
404
+ }
405
+
371
406
  /** Spawn one RPC attempt and wait for stable settlement. */
372
407
  export async function runSingleAgent(options: RunSingleOptions): Promise<SingleResult> {
373
408
  const {
@@ -420,7 +455,8 @@ export async function runSingleAgentWithMainFallback(
420
455
  ): Promise<SingleResult> {
421
456
  const agent = options.agent;
422
457
  const launchedRef = agent?.model;
423
- const startupDelays = options.startupRetryDelaysMs ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
458
+ const customStartupDelays = options.startupRetryDelaysMs;
459
+ const startupDelays = customStartupDelays ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
424
460
 
425
461
  const sessionId = options.sessionId ?? randomUUID();
426
462
  const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
@@ -463,7 +499,10 @@ export async function runSingleAgentWithMainFallback(
463
499
  }
464
500
  const start = Date.now();
465
501
  try {
466
- lastResult = await runSingleAgent(opts);
502
+ const attemptOptions = opts.resolveAgentForAttempt
503
+ ? { ...opts, agent: opts.resolveAgentForAttempt(opts.agent) }
504
+ : opts;
505
+ lastResult = await runSingleAgent(attemptOptions);
467
506
  } catch (error) {
468
507
  const failed = await dispatchFailure(error);
469
508
  return controlledDisposition(opts, failed) ?? failed;
@@ -492,8 +531,9 @@ export async function runSingleAgentWithMainFallback(
492
531
  } catch {
493
532
  /* never throw from event handling */
494
533
  }
495
- if (!(await waitForControlledRetry(delay, opts.signal, opts.control))) {
496
- return controlledDisposition(opts, lastResult) ?? lastResult;
534
+ const retryDelay = customStartupDelays ? delay : addStartupRetryJitter(delay);
535
+ if (!(await waitForControlledRetry(retryDelay, opts.signal, opts.control))) {
536
+ return controlledDisposition(opts, lastResult) ?? signalAbortDisposition(opts, lastResult) ?? lastResult;
497
537
  }
498
538
  retries++;
499
539
  }