@ferris1225/pi-subagents 4.1.18 → 4.1.21

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