@ferris1225/pi-subagents 4.2.4 → 4.2.7

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