@ferris1225/pi-subagents 4.1.7 → 4.1.9

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,1327 +1,1410 @@
1
- /**
2
- * Stable logical-thread generation lifecycle for background sub-agents.
3
- *
4
- * Dispatch owns workflow policy, the live stage projection, and internal role
5
- * briefs; this module owns one
6
- * stable parent generation end to end: managed-repository lane use,
7
- * worktree setup/finalization after downstream review, queue/process ownership,
8
- * retained-session resume/fork, and guarded one-time terminal publication.
9
- */
10
-
11
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
- import { existsSync } from "node:fs";
13
- import { rm } from "node:fs/promises";
14
- import { resolve } from "node:path";
15
- import {
16
- discoverAgents,
17
- isWriteCapableAgent,
18
- resolveAgentTools,
19
- type AgentConfig,
20
- } from "./agents.ts";
21
- import { completionTriggersTurn, type CompletionMessageItem } from "./completion.ts";
22
- import {
23
- DEFAULT_THINKING_LEVEL,
24
- loadConfig,
25
- type SubagentsConfig,
26
- type ThinkingLevel,
27
- } from "./config.ts";
28
- import {
29
- dispatchFailedResult,
30
- failedStartResult,
31
- formatCompletionBlock,
32
- modelLevelTakeoverNote,
33
- queuedResult,
34
- } from "./format.ts";
35
- import {
36
- canStartManagedWorkflow,
37
- formatChainSummary,
38
- formatManagedWorkflowSummary,
39
- getManagedWorkflowPlan,
40
- workflowAgentAvailability,
41
- type ManagedWorkflowOutcome,
42
- type ManagedWorkflowPlan,
43
- } from "./fixloop.ts";
44
- import {
45
- availableModelsInScope,
46
- currentModelRef,
47
- findModelByRef,
48
- modelRef,
49
- resolveAgentModelRoute,
50
- resolveThinkingLevel,
51
- } from "./models.ts";
52
- import { monitor, sumUsage, type ContinuationKind } from "./monitor.ts";
53
- import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
54
- import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
55
- import { forkRetainedSession } from "./session-fork.ts";
56
- import {
57
- buildResumePrompt,
58
- getResultOutput,
59
- RpcRunControl,
60
- isFailedResult,
61
- isModelLevelFailure,
62
- reviewVerdict,
63
- runSingleAgentWithMainFallback,
64
- type SingleResult,
65
- type SubagentDetails,
66
- type SubagentLiveEvent,
67
- } from "./spawn.ts";
68
- import {
69
- createWorktreeIsolation,
70
- worktreeGroupId,
71
- type IsolationMode,
72
- type WorktreeFinalization,
73
- type WorktreeIsolation,
74
- } from "./worktree.ts";
75
-
76
- export const FORK_CONTINUATION_PROMPT =
77
- "Continue from the retained context above. Review the prior work, then take the most useful next step toward completing the existing objective without repeating completed work.";
78
-
79
- /** Control operations must never wait forever on a settling generation: the
80
- * queue task can legitimately spend minutes in worktree finalization (bounded
81
- * per-Git-command timeouts) or wait behind the managed repository lane. After
82
- * this deadline the control path owns the lifecycle synchronously and proceeds
83
- * while the stuck tail settles silently in the background. */
84
- export const CONTROL_QUIESCE_TIMEOUT_MS = 20_000;
85
-
86
- /** Resolve true when the promise settles, or false after the bounded deadline. */
87
- export function quiesced(promise: Promise<unknown>, timeoutMs: number = CONTROL_QUIESCE_TIMEOUT_MS): Promise<boolean> {
88
- return Promise.race([
89
- promise.then(() => true, () => true),
90
- new Promise<boolean>((resolve) => {
91
- const timer = setTimeout(() => resolve(false), timeoutMs);
92
- if (typeof timer.unref === "function") timer.unref();
93
- }),
94
- ]);
95
- }
96
-
97
- const WORKTREE_ISOLATION_INSTRUCTIONS =
98
- "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.";
99
-
100
- export function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
101
- return {
102
- ...agent,
103
- systemPrompt: `${agent.systemPrompt.trimEnd()}\n\n${WORKTREE_ISOLATION_INSTRUCTIONS}`.trim(),
104
- };
105
- }
106
-
107
- export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
108
- return isWriteCapableAgent(agent);
109
- }
110
-
111
- /** A direct reviewer otherwise cannot infer enabled-role availability from its
112
- * isolated task. Managed internal gates receive the same contract in their
113
- * generated briefs. Advisory reviews still emit neither machine marker. */
114
- function withEnabledDocumenterReviewContract(agent: AgentConfig): AgentConfig {
115
- return {
116
- ...agent,
117
- systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: documenter is enabled. In gate reviews, documentation drift is non-gating: emit DOCUMENTATION: NEEDED with ## Documentation notes, or DOCUMENTATION: CLEAN when no sync is needed. Advisory reviews still emit neither VERDICT nor DOCUMENTATION markers.`.trim(),
118
- };
119
- }
120
-
121
- interface DispatchEnvironment {
122
- ctx: ExtensionContext;
123
- config: SubagentsConfig;
124
- agents: AgentConfig[];
125
- }
126
-
127
- interface DispatchModelRoute {
128
- agent: AgentConfig;
129
- mainFallbackRef?: string;
130
- thinkingLevel: ThinkingLevel;
131
- thinkingLevelForModel: (ref?: string) => ThinkingLevel;
132
- }
133
-
134
- export function resolveDispatchModelRoute(
135
- agent: AgentConfig,
136
- config: SubagentsConfig,
137
- ctx: ExtensionContext,
138
- ): DispatchModelRoute {
139
- const availableModels = availableModelsInScope(ctx);
140
- const mainRef = currentModelRef(ctx);
141
- const route = resolveAgentModelRoute({
142
- selectedRef: config.agentModels[agent.name],
143
- mainRef,
144
- declaredDefaultRef: agent.model,
145
- availableRefs: availableModels.map(modelRef),
146
- });
147
- const preferred = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? DEFAULT_THINKING_LEVEL;
148
- const thinkingLevelForModel = (ref?: string): ThinkingLevel => {
149
- const model = ref === mainRef && ctx.model
150
- ? ctx.model
151
- : findModelByRef(availableModels, ref);
152
- return resolveThinkingLevel(model, preferred);
153
- };
154
- return {
155
- agent: { ...agent, model: route.primaryRef },
156
- mainFallbackRef: route.mainFallbackRef,
157
- thinkingLevel: thinkingLevelForModel(route.primaryRef),
158
- thinkingLevelForModel,
159
- };
160
- }
161
-
162
- export interface ManagedWorkflowRequest extends DispatchEnvironment {
163
- plan: ManagedWorkflowPlan;
164
- initialResult: SingleResult;
165
- groupId: string;
166
- parentRunId: number;
167
- executionCwd: string;
168
- projectCwd: string;
169
- isolation: IsolationMode;
170
- /** Short identity of the isolated worktree shared by every workflow stage. */
171
- worktreeId?: string;
172
- signal: AbortSignal;
173
- rememberLatest: (result: SingleResult) => void;
174
- }
175
-
176
- interface ManagedRepositoryLaneRunner {
177
- <T>(cwd: string, task: () => Promise<T>): Promise<T>;
178
- <T>(cwd: string, task: () => Promise<T>, signal: AbortSignal): Promise<T | undefined>;
179
- }
180
-
181
- interface BackgroundDispatcherOptions extends DispatchEnvironment {
182
- runtime: SubagentRuntime;
183
- finishRun: (
184
- runId: number,
185
- status: "done" | "failed",
186
- opts?: { silent?: boolean },
187
- ) => void;
188
- makeLiveHandler: (
189
- runId: number,
190
- generation?: number,
191
- ) => (event: SubagentLiveEvent) => void;
192
- makeDetails: (
193
- mode: "single" | "parallel",
194
- background?: boolean,
195
- ) => (results: SingleResult[]) => SubagentDetails;
196
- runManagedWorkflow: (request: ManagedWorkflowRequest) => Promise<ManagedWorkflowOutcome>;
197
- runInManagedRepositoryLane: ManagedRepositoryLaneRunner;
198
- }
199
-
200
- type BackgroundStarter = (
201
- agentName: string,
202
- task: string,
203
- cwd: string | undefined,
204
- isolation?: IsolationMode,
205
- ) => Promise<SingleResult>;
206
-
207
- export function createBackgroundDispatcher(options: BackgroundDispatcherOptions): BackgroundStarter {
208
- const {
209
- runtime,
210
- ctx,
211
- config,
212
- agents,
213
- finishRun,
214
- makeLiveHandler,
215
- makeDetails,
216
- runManagedWorkflow,
217
- runInManagedRepositoryLane,
218
- } = options;
219
- interface SessionSeed {
220
- sessionId?: string;
221
- sessionDir?: string;
222
- prompt?: string;
223
- worktree?: WorktreeIsolation;
224
- forkedFromRunId?: number;
225
- continuationKind?: ContinuationKind;
226
- }
227
-
228
- interface ResumeReservation {
229
- version: number;
230
- generation: number;
231
- sessionId?: string;
232
- sessionDir?: string;
233
- }
234
-
235
- const ownsResumeReservation = (
236
- thread: SubagentThread,
237
- reservation: ResumeReservation,
238
- ): boolean =>
239
- runtime.sessionActive &&
240
- runtime.threads.get(thread.id) === thread &&
241
- !thread.retired &&
242
- thread.lifecycleOperation === "resume" &&
243
- thread.lifecycleVersion === reservation.version &&
244
- thread.generation === reservation.generation &&
245
- thread.sessionId === reservation.sessionId &&
246
- thread.sessionDir === reservation.sessionDir;
247
-
248
- const beginPreflight = (): (() => void) => {
249
- let resolvePreflight!: () => void;
250
- const preflight = new Promise<void>((resolve) => {
251
- resolvePreflight = resolve;
252
- });
253
- runtime.preflightOperations.add(preflight);
254
- return () => {
255
- runtime.preflightOperations.delete(preflight);
256
- resolvePreflight();
257
- };
258
- };
259
-
260
- const startBackground = async (
261
- agentName: string,
262
- task: string,
263
- cwd: string | undefined,
264
- isolation: IsolationMode = "shared",
265
- existingThread?: SubagentThread,
266
- appendedObjectiveOnResume = false,
267
- environment?: DispatchEnvironment,
268
- seed?: SessionSeed,
269
- resumeReservation?: ResumeReservation,
270
- ): Promise<SingleResult> => {
271
- if (!runtime.sessionActive) {
272
- return failedStartResult(agentName, task, "Parent session shut down before this subagent generation could start.");
273
- }
274
- if (existingThread && (!resumeReservation || !ownsResumeReservation(existingThread, resumeReservation))) {
275
- return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
276
- }
277
- const runCtx = environment?.ctx ?? ctx;
278
- const runConfig = environment?.config ?? config;
279
- const runAgents = environment?.agents ?? agents;
280
- const discoveredAgent = runAgents.find((candidate) => candidate.name === agentName);
281
- if (!discoveredAgent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
282
- const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
283
- resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
284
- const resolvedAgent = resolveLiveAgentTools(discoveredAgent);
285
- const agent = agentName === "reviewer" && runAgents.some((candidate) => candidate.name === "documenter")
286
- ? withEnabledDocumenterReviewContract(resolvedAgent)
287
- : resolvedAgent;
288
- if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
289
- return {
290
- ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as worker, cleaner, or documenter.`),
291
- isolation,
292
- };
293
- }
294
-
295
- const originalCwd = resolve(cwd ?? runCtx.cwd);
296
- const previousWorktree = existingThread?.worktree;
297
- let worktree = seed?.worktree ?? previousWorktree;
298
- if (isolation === "worktree") {
299
- if (worktree && worktree.state !== "active") {
300
- return {
301
- ...failedStartResult(agentName, task, `Run #${existingThread?.id ?? "?"} has no active continuation worktree.`),
302
- isolation,
303
- integrationStatus: worktree.state === "finalizing" ? "pending" : worktree.state,
304
- };
305
- }
306
- if (!worktree) {
307
- try {
308
- worktree = await createWorktreeIsolation(originalCwd);
309
- } catch (error) {
310
- return {
311
- ...failedStartResult(agentName, task, error instanceof Error ? error.message : String(error)),
312
- isolation,
313
- };
314
- }
315
- }
316
- }
317
- const executionCwd = worktree?.cwd ?? originalCwd;
318
- const worktreeGroup = worktree ? worktreeGroupId(worktree) : undefined;
319
- const resolvedRoute = resolveDispatchModelRoute(agent, runConfig, runCtx);
320
- // Isolation is a persistent system-level invariant, not a one-shot task
321
- // prefix: queued retargets, live retargets, resumes, and main-model
322
- // handoffs all keep the same worktree boundary.
323
- const route = isolation === "worktree"
324
- ? { ...resolvedRoute, agent: withWorktreeSystemPrompt(resolvedRoute.agent) }
325
- : resolvedRoute;
326
- const thinkingLevel = route.thinkingLevel;
327
- const priorTask = existingThread?.task;
328
- const priorSessionId = seed?.sessionId ?? existingThread?.sessionId;
329
- const priorSessionDir = seed?.sessionDir ?? existingThread?.sessionDir;
330
- if (existingThread && resumeReservation && !ownsResumeReservation(existingThread, resumeReservation)) {
331
- return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
332
- }
333
- const runId = existingThread?.id ?? monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
334
- isolation,
335
- ...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
336
- ...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
337
- ...(seed?.continuationKind ? { continuationKind: seed.continuationKind } : {}),
338
- });
339
- const generation = (existingThread?.generation ?? 0) + 1;
340
- const pending: SingleResult = {
341
- ...queuedResult(route.agent, task, thinkingLevel),
342
- runId,
343
- projectCwd: originalCwd,
344
- isolation,
345
- ...(isolation === "worktree" ? { integrationStatus: "pending" as const } : {}),
346
- ...(seed?.sessionId && seed.sessionDir
347
- ? { sessionId: seed.sessionId, sessionDir: seed.sessionDir }
348
- : {}),
349
- ...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
350
- };
351
- if (existingThread) {
352
- monitor.restartRun(runId, agent.name, task, route.agent.model, thinkingLevel, isolation, {
353
- elapsedMs: existingThread.elapsedMs,
354
- continuationKind: appendedObjectiveOnResume ? "resume-appended" : "resume-retained",
355
- ...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
356
- });
357
- runtime.settledRuns.delete(runId);
358
- }
359
-
360
- let thread!: SubagentThread;
361
- const control = new RpcRunControl(task, generation, (phase) => {
362
- if (runtime.threads.get(runId)?.generation !== generation || phase === "settled") return;
363
- const state: ThreadState =
364
- phase === "queued" || phase === "starting"
365
- ? "queued"
366
- : phase === "steering"
367
- ? "steering"
368
- : phase === "interrupting"
369
- ? "interrupting"
370
- : phase === "parked"
371
- ? "parked"
372
- : phase === "stopped"
373
- ? "stopped"
374
- : "running";
375
- thread.state = state;
376
- if (state === "queued") monitor.setStatus(runId, "queued");
377
- else if (state === "steering") monitor.setStatus(runId, "steering");
378
- else if (state === "interrupting") monitor.setStatus(runId, "interrupting");
379
- else if (state === "parked") monitor.setStatus(runId, "parked");
380
- else if (state === "running") monitor.setStatus(runId, "running");
381
- });
382
-
383
-
384
- if (existingThread) {
385
- thread = existingThread;
386
- thread.generation = generation;
387
- thread.agentName = agent.name;
388
- thread.task = task;
389
- thread.cwd = originalCwd;
390
- thread.executionCwd = executionCwd;
391
- thread.thinkingLevel = thinkingLevel;
392
- thread.isolation = isolation;
393
- thread.worktree = worktree;
394
- thread.state = "queued";
395
- thread.control = control;
396
- // A newly admitted generation owns no output yet. Keeping the prior
397
- // generation here would make a queued stop publish stale task,
398
- // session metadata as this generation's partial.
399
- thread.lastResult = undefined;
400
- if (seed?.sessionId && seed.sessionDir) {
401
- thread.sessionId = seed.sessionId;
402
- thread.sessionDir = seed.sessionDir;
403
- }
404
- thread.retireOnSettle = false;
405
- thread.isolationFailureNotified = false;
406
- } else {
407
- thread = {
408
- id: runId,
409
- generation,
410
- agentName: agent.name,
411
- task,
412
- cwd: originalCwd,
413
- executionCwd,
414
- thinkingLevel,
415
- isolation,
416
- worktree,
417
- state: "queued",
418
- control,
419
- generationCompletion: Promise.resolve(),
420
- lifecycleVersion: 0,
421
- elapsedMs: 0,
422
- sessionId: seed?.sessionId,
423
- sessionDir: seed?.sessionDir,
424
- forkedFromRunId: seed?.forkedFromRunId,
425
- forkChildRunIds: [],
426
- park: async () => {
427
- throw new Error("Thread park was not initialized.");
428
- },
429
- resume: async () => failedStartResult(agent.name, task, "Thread resume was not initialized."),
430
- fork: async () => failedStartResult(agent.name, task, "Thread fork was not initialized."),
431
- finalizeIsolation: async () => undefined,
432
- };
433
- runtime.threads.set(runId, thread);
434
- }
435
- thread.notifyIsolationFailure = (finalization) => {
436
- const paths = [finalization.worktreePath, finalization.patchPath].filter(Boolean).join(" · ");
437
- runCtx.ui.notify(
438
- `✗ ${agent.name} worktree ${finalization.integrated ? "cleanup" : "integration"} failed${paths ? ` · retained ${paths}` : ""}: ${finalization.error ?? "unknown Git integration error"}`,
439
- "error",
440
- );
441
- };
442
- const generationWorktree = worktree;
443
- let generationFinalization: Promise<WorktreeFinalization> | undefined;
444
- thread.finalizeIsolation = async (
445
- expectedGeneration: number,
446
- result?: SingleResult,
447
- ): Promise<WorktreeFinalization | undefined> => {
448
- if (thread.isolation !== "worktree" || !generationWorktree) return undefined;
449
- if (thread.generation !== expectedGeneration || thread.worktree !== generationWorktree) return undefined;
450
- // All normal, destructive-stop, and shutdown owners converge here. Cache
451
- // the lane-protected apply itself so superseding lifecycle paths can project
452
- // the same finalization onto their own result without acquiring twice.
453
- if (!generationFinalization) {
454
- monitor.setIsolation(
455
- runId,
456
- "worktree",
457
- "finalizing",
458
- worktreeGroupId(generationWorktree),
459
- );
460
- generationFinalization = runInManagedRepositoryLane(
461
- generationWorktree.originalRoot,
462
- () => generationWorktree.finalize(),
463
- );
464
- }
465
- const finalization = await generationFinalization;
466
- monitor.setIsolation(runId, "worktree", finalization.status, worktreeGroupId(generationWorktree));
467
- if (result) {
468
- result.runId = runId;
469
- result.isolation = "worktree";
470
- result.integrationStatus = finalization.status;
471
- result.integrationApplied = finalization.integrated;
472
- result.integrationError = finalization.error;
473
- result.integrationWorktreePath = finalization.worktreePath;
474
- result.integrationPatchPath = finalization.patchPath;
475
- result.forkedFromRunId = thread.forkedFromRunId;
476
- result.forkChildRunIds = [...thread.forkChildRunIds];
477
- if (finalization.status === "retained") {
478
- const retained = [
479
- finalization.worktreePath ? `worktree ${finalization.worktreePath}` : undefined,
480
- finalization.patchPath ? `patch ${finalization.patchPath}` : undefined,
481
- ].filter(Boolean).join(", ");
482
- const integrationMessage = finalization.integrated
483
- ? `Worktree changes were applied, but cleanup failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git cleanup error"}`
484
- : `Worktree integration failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git integration error"}`;
485
- result.exitCode = 1;
486
- result.stopReason = "error";
487
- result.errorMessage = result.errorMessage
488
- ? `${result.errorMessage}\n${integrationMessage}`
489
- : integrationMessage;
490
- result.stderr = result.stderr ? `${result.stderr.trimEnd()}\n${integrationMessage}` : integrationMessage;
491
- }
492
- }
493
- if (finalization.status === "retained") {
494
- if (!thread.isolationFailureNotified) {
495
- thread.isolationFailureNotified = true;
496
- try {
497
- thread.notifyIsolationFailure?.(finalization);
498
- } catch {
499
- /* notification failures do not hide retained artifacts */
500
- }
501
- }
502
- }
503
- return finalization;
504
- };
505
-
506
- const cleanupTrackedSessionDir = async (sessionDir: string, action: string): Promise<void> => {
507
- try {
508
- await rm(sessionDir, { recursive: true, force: true });
509
- runtime.sessionDirs.delete(sessionDir);
510
- } catch (error) {
511
- // Keep ownership so shutdown can retry; losing the path here leaks a
512
- // cloned session containing retained model context on Windows locks.
513
- try {
514
- runCtx.ui.notify(
515
- `✗ ${action}; retained ${sessionDir} for shutdown cleanup: ${error instanceof Error ? error.message : String(error)}`,
516
- "error",
517
- );
518
- } catch {
519
- /* cleanup ownership remains tracked even if the UI is unavailable */
520
- }
521
- }
522
- };
523
-
524
- const discardUnusedWorktree = async (candidate: WorktreeIsolation | undefined): Promise<void> => {
525
- if (!candidate) return;
526
- try {
527
- await candidate.discard();
528
- } catch (error) {
529
- const retainedPath = existsSync(candidate.worktreePath)
530
- ? candidate.worktreePath
531
- : existsSync(candidate.tempDir)
532
- ? candidate.tempDir
533
- : undefined;
534
- const finalization: WorktreeFinalization = {
535
- status: "retained",
536
- integrated: false,
537
- hadChanges: false,
538
- ...(retainedPath ? { worktreePath: retainedPath } : {}),
539
- ...(existsSync(candidate.patchPath) ? { patchPath: candidate.patchPath } : {}),
540
- error: `Discarding unused continuation failed: ${error instanceof Error ? error.message : String(error)}`,
541
- };
542
- await persistRecoveryRecords(runtime.configPath, [
543
- recoveryRecordFromFinalization(runId, finalization),
544
- ]).catch(() => undefined);
545
- try {
546
- thread.notifyIsolationFailure?.(finalization);
547
- } catch {
548
- /* parent UI may already be shutting down */
549
- }
550
- }
551
- };
552
-
553
- const createContinuationWorktree = async (
554
- source: WorktreeIsolation,
555
- seedIsIntegrated: boolean,
556
- ): Promise<WorktreeIsolation> => {
557
- if (source.state === "finalizing") {
558
- throw new Error(`Run #${runId}'s worktree is still finalizing.`);
559
- }
560
- const seedCheckpoint = await source.snapshotCheckpoint();
561
- return createWorktreeIsolation(thread.cwd, {
562
- seedCheckpoint,
563
- seedIsIntegrated,
564
- });
565
- };
566
-
567
- const persistElapsedTime = (): void => {
568
- thread.elapsedMs = monitor.getElapsedMs(runId) ?? thread.elapsedMs;
569
- };
570
-
571
- thread.park = async (): Promise<"queued" | "active"> => {
572
- if (thread.retired) throw new Error(`Run #${runId} was retired by subagent_stop.`);
573
- if (thread.lifecycleOperation) throw new Error(`Run #${runId} is already handling ${thread.lifecycleOperation}.`);
574
- if (thread.state === "parked") return "active";
575
- const phase = thread.control.getPhase();
576
- const queued = thread.state === "queued" && phase === "queued";
577
- if (
578
- !queued &&
579
- ((phase === "settled" && thread.state !== "running") ||
580
- !["starting", "running", "steering", "interrupting", "retrying", "settled"].includes(phase))
581
- ) {
582
- throw new Error(`Run #${runId} is ${thread.state}; only active work can be parked.`);
583
- }
584
-
585
- const version = ++thread.lifecycleVersion;
586
- const generation = thread.generation;
587
- const completion = thread.generationCompletion;
588
- const controller = thread.queueController;
589
- thread.lifecycleOperation = "park";
590
- try {
591
- if (queued) {
592
- thread.control.parkPending();
593
- runtime.backgroundQueue.cancel(controller);
594
- } else {
595
- await thread.control.park();
596
- // A managed downstream child does not attach to the top-level RPC
597
- // control after that child settles, so cancel its queue owner explicitly.
598
- if (phase === "settled") runtime.backgroundQueue.cancel(controller);
599
- }
600
- // The settling tail may be blocked on worktree finalization or the
601
- // managed repository lane. Park already owns the lifecycle, so proceed
602
- // after a bounded wait and let the tail finish silently in the background.
603
- if (!(await quiesced(completion))) {
604
- runtime.backgroundQueue.cancel(controller);
605
- }
606
- if (
607
- thread.generation !== generation ||
608
- thread.lifecycleVersion !== version ||
609
- thread.lifecycleOperation !== "park"
610
- ) {
611
- throw new Error(`Run #${runId} changed while parking.`);
612
- }
613
- thread.state = "parked";
614
- thread.queueController = undefined;
615
- runtime.runControllers.delete(runId);
616
- const parkedRun = monitor.findRun(runId);
617
- if (parkedRun?.managedWorkflow && parkedRun.task !== thread.task) {
618
- // The active child row previously showed this stage objective. Once it
619
- // disappears, keep the parked parent aligned with what resume retains.
620
- monitor.setTask(runId, thread.task);
621
- }
622
- monitor.setStatus(runId, "parked");
623
- persistElapsedTime();
624
- return queued ? "queued" : "active";
625
- } finally {
626
- if (thread.lifecycleVersion === version && thread.lifecycleOperation === "park") {
627
- thread.lifecycleOperation = undefined;
628
- }
629
- }
630
- };
631
-
632
- thread.resume = async (objective?: string, resumeCtx?: ExtensionContext): Promise<SingleResult> => {
633
- const requestedObjective = objective?.trim();
634
- if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
635
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
636
- }
637
- if (objective !== undefined && !requestedObjective) {
638
- return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
639
- }
640
- if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
641
- if (thread.lifecycleOperation) {
642
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already ${thread.lifecycleOperation === "resume" ? "resuming" : "being forked"}.`);
643
- }
644
- if (!["parked", "completed", "failed"].includes(thread.state)) {
645
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state}; it must be parked or settled before resume.`);
646
- }
647
-
648
- // Lifecycle CAS: claim synchronously before the first await, then cancel
649
- // and fully quiesce any superseded queue/process before cloning or
650
- // reusing its session. A second resume/fork sees this claim immediately.
651
- const previousState = thread.state;
652
- const previousSessionId = thread.sessionId;
653
- const previousSessionDir = thread.sessionDir;
654
- const previousExecutionCwd = thread.executionCwd;
655
- const reservation: ResumeReservation = {
656
- version: ++thread.lifecycleVersion,
657
- generation: thread.generation,
658
- sessionId: previousSessionId,
659
- sessionDir: previousSessionDir,
660
- };
661
- thread.lifecycleOperation = "resume";
662
- thread.state = "resuming";
663
- const finishPreflight = beginPreflight();
664
- const supersededController = thread.queueController;
665
- runtime.backgroundQueue.cancel(supersededController);
666
- runtime.runControllers.delete(runId);
667
-
668
- let continuationWorktree: WorktreeIsolation | undefined;
669
- let clonedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
670
- try {
671
- // Never wait forever on a previous generation that is still settling
672
- // (e.g. blocked behind the managed repository lane in finalization).
673
- if (!(await quiesced(thread.generationCompletion))) {
674
- return failedStartResult(
675
- thread.agentName,
676
- thread.task,
677
- `Run #${runId}'s previous generation is still settling; retry the resume shortly.`,
678
- );
679
- }
680
- if (!ownsResumeReservation(thread, reservation)) {
681
- return failedStartResult(
682
- thread.agentName,
683
- thread.task,
684
- thread.retired
685
- ? `Run #${runId} was retired by subagent_stop; no new generation was started.`
686
- : `Run #${runId} changed while resume was preparing; no new generation was started.`,
687
- );
688
- }
689
- thread.state = "resuming";
690
- const currentCtx = resumeCtx ?? runCtx;
691
- let seed: SessionSeed | undefined;
692
- if (thread.isolation === "worktree" && thread.worktree?.state !== "active") {
693
- if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
694
- const seedAlreadyIntegrated =
695
- thread.worktree.state === "integrated" ||
696
- thread.worktree.state === "no_changes" ||
697
- thread.lastResult?.integrationApplied === true;
698
- continuationWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
699
- if (!ownsResumeReservation(thread, reservation)) {
700
- throw new Error(`Run #${runId} changed while its continuation worktree was being created.`);
701
- }
702
- seed = { worktree: continuationWorktree };
703
- if (previousSessionId && previousSessionDir) {
704
- clonedSession = await forkRetainedSession({
705
- cwd: previousExecutionCwd,
706
- targetCwd: continuationWorktree.cwd,
707
- sessionDir: previousSessionDir,
708
- sessionId: previousSessionId,
709
- });
710
- runtime.sessionDirs.add(clonedSession.sessionDir);
711
- if (!ownsResumeReservation(thread, reservation)) {
712
- throw new Error(`Run #${runId} changed while its retained session was being cloned.`);
713
- }
714
- seed.sessionId = clonedSession.sessionId;
715
- seed.sessionDir = clonedSession.sessionDir;
716
- }
717
- }
718
-
719
- const currentConfig = await loadConfig(runtime.configPath);
720
- if (!ownsResumeReservation(thread, reservation)) {
721
- throw new Error(`Run #${runId} changed while resume configuration was loading.`);
722
- }
723
- runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
724
- const currentAgents = discoverAgents(currentCtx.cwd, {
725
- scope: currentConfig.agentScope,
726
- enabledNames: currentConfig.enabledAgents,
727
- projectTrusted: currentCtx.isProjectTrusted?.() === true,
728
- }).agents;
729
- const nextTask = requestedObjective ?? thread.task;
730
- const pending = await startBackground(
731
- thread.agentName,
732
- nextTask,
733
- thread.cwd,
734
- thread.isolation,
735
- thread,
736
- objective !== undefined,
737
- {
738
- ctx: currentCtx,
739
- config: currentConfig,
740
- agents: currentAgents,
741
- },
742
- seed,
743
- reservation,
744
- );
745
- if (pending.exitCode !== -1) {
746
- if (clonedSession) {
747
- await cleanupTrackedSessionDir(
748
- clonedSession.sessionDir,
749
- `Could not discard failed resume session clone for run #${runId}`,
750
- );
751
- }
752
- await discardUnusedWorktree(continuationWorktree);
753
- if (ownsResumeReservation(thread, reservation)) thread.state = previousState;
754
- return pending;
755
- }
756
-
757
- // The cloned branch replaces the removed-worktree session for this
758
- // logical id. Keep an undeletable old dir in runtime cleanup if needed.
759
- if (clonedSession && previousSessionDir && previousSessionDir !== clonedSession.sessionDir) {
760
- try {
761
- await rm(previousSessionDir, { recursive: true, force: true });
762
- runtime.sessionDirs.delete(previousSessionDir);
763
- } catch {
764
- /* shutdown retries cleanup of the old retained branch */
765
- }
766
- }
767
- return pending;
768
- } catch (error) {
769
- if (clonedSession) {
770
- await cleanupTrackedSessionDir(
771
- clonedSession.sessionDir,
772
- `Could not discard interrupted resume session clone for run #${runId}`,
773
- );
774
- }
775
- await discardUnusedWorktree(continuationWorktree);
776
- if (ownsResumeReservation(thread, reservation)) {
777
- thread.state = previousState;
778
- thread.sessionId = previousSessionId;
779
- thread.sessionDir = previousSessionDir;
780
- thread.executionCwd = previousExecutionCwd;
781
- }
782
- return failedStartResult(
783
- thread.agentName,
784
- requestedObjective ?? thread.task,
785
- `Could not resume run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
786
- );
787
- } finally {
788
- finishPreflight();
789
- if (
790
- thread.lifecycleOperation === "resume" &&
791
- thread.lifecycleVersion === reservation.version
792
- ) {
793
- thread.lifecycleOperation = undefined;
794
- }
795
- }
796
- };
797
-
798
- thread.fork = async (objective?: string, forkCtx?: ExtensionContext): Promise<SingleResult> => {
799
- const forkObjective = objective?.trim();
800
- if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
801
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
802
- }
803
- if (objective !== undefined && !forkObjective) {
804
- return failedStartResult(thread.agentName, thread.task, "fork objective must be non-blank when provided.");
805
- }
806
- if (thread.retired || thread.state === "stopped") {
807
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop and cannot be forked.`);
808
- }
809
- if (thread.lifecycleOperation) {
810
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already ${thread.lifecycleOperation === "resume" ? "resuming" : "being forked"}.`);
811
- }
812
- if (thread.state === "queued" && !thread.sessionId) {
813
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is queued and has no retained session to fork.`);
814
- }
815
- if (["queued", "running", "steering", "interrupting"].includes(thread.state)) {
816
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is active; park it first with subagent_control { action: "park", id: ${runId} }, then fork the stable session.`);
817
- }
818
- if (!["parked", "completed", "failed"].includes(thread.state)) {
819
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state} and has no forkable retained checkpoint.`);
820
- }
821
- if (!thread.sessionId || !thread.sessionDir) {
822
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} has no retained session to fork (it may have been parked before starting).`);
823
- }
824
- if (thread.isolation === "worktree") {
825
- const worktreeState = thread.worktree?.state;
826
- const seedIntegrated =
827
- worktreeState === "integrated" ||
828
- worktreeState === "no_changes" ||
829
- thread.lastResult?.integrationApplied === true;
830
- if (!seedIntegrated) {
831
- return failedStartResult(
832
- thread.agentName,
833
- thread.task,
834
- `Run #${runId}'s isolated checkpoint has not been integrated. Resume and settle it before forking so its seed is applied exactly once.`,
835
- );
836
- }
837
- }
838
-
839
- // Same lifecycle CAS as resume: a concurrent resume/fork cannot consume
840
- // or clone this session while the branch copy is in progress.
841
- const forkVersion = ++thread.lifecycleVersion;
842
- const forkGeneration = thread.generation;
843
- const forkSessionId = thread.sessionId;
844
- const forkSessionDir = thread.sessionDir;
845
- const ownsFork = (): boolean =>
846
- runtime.sessionActive &&
847
- runtime.threads.get(runId) === thread &&
848
- !thread.retired &&
849
- thread.lifecycleOperation === "fork" &&
850
- thread.lifecycleVersion === forkVersion &&
851
- thread.generation === forkGeneration &&
852
- thread.sessionId === forkSessionId &&
853
- thread.sessionDir === forkSessionDir;
854
- thread.lifecycleOperation = "fork";
855
- const finishPreflight = beginPreflight();
856
- let childWorktree: WorktreeIsolation | undefined;
857
- let forkedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
858
- try {
859
- // Same bounded preflight as resume: a still-settling source generation
860
- // must not block the control operation forever.
861
- if (!(await quiesced(thread.generationCompletion))) {
862
- return failedStartResult(
863
- thread.agentName,
864
- thread.task,
865
- `Run #${runId}'s previous generation is still settling; retry the fork shortly.`,
866
- );
867
- }
868
- if (!ownsFork()) {
869
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} changed while fork was preparing; no child was started.`);
870
- }
871
- const currentCtx = forkCtx ?? runCtx;
872
- if (thread.isolation === "worktree") {
873
- if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
874
- const seedAlreadyIntegrated =
875
- thread.worktree.state === "integrated" ||
876
- thread.worktree.state === "no_changes" ||
877
- thread.lastResult?.integrationApplied === true;
878
- childWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
879
- if (!ownsFork()) throw new Error(`Run #${runId} changed while its fork worktree was being created.`);
880
- }
881
- forkedSession = await forkRetainedSession({
882
- cwd: thread.executionCwd,
883
- targetCwd: childWorktree?.cwd ?? thread.cwd,
884
- sessionDir: thread.sessionDir,
885
- sessionId: thread.sessionId,
886
- });
887
- runtime.sessionDirs.add(forkedSession.sessionDir);
888
- if (!ownsFork()) throw new Error(`Run #${runId} changed while its retained session was being forked.`);
889
- const currentConfig = await loadConfig(runtime.configPath);
890
- if (!ownsFork()) throw new Error(`Run #${runId} changed while fork configuration was loading.`);
891
- runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
892
- const currentAgents = discoverAgents(currentCtx.cwd, {
893
- scope: currentConfig.agentScope,
894
- enabledNames: currentConfig.enabledAgents,
895
- projectTrusted: currentCtx.isProjectTrusted?.() === true,
896
- }).agents;
897
- if (!ownsFork()) throw new Error(`Run #${runId} changed while fork was preparing; no child was started.`);
898
- const childTask = forkObjective ?? thread.task;
899
- const child = await startBackground(
900
- thread.agentName,
901
- childTask,
902
- thread.cwd,
903
- thread.isolation,
904
- undefined,
905
- false,
906
- {
907
- ctx: currentCtx,
908
- config: currentConfig,
909
- agents: currentAgents,
910
- },
911
- {
912
- sessionId: forkedSession.sessionId,
913
- sessionDir: forkedSession.sessionDir,
914
- prompt: forkObjective ?? FORK_CONTINUATION_PROMPT,
915
- worktree: childWorktree,
916
- forkedFromRunId: runId,
917
- continuationKind: forkObjective ? "fork-appended" : "fork-retained",
918
- },
919
- );
920
- if (child.exitCode !== -1 || child.runId === undefined) {
921
- await cleanupTrackedSessionDir(
922
- forkedSession.sessionDir,
923
- `Could not discard failed fork session clone for run #${runId}`,
924
- );
925
- await discardUnusedWorktree(childWorktree);
926
- return child;
927
- }
928
-
929
- // Once the independent child is enqueued it remains valid even if the
930
- // source is retired; just skip source-side relationship mutation.
931
- if (!ownsFork()) return child;
932
- const childRunId = child.runId;
933
- if (!thread.forkChildRunIds.includes(childRunId)) thread.forkChildRunIds.push(childRunId);
934
- const childThread = runtime.threads.get(childRunId);
935
- if (childThread) childThread.forkedFromRunId = runId;
936
- monitor.setForkRelation(runId, childRunId);
937
- const sourceResult = runtime.settledRuns.get(runId) ?? thread.lastResult;
938
- if (sourceResult) sourceResult.forkChildRunIds = [...thread.forkChildRunIds];
939
- return child;
940
- } catch (error) {
941
- if (forkedSession) {
942
- await cleanupTrackedSessionDir(
943
- forkedSession.sessionDir,
944
- `Could not discard interrupted fork session clone for run #${runId}`,
945
- );
946
- }
947
- await discardUnusedWorktree(childWorktree);
948
- return failedStartResult(
949
- thread.agentName,
950
- forkObjective ?? thread.task,
951
- `Could not fork retained session for run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
952
- );
953
- } finally {
954
- finishPreflight();
955
- if (thread.lifecycleVersion === forkVersion && thread.lifecycleOperation === "fork") {
956
- thread.lifecycleOperation = undefined;
957
- }
958
- }
959
- };
960
-
961
- const onLive = makeLiveHandler(runId, generation);
962
- const workflowAvailability = workflowAgentAvailability(runAgents);
963
- const reserveManagedLane =
964
- isolation === "shared" && canStartManagedWorkflow(agent, workflowAvailability);
965
- const runGeneration = async (backgroundSignal: AbortSignal): Promise<void> => {
966
- if (runtime.threads.get(runId)?.generation !== generation) return;
967
- // Model/thinking config may have changed while this generation sat
968
- // queued behind the concurrency limit. Re-resolve the route at actual
969
- // start so /subagents-setup edits apply to not-yet-started runs.
970
- let activeRoute = route;
971
- let activeIdleTimeoutMs = runConfig.idleTimeoutSec * 1000;
972
- try {
973
- const startConfig = await loadConfig(runtime.configPath);
974
- runtime.backgroundQueue.setConcurrency(startConfig.maxConcurrency);
975
- const resolvedStart = resolveDispatchModelRoute(agent, startConfig, runCtx);
976
- activeRoute = isolation === "worktree"
977
- ? { ...resolvedStart, agent: withWorktreeSystemPrompt(resolvedStart.agent) }
978
- : resolvedStart;
979
- activeIdleTimeoutMs = startConfig.idleTimeoutSec * 1000;
980
- monitor.setModel(runId, activeRoute.agent.model);
981
- monitor.setThinking(runId, activeRoute.thinkingLevel);
982
- } catch {
983
- /* keep the dispatch-time route when fresh config is unavailable */
984
- }
985
- let result: SingleResult;
986
- try {
987
- result = await runSingleAgentWithMainFallback(
988
- {
989
- defaultCwd: executionCwd,
990
- agent: activeRoute.agent,
991
- resolveAgentForAttempt: resolveLiveAgentTools,
992
- agentName,
993
- task,
994
- cwd: executionCwd,
995
- thinkingLevel: activeRoute.thinkingLevel,
996
- thinkingLevelForModel: activeRoute.thinkingLevelForModel,
997
- signal: backgroundSignal,
998
- onLive,
999
- control,
1000
- makeDetails: makeDetails("single", true),
1001
- idleTimeoutMs: activeIdleTimeoutMs,
1002
- ...(priorSessionId && priorSessionDir
1003
- ? {
1004
- sessionId: priorSessionId,
1005
- sessionDir: priorSessionDir,
1006
- stdinText: seed?.prompt ?? (appendedObjectiveOnResume
1007
- ? task
1008
- : buildResumePrompt(priorTask ?? task, "the retained thread was resumed")),
1009
- }
1010
- : {}),
1011
- },
1012
- activeRoute.mainFallbackRef,
1013
- );
1014
- } catch (error) {
1015
- const errorMessage = error instanceof Error ? error.message : String(error);
1016
- result = {
1017
- ...pending,
1018
- task: control.getObjective(),
1019
- exitCode: 1,
1020
- stderr: errorMessage,
1021
- stopReason: backgroundSignal.aborted ? "aborted" : "error",
1022
- errorMessage,
1023
- dispatchFailed: true,
1024
- };
1025
- }
1026
-
1027
- // A stale process/generation may finish after a park/resume race. It owns
1028
- // no monitor mutation, result registration, or completion delivery.
1029
- if (runtime.threads.get(runId)?.generation !== generation) return;
1030
- result.runId = runId;
1031
- result.projectCwd = originalCwd;
1032
- result.isolation = isolation;
1033
- result.forkedFromRunId = thread.forkedFromRunId;
1034
- result.forkChildRunIds = [...thread.forkChildRunIds];
1035
- thread.task = result.task;
1036
- thread.sessionId = result.sessionId;
1037
- thread.sessionDir = result.sessionDir;
1038
- thread.lastResult = result;
1039
- runtime.retainSession(result);
1040
- monitor.setModel(runId, result.model, result.modelFallbackFrom);
1041
- monitor.setThinking(runId, result.thinking);
1042
-
1043
- const lifecycleInterrupted = (): boolean =>
1044
- thread.lifecycleOperation === "park" ||
1045
- thread.lifecycleOperation === "stop" ||
1046
- thread.state === "parked" ||
1047
- thread.state === "stopped";
1048
- // Destructive stop owns publication once it has synchronously claimed
1049
- // the lifecycle. Leave the partial result/session on the thread; the
1050
- // stop path waits for this queue task, finalizes isolation, and emits
1051
- // exactly one aborted result.
1052
- if (thread.lifecycleOperation === "stop") return;
1053
-
1054
- if (result.parked) {
1055
- thread.state = "parked";
1056
- monitor.setStatus(runId, "parked");
1057
- runtime.settledRuns.delete(runId);
1058
- return;
1059
- }
1060
- // A park/shutdown can win in the microtask gap after the top-level RPC
1061
- // settles. Do not launch an obsolete documenter/reviewer or replace the
1062
- // stable top-level session with an aborted downstream attempt.
1063
- if (backgroundSignal.aborted || lifecycleInterrupted() || !runtime.sessionActive) return;
1064
-
1065
- if (thread.retireOnSettle) runtime.retireThreadSession(thread);
1066
- let workflowOutcome: ManagedWorkflowOutcome | undefined;
1067
- const workflowPlan = getManagedWorkflowPlan(result, runConfig, workflowAvailability);
1068
- if (workflowPlan && runtime.sessionActive) {
1069
- thread.state = "running";
1070
- // The stable parent row now represents workflow ownership, not whichever
1071
- // model stage ran most recently. Internal rows own their exact role/model/
1072
- // thinking/timing telemetry and remain independently queryable.
1073
- monitor.setManagedWorkflow(runId, true);
1074
- monitor.setStatus(runId, "running");
1075
- monitor.setActivity(
1076
- runId,
1077
- workflowPlan.kind === "auto-fix" ? "auto-fix chain running" : "managed workflow running",
1078
- );
1079
- workflowOutcome = await runManagedWorkflow({
1080
- plan: workflowPlan,
1081
- initialResult: result,
1082
- groupId: `workflow-${runId}`,
1083
- parentRunId: runId,
1084
- executionCwd: thread.executionCwd,
1085
- projectCwd: originalCwd,
1086
- isolation,
1087
- ...(worktree ? { worktreeId: worktreeGroupId(worktree) } : {}),
1088
- signal: backgroundSignal,
1089
- ctx: runCtx,
1090
- config: runConfig,
1091
- agents: runAgents,
1092
- rememberLatest: (latest) => {
1093
- if (runtime.threads.get(runId) !== thread || thread.generation !== generation) return;
1094
- thread.lastResult = latest;
1095
- // Retained control follows the newest child session, but the live parent
1096
- // row keeps the original top-level role/model/usage. The active internal
1097
- // row already owns the current stage's role and telemetry.
1098
- thread.agentName = latest.agent;
1099
- thread.task = latest.task;
1100
- thread.sessionId = latest.sessionId;
1101
- thread.sessionDir = latest.sessionDir;
1102
- runtime.retainSession(latest);
1103
- },
1104
- });
1105
-
1106
- // Park/stop/shutdown owns this generation once it cancels the queue
1107
- // signal. The newest internal partial is already on thread.lastResult;
1108
- // never replace it with the old top-level result or publish stale output.
1109
- if (backgroundSignal.aborted || lifecycleInterrupted() || !runtime.sessionActive) return;
1110
-
1111
- const finalStep = workflowOutcome.steps[workflowOutcome.steps.length - 1]!;
1112
- result = {
1113
- ...finalStep.result,
1114
- runId,
1115
- projectCwd: originalCwd,
1116
- isolation,
1117
- forkedFromRunId: thread.forkedFromRunId,
1118
- forkChildRunIds: [...thread.forkChildRunIds],
1119
- };
1120
- thread.lastResult = result;
1121
- thread.agentName = result.agent;
1122
- thread.task = result.task;
1123
- thread.sessionId = result.sessionId;
1124
- thread.sessionDir = result.sessionDir;
1125
- runtime.retainSession(result);
1126
- }
1127
- // Claim terminal settlement synchronously before the first slow await.
1128
- // Park therefore either wins while RPC is still active, or is rejected
1129
- // once settlement owns the generation. Destructive stop may supersede
1130
- // this reservation; publication is revalidated after Git finalization.
1131
- const settlementVersion = ++thread.lifecycleVersion;
1132
- thread.lifecycleOperation = "settle";
1133
- const ownsSettlement = (): boolean =>
1134
- runtime.threads.get(runId) === thread &&
1135
- thread.generation === generation &&
1136
- thread.lifecycleVersion === settlementVersion &&
1137
- thread.lifecycleOperation === "settle" &&
1138
- !thread.retired;
1139
- try {
1140
- // For isolated writers this is deliberately after the managed reviewer
1141
- // and any needed documentation stage: every child sees the same worktree,
1142
- // then one lifecycle owner integrates the complete settled state exactly once.
1143
- await thread.finalizeIsolation(generation, result);
1144
- if (!ownsSettlement()) return;
1145
- if (workflowOutcome && isolation === "worktree") {
1146
- for (const step of workflowOutcome.steps) {
1147
- step.result.integrationStatus = result.integrationStatus;
1148
- step.result.integrationApplied = result.integrationApplied;
1149
- step.result.integrationError = result.integrationError;
1150
- step.result.integrationWorktreePath = result.integrationWorktreePath;
1151
- step.result.integrationPatchPath = result.integrationPatchPath;
1152
- }
1153
- }
1154
-
1155
- const failed = isFailedResult(result);
1156
- thread.state = failed ? "failed" : "completed";
1157
- // Stamp the terminal monitor state before projecting it. This gives every
1158
- // path a fixed endedAt even when the row is removed immediately.
1159
- monitor.setStatus(runId, failed ? "failed" : "done");
1160
- persistElapsedTime();
1161
- if (!runtime.sessionActive || !ownsSettlement()) return;
1162
-
1163
- const modelLevel = failed && isModelLevelFailure(result);
1164
- const dispatchFailed = result.dispatchFailed === true;
1165
- const ownedController = thread.queueController;
1166
- if (runtime.runControllers.get(runId) === ownedController) runtime.runControllers.delete(runId);
1167
- thread.queueController = undefined;
1168
- finishRun(
1169
- runId,
1170
- failed ? "failed" : "done",
1171
- workflowOutcome || modelLevel || dispatchFailed ? { silent: true } : undefined,
1172
- );
1173
- runtime.registerRunResult(runId, result);
1174
-
1175
- if (workflowOutcome) {
1176
- const lastStep = workflowOutcome.steps[workflowOutcome.steps.length - 1]!;
1177
- let block = workflowOutcome.kind === "auto-fix"
1178
- ? formatChainSummary(workflowOutcome.steps, result)
1179
- : formatManagedWorkflowSummary(workflowOutcome.steps, result);
1180
- const finalVerdict = lastStep.result.agent === "reviewer"
1181
- ? reviewVerdict(getResultOutput(lastStep.result))
1182
- : undefined;
1183
- const needsFullFinal = failed || (lastStep.result.agent === "reviewer" && finalVerdict !== "pass");
1184
- if (needsFullFinal) {
1185
- block += `\n\n${formatCompletionBlock(result, runConfig.maxResultLines, originalCwd)}`;
1186
- }
1187
- if (modelLevel) block += `\n\n${modelLevelTakeoverNote(result, { runId })}`;
1188
- runtime.sendCompletionGroup([{
1189
- agent: `${workflowOutcome.kind === "auto-fix" ? "auto-fix chain" : "managed workflow"} (${result.agent})`,
1190
- block,
1191
- triggerTurn: true,
1192
- usage: sumUsage(workflowOutcome.steps.map((step) => step.result.usage)),
1193
- }]);
1194
- runtime.completionBatcher.flush();
1195
- return;
1196
- }
1197
-
1198
- const completion: CompletionMessageItem = {
1199
- agent: result.agent,
1200
- block: modelLevel
1201
- ? `${formatCompletionBlock(result, runConfig.maxResultLines, result.projectCwd ?? originalCwd)}\n\n${modelLevelTakeoverNote(result, { runId })}`
1202
- : formatCompletionBlock(result, runConfig.maxResultLines, result.projectCwd ?? originalCwd),
1203
- triggerTurn: completionTriggersTurn(result, runConfig.notifyOnReviewPass),
1204
- usage: result.usage,
1205
- };
1206
- if (modelLevel) {
1207
- const detail = result.errorMessage?.trim() || "model unavailable or broken";
1208
- runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${detail} — task handed to the main window`, "error");
1209
- } else if (dispatchFailed) {
1210
- runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${result.errorMessage ?? "dispatch crashed"}`, "error");
1211
- }
1212
- if (failed) {
1213
- runtime.sendCompletionGroup([completion]);
1214
- runtime.completionBatcher.flush();
1215
- } else {
1216
- runtime.completionBatcher.push(completion);
1217
- }
1218
- } finally {
1219
- if (ownsSettlement()) thread.lifecycleOperation = undefined;
1220
- }
1221
- };
1222
- const queuedGeneration = reserveManagedLane
1223
- ? async (backgroundSignal: AbortSignal): Promise<void> => {
1224
- await runInManagedRepositoryLane(
1225
- originalCwd,
1226
- () => runGeneration(backgroundSignal),
1227
- backgroundSignal,
1228
- );
1229
- }
1230
- : runGeneration;
1231
- const queueController = runtime.backgroundQueue.enqueue(
1232
- queuedGeneration,
1233
- () => {
1234
- if (runtime.threads.get(runId)?.generation !== generation) return;
1235
- // Queued park/stop owns publication and may still be finalizing an
1236
- // isolated worktree. Do not expose a terminal monitor state before
1237
- // that owner records the checkpoint or aborted result.
1238
- if (thread.lifecycleOperation === "park" || thread.lifecycleOperation === "stop") return;
1239
- runtime.runControllers.delete(runId);
1240
- thread.queueController = undefined;
1241
- if (thread.state === "parked") {
1242
- monitor.setStatus(runId, "parked");
1243
- return;
1244
- }
1245
- thread.state = "stopped";
1246
- monitor.setStatus(runId, "failed");
1247
- if (!runtime.sessionActive) {
1248
- monitor.removeRun(runId);
1249
- return;
1250
- }
1251
- finishRun(runId, "failed");
1252
- },
1253
- async (error) => {
1254
- if (runtime.threads.get(runId)?.generation !== generation) return;
1255
- // Queue-level crashes use the same settlement reservation as ordinary
1256
- // results. A concurrent destructive stop may supersede it while slow
1257
- // worktree finalization is running, in which case stop publishes once.
1258
- if (thread.lifecycleOperation === "stop") return;
1259
- const settlementVersion = ++thread.lifecycleVersion;
1260
- thread.lifecycleOperation = "settle";
1261
- const ownsSettlement = (): boolean =>
1262
- runtime.threads.get(runId) === thread &&
1263
- thread.generation === generation &&
1264
- thread.lifecycleVersion === settlementVersion &&
1265
- thread.lifecycleOperation === "settle" &&
1266
- !thread.retired;
1267
- try {
1268
- const errorMessage = error instanceof Error ? error.message : String(error);
1269
- const latest = thread.lastResult;
1270
- const crashed: SingleResult = latest
1271
- ? {
1272
- ...latest,
1273
- runId,
1274
- projectCwd: originalCwd,
1275
- isolation,
1276
- exitCode: 1,
1277
- stopReason: "error",
1278
- errorMessage: `Managed workflow dispatch failed: ${errorMessage}`,
1279
- dispatchFailed: true,
1280
- forkedFromRunId: thread.forkedFromRunId,
1281
- }
1282
- : {
1283
- ...dispatchFailedResult(route.agent, control.getObjective(), error, thinkingLevel),
1284
- runId,
1285
- projectCwd: originalCwd,
1286
- isolation,
1287
- forkedFromRunId: thread.forkedFromRunId,
1288
- };
1289
- thread.lastResult = crashed;
1290
- runtime.retainSession(crashed);
1291
- await thread.finalizeIsolation(generation, crashed);
1292
- if (!ownsSettlement()) return;
1293
- thread.state = "failed";
1294
- monitor.setStatus(runId, "failed");
1295
- persistElapsedTime();
1296
- finishRun(runId, "failed", { silent: true });
1297
- runtime.registerRunResult(runId, crashed);
1298
- runtime.runControllers.delete(runId);
1299
- thread.queueController = undefined;
1300
- if (!runtime.sessionActive || !ownsSettlement()) return;
1301
- try {
1302
- runCtx.ui.notify(`✗ ${crashed.agent} dispatch failed: ${crashed.errorMessage}`, "error");
1303
- runtime.sendCompletionGroup([
1304
- {
1305
- agent: crashed.agent,
1306
- block: formatCompletionBlock(crashed, runConfig.maxResultLines, crashed.projectCwd ?? originalCwd),
1307
- triggerTurn: true,
1308
- usage: crashed.usage,
1309
- },
1310
- ]);
1311
- runtime.completionBatcher.flush();
1312
- } catch {
1313
- /* a second delivery failure must not throw through the queue */
1314
- }
1315
- } finally {
1316
- if (ownsSettlement()) thread.lifecycleOperation = undefined;
1317
- }
1318
- },
1319
- );
1320
- thread.queueController = queueController;
1321
- thread.generationCompletion = runtime.backgroundQueue.waitForTask(queueController);
1322
- runtime.runControllers.set(runId, queueController);
1323
- return pending;
1324
- };
1325
-
1326
- return startBackground;
1327
- }
1
+ /**
2
+ * Stable logical-thread generation lifecycle for background sub-agents.
3
+ *
4
+ * Dispatch owns workflow policy, the live stage projection, and internal role
5
+ * briefs; this module owns one
6
+ * stable parent generation end to end: managed-repository lane use,
7
+ * worktree setup/finalization after downstream review, queue/process ownership,
8
+ * retained-session resume, and guarded one-time terminal publication.
9
+ */
10
+
11
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { existsSync } from "node:fs";
13
+ import { rm } from "node:fs/promises";
14
+ import { realpath } from "node:fs/promises";
15
+ import { resolve } from "node:path";
16
+ import {
17
+ discoverAgents,
18
+ isWriteCapableAgent,
19
+ resolveAgentTools,
20
+ type AgentConfig,
21
+ } from "./agents.ts";
22
+ import { completionTriggersTurn, type CompletionMessageItem } from "./completion.ts";
23
+ import {
24
+ DEFAULT_THINKING_LEVEL,
25
+ loadConfig,
26
+ type SubagentsConfig,
27
+ type ThinkingLevel,
28
+ } from "./config.ts";
29
+ import {
30
+ getStateRoot,
31
+ readThreadRecords,
32
+ referencedDurablePaths,
33
+ removeThreadRecord,
34
+ pruneThreadRecords,
35
+ restoredResultFromSummary,
36
+ threadRecordFromThread,
37
+ ThreadRecord,
38
+ upsertThreadRecord,
39
+ } from "./durable.ts";
40
+ import {
41
+ dispatchFailedResult,
42
+ failedStartResult,
43
+ formatCompletionBlock,
44
+ modelLevelTakeoverNote,
45
+ queuedResult,
46
+ } from "./format.ts";
47
+ import {
48
+ canStartManagedWorkflow,
49
+ formatChainSummary,
50
+ formatManagedWorkflowSummary,
51
+ getManagedWorkflowPlan,
52
+ workflowAgentAvailability,
53
+ type ManagedWorkflowOutcome,
54
+ type ManagedWorkflowPlan,
55
+ } from "./fixloop.ts";
56
+ import {
57
+ availableModelsInScope,
58
+ currentModelRef,
59
+ findModelByRef,
60
+ modelRef,
61
+ resolveAgentModelRoute,
62
+ resolveThinkingLevel,
63
+ } from "./models.ts";
64
+ import { monitor, sumUsage } from "./monitor.ts";
65
+ import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
66
+ import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
67
+ import { forkRetainedSession } from "./session-fork.ts";
68
+ import {
69
+ buildResumePrompt,
70
+ getResultOutput,
71
+ RpcRunControl,
72
+ isFailedResult,
73
+ isModelLevelFailure,
74
+ reviewVerdict,
75
+ runSingleAgentWithMainFallback,
76
+ sessionExists,
77
+ type SingleResult,
78
+ type SubagentDetails,
79
+ type SubagentLiveEvent,
80
+ } from "./spawn.ts";
81
+ import {
82
+ isProcessAlive,
83
+ killProcessTree,
84
+ sweepOrphanTempDirs,
85
+ sweepUnreferencedState,
86
+ } from "./temp-hygiene.ts";
87
+ import {
88
+ createWorktreeIsolation,
89
+ resolveRepositoryRoot,
90
+ restoreWorktreeIsolation,
91
+ worktreeGroupId,
92
+ type IsolationMode,
93
+ type WorktreeFinalization,
94
+ type WorktreeIsolation,
95
+ } from "./worktree.ts";
96
+
97
+ /** Control operations must never wait forever on a settling generation: the
98
+ * queue task can legitimately spend minutes in worktree finalization (bounded
99
+ * per-Git-command timeouts) or wait behind the managed repository lane. After
100
+ * this deadline the control path owns the lifecycle synchronously and proceeds
101
+ * while the stuck tail settles silently in the background. */
102
+ export const CONTROL_QUIESCE_TIMEOUT_MS = 20_000;
103
+
104
+ /** Resolve true when the promise settles, or false after the bounded deadline. */
105
+ export function quiesced(promise: Promise<unknown>, timeoutMs: number = CONTROL_QUIESCE_TIMEOUT_MS): Promise<boolean> {
106
+ return Promise.race([
107
+ promise.then(() => true, () => true),
108
+ new Promise<boolean>((resolve) => {
109
+ const timer = setTimeout(() => resolve(false), timeoutMs);
110
+ if (typeof timer.unref === "function") timer.unref();
111
+ }),
112
+ ]);
113
+ }
114
+
115
+ const managedRepositoryRootTails = new Map<string, Promise<void>>();
116
+
117
+ async function canonicalManagedRepositoryRoot(cwd: string): Promise<string> {
118
+ try {
119
+ // Repository identity does not depend on HEAD: empty repositories must
120
+ // serialize root and nested cwd requests under the same lane too.
121
+ return await resolveRepositoryRoot(cwd);
122
+ } catch {
123
+ try {
124
+ return await realpath(resolve(cwd));
125
+ } catch {
126
+ return resolve(cwd);
127
+ }
128
+ }
129
+ }
130
+
131
+ /** Run one operation under the canonical original-repository lane.
132
+ *
133
+ * Shared managed generations use the abortable overload for their complete
134
+ * writer/reviewer workflow. Isolated generations use the non-abortable overload
135
+ * only for their final worktree apply, so model work remains parallel while the
136
+ * original checkout mutation cannot race a shared writer or reviewer snapshot.
137
+ */
138
+ export async function runInManagedRepositoryLane<T>(
139
+ cwd: string,
140
+ task: () => Promise<T>,
141
+ ): Promise<T>;
142
+ export async function runInManagedRepositoryLane<T>(
143
+ cwd: string,
144
+ task: () => Promise<T>,
145
+ signal: AbortSignal,
146
+ ): Promise<T | undefined>;
147
+ export async function runInManagedRepositoryLane<T>(
148
+ cwd: string,
149
+ task: () => Promise<T>,
150
+ signal?: AbortSignal,
151
+ ): Promise<T | undefined> {
152
+ if (signal?.aborted) return undefined;
153
+ const root = await canonicalManagedRepositoryRoot(cwd);
154
+ const key = process.platform === "win32" ? root.toLowerCase() : root;
155
+ const previous = managedRepositoryRootTails.get(key) ?? Promise.resolve();
156
+ let release!: () => void;
157
+ const gate = new Promise<void>((resolveGate) => {
158
+ release = resolveGate;
159
+ });
160
+ const tail = previous.catch(() => undefined).then(() => gate);
161
+ managedRepositoryRootTails.set(key, tail);
162
+ let onAbort: (() => void) | undefined;
163
+ try {
164
+ if (signal) {
165
+ await Promise.race([
166
+ previous.catch(() => undefined),
167
+ new Promise<void>((resolveAborted) => {
168
+ if (signal.aborted) resolveAborted();
169
+ else {
170
+ onAbort = resolveAborted;
171
+ signal.addEventListener("abort", onAbort, { once: true });
172
+ }
173
+ }),
174
+ ]);
175
+ } else {
176
+ await previous.catch(() => undefined);
177
+ }
178
+ if (signal?.aborted) return undefined;
179
+ return await task();
180
+ } finally {
181
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
182
+ release();
183
+ // An aborted waiter may finish before the prior owner. Keep its chained
184
+ // tail installed until that owner also settles, otherwise a newcomer could
185
+ // observe an empty map and race the still-running workflow.
186
+ void tail.then(() => {
187
+ if (managedRepositoryRootTails.get(key) === tail) managedRepositoryRootTails.delete(key);
188
+ });
189
+ }
190
+ }
191
+
192
+ /** Track resume setup that has claimed a thread but has not yet enqueued
193
+ * its next generation. Shutdown invalidates these claims and waits for cleanup. */
194
+ export function beginRuntimePreflight(runtime: SubagentRuntime): () => void {
195
+ let resolvePreflight!: () => void;
196
+ const preflight = new Promise<void>((resolve) => {
197
+ resolvePreflight = resolve;
198
+ });
199
+ runtime.preflightOperations.add(preflight);
200
+ return () => {
201
+ runtime.preflightOperations.delete(preflight);
202
+ resolvePreflight();
203
+ };
204
+ }
205
+
206
+ /** Synchronous CAS used by lifecycle controls across their async preflight. */
207
+ export function ownsResumeReservation(
208
+ runtime: SubagentRuntime,
209
+ thread: SubagentThread,
210
+ reservation: { version: number; generation: number; sessionId?: string; sessionDir?: string },
211
+ ): boolean {
212
+ return (
213
+ runtime.sessionActive &&
214
+ runtime.threads.get(thread.id) === thread &&
215
+ !thread.retired &&
216
+ thread.lifecycleOperation === "resume" &&
217
+ thread.lifecycleVersion === reservation.version &&
218
+ thread.generation === reservation.generation &&
219
+ thread.sessionId === reservation.sessionId &&
220
+ thread.sessionDir === reservation.sessionDir
221
+ );
222
+ }
223
+
224
+ /** Fire-and-forget durable checkpoint write. The live session keeps working
225
+ * when the manifest is unwritable; only cross-reload resume is degraded. */
226
+ export function persistThreadCheckpoint(
227
+ runtime: SubagentRuntime,
228
+ thread: SubagentThread,
229
+ state: "parked" | "completed" | "failed",
230
+ ): void {
231
+ void upsertThreadRecord(runtime.configPath, threadRecordFromThread(thread, state)).catch(
232
+ () => undefined,
233
+ );
234
+ }
235
+
236
+ const WORKTREE_ISOLATION_INSTRUCTIONS =
237
+ "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.";
238
+
239
+ export function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
240
+ return {
241
+ ...agent,
242
+ systemPrompt: `${agent.systemPrompt.trimEnd()}\n\n${WORKTREE_ISOLATION_INSTRUCTIONS}`.trim(),
243
+ };
244
+ }
245
+
246
+ export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
247
+ return isWriteCapableAgent(agent);
248
+ }
249
+
250
+ /** A direct reviewer otherwise cannot infer enabled-role availability from its
251
+ * isolated task. Managed internal gates receive the same contract in their
252
+ * generated briefs. Advisory reviews still emit neither machine marker. */
253
+ function withEnabledDocumenterReviewContract(agent: AgentConfig): AgentConfig {
254
+ return {
255
+ ...agent,
256
+ systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: documenter is enabled. In gate reviews, documentation drift is non-gating: emit DOCUMENTATION: NEEDED with ## Documentation notes, or DOCUMENTATION: CLEAN when no sync is needed. Advisory reviews still emit neither VERDICT nor DOCUMENTATION markers.`.trim(),
257
+ };
258
+ }
259
+
260
+ /** The dispatcher explicitly requested a report-only review: forbid the gate
261
+ * markers at the source and (in dispatch) refuse to chain on them anyway. */
262
+ function withAdvisoryReviewContract(agent: AgentConfig): AgentConfig {
263
+ return {
264
+ ...agent,
265
+ systemPrompt: `${agent.systemPrompt.trimEnd()}
266
+
267
+ Runtime workflow context: this dispatch is advisory. Report findings only; do not emit VERDICT or DOCUMENTATION markers — the runtime will not act on them.`.trim(),
268
+ };
269
+ }
270
+
271
+ export interface DispatchEnvironment {
272
+ ctx: ExtensionContext;
273
+ config: SubagentsConfig;
274
+ agents: AgentConfig[];
275
+ }
276
+
277
+ export interface SessionSeed {
278
+ sessionId?: string;
279
+ sessionDir?: string;
280
+ prompt?: string;
281
+ worktree?: WorktreeIsolation;
282
+ }
283
+
284
+ export interface ResumeReservation {
285
+ version: number;
286
+ generation: number;
287
+ sessionId?: string;
288
+ sessionDir?: string;
289
+ }
290
+
291
+ /** The dispatcher's full internal entry point; the public tool surface only
292
+ * uses the first five parameters. */
293
+ export type StartBackgroundInternal = (
294
+ agentName: string,
295
+ task: string,
296
+ cwd: string | undefined,
297
+ isolation?: IsolationMode,
298
+ existingThread?: SubagentThread,
299
+ appendedObjectiveOnResume?: boolean,
300
+ environment?: DispatchEnvironment,
301
+ seed?: SessionSeed,
302
+ resumeReservation?: ResumeReservation,
303
+ options?: { advisoryReview?: boolean },
304
+ ) => Promise<SingleResult>;
305
+
306
+ export interface ThreadLifecycleDeps {
307
+ runtime: SubagentRuntime;
308
+ /** Fallback context when a control caller supplies none; restored threads
309
+ * install without one and rely on the per-call context. */
310
+ runCtx?: ExtensionContext;
311
+ /** Fresh dispatch passes the live dispatcher; restored threads resolve it
312
+ * from the runtime at call time so they never pin a stale closure. */
313
+ startBackground: StartBackgroundInternal;
314
+ }
315
+
316
+ interface DispatchModelRoute {
317
+ agent: AgentConfig;
318
+ mainFallbackRef?: string;
319
+ thinkingLevel: ThinkingLevel;
320
+ thinkingLevelForModel: (ref?: string) => ThinkingLevel;
321
+ }
322
+
323
+ export function resolveDispatchModelRoute(
324
+ agent: AgentConfig,
325
+ config: SubagentsConfig,
326
+ ctx: ExtensionContext,
327
+ ): DispatchModelRoute {
328
+ const availableModels = availableModelsInScope(ctx);
329
+ const mainRef = currentModelRef(ctx);
330
+ const route = resolveAgentModelRoute({
331
+ selectedRef: config.agentModels[agent.name],
332
+ mainRef,
333
+ declaredDefaultRef: agent.model,
334
+ availableRefs: availableModels.map(modelRef),
335
+ });
336
+ const preferred = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? DEFAULT_THINKING_LEVEL;
337
+ const thinkingLevelForModel = (ref?: string): ThinkingLevel => {
338
+ const model = ref === mainRef && ctx.model
339
+ ? ctx.model
340
+ : findModelByRef(availableModels, ref);
341
+ return resolveThinkingLevel(model, preferred);
342
+ };
343
+ return {
344
+ agent: { ...agent, model: route.primaryRef },
345
+ mainFallbackRef: route.mainFallbackRef,
346
+ thinkingLevel: thinkingLevelForModel(route.primaryRef),
347
+ thinkingLevelForModel,
348
+ };
349
+ }
350
+
351
+ export interface ManagedWorkflowRequest extends DispatchEnvironment {
352
+ plan: ManagedWorkflowPlan;
353
+ initialResult: SingleResult;
354
+ groupId: string;
355
+ parentRunId: number;
356
+ executionCwd: string;
357
+ projectCwd: string;
358
+ isolation: IsolationMode;
359
+ /** Short identity of the isolated worktree shared by every workflow stage. */
360
+ worktreeId?: string;
361
+ signal: AbortSignal;
362
+ rememberLatest: (result: SingleResult) => void;
363
+ }
364
+
365
+ interface BackgroundDispatcherOptions {
366
+ runtime: SubagentRuntime;
367
+ /** Live dispatch environment; resolved lazily so control operations work
368
+ * before the first dispatch of a process (restored threads). */
369
+ getEnvironment: () => DispatchEnvironment;
370
+ finishRun: (
371
+ runId: number,
372
+ status: "done" | "failed",
373
+ opts?: { silent?: boolean },
374
+ ) => void;
375
+ makeLiveHandler: (
376
+ runId: number,
377
+ generation?: number,
378
+ ) => (event: SubagentLiveEvent) => void;
379
+ makeDetails: (
380
+ mode: "single" | "parallel",
381
+ background?: boolean,
382
+ ) => (results: SingleResult[]) => SubagentDetails;
383
+ runManagedWorkflow: (request: ManagedWorkflowRequest) => Promise<ManagedWorkflowOutcome>;
384
+ }
385
+
386
+ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions): StartBackgroundInternal {
387
+ const {
388
+ runtime,
389
+ getEnvironment,
390
+ finishRun,
391
+ makeLiveHandler,
392
+ makeDetails,
393
+ runManagedWorkflow,
394
+ } = options;
395
+
396
+ const startBackground: StartBackgroundInternal = async (
397
+ agentName: string,
398
+ task: string,
399
+ cwd: string | undefined,
400
+ isolation: IsolationMode = "shared",
401
+ existingThread?: SubagentThread,
402
+ appendedObjectiveOnResume = false,
403
+ environment?: DispatchEnvironment,
404
+ seed?: SessionSeed,
405
+ resumeReservation?: ResumeReservation,
406
+ options?: { advisoryReview?: boolean },
407
+ ): Promise<SingleResult> => {
408
+ if (!runtime.sessionActive) {
409
+ return failedStartResult(agentName, task, "Parent session shut down before this subagent generation could start.");
410
+ }
411
+ if (existingThread && (!resumeReservation || !ownsResumeReservation(runtime, existingThread, resumeReservation))) {
412
+ return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
413
+ }
414
+ const baseEnvironment = environment ?? getEnvironment();
415
+ const runCtx = baseEnvironment.ctx;
416
+ const runConfig = baseEnvironment.config;
417
+ const runAgents = baseEnvironment.agents;
418
+ const stateRoot = getStateRoot(runtime.configPath);
419
+ const discoveredAgent = runAgents.find((candidate) => candidate.name === agentName);
420
+ if (!discoveredAgent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
421
+ const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
422
+ resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
423
+ const resolvedAgent = resolveLiveAgentTools(discoveredAgent);
424
+ const advisoryReview = options?.advisoryReview ?? existingThread?.advisoryReview ?? false;
425
+ const agent = agentName === "reviewer"
426
+ ? advisoryReview
427
+ ? withAdvisoryReviewContract(resolvedAgent)
428
+ : runAgents.some((candidate) => candidate.name === "documenter")
429
+ ? withEnabledDocumenterReviewContract(resolvedAgent)
430
+ : resolvedAgent
431
+ : resolvedAgent;
432
+ if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
433
+ return {
434
+ ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as worker, cleaner, or documenter.`),
435
+ isolation,
436
+ };
437
+ }
438
+
439
+ const originalCwd = resolve(cwd ?? runCtx.cwd);
440
+ const previousWorktree = existingThread?.worktree;
441
+ let worktree = seed?.worktree ?? previousWorktree;
442
+ if (isolation === "worktree") {
443
+ if (worktree && worktree.state !== "active") {
444
+ return {
445
+ ...failedStartResult(agentName, task, `Run #${existingThread?.id ?? "?"} has no active continuation worktree.`),
446
+ isolation,
447
+ integrationStatus: worktree.state === "finalizing" ? "pending" : worktree.state,
448
+ };
449
+ }
450
+ if (!worktree) {
451
+ try {
452
+ worktree = await createWorktreeIsolation(originalCwd, { tempBaseDir: stateRoot });
453
+ } catch (error) {
454
+ return {
455
+ ...failedStartResult(agentName, task, error instanceof Error ? error.message : String(error)),
456
+ isolation,
457
+ };
458
+ }
459
+ }
460
+ }
461
+ const executionCwd = worktree?.cwd ?? originalCwd;
462
+ const worktreeGroup = worktree ? worktreeGroupId(worktree) : undefined;
463
+ const resolvedRoute = resolveDispatchModelRoute(agent, runConfig, runCtx);
464
+ // Isolation is a persistent system-level invariant, not a one-shot task
465
+ // prefix: resumes and main-model
466
+ // handoffs all keep the same worktree boundary.
467
+ const route = isolation === "worktree"
468
+ ? { ...resolvedRoute, agent: withWorktreeSystemPrompt(resolvedRoute.agent) }
469
+ : resolvedRoute;
470
+ const thinkingLevel = route.thinkingLevel;
471
+ const priorTask = existingThread?.task;
472
+ const priorSessionId = seed?.sessionId ?? existingThread?.sessionId;
473
+ const priorSessionDir = seed?.sessionDir ?? existingThread?.sessionDir;
474
+ if (existingThread && resumeReservation && !ownsResumeReservation(runtime, existingThread, resumeReservation)) {
475
+ return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
476
+ }
477
+ const runId = existingThread?.id ?? monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
478
+ isolation,
479
+ ...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
480
+ });
481
+ const generation = (existingThread?.generation ?? 0) + 1;
482
+ const pending: SingleResult = {
483
+ ...queuedResult(route.agent, task, thinkingLevel),
484
+ runId,
485
+ projectCwd: originalCwd,
486
+ isolation,
487
+ ...(isolation === "worktree" ? { integrationStatus: "pending" as const } : {}),
488
+ ...(seed?.sessionId && seed.sessionDir
489
+ ? { sessionId: seed.sessionId, sessionDir: seed.sessionDir }
490
+ : {}),
491
+ };
492
+ if (existingThread) {
493
+ monitor.restartRun(runId, agent.name, task, route.agent.model, thinkingLevel, isolation, {
494
+ elapsedMs: existingThread.elapsedMs,
495
+ continuationKind: appendedObjectiveOnResume ? "resume-appended" : "resume-retained",
496
+ ...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
497
+ });
498
+ runtime.settledRuns.delete(runId);
499
+ }
500
+
501
+ let thread!: SubagentThread;
502
+ const control = new RpcRunControl(task, generation, (phase) => {
503
+ if (runtime.threads.get(runId)?.generation !== generation || phase === "settled") return;
504
+ const state: ThreadState =
505
+ phase === "queued" || phase === "starting"
506
+ ? "queued"
507
+ : phase === "interrupting"
508
+ ? "interrupting"
509
+ : phase === "stopped"
510
+ ? "stopped"
511
+ : "running";
512
+ thread.state = state;
513
+ if (state === "queued") monitor.setStatus(runId, "queued");
514
+ else if (state === "interrupting") monitor.setStatus(runId, "interrupting");
515
+ else if (state === "running") monitor.setStatus(runId, "running");
516
+ });
517
+
518
+
519
+ if (existingThread) {
520
+ thread = existingThread;
521
+ thread.generation = generation;
522
+ thread.agentName = agent.name;
523
+ thread.task = task;
524
+ thread.cwd = originalCwd;
525
+ thread.executionCwd = executionCwd;
526
+ thread.thinkingLevel = thinkingLevel;
527
+ thread.isolation = isolation;
528
+ thread.advisoryReview = advisoryReview;
529
+ thread.worktree = worktree;
530
+ thread.state = "queued";
531
+ thread.control = control;
532
+ // A newly admitted generation owns no output yet. Keeping the prior
533
+ // generation here would make a queued stop publish stale task,
534
+ // session metadata as this generation's partial.
535
+ thread.lastResult = undefined;
536
+ if (seed?.sessionId && seed.sessionDir) {
537
+ thread.sessionId = seed.sessionId;
538
+ thread.sessionDir = seed.sessionDir;
539
+ }
540
+ thread.retireOnSettle = false;
541
+ thread.isolationFailureNotified = false;
542
+ } else {
543
+ thread = {
544
+ id: runId,
545
+ generation,
546
+ agentName: agent.name,
547
+ task,
548
+ cwd: originalCwd,
549
+ executionCwd,
550
+ thinkingLevel,
551
+ isolation,
552
+ advisoryReview,
553
+ worktree,
554
+ state: "queued",
555
+ control,
556
+ generationCompletion: Promise.resolve(),
557
+ lifecycleVersion: 0,
558
+ elapsedMs: 0,
559
+ sessionId: seed?.sessionId,
560
+ sessionDir: seed?.sessionDir,
561
+ resume: async () => failedStartResult(agent.name, task, "Thread resume was not initialized."),
562
+ finalizeIsolation: async () => undefined,
563
+ };
564
+ runtime.threads.set(runId, thread);
565
+ }
566
+ installThreadLifecycle(thread, {
567
+ runtime,
568
+ runCtx,
569
+ startBackground: (...args) => startBackground(...args),
570
+ });
571
+
572
+ const onLive = makeLiveHandler(runId, generation);
573
+ const workflowAvailability = workflowAgentAvailability(runAgents);
574
+ const reserveManagedLane =
575
+ isolation === "shared" && canStartManagedWorkflow(agent, workflowAvailability);
576
+ // Assigned once enqueue returns; only read after the generation's first
577
+ // await, so the managed-continuation slot suspension always sees it.
578
+ let generationController: AbortController | undefined;
579
+ const runGeneration = async (backgroundSignal: AbortSignal): Promise<void> => {
580
+ if (runtime.threads.get(runId)?.generation !== generation) return;
581
+ // Model/thinking config may have changed while this generation sat
582
+ // queued behind the concurrency limit. Re-resolve the route at actual
583
+ // start so /subagents-setup edits apply to not-yet-started runs.
584
+ let activeRoute = route;
585
+ let activeIdleTimeoutMs = runConfig.idleTimeoutSec * 1000;
586
+ try {
587
+ const startConfig = await loadConfig(runtime.configPath);
588
+ const resolvedStart = resolveDispatchModelRoute(agent, startConfig, runCtx);
589
+ activeRoute = isolation === "worktree"
590
+ ? { ...resolvedStart, agent: withWorktreeSystemPrompt(resolvedStart.agent) }
591
+ : resolvedStart;
592
+ activeIdleTimeoutMs = startConfig.idleTimeoutSec * 1000;
593
+ monitor.setModel(runId, activeRoute.agent.model);
594
+ monitor.setThinking(runId, activeRoute.thinkingLevel);
595
+ } catch {
596
+ /* keep the dispatch-time route when fresh config is unavailable */
597
+ }
598
+ let result: SingleResult;
599
+ try {
600
+ result = await runSingleAgentWithMainFallback(
601
+ {
602
+ defaultCwd: executionCwd,
603
+ agent: activeRoute.agent,
604
+ resolveAgentForAttempt: resolveLiveAgentTools,
605
+ agentName,
606
+ task,
607
+ cwd: executionCwd,
608
+ thinkingLevel: activeRoute.thinkingLevel,
609
+ thinkingLevelForModel: activeRoute.thinkingLevelForModel,
610
+ signal: backgroundSignal,
611
+ onLive,
612
+ control,
613
+ makeDetails: makeDetails("single", true),
614
+ idleTimeoutMs: activeIdleTimeoutMs,
615
+ sessionRoot: stateRoot,
616
+ ...(priorSessionId && priorSessionDir
617
+ ? {
618
+ sessionId: priorSessionId,
619
+ sessionDir: priorSessionDir,
620
+ stdinText: seed?.prompt ?? (appendedObjectiveOnResume
621
+ ? task
622
+ : buildResumePrompt(priorTask ?? task, "the retained thread was resumed")),
623
+ }
624
+ : {}),
625
+ },
626
+ activeRoute.mainFallbackRef,
627
+ );
628
+ } catch (error) {
629
+ const errorMessage = error instanceof Error ? error.message : String(error);
630
+ result = {
631
+ ...pending,
632
+ task: control.getObjective(),
633
+ exitCode: 1,
634
+ stderr: errorMessage,
635
+ stopReason: backgroundSignal.aborted ? "aborted" : "error",
636
+ errorMessage,
637
+ dispatchFailed: true,
638
+ };
639
+ }
640
+
641
+ // A stale process/generation may finish after a superseded resume. It owns
642
+ // no monitor mutation, result registration, or completion delivery.
643
+ if (runtime.threads.get(runId)?.generation !== generation) return;
644
+ result.runId = runId;
645
+ result.projectCwd = originalCwd;
646
+ result.isolation = isolation;
647
+ thread.task = result.task;
648
+ thread.sessionId = result.sessionId;
649
+ thread.sessionDir = result.sessionDir;
650
+ thread.lastResult = result;
651
+ runtime.retainSession(result);
652
+ monitor.setModel(runId, result.model, result.modelFallbackFrom);
653
+ monitor.setThinking(runId, result.thinking);
654
+ // Checkpoint the session location durably: a crash or reload before
655
+ // settlement still restores this thread with its retained context.
656
+ if (result.sessionId && result.sessionDir) {
657
+ persistThreadCheckpoint(runtime, thread, "parked");
658
+ }
659
+
660
+ const lifecycleInterrupted = (): boolean =>
661
+ thread.lifecycleOperation === "stop" ||
662
+ thread.state === "stopped";
663
+ // Destructive stop owns publication once it has synchronously claimed
664
+ // the lifecycle. Leave the partial result/session on the thread; the
665
+ // stop path waits for this queue task, finalizes isolation, and emits
666
+ // exactly one aborted result.
667
+ if (thread.lifecycleOperation === "stop") return;
668
+
669
+ // A shutdown can win in the microtask gap after the top-level RPC
670
+ // settles. Do not launch an obsolete documenter/reviewer or replace the
671
+ // stable top-level session with an aborted downstream attempt.
672
+ if (backgroundSignal.aborted || lifecycleInterrupted() || !runtime.sessionActive) return;
673
+
674
+ if (thread.retireOnSettle) runtime.retireThreadSession(thread);
675
+ let workflowOutcome: ManagedWorkflowOutcome | undefined;
676
+ const workflowPlan = getManagedWorkflowPlan(result, workflowAvailability, thread.advisoryReview);
677
+ if (workflowPlan && runtime.sessionActive) {
678
+ // The continuation is runtime-initiated (gate review, auto-fix
679
+ // rounds, documentation sync): release this generation's
680
+ // concurrency slot so managed chains never starve manual
681
+ // dispatches. Cancellation and quiescence guarantees are
682
+ // unchanged — the task stays abortable and awaited.
683
+ runtime.backgroundQueue.suspend(generationController);
684
+ thread.state = "running";
685
+ // The stable parent row now represents workflow ownership, not whichever
686
+ // model stage ran most recently. Internal rows own their exact role/model/
687
+ // thinking/timing telemetry and remain independently queryable.
688
+ monitor.setManagedWorkflow(runId, true);
689
+ monitor.setStatus(runId, "running");
690
+ monitor.setActivity(
691
+ runId,
692
+ workflowPlan.kind === "auto-fix" ? "auto-fix chain running" : "managed workflow running",
693
+ );
694
+ workflowOutcome = await runManagedWorkflow({
695
+ plan: workflowPlan,
696
+ initialResult: result,
697
+ groupId: `workflow-${runId}`,
698
+ parentRunId: runId,
699
+ executionCwd: thread.executionCwd,
700
+ projectCwd: originalCwd,
701
+ isolation,
702
+ ...(worktree ? { worktreeId: worktreeGroupId(worktree) } : {}),
703
+ signal: backgroundSignal,
704
+ ctx: runCtx,
705
+ config: runConfig,
706
+ agents: runAgents,
707
+ rememberLatest: (latest) => {
708
+ if (runtime.threads.get(runId) !== thread || thread.generation !== generation) return;
709
+ thread.lastResult = latest;
710
+ // Retained control follows the newest child session, but the live parent
711
+ // row keeps the original top-level role/model/usage. The active internal
712
+ // row already owns the current stage's role and telemetry.
713
+ thread.agentName = latest.agent;
714
+ thread.task = latest.task;
715
+ thread.sessionId = latest.sessionId;
716
+ thread.sessionDir = latest.sessionDir;
717
+ runtime.retainSession(latest);
718
+ if (latest.sessionId && latest.sessionDir) {
719
+ persistThreadCheckpoint(runtime, thread, "parked");
720
+ }
721
+ },
722
+ });
723
+
724
+ // Park/stop/shutdown owns this generation once it cancels the queue
725
+ // signal. The newest internal partial is already on thread.lastResult;
726
+ // never replace it with the old top-level result or publish stale output.
727
+ if (backgroundSignal.aborted || lifecycleInterrupted() || !runtime.sessionActive) return;
728
+
729
+ const finalStep = workflowOutcome.steps[workflowOutcome.steps.length - 1]!;
730
+ result = {
731
+ ...finalStep.result,
732
+ runId,
733
+ projectCwd: originalCwd,
734
+ isolation,
735
+ };
736
+ thread.lastResult = result;
737
+ thread.agentName = result.agent;
738
+ thread.task = result.task;
739
+ thread.sessionId = result.sessionId;
740
+ thread.sessionDir = result.sessionDir;
741
+ runtime.retainSession(result);
742
+ }
743
+ // Claim terminal settlement synchronously before the first slow await.
744
+ // Park therefore either wins while RPC is still active, or is rejected
745
+ // once settlement owns the generation. Destructive stop may supersede
746
+ // this reservation; publication is revalidated after Git finalization.
747
+ const settlementVersion = ++thread.lifecycleVersion;
748
+ thread.lifecycleOperation = "settle";
749
+ const ownsSettlement = (): boolean =>
750
+ runtime.threads.get(runId) === thread &&
751
+ thread.generation === generation &&
752
+ thread.lifecycleVersion === settlementVersion &&
753
+ thread.lifecycleOperation === "settle" &&
754
+ !thread.retired;
755
+ try {
756
+ // For isolated writers this is deliberately after the managed reviewer
757
+ // and any needed documentation stage: every child sees the same worktree,
758
+ // then one lifecycle owner integrates the complete settled state exactly once.
759
+ await thread.finalizeIsolation(generation, result);
760
+ if (!ownsSettlement()) return;
761
+ if (workflowOutcome && isolation === "worktree") {
762
+ for (const step of workflowOutcome.steps) {
763
+ step.result.integrationStatus = result.integrationStatus;
764
+ step.result.integrationApplied = result.integrationApplied;
765
+ step.result.integrationError = result.integrationError;
766
+ step.result.integrationWorktreePath = result.integrationWorktreePath;
767
+ step.result.integrationPatchPath = result.integrationPatchPath;
768
+ }
769
+ }
770
+
771
+ const failed = isFailedResult(result);
772
+ thread.state = failed ? "failed" : "completed";
773
+ // Stamp the terminal monitor state before projecting it. This gives every
774
+ // path a fixed endedAt even when the row is removed immediately.
775
+ monitor.setStatus(runId, failed ? "failed" : "done");
776
+ thread.elapsedMs = monitor.getElapsedMs(runId) ?? thread.elapsedMs;
777
+ // Persist before the sessionActive check: a shutdown that won the
778
+ // lifecycle race must still leave the settled record on disk.
779
+ persistThreadCheckpoint(runtime, thread, failed ? "failed" : "completed");
780
+ if (!runtime.sessionActive || !ownsSettlement()) return;
781
+
782
+ const modelLevel = failed && isModelLevelFailure(result);
783
+ const dispatchFailed = result.dispatchFailed === true;
784
+ const ownedController = thread.queueController;
785
+ if (runtime.runControllers.get(runId) === ownedController) runtime.runControllers.delete(runId);
786
+ thread.queueController = undefined;
787
+ finishRun(
788
+ runId,
789
+ failed ? "failed" : "done",
790
+ workflowOutcome || modelLevel || dispatchFailed ? { silent: true } : undefined,
791
+ );
792
+ runtime.registerRunResult(runId, result);
793
+
794
+ if (workflowOutcome) {
795
+ const lastStep = workflowOutcome.steps[workflowOutcome.steps.length - 1]!;
796
+ let block = workflowOutcome.kind === "auto-fix"
797
+ ? formatChainSummary(workflowOutcome.steps, result)
798
+ : formatManagedWorkflowSummary(workflowOutcome.steps, result);
799
+ const finalVerdict = lastStep.result.agent === "reviewer"
800
+ ? reviewVerdict(getResultOutput(lastStep.result))
801
+ : undefined;
802
+ const needsFullFinal = failed || (lastStep.result.agent === "reviewer" && finalVerdict !== "pass");
803
+ if (needsFullFinal) {
804
+ block += `\n\n${formatCompletionBlock(result, runConfig.maxResultLines, originalCwd)}`;
805
+ }
806
+ if (modelLevel) block += `\n\n${modelLevelTakeoverNote(result, { runId })}`;
807
+ runtime.sendCompletionGroup([{
808
+ agent: `${workflowOutcome.kind === "auto-fix" ? "auto-fix chain" : "managed workflow"} (${result.agent})`,
809
+ block,
810
+ triggerTurn: true,
811
+ usage: sumUsage(workflowOutcome.steps.map((step) => step.result.usage)),
812
+ }]);
813
+ runtime.completionBatcher.flush();
814
+ return;
815
+ }
816
+
817
+ const completion: CompletionMessageItem = {
818
+ agent: result.agent,
819
+ block: modelLevel
820
+ ? `${formatCompletionBlock(result, runConfig.maxResultLines, result.projectCwd ?? originalCwd)}\n\n${modelLevelTakeoverNote(result, { runId })}`
821
+ : formatCompletionBlock(result, runConfig.maxResultLines, result.projectCwd ?? originalCwd),
822
+ triggerTurn: completionTriggersTurn(result, runConfig.notifyOnReviewPass),
823
+ usage: result.usage,
824
+ };
825
+ if (modelLevel) {
826
+ const detail = result.errorMessage?.trim() || "model unavailable or broken";
827
+ runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${detail} — task handed to the main window`, "error");
828
+ } else if (dispatchFailed) {
829
+ runCtx.ui.notify(`✗ ${result.agent} dispatch failed: ${result.errorMessage ?? "dispatch crashed"}`, "error");
830
+ }
831
+ if (failed) {
832
+ runtime.sendCompletionGroup([completion]);
833
+ runtime.completionBatcher.flush();
834
+ } else {
835
+ runtime.completionBatcher.push(completion);
836
+ }
837
+ } finally {
838
+ if (ownsSettlement()) thread.lifecycleOperation = undefined;
839
+ }
840
+ };
841
+ const queuedGeneration = reserveManagedLane
842
+ ? async (backgroundSignal: AbortSignal): Promise<void> => {
843
+ await runInManagedRepositoryLane(
844
+ originalCwd,
845
+ () => runGeneration(backgroundSignal),
846
+ backgroundSignal,
847
+ );
848
+ }
849
+ : runGeneration;
850
+ const queueController = runtime.backgroundQueue.enqueue(
851
+ queuedGeneration,
852
+ () => {
853
+ if (runtime.threads.get(runId)?.generation !== generation) return;
854
+ // A destructive stop owns publication and may still be finalizing an
855
+ // isolated worktree. Do not expose a terminal monitor state before
856
+ // that owner records the aborted result.
857
+ if (thread.lifecycleOperation === "stop") return;
858
+ runtime.runControllers.delete(runId);
859
+ thread.queueController = undefined;
860
+ thread.state = "stopped";
861
+ monitor.setStatus(runId, "failed");
862
+ if (!runtime.sessionActive) {
863
+ monitor.removeRun(runId);
864
+ return;
865
+ }
866
+ finishRun(runId, "failed");
867
+ },
868
+ async (error) => {
869
+ if (runtime.threads.get(runId)?.generation !== generation) return;
870
+ // Queue-level crashes use the same settlement reservation as ordinary
871
+ // results. A concurrent destructive stop may supersede it while slow
872
+ // worktree finalization is running, in which case stop publishes once.
873
+ if (thread.lifecycleOperation === "stop") return;
874
+ const settlementVersion = ++thread.lifecycleVersion;
875
+ thread.lifecycleOperation = "settle";
876
+ const ownsSettlement = (): boolean =>
877
+ runtime.threads.get(runId) === thread &&
878
+ thread.generation === generation &&
879
+ thread.lifecycleVersion === settlementVersion &&
880
+ thread.lifecycleOperation === "settle" &&
881
+ !thread.retired;
882
+ try {
883
+ const errorMessage = error instanceof Error ? error.message : String(error);
884
+ const latest = thread.lastResult;
885
+ const crashed: SingleResult = latest
886
+ ? {
887
+ ...latest,
888
+ runId,
889
+ projectCwd: originalCwd,
890
+ isolation,
891
+ exitCode: 1,
892
+ stopReason: "error",
893
+ errorMessage: `Managed workflow dispatch failed: ${errorMessage}`,
894
+ dispatchFailed: true,
895
+ }
896
+ : {
897
+ ...dispatchFailedResult(route.agent, control.getObjective(), error, thinkingLevel),
898
+ runId,
899
+ projectCwd: originalCwd,
900
+ isolation,
901
+ };
902
+ thread.lastResult = crashed;
903
+ runtime.retainSession(crashed);
904
+ await thread.finalizeIsolation(generation, crashed);
905
+ if (!ownsSettlement()) return;
906
+ thread.state = "failed";
907
+ monitor.setStatus(runId, "failed");
908
+ thread.elapsedMs = monitor.getElapsedMs(runId) ?? thread.elapsedMs;
909
+ persistThreadCheckpoint(runtime, thread, "failed");
910
+ finishRun(runId, "failed", { silent: true });
911
+ runtime.registerRunResult(runId, crashed);
912
+ runtime.runControllers.delete(runId);
913
+ thread.queueController = undefined;
914
+ if (!runtime.sessionActive || !ownsSettlement()) return;
915
+ try {
916
+ runCtx.ui.notify(`✗ ${crashed.agent} dispatch failed: ${crashed.errorMessage}`, "error");
917
+ runtime.sendCompletionGroup([
918
+ {
919
+ agent: crashed.agent,
920
+ block: formatCompletionBlock(crashed, runConfig.maxResultLines, crashed.projectCwd ?? originalCwd),
921
+ triggerTurn: true,
922
+ usage: crashed.usage,
923
+ },
924
+ ]);
925
+ runtime.completionBatcher.flush();
926
+ } catch {
927
+ /* a second delivery failure must not throw through the queue */
928
+ }
929
+ } finally {
930
+ if (ownsSettlement()) thread.lifecycleOperation = undefined;
931
+ }
932
+ },
933
+ );
934
+ generationController = queueController;
935
+ thread.queueController = queueController;
936
+ thread.generationCompletion = runtime.backgroundQueue.waitForTask(queueController);
937
+ runtime.runControllers.set(runId, queueController);
938
+ return pending;
939
+ };
940
+
941
+ return startBackground;
942
+ }
943
+
944
+ /** Install resume/finalize control surfaces on a thread. Called for
945
+ * every fresh generation (closures refresh with the current dispatch context)
946
+ * and for threads restored from the durable manifest, whose startBackground
947
+ * resolves the live dispatcher at call time. */
948
+ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifecycleDeps): void {
949
+ const { runtime, startBackground } = deps;
950
+ const runId = thread.id;
951
+ const stateRoot = getStateRoot(runtime.configPath);
952
+
953
+ thread.notifyIsolationFailure = (finalization) => {
954
+ const paths = [finalization.worktreePath, finalization.patchPath].filter(Boolean).join(" · ");
955
+ deps.runCtx?.ui.notify(
956
+ `✗ ${thread.agentName} worktree ${finalization.integrated ? "cleanup" : "integration"} failed${paths ? ` · retained ${paths}` : ""}: ${finalization.error ?? "unknown Git integration error"}`,
957
+ "error",
958
+ );
959
+ };
960
+
961
+ const generationWorktree = thread.worktree;
962
+ let generationFinalization: Promise<WorktreeFinalization> | undefined;
963
+ thread.finalizeIsolation = async (
964
+ expectedGeneration: number,
965
+ result?: SingleResult,
966
+ ): Promise<WorktreeFinalization | undefined> => {
967
+ if (thread.isolation !== "worktree" || !generationWorktree) return undefined;
968
+ if (thread.generation !== expectedGeneration || thread.worktree !== generationWorktree) return undefined;
969
+ // All normal, destructive-stop, and shutdown owners converge here. Cache
970
+ // the lane-protected apply itself so superseding lifecycle paths can project
971
+ // the same finalization onto their own result without acquiring twice.
972
+ if (!generationFinalization) {
973
+ monitor.setIsolation(
974
+ runId,
975
+ "worktree",
976
+ "finalizing",
977
+ worktreeGroupId(generationWorktree),
978
+ );
979
+ generationFinalization = runInManagedRepositoryLane(
980
+ generationWorktree.originalRoot,
981
+ () => generationWorktree.finalize(),
982
+ );
983
+ }
984
+ const finalization = await generationFinalization;
985
+ monitor.setIsolation(runId, "worktree", finalization.status, worktreeGroupId(generationWorktree));
986
+ if (result) {
987
+ result.runId = runId;
988
+ result.isolation = "worktree";
989
+ result.integrationStatus = finalization.status;
990
+ result.integrationApplied = finalization.integrated;
991
+ result.integrationError = finalization.error;
992
+ result.integrationWorktreePath = finalization.worktreePath;
993
+ result.integrationPatchPath = finalization.patchPath;
994
+ if (finalization.status === "retained") {
995
+ const retained = [
996
+ finalization.worktreePath ? `worktree ${finalization.worktreePath}` : undefined,
997
+ finalization.patchPath ? `patch ${finalization.patchPath}` : undefined,
998
+ ].filter(Boolean).join(", ");
999
+ const integrationMessage = finalization.integrated
1000
+ ? `Worktree changes were applied, but cleanup failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git cleanup error"}`
1001
+ : `Worktree integration failed${retained ? `; retained ${retained}` : ""}: ${finalization.error ?? "unknown Git integration error"}`;
1002
+ result.exitCode = 1;
1003
+ result.stopReason = "error";
1004
+ result.errorMessage = result.errorMessage
1005
+ ? `${result.errorMessage}\n${integrationMessage}`
1006
+ : integrationMessage;
1007
+ result.stderr = result.stderr ? `${result.stderr.trimEnd()}\n${integrationMessage}` : integrationMessage;
1008
+ }
1009
+ }
1010
+ if (finalization.status === "retained") {
1011
+ if (!thread.isolationFailureNotified) {
1012
+ thread.isolationFailureNotified = true;
1013
+ try {
1014
+ thread.notifyIsolationFailure?.(finalization);
1015
+ } catch {
1016
+ /* notification failures do not hide retained artifacts */
1017
+ }
1018
+ }
1019
+ // Every retained finalization gets a durable recovery record, so the
1020
+ // artifacts stay findable even when the owning process dies next.
1021
+ void persistRecoveryRecords(runtime.configPath, [
1022
+ recoveryRecordFromFinalization(runId, finalization),
1023
+ ]).catch(() => undefined);
1024
+ }
1025
+ return finalization;
1026
+ };
1027
+
1028
+ const cleanupTrackedSessionDir = async (sessionDir: string, action: string): Promise<void> => {
1029
+ try {
1030
+ await rm(sessionDir, { recursive: true, force: true });
1031
+ runtime.sessionDirs.delete(sessionDir);
1032
+ } catch (error) {
1033
+ // Keep ownership so shutdown can retry; losing the path here leaks a
1034
+ // cloned session containing retained model context on Windows locks.
1035
+ try {
1036
+ deps.runCtx?.ui.notify(
1037
+ `✗ ${action}; retained ${sessionDir} for shutdown cleanup: ${error instanceof Error ? error.message : String(error)}`,
1038
+ "error",
1039
+ );
1040
+ } catch {
1041
+ /* cleanup ownership remains tracked even if the UI is unavailable */
1042
+ }
1043
+ }
1044
+ };
1045
+
1046
+ const discardUnusedWorktree = async (candidate: WorktreeIsolation | undefined): Promise<void> => {
1047
+ if (!candidate) return;
1048
+ try {
1049
+ await candidate.discard();
1050
+ } catch (error) {
1051
+ const retainedPath = existsSync(candidate.worktreePath)
1052
+ ? candidate.worktreePath
1053
+ : existsSync(candidate.tempDir)
1054
+ ? candidate.tempDir
1055
+ : undefined;
1056
+ const finalization: WorktreeFinalization = {
1057
+ status: "retained",
1058
+ integrated: false,
1059
+ hadChanges: false,
1060
+ ...(retainedPath ? { worktreePath: retainedPath } : {}),
1061
+ ...(existsSync(candidate.patchPath) ? { patchPath: candidate.patchPath } : {}),
1062
+ error: `Discarding unused continuation failed: ${error instanceof Error ? error.message : String(error)}`,
1063
+ };
1064
+ await persistRecoveryRecords(runtime.configPath, [
1065
+ recoveryRecordFromFinalization(runId, finalization),
1066
+ ]).catch(() => undefined);
1067
+ try {
1068
+ thread.notifyIsolationFailure?.(finalization);
1069
+ } catch {
1070
+ /* parent UI may already be shutting down */
1071
+ }
1072
+ }
1073
+ };
1074
+
1075
+ const createContinuationWorktree = async (
1076
+ source: WorktreeIsolation,
1077
+ seedIsIntegrated: boolean,
1078
+ ): Promise<WorktreeIsolation> => {
1079
+ if (source.state === "finalizing") {
1080
+ throw new Error(`Run #${runId}'s worktree is still finalizing.`);
1081
+ }
1082
+ const seedCheckpoint = await source.snapshotCheckpoint();
1083
+ return createWorktreeIsolation(thread.cwd, {
1084
+ seedCheckpoint,
1085
+ seedIsIntegrated,
1086
+ tempBaseDir: stateRoot,
1087
+ });
1088
+ };
1089
+
1090
+ const persistElapsedTime = (): void => {
1091
+ thread.elapsedMs = monitor.getElapsedMs(runId) ?? thread.elapsedMs;
1092
+ };
1093
+
1094
+ thread.resume = async (objective?: string, resumeCtx?: ExtensionContext): Promise<SingleResult> => {
1095
+ const requestedObjective = objective?.trim();
1096
+ if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
1097
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
1098
+ }
1099
+ if (objective !== undefined && !requestedObjective) {
1100
+ return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
1101
+ }
1102
+ if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
1103
+ if (thread.lifecycleOperation) {
1104
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already resuming.`);
1105
+ }
1106
+ if (!["parked", "completed", "failed"].includes(thread.state)) {
1107
+ return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state}; it must be parked or settled before resume.`);
1108
+ }
1109
+
1110
+ // Lifecycle CAS: claim synchronously before the first await, then cancel
1111
+ // and fully quiesce any superseded queue/process before cloning or
1112
+ // reusing its session. A second resume sees this claim immediately.
1113
+ const previousState = thread.state;
1114
+ const previousSessionId = thread.sessionId;
1115
+ const previousSessionDir = thread.sessionDir;
1116
+ const previousExecutionCwd = thread.executionCwd;
1117
+ const reservation: ResumeReservation = {
1118
+ version: ++thread.lifecycleVersion,
1119
+ generation: thread.generation,
1120
+ sessionId: previousSessionId,
1121
+ sessionDir: previousSessionDir,
1122
+ };
1123
+ thread.lifecycleOperation = "resume";
1124
+ thread.state = "resuming";
1125
+ const finishPreflight = beginRuntimePreflight(runtime);
1126
+ const supersededController = thread.queueController;
1127
+ runtime.backgroundQueue.cancel(supersededController);
1128
+ runtime.runControllers.delete(runId);
1129
+
1130
+ let continuationWorktree: WorktreeIsolation | undefined;
1131
+ let clonedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
1132
+ try {
1133
+ // Never wait forever on a previous generation that is still settling
1134
+ // (e.g. blocked behind the managed repository lane in finalization).
1135
+ if (!(await quiesced(thread.generationCompletion))) {
1136
+ return failedStartResult(
1137
+ thread.agentName,
1138
+ thread.task,
1139
+ `Run #${runId}'s previous generation is still settling; retry the resume shortly.`,
1140
+ );
1141
+ }
1142
+ if (!ownsResumeReservation(runtime, thread, reservation)) {
1143
+ return failedStartResult(
1144
+ thread.agentName,
1145
+ thread.task,
1146
+ thread.retired
1147
+ ? `Run #${runId} was retired by subagent_stop; no new generation was started.`
1148
+ : `Run #${runId} changed while resume was preparing; no new generation was started.`,
1149
+ );
1150
+ }
1151
+ thread.state = "resuming";
1152
+ const currentCtx = resumeCtx ?? deps.runCtx;
1153
+ if (!currentCtx) {
1154
+ throw new Error(`Run #${runId} has no dispatch context for resume.`);
1155
+ }
1156
+ let seed: SessionSeed | undefined;
1157
+ if (thread.isolation === "worktree" && thread.worktree?.state !== "active") {
1158
+ if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
1159
+ const seedAlreadyIntegrated =
1160
+ thread.worktree.state === "integrated" ||
1161
+ thread.worktree.state === "no_changes" ||
1162
+ thread.lastResult?.integrationApplied === true;
1163
+ continuationWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
1164
+ if (!ownsResumeReservation(runtime, thread, reservation)) {
1165
+ throw new Error(`Run #${runId} changed while its continuation worktree was being created.`);
1166
+ }
1167
+ seed = { worktree: continuationWorktree };
1168
+ if (previousSessionId && previousSessionDir) {
1169
+ clonedSession = await forkRetainedSession({
1170
+ cwd: previousExecutionCwd,
1171
+ targetCwd: continuationWorktree.cwd,
1172
+ sessionDir: previousSessionDir,
1173
+ sessionId: previousSessionId,
1174
+ targetRoot: stateRoot,
1175
+ });
1176
+ runtime.sessionDirs.add(clonedSession.sessionDir);
1177
+ if (!ownsResumeReservation(runtime, thread, reservation)) {
1178
+ throw new Error(`Run #${runId} changed while its retained session was being cloned.`);
1179
+ }
1180
+ seed.sessionId = clonedSession.sessionId;
1181
+ seed.sessionDir = clonedSession.sessionDir;
1182
+ }
1183
+ }
1184
+
1185
+ const currentConfig = await loadConfig(runtime.configPath);
1186
+ if (!ownsResumeReservation(runtime, thread, reservation)) {
1187
+ throw new Error(`Run #${runId} changed while resume configuration was loading.`);
1188
+ }
1189
+ const currentAgents = discoverAgents(currentCtx.cwd, {
1190
+ scope: currentConfig.agentScope,
1191
+ enabledNames: currentConfig.enabledAgents,
1192
+ projectTrusted: currentCtx.isProjectTrusted?.() === true,
1193
+ }).agents;
1194
+ const nextTask = requestedObjective ?? thread.task;
1195
+ const pending = await startBackground(
1196
+ thread.agentName,
1197
+ nextTask,
1198
+ thread.cwd,
1199
+ thread.isolation,
1200
+ thread,
1201
+ objective !== undefined,
1202
+ {
1203
+ ctx: currentCtx,
1204
+ config: currentConfig,
1205
+ agents: currentAgents,
1206
+ },
1207
+ seed,
1208
+ reservation,
1209
+ );
1210
+ if (pending.exitCode !== -1) {
1211
+ if (clonedSession) {
1212
+ await cleanupTrackedSessionDir(
1213
+ clonedSession.sessionDir,
1214
+ `Could not discard failed resume session clone for run #${runId}`,
1215
+ );
1216
+ }
1217
+ await discardUnusedWorktree(continuationWorktree);
1218
+ if (ownsResumeReservation(runtime, thread, reservation)) thread.state = previousState;
1219
+ return pending;
1220
+ }
1221
+
1222
+ // The cloned branch replaces the removed-worktree session for this
1223
+ // logical id. Keep an undeletable old dir in runtime cleanup if needed.
1224
+ if (clonedSession && previousSessionDir && previousSessionDir !== clonedSession.sessionDir) {
1225
+ try {
1226
+ await rm(previousSessionDir, { recursive: true, force: true });
1227
+ runtime.sessionDirs.delete(previousSessionDir);
1228
+ } catch {
1229
+ /* shutdown retries cleanup of the old retained branch */
1230
+ }
1231
+ }
1232
+ return pending;
1233
+ } catch (error) {
1234
+ if (clonedSession) {
1235
+ await cleanupTrackedSessionDir(
1236
+ clonedSession.sessionDir,
1237
+ `Could not discard interrupted resume session clone for run #${runId}`,
1238
+ );
1239
+ }
1240
+ await discardUnusedWorktree(continuationWorktree);
1241
+ if (ownsResumeReservation(runtime, thread, reservation)) {
1242
+ thread.state = previousState;
1243
+ thread.sessionId = previousSessionId;
1244
+ thread.sessionDir = previousSessionDir;
1245
+ thread.executionCwd = previousExecutionCwd;
1246
+ }
1247
+ return failedStartResult(
1248
+ thread.agentName,
1249
+ requestedObjective ?? thread.task,
1250
+ `Could not resume run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
1251
+ );
1252
+ } finally {
1253
+ finishPreflight();
1254
+ if (
1255
+ thread.lifecycleOperation === "resume" &&
1256
+ thread.lifecycleVersion === reservation.version
1257
+ ) {
1258
+ thread.lifecycleOperation = undefined;
1259
+ }
1260
+ }
1261
+ };
1262
+ }
1263
+
1264
+ /** Dropped restored record: no session means no context to resume, so its
1265
+ * artifacts go away with the record. */
1266
+ async function discardRestoredRecord(runtime: SubagentRuntime, record: ThreadRecord): Promise<void> {
1267
+ if (record.sessionDir) {
1268
+ await rm(record.sessionDir, { recursive: true, force: true }).catch(() => undefined);
1269
+ runtime.sessionDirs.delete(record.sessionDir);
1270
+ }
1271
+ if (record.worktree && (record.worktree.state === "active" || record.worktree.state === "retained")) {
1272
+ const worktree = await restoreWorktreeIsolation(record.worktree).catch(() => undefined);
1273
+ await worktree?.discard().catch(() => undefined);
1274
+ }
1275
+ await removeThreadRecord(runtime.configPath, record.runId).catch(() => undefined);
1276
+ }
1277
+
1278
+ function createRestoredThread(
1279
+ runtime: SubagentRuntime,
1280
+ record: ThreadRecord,
1281
+ worktree: WorktreeIsolation | undefined,
1282
+ state: ThreadState,
1283
+ ): SubagentThread {
1284
+ const thread: SubagentThread = {
1285
+ id: record.runId,
1286
+ generation: record.generation,
1287
+ agentName: record.agentName,
1288
+ task: record.task,
1289
+ cwd: record.cwd,
1290
+ executionCwd: record.executionCwd,
1291
+ ...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel as ThinkingLevel } : {}),
1292
+ isolation: record.isolation,
1293
+ advisoryReview: false,
1294
+ worktree,
1295
+ state,
1296
+ control: new RpcRunControl(record.task, record.generation),
1297
+ generationCompletion: Promise.resolve(),
1298
+ lifecycleVersion: 0,
1299
+ elapsedMs: record.elapsedMs,
1300
+ sessionId: record.sessionId,
1301
+ sessionDir: record.sessionDir,
1302
+ lastResult: restoredResultFromSummary(record),
1303
+ resume: async () => failedStartResult(record.agentName, record.task, "Thread resume was not initialized."),
1304
+ finalizeIsolation: async () => undefined,
1305
+ };
1306
+ installThreadLifecycle(thread, {
1307
+ runtime,
1308
+ startBackground: (...args) => {
1309
+ const dispatcher = runtime.dispatcher;
1310
+ if (!dispatcher) {
1311
+ return Promise.resolve(failedStartResult(
1312
+ record.agentName,
1313
+ record.task,
1314
+ `Run #${record.runId} cannot continue: no dispatch context is available yet. Dispatch any subagent once, then retry.`,
1315
+ ));
1316
+ }
1317
+ return dispatcher(...args);
1318
+ },
1319
+ });
1320
+ return thread;
1321
+ }
1322
+
1323
+ /** Rebuild parked and settled threads from the durable manifest after a reload
1324
+ * or restart. Orphaned children recorded by the previous process are killed
1325
+ * first; records whose retained session vanished drop out with their
1326
+ * artifacts. Returns the restored run ids. */
1327
+ export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<number[]> {
1328
+ const records = await readThreadRecords(runtime.configPath);
1329
+ const restoredIds: number[] = [];
1330
+ for (const record of records) {
1331
+ if (runtime.threads.has(record.runId) || monitor.findRun(record.runId)) continue;
1332
+ // A child orphaned by reload/crash may still hold the retained session.
1333
+ // The on-disk session checkpoint is what survives; kill the writer.
1334
+ for (const pid of record.childPids) {
1335
+ if (isProcessAlive(pid)) killProcessTree(pid);
1336
+ }
1337
+ const sessionValid =
1338
+ record.sessionId !== undefined &&
1339
+ record.sessionDir !== undefined &&
1340
+ sessionExists(record.sessionDir, record.sessionId);
1341
+ if (!sessionValid) {
1342
+ await discardRestoredRecord(runtime, record);
1343
+ continue;
1344
+ }
1345
+ const worktree = record.worktree
1346
+ ? await restoreWorktreeIsolation(record.worktree).catch(() => undefined)
1347
+ : undefined;
1348
+ // A worktree thread whose isolated filesystem is gone cannot continue its
1349
+ // isolation invariant; surface it as failed instead of pretending.
1350
+ const state: ThreadState = worktree
1351
+ ? record.state
1352
+ : record.isolation === "worktree" && record.worktree
1353
+ ? "failed"
1354
+ : record.state;
1355
+ const thread = createRestoredThread(runtime, record, worktree, state);
1356
+ runtime.threads.set(record.runId, thread);
1357
+ runtime.sessionDirs.add(record.sessionDir!);
1358
+ if (state === "parked") {
1359
+ monitor.restoreRun({
1360
+ id: record.runId,
1361
+ agent: record.agentName,
1362
+ task: record.task,
1363
+ status: "parked",
1364
+ elapsedMs: record.elapsedMs,
1365
+ isolation: record.isolation,
1366
+ ...(record.worktree
1367
+ ? {
1368
+ integrationStatus: record.worktree.state === "active"
1369
+ ? ("pending" as const)
1370
+ : record.worktree.state,
1371
+ ...(worktree ? { worktreeId: worktreeGroupId(worktree) } : {}),
1372
+ }
1373
+ : {}),
1374
+ });
1375
+ } else if (thread.lastResult) {
1376
+ runtime.registerRunResult(record.runId, thread.lastResult);
1377
+ }
1378
+ restoredIds.push(record.runId);
1379
+ }
1380
+ return restoredIds;
1381
+ }
1382
+
1383
+ /** Load-time durable bootstrap: restore threads, age out expired records, and
1384
+ * sweep leaked temp/state directories. Every stage is best-effort so a broken
1385
+ * manifest never blocks extension registration. */
1386
+ export async function bootstrapDurableState(runtime: SubagentRuntime): Promise<void> {
1387
+ try {
1388
+ runtime.restoredRunIds = await restoreDurableThreads(runtime);
1389
+ } catch {
1390
+ /* restore is best-effort */
1391
+ }
1392
+ try {
1393
+ await pruneThreadRecords(runtime.configPath);
1394
+ } catch {
1395
+ /* retention is best-effort */
1396
+ }
1397
+ try {
1398
+ sweepOrphanTempDirs();
1399
+ } catch {
1400
+ /* temp hygiene is best-effort */
1401
+ }
1402
+ try {
1403
+ sweepUnreferencedState(
1404
+ getStateRoot(runtime.configPath),
1405
+ referencedDurablePaths(await readThreadRecords(runtime.configPath)),
1406
+ );
1407
+ } catch {
1408
+ /* state hygiene is best-effort */
1409
+ }
1410
+ }