@ferris1225/pi-subagents 4.0.1 → 4.1.2

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);
@@ -368,6 +391,15 @@ function controlledDisposition(options: RunSingleOptions, base?: SingleResult):
368
391
  return result;
369
392
  }
370
393
 
394
+ function signalAbortDisposition(options: RunSingleOptions, base: SingleResult): SingleResult | undefined {
395
+ if (!options.signal?.aborted) return undefined;
396
+ base.parked = undefined;
397
+ base.exitCode = 1;
398
+ base.stopReason = "aborted";
399
+ base.errorMessage = "Subagent was aborted";
400
+ return base;
401
+ }
402
+
371
403
  /** Spawn one RPC attempt and wait for stable settlement. */
372
404
  export async function runSingleAgent(options: RunSingleOptions): Promise<SingleResult> {
373
405
  const {
@@ -420,7 +452,8 @@ export async function runSingleAgentWithMainFallback(
420
452
  ): Promise<SingleResult> {
421
453
  const agent = options.agent;
422
454
  const launchedRef = agent?.model;
423
- const startupDelays = options.startupRetryDelaysMs ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
455
+ const customStartupDelays = options.startupRetryDelaysMs;
456
+ const startupDelays = customStartupDelays ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
424
457
 
425
458
  const sessionId = options.sessionId ?? randomUUID();
426
459
  const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
@@ -492,8 +525,9 @@ export async function runSingleAgentWithMainFallback(
492
525
  } catch {
493
526
  /* never throw from event handling */
494
527
  }
495
- if (!(await waitForControlledRetry(delay, opts.signal, opts.control))) {
496
- return controlledDisposition(opts, lastResult) ?? lastResult;
528
+ const retryDelay = customStartupDelays ? delay : addStartupRetryJitter(delay);
529
+ if (!(await waitForControlledRetry(retryDelay, opts.signal, opts.control))) {
530
+ return controlledDisposition(opts, lastResult) ?? signalAbortDisposition(opts, lastResult) ?? lastResult;
497
531
  }
498
532
  retries++;
499
533
  }
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * Stable logical-thread generation lifecycle for background sub-agents.
3
3
  *
4
- * Dispatch owns the public tool contract and auto-fix policy; this module owns
5
- * one thread generation end to end: worktree setup/finalization, queue/process
6
- * ownership, retained-session resume/fork, and guarded terminal publication.
4
+ * Dispatch owns workflow policy and internal role briefs; this module owns one
5
+ * stable parent generation end to end: managed-repository lane use,
6
+ * worktree setup/finalization after downstream review, queue/process ownership,
7
+ * retained-session resume/fork, and guarded one-time terminal publication.
7
8
  */
8
9
 
9
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
11
  import { existsSync } from "node:fs";
11
12
  import { rm } from "node:fs/promises";
12
13
  import { resolve } from "node:path";
13
- import { discoverAgents, type AgentConfig } from "./agents.ts";
14
+ import { discoverAgents, isWriteCapableAgent, type AgentConfig } from "./agents.ts";
14
15
  import { completionTriggersTurn, type CompletionMessageItem } from "./completion.ts";
15
16
  import {
16
17
  DEFAULT_THINKING_LEVEL,
@@ -25,7 +26,15 @@ import {
25
26
  modelLevelTakeoverNote,
26
27
  queuedResult,
27
28
  } from "./format.ts";
28
- import { shouldTriggerFixLoop } from "./fixloop.ts";
29
+ import {
30
+ canStartManagedWorkflow,
31
+ formatChainSummary,
32
+ formatManagedWorkflowSummary,
33
+ getManagedWorkflowPlan,
34
+ workflowAgentAvailability,
35
+ type ManagedWorkflowOutcome,
36
+ type ManagedWorkflowPlan,
37
+ } from "./fixloop.ts";
29
38
  import {
30
39
  availableModelsInScope,
31
40
  currentModelRef,
@@ -34,15 +43,17 @@ import {
34
43
  resolveAgentModelRoute,
35
44
  resolveThinkingLevel,
36
45
  } from "./models.ts";
37
- import { monitor } from "./monitor.ts";
46
+ import { monitor, sumUsage } from "./monitor.ts";
38
47
  import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
39
48
  import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
40
49
  import { forkRetainedSession } from "./session-fork.ts";
41
50
  import {
42
51
  buildResumePrompt,
52
+ getResultOutput,
43
53
  RpcRunControl,
44
54
  isFailedResult,
45
55
  isModelLevelFailure,
56
+ reviewVerdict,
46
57
  runSingleAgentWithMainFallback,
47
58
  type SingleResult,
48
59
  type SubagentDetails,
@@ -61,7 +72,7 @@ export const FORK_CONTINUATION_PROMPT =
61
72
  const WORKTREE_ISOLATION_INSTRUCTIONS =
62
73
  "You are running in a temporary detached Git worktree. Work only in the current cwd; do not create another worktree or manually copy/apply changes to the original checkout. The parent dispatcher will integrate your tracked, deleted, and untracked changes when this thread finally settles.";
63
74
 
64
- function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
75
+ export function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
65
76
  return {
66
77
  ...agent,
67
78
  systemPrompt: `${agent.systemPrompt.trimEnd()}\n\n${WORKTREE_ISOLATION_INSTRUCTIONS}`.trim(),
@@ -69,10 +80,7 @@ function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
69
80
  }
70
81
 
71
82
  export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
72
- if (agent.name === "explorer" || agent.name === "reviewer") return false;
73
- if (agent.name === "worker") return true;
74
- if (!agent.tools) return true;
75
- return agent.tools.includes("edit") || agent.tools.includes("write");
83
+ return isWriteCapableAgent(agent);
76
84
  }
77
85
 
78
86
  interface DispatchEnvironment {
@@ -116,6 +124,23 @@ export function resolveDispatchModelRoute(
116
124
  };
117
125
  }
118
126
 
127
+ export interface ManagedWorkflowRequest extends DispatchEnvironment {
128
+ plan: ManagedWorkflowPlan;
129
+ initialResult: SingleResult;
130
+ groupId: string;
131
+ parentRunId: number;
132
+ executionCwd: string;
133
+ projectCwd: string;
134
+ isolation: IsolationMode;
135
+ signal: AbortSignal;
136
+ rememberLatest: (result: SingleResult) => void;
137
+ }
138
+
139
+ interface ManagedRepositoryLaneRunner {
140
+ <T>(cwd: string, task: () => Promise<T>): Promise<T>;
141
+ <T>(cwd: string, task: () => Promise<T>, signal: AbortSignal): Promise<T | undefined>;
142
+ }
143
+
119
144
  interface BackgroundDispatcherOptions extends DispatchEnvironment {
120
145
  runtime: SubagentRuntime;
121
146
  finishRun: (
@@ -131,12 +156,8 @@ interface BackgroundDispatcherOptions extends DispatchEnvironment {
131
156
  mode: "single" | "parallel",
132
157
  background?: boolean,
133
158
  ) => (results: SingleResult[]) => SubagentDetails;
134
- startFixLoop: (
135
- initialReviewerResult: SingleResult,
136
- parentGroupId: string,
137
- parentRunId: number,
138
- executionCwd: string,
139
- ) => void;
159
+ runManagedWorkflow: (request: ManagedWorkflowRequest) => Promise<ManagedWorkflowOutcome>;
160
+ runInManagedRepositoryLane: ManagedRepositoryLaneRunner;
140
161
  }
141
162
 
142
163
  type BackgroundStarter = (
@@ -155,7 +176,8 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
155
176
  finishRun,
156
177
  makeLiveHandler,
157
178
  makeDetails,
158
- startFixLoop,
179
+ runManagedWorkflow,
180
+ runInManagedRepositoryLane,
159
181
  } = options;
160
182
  interface SessionSeed {
161
183
  sessionId?: string;
@@ -221,7 +243,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
221
243
  if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
222
244
  if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
223
245
  return {
224
- ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as worker or cleaner.`),
246
+ ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as worker, cleaner, or documenter.`),
225
247
  isolation,
226
248
  };
227
249
  }
@@ -365,13 +387,22 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
365
387
  "error",
366
388
  );
367
389
  };
390
+ const generationWorktree = worktree;
391
+ let generationFinalization: Promise<WorktreeFinalization> | undefined;
368
392
  thread.finalizeIsolation = async (
369
393
  expectedGeneration: number,
370
394
  result?: SingleResult,
371
395
  ): Promise<WorktreeFinalization | undefined> => {
372
- if (thread.isolation !== "worktree" || !thread.worktree) return undefined;
373
- if (thread.generation !== expectedGeneration) return undefined;
374
- const finalization = await thread.worktree.finalize();
396
+ if (thread.isolation !== "worktree" || !generationWorktree) return undefined;
397
+ if (thread.generation !== expectedGeneration || thread.worktree !== generationWorktree) return undefined;
398
+ // All normal, destructive-stop, and shutdown owners converge here. Cache
399
+ // the lane-protected apply itself so superseding lifecycle paths can project
400
+ // the same finalization onto their own result without acquiring twice.
401
+ generationFinalization ??= runInManagedRepositoryLane(
402
+ generationWorktree.originalRoot,
403
+ () => generationWorktree.finalize(),
404
+ );
405
+ const finalization = await generationFinalization;
375
406
  monitor.setIsolation(runId, "worktree", finalization.status);
376
407
  if (result) {
377
408
  result.runId = runId;
@@ -498,8 +529,8 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
498
529
  runtime.backgroundQueue.cancel(controller);
499
530
  } else {
500
531
  await thread.control.park();
501
- // Auto-fix orchestration has no live RPC attempt once its parent
502
- // review settled, so cancel its queue owner explicitly.
532
+ // A managed downstream child does not attach to the top-level RPC
533
+ // control after that child settles, so cancel its queue owner explicitly.
503
534
  if (phase === "settled") runtime.backgroundQueue.cancel(controller);
504
535
  }
505
536
  await completion;
@@ -835,8 +866,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
835
866
  };
836
867
 
837
868
  const onLive = makeLiveHandler(runId, generation);
838
- const queueController = runtime.backgroundQueue.enqueue(
839
- async (backgroundSignal) => {
869
+ const workflowAvailability = workflowAgentAvailability(runAgents);
870
+ const reserveManagedLane =
871
+ isolation === "shared" && canStartManagedWorkflow(agent, workflowAvailability);
872
+ const runGeneration = async (backgroundSignal: AbortSignal): Promise<void> => {
840
873
  if (runtime.threads.get(runId)?.generation !== generation) return;
841
874
  let result: SingleResult;
842
875
  try {
@@ -887,8 +920,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
887
920
  result.isolation = isolation;
888
921
  result.forkedFromRunId = thread.forkedFromRunId;
889
922
  result.forkChildRunIds = [...thread.forkChildRunIds];
890
- thread.queueController = undefined;
891
- runtime.runControllers.delete(runId);
892
923
  thread.task = result.task;
893
924
  thread.sessionId = result.sessionId;
894
925
  thread.sessionDir = result.sessionDir;
@@ -897,6 +928,11 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
897
928
  monitor.setModel(runId, result.model, result.modelFallbackFrom);
898
929
  monitor.setThinking(runId, result.thinking);
899
930
 
931
+ const lifecycleInterrupted = (): boolean =>
932
+ thread.lifecycleOperation === "park" ||
933
+ thread.lifecycleOperation === "stop" ||
934
+ thread.state === "parked" ||
935
+ thread.state === "stopped";
900
936
  // Destructive stop owns publication once it has synchronously claimed
901
937
  // the lifecycle. Leave the partial result/session on the thread; the
902
938
  // stop path waits for this queue task, finalizes isolation, and emits
@@ -909,17 +945,68 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
909
945
  runtime.settledRuns.delete(runId);
910
946
  return;
911
947
  }
948
+ // A park/shutdown can win in the microtask gap after the top-level RPC
949
+ // settles. Do not launch an obsolete documenter/reviewer or replace the
950
+ // stable top-level session with an aborted downstream attempt.
951
+ if (backgroundSignal.aborted || lifecycleInterrupted() || !runtime.sessionActive) return;
912
952
 
913
953
  if (thread.retireOnSettle) runtime.retireThreadSession(thread);
914
- const wantsFixLoop = shouldTriggerFixLoop(result, runConfig);
915
- if (wantsFixLoop && isolation === "shared" && runtime.sessionActive) {
954
+ let workflowOutcome: ManagedWorkflowOutcome | undefined;
955
+ const workflowPlan = getManagedWorkflowPlan(result, runConfig, workflowAvailability);
956
+ if (workflowPlan && runtime.sessionActive) {
916
957
  thread.state = "running";
917
- // The review being done does not mean the logical run is over:
918
- // the same row now represents the chain until it resolves.
958
+ // The stable parent row remains active until every internal writer and
959
+ // reviewer settles. Internal rows are independently queryable but never
960
+ // enter this top-level lifecycle or publish completions.
919
961
  monitor.setStatus(runId, "running");
920
- monitor.setActivity(runId, "auto-fix chain running");
921
- startFixLoop(result, `fix-${runId}`, runId, thread.executionCwd);
922
- return;
962
+ monitor.setActivity(
963
+ runId,
964
+ workflowPlan.kind === "auto-fix" ? "auto-fix chain running" : "managed workflow running",
965
+ );
966
+ workflowOutcome = await runManagedWorkflow({
967
+ plan: workflowPlan,
968
+ initialResult: result,
969
+ groupId: `workflow-${runId}`,
970
+ parentRunId: runId,
971
+ executionCwd: thread.executionCwd,
972
+ projectCwd: originalCwd,
973
+ isolation,
974
+ signal: backgroundSignal,
975
+ ctx: runCtx,
976
+ config: runConfig,
977
+ agents: runAgents,
978
+ rememberLatest: (latest) => {
979
+ if (runtime.threads.get(runId) !== thread || thread.generation !== generation) return;
980
+ thread.lastResult = latest;
981
+ thread.agentName = latest.agent;
982
+ monitor.setAgent(runId, latest.agent);
983
+ thread.task = latest.task;
984
+ thread.sessionId = latest.sessionId;
985
+ thread.sessionDir = latest.sessionDir;
986
+ runtime.retainSession(latest);
987
+ },
988
+ });
989
+
990
+ // Park/stop/shutdown owns this generation once it cancels the queue
991
+ // signal. The newest internal partial is already on thread.lastResult;
992
+ // never replace it with the old top-level result or publish stale output.
993
+ if (backgroundSignal.aborted || lifecycleInterrupted() || !runtime.sessionActive) return;
994
+
995
+ const finalStep = workflowOutcome.steps[workflowOutcome.steps.length - 1]!;
996
+ result = {
997
+ ...finalStep.result,
998
+ runId,
999
+ projectCwd: originalCwd,
1000
+ isolation,
1001
+ forkedFromRunId: thread.forkedFromRunId,
1002
+ forkChildRunIds: [...thread.forkChildRunIds],
1003
+ };
1004
+ thread.lastResult = result;
1005
+ thread.agentName = result.agent;
1006
+ thread.task = result.task;
1007
+ thread.sessionId = result.sessionId;
1008
+ thread.sessionDir = result.sessionDir;
1009
+ runtime.retainSession(result);
923
1010
  }
924
1011
  // Claim terminal settlement synchronously before the first slow await.
925
1012
  // Park therefore either wins while RPC is still active, or is rejected
@@ -934,12 +1021,20 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
934
1021
  thread.lifecycleOperation === "settle" &&
935
1022
  !thread.retired;
936
1023
  try {
937
- // Worktree isolation is rejected for reviewers, the only role that can
938
- // trigger auto-fix. Keep that invariant explicit: an isolated result is
939
- // finalized once here and can never start a chain that would integrate
940
- // the same worktree early.
1024
+ // For isolated writers this is deliberately after the managed documenter
1025
+ // and reviewer stages: every child sees the same worktree, then one
1026
+ // lifecycle owner integrates the complete writer+docs state exactly once.
941
1027
  await thread.finalizeIsolation(generation, result);
942
1028
  if (!ownsSettlement()) return;
1029
+ if (workflowOutcome && isolation === "worktree") {
1030
+ for (const step of workflowOutcome.steps) {
1031
+ step.result.integrationStatus = result.integrationStatus;
1032
+ step.result.integrationApplied = result.integrationApplied;
1033
+ step.result.integrationError = result.integrationError;
1034
+ step.result.integrationWorktreePath = result.integrationWorktreePath;
1035
+ step.result.integrationPatchPath = result.integrationPatchPath;
1036
+ }
1037
+ }
943
1038
 
944
1039
  const failed = isFailedResult(result);
945
1040
  thread.state = failed ? "failed" : "completed";
@@ -950,8 +1045,39 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
950
1045
 
951
1046
  const modelLevel = failed && isModelLevelFailure(result);
952
1047
  const dispatchFailed = result.dispatchFailed === true;
953
- finishRun(runId, failed ? "failed" : "done", modelLevel || dispatchFailed ? { silent: true } : undefined);
1048
+ const ownedController = thread.queueController;
1049
+ if (runtime.runControllers.get(runId) === ownedController) runtime.runControllers.delete(runId);
1050
+ thread.queueController = undefined;
1051
+ finishRun(
1052
+ runId,
1053
+ failed ? "failed" : "done",
1054
+ workflowOutcome || modelLevel || dispatchFailed ? { silent: true } : undefined,
1055
+ );
954
1056
  runtime.registerRunResult(runId, result);
1057
+
1058
+ if (workflowOutcome) {
1059
+ const lastStep = workflowOutcome.steps[workflowOutcome.steps.length - 1]!;
1060
+ let block = workflowOutcome.kind === "auto-fix"
1061
+ ? formatChainSummary(workflowOutcome.steps, result)
1062
+ : formatManagedWorkflowSummary(workflowOutcome.steps, result);
1063
+ const finalVerdict = lastStep.result.agent === "reviewer"
1064
+ ? reviewVerdict(getResultOutput(lastStep.result))
1065
+ : undefined;
1066
+ const needsFullFinal = failed || (lastStep.result.agent === "reviewer" && finalVerdict !== "pass");
1067
+ if (needsFullFinal) {
1068
+ block += `\n\n${formatCompletionBlock(result, runConfig.maxResultLines, originalCwd)}`;
1069
+ }
1070
+ if (modelLevel) block += `\n\n${modelLevelTakeoverNote(result, { runId })}`;
1071
+ runtime.sendCompletionGroup([{
1072
+ agent: `${workflowOutcome.kind === "auto-fix" ? "auto-fix chain" : "managed workflow"} (${result.agent})`,
1073
+ block,
1074
+ triggerTurn: true,
1075
+ usage: sumUsage(workflowOutcome.steps.map((step) => step.result.usage)),
1076
+ }]);
1077
+ runtime.completionBatcher.flush();
1078
+ return;
1079
+ }
1080
+
955
1081
  const completion: CompletionMessageItem = {
956
1082
  agent: result.agent,
957
1083
  block: modelLevel
@@ -975,7 +1101,18 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
975
1101
  } finally {
976
1102
  if (ownsSettlement()) thread.lifecycleOperation = undefined;
977
1103
  }
978
- },
1104
+ };
1105
+ const queuedGeneration = reserveManagedLane
1106
+ ? async (backgroundSignal: AbortSignal): Promise<void> => {
1107
+ await runInManagedRepositoryLane(
1108
+ originalCwd,
1109
+ () => runGeneration(backgroundSignal),
1110
+ backgroundSignal,
1111
+ );
1112
+ }
1113
+ : runGeneration;
1114
+ const queueController = runtime.backgroundQueue.enqueue(
1115
+ queuedGeneration,
979
1116
  () => {
980
1117
  if (runtime.threads.get(runId)?.generation !== generation) return;
981
1118
  // Queued park/stop owns publication and may still be finalizing an
@@ -1011,12 +1148,29 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
1011
1148
  thread.lifecycleOperation === "settle" &&
1012
1149
  !thread.retired;
1013
1150
  try {
1014
- const crashed: SingleResult = {
1015
- ...dispatchFailedResult(route.agent, control.getObjective(), error, thinkingLevel),
1016
- runId,
1017
- isolation,
1018
- forkedFromRunId: thread.forkedFromRunId,
1019
- };
1151
+ const errorMessage = error instanceof Error ? error.message : String(error);
1152
+ const latest = thread.lastResult;
1153
+ const crashed: SingleResult = latest
1154
+ ? {
1155
+ ...latest,
1156
+ runId,
1157
+ projectCwd: originalCwd,
1158
+ isolation,
1159
+ exitCode: 1,
1160
+ stopReason: "error",
1161
+ errorMessage: `Managed workflow dispatch failed: ${errorMessage}`,
1162
+ dispatchFailed: true,
1163
+ forkedFromRunId: thread.forkedFromRunId,
1164
+ }
1165
+ : {
1166
+ ...dispatchFailedResult(route.agent, control.getObjective(), error, thinkingLevel),
1167
+ runId,
1168
+ projectCwd: originalCwd,
1169
+ isolation,
1170
+ forkedFromRunId: thread.forkedFromRunId,
1171
+ };
1172
+ thread.lastResult = crashed;
1173
+ runtime.retainSession(crashed);
1020
1174
  await thread.finalizeIsolation(generation, crashed);
1021
1175
  if (!ownsSettlement()) return;
1022
1176
  thread.state = "failed";
@@ -1027,10 +1181,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
1027
1181
  thread.queueController = undefined;
1028
1182
  if (!runtime.sessionActive || !ownsSettlement()) return;
1029
1183
  try {
1030
- runCtx.ui.notify(`✗ ${agent.name} dispatch failed: ${crashed.errorMessage}`, "error");
1184
+ runCtx.ui.notify(`✗ ${crashed.agent} dispatch failed: ${crashed.errorMessage}`, "error");
1031
1185
  runtime.sendCompletionGroup([
1032
1186
  {
1033
- agent: agent.name,
1187
+ agent: crashed.agent,
1034
1188
  block: formatCompletionBlock(crashed, runConfig.maxResultLines, crashed.projectCwd ?? originalCwd),
1035
1189
  triggerTurn: true,
1036
1190
  usage: crashed.usage,
package/src/tools.ts CHANGED
@@ -60,16 +60,17 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
60
60
  label: "Subagent Control",
61
61
  description: [
62
62
  "Control an existing sub-agent thread by stable run id.",
63
- "steer queues an instruction after the current child tool batch.",
64
- "retarget aborts the current objective to a stable checkpoint, suppresses that aborted completion, then starts the replacement objective in the same session.",
63
+ "steer queues an instruction after the current tool batch while the top-level RPC child is active.",
64
+ "retarget replaces the objective in that same active top-level child.",
65
+ "Managed downstream documenter/reviewer/fix stages are controlled by the parent queue rather than its settled RPC control: use park or stop there, then resume with an objective to redirect retained context.",
65
66
  "park aborts to a stable checkpoint, terminates the child, preserves context, and releases its concurrency slot.",
66
67
  "resume restarts a parked, completed, or failed retained thread with the same run id; objective is optional.",
67
68
  "fork copies a parked/completed/failed retained session branch into a new logical thread and run id; an isolated checkpoint must be settled and integrated first; objective is optional.",
68
69
  ].join(" "),
69
- promptSnippet: "Control a subagent thread: steer, retarget, park, resume, or fork by stable run id.",
70
+ promptSnippet: "Control a subagent thread: steer/retarget an active top-level child; park/stop a managed downstream stage; resume or fork retained context.",
70
71
  promptGuidelines: [
71
- "Use subagent_control steer to refine active work without restarting it; the instruction is delivered after the child's current tool batch.",
72
- "Use subagent_control retarget when the active objective is obsolete; do not call subagent_stop and start a fresh thread because retarget preserves context and suppresses the abandoned completion.",
72
+ "Use subagent_control steer to refine an active top-level RPC child without restarting it; the instruction is delivered after its current tool batch.",
73
+ "Use subagent_control retarget only while that top-level child is active. During a managed downstream stage, park it and resume with a replacement objective instead.",
73
74
  "Use subagent_control park to checkpoint useful context while releasing the process/concurrency slot, and resume to continue the same run id later.",
74
75
  "Use subagent_control fork only on a parked or settled retained thread; isolated work must settle and integrate before it can fork. Fork creates a new run id while leaving the source untouched.",
75
76
  "Use subagent_stop only for destructive cancellation; it retires that thread's retained session without retiring independent forks.",
@@ -409,6 +410,8 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
409
410
  if (active) {
410
411
  const parked = active.status === "parked";
411
412
  const activeThread = runtime.threads.get(active.id);
413
+ const managedDownstream =
414
+ activeThread?.state === "running" && activeThread.control.getPhase() === "settled";
412
415
  const metadata = [
413
416
  activeThread?.isolation === "worktree" ? `worktree ${active.integrationStatus ?? activeThread.worktree?.state ?? "active"}` : undefined,
414
417
  activeThread?.forkedFromRunId !== undefined ? `forked from #${activeThread.forkedFromRunId}` : undefined,
@@ -420,7 +423,9 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
420
423
  type: "text",
421
424
  text: parked
422
425
  ? `Run #${active.id} ${active.agent} is parked with retained context${metadata ? ` (${metadata})` : ""}. Use subagent_control resume to restart it, or subagent_stop to retire it.`
423
- : `Run #${active.id} ${active.agent} is still active (${active.activity ?? statusLabel(active.status)}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait to block for its result, subagent_control to steer/park it, or subagent_stop to cancel it.`,
426
+ : managedDownstream
427
+ ? `Run #${active.id} ${active.agent} is in a managed downstream stage (${active.activity ?? statusLabel(active.status)}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait for its result, subagent_control park to checkpoint it, or subagent_stop to cancel it. Steer/retarget are unavailable until you park and resume the retained stage.`
428
+ : `Run #${active.id} ${active.agent} is still active (${active.activity ?? statusLabel(active.status)}${metadata ? ` · ${metadata}` : ""}). Use subagent_wait to block for its result, subagent_control to steer/park it, or subagent_stop to cancel it.`,
424
429
  },
425
430
  ],
426
431
  details: {},
@@ -617,9 +622,19 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
617
622
  });
618
623
  }
619
624
 
625
+ // Interrupt every claimed generation before awaiting any one of them.
626
+ // An isolated stop may need the repository lane for final integration;
627
+ // cancelling all holders first prevents stop-all from waiting behind a
628
+ // later shared workflow that this same operation has not interrupted yet.
629
+ const interruptionPromises = claimed.map(({ thread, stopMessage, controller }) => {
630
+ const stopping = thread.control.stop(stopMessage).catch(() => undefined);
631
+ runtime.backgroundQueue.cancel(controller);
632
+ return stopping;
633
+ });
634
+
620
635
  const stopped: string[] = [];
621
636
  const retainedIntegration: string[] = [];
622
- for (const claim of claimed) {
637
+ for (const [claimIndex, claim] of claimed.entries()) {
623
638
  const {
624
639
  runId,
625
640
  thread,
@@ -634,8 +649,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
634
649
  stopVersion,
635
650
  stopMessage,
636
651
  } = claim;
637
- await thread.control.stop(stopMessage).catch(() => undefined);
638
- runtime.backgroundQueue.cancel(controller);
652
+ await interruptionPromises[claimIndex];
639
653
  await completion;
640
654
  if (runtime.runControllers.get(runId) === controller) runtime.runControllers.delete(runId);
641
655
  if (thread.queueController === controller) thread.queueController = undefined;
package/src/widget.ts CHANGED
@@ -93,8 +93,8 @@ function runActivityLine(run: RunView, theme: Theme, width: number, indent: stri
93
93
  return [truncateToWidth(`${indent}${dim(activitySummary)}`, width, "")];
94
94
  }
95
95
 
96
- /** Render active runs as a tree: main-agent dispatches are roots, auto-fix chain
97
- * rounds nest under the triggering reviewer row that owns the chain. No run ids
96
+ /** Render active runs as a tree: main-agent dispatches are roots and managed
97
+ * documenter/reviewer/fix steps nest under the stable parent row. No run ids
98
98
  * appear here — the tree and the task label say what each row is, and ids stay
99
99
  * available through subagent_status when a thread must be controlled. */
100
100
  export function formatActiveRunLines(
@@ -120,8 +120,8 @@ export function formatActiveRunLines(
120
120
  for (const root of roots) {
121
121
  const children = childrenOf.get(root.id) ?? [];
122
122
  lines.push(runPrimaryLine(root, theme, width, now, ""));
123
- // The parent's "auto-fix chain running" placeholder is redundant while its
124
- // child rows show live progress; keep it only between rounds.
123
+ // The parent's managed-workflow placeholder is redundant while a child
124
+ // row shows live progress; keep it only between stages.
125
125
  if (children.length === 0) lines.push(...runActivityLine(root, theme, width, " "));
126
126
  children.forEach((child, index) => {
127
127
  const connector = index === children.length - 1 ? "└ " : "├ ";