@ferris1225/pi-subagents 4.1.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/runtime.ts CHANGED
@@ -58,9 +58,9 @@ export interface SubagentThread {
58
58
  state: ThreadState;
59
59
  control: RpcRunControl;
60
60
  queueController?: AbortController;
61
- /** Resolves only after the current generation's queue work has fully
62
- * quiesced and released its concurrency slot. Auto-fix orchestration is part
63
- * of the parent generation and replaces/extends this promise. */
61
+ /** Resolves only after the current generation's top-level child, downstream
62
+ * managed workflow, and queue work have fully quiesced and released their
63
+ * concurrency slot. */
64
64
  generationCompletion: Promise<void>;
65
65
  /** Synchronous CAS used by lifecycle controls across their async preflight. */
66
66
  lifecycleVersion: number;
@@ -81,7 +81,8 @@ export interface SubagentThread {
81
81
  fork: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
82
82
  forkedFromRunId?: number;
83
83
  forkChildRunIds: number[];
84
- /** Dispatch-owned, generation-guarded worktree settlement hook. */
84
+ /** Dispatch-owned, generation-guarded worktree settlement hook. Its apply
85
+ * runs under the canonical original-repository lane. */
85
86
  finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
86
87
  /** Best-effort shutdown notification for retained integration artifacts. */
87
88
  notifyIsolationFailure?: (finalization: WorktreeFinalization) => void;
@@ -110,7 +111,7 @@ export interface SubagentRuntime {
110
111
  * next generation. Shutdown invalidates these claims and waits for cleanup. */
111
112
  preflightOperations: Set<Promise<void>>;
112
113
  /** Every session directory retained for this parent session, including
113
- * auto-fix internals that are not directly controllable. */
114
+ * managed-workflow internals that are not directly controllable. */
114
115
  sessionDirs: Set<string>;
115
116
  retainSession: (result: Pick<SingleResult, "sessionDir">) => void;
116
117
  retireThreadSession: (thread: SubagentThread) => void;
@@ -134,8 +135,8 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
134
135
  // Computing this at delivery (emit) time — not when the item was
135
136
  // pushed — reflects the current monitor state, since finishing runs
136
137
  // are removed from the monitor before their completion is pushed.
137
- // Auto-fix parents are flipped back to "running" while their chain
138
- // owns the logical run, so they are included without a special case.
138
+ // Managed-workflow parents remain "running" through documenter,
139
+ // reviewer, and any fix rounds, so they are included without a special case.
139
140
  const active = monitor
140
141
  .getRuns()
141
142
  .filter((run) => isRunActiveStatus(run.status))
package/src/setup.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  AGENT_SCOPE_VALUES,
15
15
  BUILTIN_AGENT_NAMES,
16
16
  CLEANER_DEFAULTED_FEATURE,
17
+ DOCUMENTER_DEFAULTED_FEATURE,
17
18
  DEFAULT_CONFIG,
18
19
  DEFAULT_ENABLED_AGENTS,
19
20
  DEFAULT_IDLE_TIMEOUT_SEC,
@@ -61,7 +62,8 @@ function actualAgentThinkingDefault(
61
62
  const MODULE_HINTS: Record<string, string> = {
62
63
  explorer: "read-only codebase recon (fast model)",
63
64
  worker: "implement / fix / refactor / test (full tools)",
64
- cleaner: "prove and apply safe cleanup cuts (full tools)",
65
+ cleaner: "apply proven cleanup and deduplicate code (full tools)",
66
+ documenter: "sync diff or whole-codebase comments/docs (full tools)",
65
67
  reviewer: "read-only audits and pre-commit gates",
66
68
  };
67
69
 
@@ -303,7 +305,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
303
305
  if (maxConcurrency === undefined) return notifyCancelled(ctx);
304
306
  const maxFixRounds = await pickCount(
305
307
  ctx,
306
- "Reviewer auto-fix rounds? (0 = main agent handles fixes)",
308
+ "Reviewer worker-fix rounds? (0 = no automatic fixes)",
307
309
  FIX_ROUNDS_STEPS,
308
310
  base.maxFixRounds,
309
311
  DEFAULT_MAX_FIX_ROUNDS,
@@ -330,9 +332,13 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
330
332
  maxConcurrency,
331
333
  maxFixRounds,
332
334
  idleTimeoutSec,
333
- // Full setup is an explicit decision point: mark the cleaner
334
- // default-enable upgrade as processed so the saved list is kept as-is.
335
- announcedFeatures: [...new Set([...base.announcedFeatures, CLEANER_DEFAULTED_FEATURE])],
335
+ // Full setup is an explicit decision point: mark role-enable migrations as
336
+ // processed so the user's saved selection is kept as-is.
337
+ announcedFeatures: [...new Set([
338
+ ...base.announcedFeatures,
339
+ CLEANER_DEFAULTED_FEATURE,
340
+ DOCUMENTER_DEFAULTED_FEATURE,
341
+ ])],
336
342
  };
337
343
  await saveConfig(next, configPath);
338
344
  ctx.ui.notify(`pi-subagents configured with Auto thinking. Saved to ${configPath}`, "info");
@@ -346,7 +352,7 @@ async function updateRuntimeSetting(
346
352
  "Proactive injection",
347
353
  "Agent scope",
348
354
  "Max concurrency",
349
- "Reviewer auto-fix rounds",
355
+ "Reviewer worker-fix rounds",
350
356
  "Idle timeout",
351
357
  ]);
352
358
  if (choice === undefined) return undefined;
@@ -364,7 +370,7 @@ async function updateRuntimeSetting(
364
370
  if (value === undefined) return undefined;
365
371
  next.maxConcurrency = value;
366
372
  } else if (choice.startsWith("Reviewer")) {
367
- const value = await pickCount(ctx, "Reviewer auto-fix rounds?", FIX_ROUNDS_STEPS, config.maxFixRounds, DEFAULT_MAX_FIX_ROUNDS);
373
+ const value = await pickCount(ctx, "Reviewer worker-fix rounds?", FIX_ROUNDS_STEPS, config.maxFixRounds, DEFAULT_MAX_FIX_ROUNDS);
368
374
  if (value === undefined) return undefined;
369
375
  next.maxFixRounds = value;
370
376
  } else {
@@ -405,6 +411,17 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
405
411
  next.agentThinkingLevels.cleaner = config.agentThinkingLevels.reviewer;
406
412
  }
407
413
  }
414
+ // Documenter intentionally follows the faster explorer route. Fresh
415
+ // installs leave it unselected; enabling it later inherits any explorer
416
+ // overrides instead of silently choosing a stronger model.
417
+ if (!config.enabledAgents.includes("documenter") && enabled.includes("documenter")) {
418
+ if (!next.agentModels.documenter && config.agentModels.explorer) {
419
+ next.agentModels.documenter = config.agentModels.explorer;
420
+ }
421
+ if (!next.agentThinkingLevels.documenter && config.agentThinkingLevels.explorer) {
422
+ next.agentThinkingLevels.documenter = config.agentThinkingLevels.explorer;
423
+ }
424
+ }
408
425
  next.agentModels = keepAgentEntries(next.agentModels, enabled);
409
426
  next.agentThinkingLevels = keepAgentEntries(next.agentThinkingLevels, enabled);
410
427
  } else if (choice.startsWith("Configure")) {
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,