@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.
@@ -1,16 +1,22 @@
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 {
15
+ discoverAgents,
16
+ isWriteCapableAgent,
17
+ resolveAgentTools,
18
+ type AgentConfig,
19
+ } from "./agents.ts";
14
20
  import { completionTriggersTurn, type CompletionMessageItem } from "./completion.ts";
15
21
  import {
16
22
  DEFAULT_THINKING_LEVEL,
@@ -25,7 +31,15 @@ import {
25
31
  modelLevelTakeoverNote,
26
32
  queuedResult,
27
33
  } from "./format.ts";
28
- import { shouldTriggerFixLoop } from "./fixloop.ts";
34
+ import {
35
+ canStartManagedWorkflow,
36
+ formatChainSummary,
37
+ formatManagedWorkflowSummary,
38
+ getManagedWorkflowPlan,
39
+ workflowAgentAvailability,
40
+ type ManagedWorkflowOutcome,
41
+ type ManagedWorkflowPlan,
42
+ } from "./fixloop.ts";
29
43
  import {
30
44
  availableModelsInScope,
31
45
  currentModelRef,
@@ -34,15 +48,17 @@ import {
34
48
  resolveAgentModelRoute,
35
49
  resolveThinkingLevel,
36
50
  } from "./models.ts";
37
- import { monitor } from "./monitor.ts";
51
+ import { monitor, sumUsage, type ContinuationKind } from "./monitor.ts";
38
52
  import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
39
53
  import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
40
54
  import { forkRetainedSession } from "./session-fork.ts";
41
55
  import {
42
56
  buildResumePrompt,
57
+ getResultOutput,
43
58
  RpcRunControl,
44
59
  isFailedResult,
45
60
  isModelLevelFailure,
61
+ reviewVerdict,
46
62
  runSingleAgentWithMainFallback,
47
63
  type SingleResult,
48
64
  type SubagentDetails,
@@ -61,7 +77,7 @@ export const FORK_CONTINUATION_PROMPT =
61
77
  const WORKTREE_ISOLATION_INSTRUCTIONS =
62
78
  "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
79
 
64
- function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
80
+ export function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
65
81
  return {
66
82
  ...agent,
67
83
  systemPrompt: `${agent.systemPrompt.trimEnd()}\n\n${WORKTREE_ISOLATION_INSTRUCTIONS}`.trim(),
@@ -69,10 +85,7 @@ function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
69
85
  }
70
86
 
71
87
  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");
88
+ return isWriteCapableAgent(agent);
76
89
  }
77
90
 
78
91
  interface DispatchEnvironment {
@@ -116,6 +129,23 @@ export function resolveDispatchModelRoute(
116
129
  };
117
130
  }
118
131
 
132
+ export interface ManagedWorkflowRequest extends DispatchEnvironment {
133
+ plan: ManagedWorkflowPlan;
134
+ initialResult: SingleResult;
135
+ groupId: string;
136
+ parentRunId: number;
137
+ executionCwd: string;
138
+ projectCwd: string;
139
+ isolation: IsolationMode;
140
+ signal: AbortSignal;
141
+ rememberLatest: (result: SingleResult) => void;
142
+ }
143
+
144
+ interface ManagedRepositoryLaneRunner {
145
+ <T>(cwd: string, task: () => Promise<T>): Promise<T>;
146
+ <T>(cwd: string, task: () => Promise<T>, signal: AbortSignal): Promise<T | undefined>;
147
+ }
148
+
119
149
  interface BackgroundDispatcherOptions extends DispatchEnvironment {
120
150
  runtime: SubagentRuntime;
121
151
  finishRun: (
@@ -131,12 +161,8 @@ interface BackgroundDispatcherOptions extends DispatchEnvironment {
131
161
  mode: "single" | "parallel",
132
162
  background?: boolean,
133
163
  ) => (results: SingleResult[]) => SubagentDetails;
134
- startFixLoop: (
135
- initialReviewerResult: SingleResult,
136
- parentGroupId: string,
137
- parentRunId: number,
138
- executionCwd: string,
139
- ) => void;
164
+ runManagedWorkflow: (request: ManagedWorkflowRequest) => Promise<ManagedWorkflowOutcome>;
165
+ runInManagedRepositoryLane: ManagedRepositoryLaneRunner;
140
166
  }
141
167
 
142
168
  type BackgroundStarter = (
@@ -155,7 +181,8 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
155
181
  finishRun,
156
182
  makeLiveHandler,
157
183
  makeDetails,
158
- startFixLoop,
184
+ runManagedWorkflow,
185
+ runInManagedRepositoryLane,
159
186
  } = options;
160
187
  interface SessionSeed {
161
188
  sessionId?: string;
@@ -163,6 +190,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
163
190
  prompt?: string;
164
191
  worktree?: WorktreeIsolation;
165
192
  forkedFromRunId?: number;
193
+ continuationKind?: ContinuationKind;
166
194
  }
167
195
 
168
196
  interface ResumeReservation {
@@ -203,7 +231,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
203
231
  cwd: string | undefined,
204
232
  isolation: IsolationMode = "shared",
205
233
  existingThread?: SubagentThread,
206
- newObjectiveOnResume = false,
234
+ appendedObjectiveOnResume = false,
207
235
  environment?: DispatchEnvironment,
208
236
  seed?: SessionSeed,
209
237
  resumeReservation?: ResumeReservation,
@@ -217,11 +245,14 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
217
245
  const runCtx = environment?.ctx ?? ctx;
218
246
  const runConfig = environment?.config ?? config;
219
247
  const runAgents = environment?.agents ?? agents;
220
- const agent = runAgents.find((candidate) => candidate.name === agentName);
221
- if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
248
+ const discoveredAgent = runAgents.find((candidate) => candidate.name === agentName);
249
+ if (!discoveredAgent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
250
+ const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
251
+ resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
252
+ const agent = resolveLiveAgentTools(discoveredAgent);
222
253
  if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
223
254
  return {
224
- ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as worker or cleaner.`),
255
+ ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as worker, cleaner, or documenter.`),
225
256
  isolation,
226
257
  };
227
258
  }
@@ -266,6 +297,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
266
297
  const runId = existingThread?.id ?? monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
267
298
  isolation,
268
299
  ...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
300
+ ...(seed?.continuationKind ? { continuationKind: seed.continuationKind } : {}),
269
301
  });
270
302
  const generation = (existingThread?.generation ?? 0) + 1;
271
303
  const pending: SingleResult = {
@@ -280,7 +312,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
280
312
  ...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
281
313
  };
282
314
  if (existingThread) {
283
- monitor.restartRun(runId, agent.name, task, route.agent.model, thinkingLevel, isolation);
315
+ monitor.restartRun(runId, agent.name, task, route.agent.model, thinkingLevel, isolation, {
316
+ elapsedMs: existingThread.elapsedMs,
317
+ continuationKind: appendedObjectiveOnResume ? "resume-appended" : "resume-retained",
318
+ });
284
319
  runtime.settledRuns.delete(runId);
285
320
  }
286
321
 
@@ -345,6 +380,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
345
380
  control,
346
381
  generationCompletion: Promise.resolve(),
347
382
  lifecycleVersion: 0,
383
+ elapsedMs: 0,
348
384
  sessionId: seed?.sessionId,
349
385
  sessionDir: seed?.sessionDir,
350
386
  forkedFromRunId: seed?.forkedFromRunId,
@@ -365,13 +401,22 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
365
401
  "error",
366
402
  );
367
403
  };
404
+ const generationWorktree = worktree;
405
+ let generationFinalization: Promise<WorktreeFinalization> | undefined;
368
406
  thread.finalizeIsolation = async (
369
407
  expectedGeneration: number,
370
408
  result?: SingleResult,
371
409
  ): 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();
410
+ if (thread.isolation !== "worktree" || !generationWorktree) return undefined;
411
+ if (thread.generation !== expectedGeneration || thread.worktree !== generationWorktree) return undefined;
412
+ // All normal, destructive-stop, and shutdown owners converge here. Cache
413
+ // the lane-protected apply itself so superseding lifecycle paths can project
414
+ // the same finalization onto their own result without acquiring twice.
415
+ generationFinalization ??= runInManagedRepositoryLane(
416
+ generationWorktree.originalRoot,
417
+ () => generationWorktree.finalize(),
418
+ );
419
+ const finalization = await generationFinalization;
375
420
  monitor.setIsolation(runId, "worktree", finalization.status);
376
421
  if (result) {
377
422
  result.runId = runId;
@@ -473,6 +518,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
473
518
  });
474
519
  };
475
520
 
521
+ const persistElapsedTime = (): void => {
522
+ thread.elapsedMs = monitor.getElapsedMs(runId) ?? thread.elapsedMs;
523
+ };
524
+
476
525
  thread.park = async (): Promise<"queued" | "active"> => {
477
526
  if (thread.retired) throw new Error(`Run #${runId} was retired by subagent_stop.`);
478
527
  if (thread.lifecycleOperation) throw new Error(`Run #${runId} is already handling ${thread.lifecycleOperation}.`);
@@ -498,8 +547,8 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
498
547
  runtime.backgroundQueue.cancel(controller);
499
548
  } else {
500
549
  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.
550
+ // A managed downstream child does not attach to the top-level RPC
551
+ // control after that child settles, so cancel its queue owner explicitly.
503
552
  if (phase === "settled") runtime.backgroundQueue.cancel(controller);
504
553
  }
505
554
  await completion;
@@ -513,7 +562,14 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
513
562
  thread.state = "parked";
514
563
  thread.queueController = undefined;
515
564
  runtime.runControllers.delete(runId);
565
+ const parkedRun = monitor.findRun(runId);
566
+ if (parkedRun?.managedWorkflow && parkedRun.task !== thread.task) {
567
+ // The active child row previously showed this stage objective. Once it
568
+ // disappears, keep the parked parent aligned with what resume retains.
569
+ monitor.setTask(runId, thread.task);
570
+ }
516
571
  monitor.setStatus(runId, "parked");
572
+ persistElapsedTime();
517
573
  return queued ? "queued" : "active";
518
574
  } finally {
519
575
  if (thread.lifecycleVersion === version && thread.lifecycleOperation === "park") {
@@ -791,6 +847,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
791
847
  prompt: forkObjective ?? FORK_CONTINUATION_PROMPT,
792
848
  worktree: childWorktree,
793
849
  forkedFromRunId: runId,
850
+ continuationKind: forkObjective ? "fork-appended" : "fork-retained",
794
851
  },
795
852
  );
796
853
  if (child.exitCode !== -1 || child.runId === undefined) {
@@ -835,8 +892,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
835
892
  };
836
893
 
837
894
  const onLive = makeLiveHandler(runId, generation);
838
- const queueController = runtime.backgroundQueue.enqueue(
839
- async (backgroundSignal) => {
895
+ const workflowAvailability = workflowAgentAvailability(runAgents);
896
+ const reserveManagedLane =
897
+ isolation === "shared" && canStartManagedWorkflow(agent, workflowAvailability);
898
+ const runGeneration = async (backgroundSignal: AbortSignal): Promise<void> => {
840
899
  if (runtime.threads.get(runId)?.generation !== generation) return;
841
900
  let result: SingleResult;
842
901
  try {
@@ -844,6 +903,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
844
903
  {
845
904
  defaultCwd: executionCwd,
846
905
  agent: route.agent,
906
+ resolveAgentForAttempt: resolveLiveAgentTools,
847
907
  agentName,
848
908
  task,
849
909
  cwd: executionCwd,
@@ -858,7 +918,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
858
918
  ? {
859
919
  sessionId: priorSessionId,
860
920
  sessionDir: priorSessionDir,
861
- stdinText: seed?.prompt ?? (newObjectiveOnResume
921
+ stdinText: seed?.prompt ?? (appendedObjectiveOnResume
862
922
  ? task
863
923
  : buildResumePrompt(priorTask ?? task, "the retained thread was resumed")),
864
924
  }
@@ -887,8 +947,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
887
947
  result.isolation = isolation;
888
948
  result.forkedFromRunId = thread.forkedFromRunId;
889
949
  result.forkChildRunIds = [...thread.forkChildRunIds];
890
- thread.queueController = undefined;
891
- runtime.runControllers.delete(runId);
892
950
  thread.task = result.task;
893
951
  thread.sessionId = result.sessionId;
894
952
  thread.sessionDir = result.sessionDir;
@@ -897,6 +955,11 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
897
955
  monitor.setModel(runId, result.model, result.modelFallbackFrom);
898
956
  monitor.setThinking(runId, result.thinking);
899
957
 
958
+ const lifecycleInterrupted = (): boolean =>
959
+ thread.lifecycleOperation === "park" ||
960
+ thread.lifecycleOperation === "stop" ||
961
+ thread.state === "parked" ||
962
+ thread.state === "stopped";
900
963
  // Destructive stop owns publication once it has synchronously claimed
901
964
  // the lifecycle. Leave the partial result/session on the thread; the
902
965
  // stop path waits for this queue task, finalizes isolation, and emits
@@ -909,17 +972,71 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
909
972
  runtime.settledRuns.delete(runId);
910
973
  return;
911
974
  }
975
+ // A park/shutdown can win in the microtask gap after the top-level RPC
976
+ // settles. Do not launch an obsolete documenter/reviewer or replace the
977
+ // stable top-level session with an aborted downstream attempt.
978
+ if (backgroundSignal.aborted || lifecycleInterrupted() || !runtime.sessionActive) return;
912
979
 
913
980
  if (thread.retireOnSettle) runtime.retireThreadSession(thread);
914
- const wantsFixLoop = shouldTriggerFixLoop(result, runConfig);
915
- if (wantsFixLoop && isolation === "shared" && runtime.sessionActive) {
981
+ let workflowOutcome: ManagedWorkflowOutcome | undefined;
982
+ const workflowPlan = getManagedWorkflowPlan(result, runConfig, workflowAvailability);
983
+ if (workflowPlan && runtime.sessionActive) {
916
984
  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.
985
+ // The stable parent row now represents workflow ownership, not whichever
986
+ // model stage ran most recently. Internal rows own their exact role/model/
987
+ // thinking/timing telemetry and remain independently queryable.
988
+ monitor.setManagedWorkflow(runId, true);
919
989
  monitor.setStatus(runId, "running");
920
- monitor.setActivity(runId, "auto-fix chain running");
921
- startFixLoop(result, `fix-${runId}`, runId, thread.executionCwd);
922
- return;
990
+ monitor.setActivity(
991
+ runId,
992
+ workflowPlan.kind === "auto-fix" ? "auto-fix chain running" : "managed workflow running",
993
+ );
994
+ workflowOutcome = await runManagedWorkflow({
995
+ plan: workflowPlan,
996
+ initialResult: result,
997
+ groupId: `workflow-${runId}`,
998
+ parentRunId: runId,
999
+ executionCwd: thread.executionCwd,
1000
+ projectCwd: originalCwd,
1001
+ isolation,
1002
+ signal: backgroundSignal,
1003
+ ctx: runCtx,
1004
+ config: runConfig,
1005
+ agents: runAgents,
1006
+ rememberLatest: (latest) => {
1007
+ if (runtime.threads.get(runId) !== thread || thread.generation !== generation) return;
1008
+ thread.lastResult = latest;
1009
+ // Retained control follows the newest child session, but the live parent
1010
+ // row keeps the original top-level role/model/usage. The active internal
1011
+ // row already owns the current stage's role and telemetry.
1012
+ thread.agentName = latest.agent;
1013
+ thread.task = latest.task;
1014
+ thread.sessionId = latest.sessionId;
1015
+ thread.sessionDir = latest.sessionDir;
1016
+ runtime.retainSession(latest);
1017
+ },
1018
+ });
1019
+
1020
+ // Park/stop/shutdown owns this generation once it cancels the queue
1021
+ // signal. The newest internal partial is already on thread.lastResult;
1022
+ // never replace it with the old top-level result or publish stale output.
1023
+ if (backgroundSignal.aborted || lifecycleInterrupted() || !runtime.sessionActive) return;
1024
+
1025
+ const finalStep = workflowOutcome.steps[workflowOutcome.steps.length - 1]!;
1026
+ result = {
1027
+ ...finalStep.result,
1028
+ runId,
1029
+ projectCwd: originalCwd,
1030
+ isolation,
1031
+ forkedFromRunId: thread.forkedFromRunId,
1032
+ forkChildRunIds: [...thread.forkChildRunIds],
1033
+ };
1034
+ thread.lastResult = result;
1035
+ thread.agentName = result.agent;
1036
+ thread.task = result.task;
1037
+ thread.sessionId = result.sessionId;
1038
+ thread.sessionDir = result.sessionDir;
1039
+ runtime.retainSession(result);
923
1040
  }
924
1041
  // Claim terminal settlement synchronously before the first slow await.
925
1042
  // Park therefore either wins while RPC is still active, or is rejected
@@ -934,24 +1051,64 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
934
1051
  thread.lifecycleOperation === "settle" &&
935
1052
  !thread.retired;
936
1053
  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.
1054
+ // For isolated writers this is deliberately after the managed documenter
1055
+ // and reviewer stages: every child sees the same worktree, then one
1056
+ // lifecycle owner integrates the complete writer+docs state exactly once.
941
1057
  await thread.finalizeIsolation(generation, result);
942
1058
  if (!ownsSettlement()) return;
1059
+ if (workflowOutcome && isolation === "worktree") {
1060
+ for (const step of workflowOutcome.steps) {
1061
+ step.result.integrationStatus = result.integrationStatus;
1062
+ step.result.integrationApplied = result.integrationApplied;
1063
+ step.result.integrationError = result.integrationError;
1064
+ step.result.integrationWorktreePath = result.integrationWorktreePath;
1065
+ step.result.integrationPatchPath = result.integrationPatchPath;
1066
+ }
1067
+ }
943
1068
 
944
1069
  const failed = isFailedResult(result);
945
1070
  thread.state = failed ? "failed" : "completed";
946
1071
  // Stamp the terminal monitor state before projecting it. This gives every
947
1072
  // path a fixed endedAt even when the row is removed immediately.
948
1073
  monitor.setStatus(runId, failed ? "failed" : "done");
1074
+ persistElapsedTime();
949
1075
  if (!runtime.sessionActive || !ownsSettlement()) return;
950
1076
 
951
1077
  const modelLevel = failed && isModelLevelFailure(result);
952
1078
  const dispatchFailed = result.dispatchFailed === true;
953
- finishRun(runId, failed ? "failed" : "done", modelLevel || dispatchFailed ? { silent: true } : undefined);
1079
+ const ownedController = thread.queueController;
1080
+ if (runtime.runControllers.get(runId) === ownedController) runtime.runControllers.delete(runId);
1081
+ thread.queueController = undefined;
1082
+ finishRun(
1083
+ runId,
1084
+ failed ? "failed" : "done",
1085
+ workflowOutcome || modelLevel || dispatchFailed ? { silent: true } : undefined,
1086
+ );
954
1087
  runtime.registerRunResult(runId, result);
1088
+
1089
+ if (workflowOutcome) {
1090
+ const lastStep = workflowOutcome.steps[workflowOutcome.steps.length - 1]!;
1091
+ let block = workflowOutcome.kind === "auto-fix"
1092
+ ? formatChainSummary(workflowOutcome.steps, result)
1093
+ : formatManagedWorkflowSummary(workflowOutcome.steps, result);
1094
+ const finalVerdict = lastStep.result.agent === "reviewer"
1095
+ ? reviewVerdict(getResultOutput(lastStep.result))
1096
+ : undefined;
1097
+ const needsFullFinal = failed || (lastStep.result.agent === "reviewer" && finalVerdict !== "pass");
1098
+ if (needsFullFinal) {
1099
+ block += `\n\n${formatCompletionBlock(result, runConfig.maxResultLines, originalCwd)}`;
1100
+ }
1101
+ if (modelLevel) block += `\n\n${modelLevelTakeoverNote(result, { runId })}`;
1102
+ runtime.sendCompletionGroup([{
1103
+ agent: `${workflowOutcome.kind === "auto-fix" ? "auto-fix chain" : "managed workflow"} (${result.agent})`,
1104
+ block,
1105
+ triggerTurn: true,
1106
+ usage: sumUsage(workflowOutcome.steps.map((step) => step.result.usage)),
1107
+ }]);
1108
+ runtime.completionBatcher.flush();
1109
+ return;
1110
+ }
1111
+
955
1112
  const completion: CompletionMessageItem = {
956
1113
  agent: result.agent,
957
1114
  block: modelLevel
@@ -975,7 +1132,18 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
975
1132
  } finally {
976
1133
  if (ownsSettlement()) thread.lifecycleOperation = undefined;
977
1134
  }
978
- },
1135
+ };
1136
+ const queuedGeneration = reserveManagedLane
1137
+ ? async (backgroundSignal: AbortSignal): Promise<void> => {
1138
+ await runInManagedRepositoryLane(
1139
+ originalCwd,
1140
+ () => runGeneration(backgroundSignal),
1141
+ backgroundSignal,
1142
+ );
1143
+ }
1144
+ : runGeneration;
1145
+ const queueController = runtime.backgroundQueue.enqueue(
1146
+ queuedGeneration,
979
1147
  () => {
980
1148
  if (runtime.threads.get(runId)?.generation !== generation) return;
981
1149
  // Queued park/stop owns publication and may still be finalizing an
@@ -1011,26 +1179,44 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
1011
1179
  thread.lifecycleOperation === "settle" &&
1012
1180
  !thread.retired;
1013
1181
  try {
1014
- const crashed: SingleResult = {
1015
- ...dispatchFailedResult(route.agent, control.getObjective(), error, thinkingLevel),
1016
- runId,
1017
- isolation,
1018
- forkedFromRunId: thread.forkedFromRunId,
1019
- };
1182
+ const errorMessage = error instanceof Error ? error.message : String(error);
1183
+ const latest = thread.lastResult;
1184
+ const crashed: SingleResult = latest
1185
+ ? {
1186
+ ...latest,
1187
+ runId,
1188
+ projectCwd: originalCwd,
1189
+ isolation,
1190
+ exitCode: 1,
1191
+ stopReason: "error",
1192
+ errorMessage: `Managed workflow dispatch failed: ${errorMessage}`,
1193
+ dispatchFailed: true,
1194
+ forkedFromRunId: thread.forkedFromRunId,
1195
+ }
1196
+ : {
1197
+ ...dispatchFailedResult(route.agent, control.getObjective(), error, thinkingLevel),
1198
+ runId,
1199
+ projectCwd: originalCwd,
1200
+ isolation,
1201
+ forkedFromRunId: thread.forkedFromRunId,
1202
+ };
1203
+ thread.lastResult = crashed;
1204
+ runtime.retainSession(crashed);
1020
1205
  await thread.finalizeIsolation(generation, crashed);
1021
1206
  if (!ownsSettlement()) return;
1022
1207
  thread.state = "failed";
1023
1208
  monitor.setStatus(runId, "failed");
1209
+ persistElapsedTime();
1024
1210
  finishRun(runId, "failed", { silent: true });
1025
1211
  runtime.registerRunResult(runId, crashed);
1026
1212
  runtime.runControllers.delete(runId);
1027
1213
  thread.queueController = undefined;
1028
1214
  if (!runtime.sessionActive || !ownsSettlement()) return;
1029
1215
  try {
1030
- runCtx.ui.notify(`✗ ${agent.name} dispatch failed: ${crashed.errorMessage}`, "error");
1216
+ runCtx.ui.notify(`✗ ${crashed.agent} dispatch failed: ${crashed.errorMessage}`, "error");
1031
1217
  runtime.sendCompletionGroup([
1032
1218
  {
1033
- agent: agent.name,
1219
+ agent: crashed.agent,
1034
1220
  block: formatCompletionBlock(crashed, runConfig.maxResultLines, crashed.projectCwd ?? originalCwd),
1035
1221
  triggerTurn: true,
1036
1222
  usage: crashed.usage,