@ferris1225/pi-subagents 4.3.8 → 4.3.10

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.
@@ -4,36 +4,20 @@
4
4
  * Dispatch owns tool policy and role briefs; this module owns one
5
5
  * stable parent generation end to end: managed-repository lane use,
6
6
  * worktree setup/finalization, queue/process ownership,
7
- * retained-session resume, and guarded one-time terminal publication.
7
+ * recovery artifacts, and guarded one-time terminal publication.
8
8
  */
9
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
10
  import { join, resolve } from "node:path";
14
- import {
15
- discoverAgents,
16
- isWriteCapableAgent,
17
- resolveAgentTools,
18
- type AgentConfig,
19
- } from "../delegation/agents.ts";
20
- import { type CompletionMessageItem } from "./completion.ts";
11
+ import { isWriteCapableAgent, resolveAgentTools, type AgentConfig } from "../delegation/agents.ts";
12
+ import type { CompletionMessageItem } from "./completion.ts";
21
13
  import { loadConfig } from "../configuration/config.ts";
22
- import {
23
- dispatchFailedResult,
24
- failedStartResult,
25
- formatCompletionBlock,
26
- modelLevelTakeoverNote,
27
- queuedResult,
28
- } from "../presentation/format.ts";
14
+ import { dispatchFailedResult, failedStartResult, formatCompletionBlock, modelLevelTakeoverNote, queuedResult } from "../presentation/format.ts";
29
15
  import { monitor } from "../presentation/monitor.ts";
30
16
  import { findDuplicateDispatch } from "../delegation/prompt.ts";
17
+ import { findWriterLeaseScopeOverlap, normalizePhaseId, normalizePhaseScope } from "../delegation/phase-scope.ts";
31
18
  import { persistRecoveryRecords, recoveryRecordFromFinalization } from "../isolation/recovery.ts";
32
19
  import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
33
- import { forkRetainedSession } from "../execution/session-fork.ts";
34
20
  import {
35
- buildAppendedObjectivePrompt,
36
- buildResumePrompt,
37
21
  getProjectRoot,
38
22
  RpcRunControl,
39
23
  isFailedResult,
@@ -44,18 +28,13 @@ import {
44
28
  type SubagentLiveEvent,
45
29
  } from "../execution/spawn.ts";
46
30
  import {
47
- beginRuntimePreflight,
48
31
  isWorktreeCapableAgent,
49
- ownsResumeReservation,
50
32
  persistThreadCheckpoint,
51
33
  projectResultsRoot,
52
- quiesced,
53
34
  resolveDispatchModelRoute,
54
35
  runInManagedRepositoryLane,
55
36
  withWorktreeSystemPrompt,
56
37
  type DispatchEnvironment,
57
- type ResumeReservation,
58
- type SessionSeed,
59
38
  type StartBackgroundInternal,
60
39
  type StartBackgroundOptions,
61
40
  type ThreadLifecycleDeps,
@@ -70,8 +49,7 @@ import {
70
49
 
71
50
  interface BackgroundDispatcherOptions {
72
51
  runtime: SubagentRuntime;
73
- /** Live dispatch environment; resolved lazily so control operations work
74
- * before the first dispatch of a process (restored threads). */
52
+ /** Current parent context, config, and role catalog for a fresh dispatch. */
75
53
  getEnvironment: () => DispatchEnvironment;
76
54
  finishRun: (
77
55
  runId: number,
@@ -104,108 +82,67 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
104
82
  isolation: IsolationMode = "shared",
105
83
  startOptions: StartBackgroundOptions = {},
106
84
  ): Promise<SingleResult> => {
107
- const {
108
- existingThread,
109
- appendedObjectiveOnResume = false,
110
- environment,
111
- seed,
112
- resumeReservation,
113
- deliveryRoute = "background",
114
- } = startOptions;
115
85
  if (!runtime.sessionActive) {
116
- return failedStartResult(agentName, task, "Parent session shut down before this subagent generation could start.");
117
- }
118
- if (existingThread && (!resumeReservation || !ownsResumeReservation(runtime, existingThread, resumeReservation))) {
119
- return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
86
+ return failedStartResult(agentName, task, "Parent session shut down before this subagent run could start.");
120
87
  }
121
- const baseEnvironment = environment ?? getEnvironment();
88
+ const baseEnvironment = getEnvironment();
122
89
  const runCtx = baseEnvironment.ctx;
123
90
  const runConfig = baseEnvironment.config;
124
- const runAgents = baseEnvironment.agents;
125
- const discoveredAgent = runAgents.find((candidate) => candidate.name === agentName);
91
+ const discoveredAgent = baseEnvironment.agents.find((candidate) => candidate.name === agentName);
126
92
  if (!discoveredAgent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
127
93
  const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
128
94
  resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
129
95
  const agent = resolveLiveAgentTools(discoveredAgent);
96
+ const originalCwd = resolve(cwd ?? runCtx.cwd);
97
+ let phaseId: string | undefined;
98
+ let scope: ReturnType<typeof normalizePhaseScope>;
99
+ try {
100
+ phaseId = normalizePhaseId(startOptions.phaseId);
101
+ scope = normalizePhaseScope(startOptions.scope, originalCwd);
102
+ } catch (error) {
103
+ return failedStartResult(agentName, task, error instanceof Error ? error.message : String(error));
104
+ }
105
+ const writeCapable = Boolean(startOptions.writeCapable) || isWriteCapableAgent(agent);
130
106
  if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
131
107
  return {
132
108
  ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as artisan.`),
133
109
  isolation,
134
110
  };
135
111
  }
136
-
137
- const originalCwd = resolve(cwd ?? runCtx.cwd);
138
- if (!existingThread) {
139
- const duplicate = findDuplicateDispatch(runtime.threads.values(), task, originalCwd);
140
- if (duplicate?.kind === "active") {
141
- return failedStartResult(
142
- agentName,
143
- task,
144
- `Duplicate active dispatch matches run #${duplicate.source.id} (${duplicate.source.agentName}). Use that logical thread instead; resume #${duplicate.source.id} when it is eligible.`,
145
- );
146
- }
147
- if (duplicate?.kind === "settled") {
148
- // The same brief on the same tree would re-buy work whose result main
149
- // already holds; the retained session continues it for a fraction.
150
- return failedStartResult(
151
- agentName,
152
- task,
153
- `Run #${duplicate.source.id} (${duplicate.source.agentName}) already ${duplicate.source.state} this exact brief and kept its context; its result was delivered. Resume #${duplicate.source.id} with an appended objective instead of paying for a second run, or restate the brief with what changed.`,
154
- );
112
+ const duplicate = findDuplicateDispatch(runtime.threads.values(), task, originalCwd, phaseId);
113
+ if (duplicate) {
114
+ return failedStartResult(agentName, task,
115
+ `Run #${duplicate.source.id} (${duplicate.source.agentName}) already owns this logical phase (${duplicate.source.state}). Do not redispatch it; inspect its result or let it finish. Main handles follow-up work.`);
116
+ }
117
+ if (writeCapable && scope) {
118
+ const conflict = findWriterLeaseScopeOverlap(scope, runtime.threads.values());
119
+ if (conflict) {
120
+ return failedStartResult(agentName, task,
121
+ `Declared writer scope ${conflict.overlap.left} overlaps active run #${conflict.lease.id} scope ${conflict.overlap.right}; no run was started.`);
155
122
  }
156
123
  }
157
124
  const projectRoot = getProjectRoot(runtime.configPath, originalCwd);
158
125
  const sessionsRoot = join(projectRoot, "sessions");
159
126
  const worktreesRoot = join(projectRoot, "worktrees");
160
127
  const scratchRoot = join(projectRoot, "tmp");
161
- const previousWorktree = existingThread?.worktree;
162
- let worktree = seed?.worktree ?? previousWorktree;
163
- if (isolation === "worktree" && worktree && worktree.state !== "active") {
164
- return {
165
- ...failedStartResult(agentName, task, `Run #${existingThread?.id ?? "?"} has no active continuation worktree.`),
166
- isolation,
167
- integrationStatus: worktree.state === "finalizing" ? "pending" : worktree.state,
168
- };
169
- }
170
- let executionCwd = worktree?.cwd ?? originalCwd;
171
- let worktreeGroup = worktree ? worktreeGroupId(worktree) : undefined;
172
- // A resume re-runs at the strength its dispatch asked for, so the retained
173
- // request survives generations (and, via the durable record, restarts).
128
+ let worktree: WorktreeIsolation | undefined;
129
+ let executionCwd = originalCwd;
130
+ let worktreeGroup: string | undefined;
174
131
  const resolvedRoute = resolveDispatchModelRoute(agent, runConfig, runCtx);
175
- // Isolation is a persistent system-level invariant, not a one-shot task
176
- // prefix: resumes and main-model
177
- // handoffs all keep the same worktree boundary.
178
132
  const route = isolation === "worktree"
179
133
  ? { ...resolvedRoute, agent: withWorktreeSystemPrompt(resolvedRoute.agent) }
180
134
  : resolvedRoute;
181
135
  const thinkingLevel = route.thinkingLevel;
182
- const priorTask = existingThread?.task;
183
- const priorSessionId = seed?.sessionId ?? existingThread?.sessionId;
184
- const priorSessionDir = seed?.sessionDir ?? existingThread?.sessionDir;
185
- const runId = existingThread?.id ?? monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
186
- isolation,
187
- ...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
188
- });
189
- runtime.claimRunDelivery(runId, deliveryRoute);
190
- const generation = (existingThread?.generation ?? 0) + 1;
136
+ const runId = monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, { isolation });
137
+ runtime.claimRunDelivery(runId, startOptions.deliveryRoute ?? "background");
138
+ const generation = 1;
191
139
  const pending: SingleResult = {
192
140
  ...queuedResult(route.agent, task, thinkingLevel),
193
141
  runId,
194
142
  projectCwd: originalCwd,
195
143
  isolation,
196
144
  ...(isolation === "worktree" ? { integrationStatus: "pending" as const } : {}),
197
- ...(seed?.sessionId && seed.sessionDir
198
- ? { sessionId: seed.sessionId, sessionDir: seed.sessionDir }
199
- : {}),
200
145
  };
201
- if (existingThread) {
202
- monitor.restartRun(runId, agent.name, task, route.agent.model, thinkingLevel, isolation, {
203
- elapsedMs: existingThread.elapsedMs,
204
- continuationKind: appendedObjectiveOnResume ? "resume-appended" : "resume-retained",
205
- ...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
206
- });
207
- runtime.settledRuns.delete(runId);
208
- }
209
146
 
210
147
  let thread!: SubagentThread;
211
148
  const control = new RpcRunControl(task, generation, (phase) => {
@@ -224,56 +161,29 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
224
161
  else if (state === "running") monitor.setStatus(runId, "running");
225
162
  });
226
163
 
227
-
228
- if (existingThread) {
229
- thread = existingThread;
230
- thread.generation = generation;
231
- thread.agentName = agent.name;
232
- thread.task = task;
233
- thread.cwd = originalCwd;
234
- thread.executionCwd = executionCwd;
235
- thread.thinkingLevel = thinkingLevel;
236
- thread.isolation = isolation;
237
- thread.worktree = worktree;
238
- thread.state = "queued";
239
- thread.control = control;
240
- // A newly admitted generation owns no output yet. Keeping the prior
241
- // generation here would make a queued stop publish stale task,
242
- // session metadata as this generation's partial.
243
- thread.lastResult = undefined;
244
- if (seed?.sessionId && seed.sessionDir) {
245
- thread.sessionId = seed.sessionId;
246
- thread.sessionDir = seed.sessionDir;
247
- }
248
- thread.retireOnSettle = false;
249
- thread.isolationFailureNotified = false;
250
- } else {
251
- thread = {
252
- id: runId,
253
- generation,
254
- agentName: agent.name,
255
- task,
256
- cwd: originalCwd,
257
- executionCwd,
258
- thinkingLevel,
259
- isolation,
260
- worktree,
261
- state: "queued",
262
- control,
263
- generationCompletion: Promise.resolve(),
264
- lifecycleVersion: 0,
265
- elapsedMs: 0,
266
- sessionId: seed?.sessionId,
267
- sessionDir: seed?.sessionDir,
268
- resume: async () => failedStartResult(agent.name, task, "Thread resume was not initialized."),
269
- finalizeIsolation: async () => undefined,
270
- };
271
- runtime.threads.set(runId, thread);
272
- }
164
+ thread = {
165
+ id: runId,
166
+ generation,
167
+ agentName: agent.name,
168
+ task,
169
+ phaseId,
170
+ scope,
171
+ writeCapable,
172
+ cwd: originalCwd,
173
+ executionCwd,
174
+ thinkingLevel,
175
+ isolation,
176
+ state: "queued",
177
+ control,
178
+ generationCompletion: Promise.resolve(),
179
+ lifecycleVersion: 0,
180
+ elapsedMs: 0,
181
+ finalizeIsolation: async () => undefined,
182
+ };
183
+ runtime.threads.set(runId, thread);
273
184
  const installCurrentLifecycle = (): void => installThreadLifecycle(thread, {
274
185
  runtime,
275
186
  runCtx,
276
- startBackground: (...args) => startBackground(...args),
277
187
  });
278
188
  if (isolation === "shared" || worktree) installCurrentLifecycle();
279
189
 
@@ -281,7 +191,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
281
191
  // Shared write-capable runs serialize on the repository lane so their
282
192
  // edits cannot race; the lane wait releases the process slot because it
283
193
  // is write serialization, not pool pacing.
284
- const reserveManagedLane = isolation === "shared" && isWriteCapableAgent(agent);
194
+ const reserveManagedLane = isolation === "shared" && writeCapable;
285
195
  const runGeneration = async (backgroundSignal: AbortSignal, controller: AbortController): Promise<void> => {
286
196
  if (runtime.threads.get(runId)?.generation !== generation) return;
287
197
  if (isolation === "worktree" && !worktree) {
@@ -339,15 +249,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
339
249
  idleTimeoutMs: activeIdleTimeoutMs,
340
250
  sessionRoot: sessionsRoot,
341
251
  scratchRoot,
342
- ...(priorSessionId && priorSessionDir
343
- ? {
344
- sessionId: priorSessionId,
345
- sessionDir: priorSessionDir,
346
- stdinText: appendedObjectiveOnResume
347
- ? buildAppendedObjectivePrompt(priorTask ?? task, task)
348
- : buildResumePrompt(priorTask ?? task, "the retained thread was resumed"),
349
- }
350
- : {}),
351
252
  },
352
253
  activeRoute.mainFallbackRef,
353
254
  );
@@ -364,8 +265,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
364
265
  };
365
266
  }
366
267
 
367
- // A stale process/generation may finish after a superseded resume. It owns
368
- // no monitor mutation, result registration, or completion delivery.
268
+ // Stale work from a retired parent owns no publication or monitor updates.
369
269
  if (runtime.threads.get(runId)?.generation !== generation) return;
370
270
  result.runId = runId;
371
271
  result.projectCwd = originalCwd;
@@ -384,14 +284,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
384
284
  }
385
285
 
386
286
  const lifecycleInterrupted = (): boolean =>
387
- thread.lifecycleOperation === "stop" ||
388
- thread.lifecycleOperation === "park" ||
389
- thread.state === "stopped";
390
- // Destructive stop and park own publication once they have
391
- // synchronously claimed the lifecycle. Leave the partial result/session
392
- // on the thread; stop waits for this queue task, finalizes isolation,
393
- // and emits exactly one aborted result, while park records the
394
- // checkpoint and answers through its own tool result.
287
+ thread.lifecycleOperation === "stop" || thread.state === "stopped";
288
+ // Stop owns publication once it claims the lifecycle. Leave the partial
289
+ // result on the thread for that owner to finalize and deliver once.
395
290
  if (lifecycleInterrupted()) return;
396
291
 
397
292
  // A shutdown can win in the microtask gap after the child RPC
@@ -445,7 +340,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
445
340
  const completion: CompletionMessageItem = {
446
341
  agent: result.agent,
447
342
  block: modelLevel
448
- ? `${formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) })}\n\n${modelLevelTakeoverNote(result, { runId })}`
343
+ ? `${formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) })}\n\n${modelLevelTakeoverNote(result)}`
449
344
  : formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) }),
450
345
  usage: result.usage,
451
346
  };
@@ -483,9 +378,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
483
378
  () => {
484
379
  if (runtime.threads.get(runId)?.generation !== generation) return;
485
380
  // A destructive stop owns publication and may still be finalizing an
486
- // isolated worktree; a park owns the checkpoint. Do not expose a
381
+ // isolated worktree. Do not expose a
487
382
  // terminal monitor state before that owner records its outcome.
488
- if (thread.lifecycleOperation === "stop" || thread.lifecycleOperation === "park") return;
383
+ if (thread.lifecycleOperation === "stop") return;
489
384
  runtime.runControllers.delete(runId);
490
385
  thread.queueController = undefined;
491
386
  thread.state = "stopped";
@@ -499,10 +394,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
499
394
  async (error) => {
500
395
  if (runtime.threads.get(runId)?.generation !== generation) return;
501
396
  // Queue-level crashes use the same settlement reservation as ordinary
502
- // results. A concurrent destructive stop or park may supersede it while
397
+ // results. A concurrent destructive stop may supersede it while
503
398
  // slow worktree finalization is running, in which case that owner
504
399
  // publishes once.
505
- if (thread.lifecycleOperation === "stop" || thread.lifecycleOperation === "park") return;
400
+ if (thread.lifecycleOperation === "stop") return;
506
401
  const settlementVersion = ++thread.lifecycleVersion;
507
402
  thread.lifecycleOperation = "settle";
508
403
  const ownsSettlement = (): boolean =>
@@ -569,16 +464,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
569
464
  return startBackground;
570
465
  }
571
466
 
572
- /** Install resume/finalize control surfaces on a thread. Called for
573
- * every fresh generation (closures refresh with the current dispatch context)
574
- * and for threads restored from the durable manifest, whose startBackground
575
- * resolves the live dispatcher at call time. */
467
+ /** Install the shared settlement hook for a fresh run or recovered worktree. */
576
468
  export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifecycleDeps): void {
577
- const { runtime, startBackground } = deps;
469
+ const { runtime } = deps;
578
470
  const runId = thread.id;
579
- const projectRoot = getProjectRoot(runtime.configPath, thread.cwd);
580
- const sessionsRoot = join(projectRoot, "sessions");
581
- const worktreesRoot = join(projectRoot, "worktrees");
582
471
 
583
472
  thread.notifyIsolationFailure = (finalization) => {
584
473
  const paths = [finalization.worktreePath, finalization.patchPath].filter(Boolean).join(" · ");
@@ -655,240 +544,4 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
655
544
  return finalization;
656
545
  };
657
546
 
658
- const cleanupTrackedSessionDir = async (sessionDir: string, action: string): Promise<void> => {
659
- try {
660
- await rm(sessionDir, { recursive: true, force: true });
661
- runtime.sessionDirs.delete(sessionDir);
662
- } catch (error) {
663
- // Keep ownership so shutdown can retry; losing the path here leaks a
664
- // cloned session containing retained model context on Windows locks.
665
- try {
666
- deps.runCtx?.ui.notify(
667
- `✗ ${action}; retained ${sessionDir} for shutdown cleanup: ${error instanceof Error ? error.message : String(error)}`,
668
- "error",
669
- );
670
- } catch {
671
- /* cleanup ownership remains tracked even if the UI is unavailable */
672
- }
673
- }
674
- };
675
-
676
- const discardUnusedWorktree = async (candidate: WorktreeIsolation | undefined): Promise<void> => {
677
- if (!candidate) return;
678
- try {
679
- await candidate.discard();
680
- } catch (error) {
681
- const retainedPath = existsSync(candidate.worktreePath)
682
- ? candidate.worktreePath
683
- : existsSync(candidate.tempDir)
684
- ? candidate.tempDir
685
- : undefined;
686
- const finalization: WorktreeFinalization = {
687
- status: "retained",
688
- integrated: false,
689
- hadChanges: false,
690
- originalRoot: candidate.originalRoot,
691
- ...(retainedPath ? { worktreePath: retainedPath } : {}),
692
- ...(existsSync(candidate.patchPath) ? { patchPath: candidate.patchPath } : {}),
693
- error: `Discarding unused continuation failed: ${error instanceof Error ? error.message : String(error)}`,
694
- };
695
- await persistRecoveryRecords(runtime.configPath, [
696
- recoveryRecordFromFinalization(runId, finalization),
697
- ]).catch(() => undefined);
698
- try {
699
- thread.notifyIsolationFailure?.(finalization);
700
- } catch {
701
- /* parent UI may already be shutting down */
702
- }
703
- }
704
- };
705
-
706
- const createContinuationWorktree = async (
707
- source: WorktreeIsolation,
708
- seedIsIntegrated: boolean,
709
- ): Promise<WorktreeIsolation> => {
710
- if (source.state === "finalizing") {
711
- throw new Error(`Run #${runId}'s worktree is still finalizing.`);
712
- }
713
- const seedCheckpoint = await source.snapshotCheckpoint();
714
- return createWorktreeIsolation(thread.cwd, {
715
- seedCheckpoint,
716
- seedIsIntegrated,
717
- tempBaseDir: worktreesRoot,
718
- });
719
- };
720
-
721
- thread.resume = async (objective?: string, resumeCtx?: ExtensionContext): Promise<SingleResult> => {
722
- const requestedObjective = objective?.trim();
723
- if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
724
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
725
- }
726
- if (objective !== undefined && !requestedObjective) {
727
- return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
728
- }
729
- if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
730
- if (thread.resumeUnavailableReason) {
731
- return failedStartResult(thread.agentName, thread.task, thread.resumeUnavailableReason);
732
- }
733
- if (thread.lifecycleOperation) {
734
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already resuming.`);
735
- }
736
- if (!["parked", "completed", "failed"].includes(thread.state)) {
737
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state}; it must be parked or settled before resume.`);
738
- }
739
-
740
- // Lifecycle CAS: claim synchronously before the first await, then cancel
741
- // and fully quiesce any superseded queue/process before cloning or
742
- // reusing its session. A second resume sees this claim immediately.
743
- const previousState = thread.state;
744
- const previousSessionId = thread.sessionId;
745
- const previousSessionDir = thread.sessionDir;
746
- const previousExecutionCwd = thread.executionCwd;
747
- const reservation: ResumeReservation = {
748
- version: ++thread.lifecycleVersion,
749
- generation: thread.generation,
750
- sessionId: previousSessionId,
751
- sessionDir: previousSessionDir,
752
- };
753
- thread.lifecycleOperation = "resume";
754
- thread.state = "resuming";
755
- const finishPreflight = beginRuntimePreflight(runtime);
756
- const supersededController = thread.queueController;
757
- runtime.backgroundQueue.cancel(supersededController);
758
- runtime.runControllers.delete(runId);
759
-
760
- let continuationWorktree: WorktreeIsolation | undefined;
761
- let clonedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
762
- try {
763
- // Never wait forever on a previous generation that is still settling
764
- // (e.g. blocked behind the managed repository lane in finalization).
765
- if (!(await quiesced(thread.generationCompletion))) {
766
- return failedStartResult(
767
- thread.agentName,
768
- thread.task,
769
- `Run #${runId}'s previous generation is still settling; retry the resume shortly.`,
770
- );
771
- }
772
- if (!ownsResumeReservation(runtime, thread, reservation)) {
773
- return failedStartResult(
774
- thread.agentName,
775
- thread.task,
776
- thread.retired
777
- ? `Run #${runId} was retired by subagent_stop; no new generation was started.`
778
- : `Run #${runId} changed while resume was preparing; no new generation was started.`,
779
- );
780
- }
781
- thread.state = "resuming";
782
- const currentCtx = resumeCtx ?? deps.runCtx;
783
- if (!currentCtx) {
784
- throw new Error(`Run #${runId} has no dispatch context for resume.`);
785
- }
786
- let seed: SessionSeed | undefined;
787
- if (thread.isolation === "worktree" && thread.worktree?.state !== "active") {
788
- if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
789
- const seedAlreadyIntegrated =
790
- thread.worktree.state === "integrated" ||
791
- thread.worktree.state === "no_changes" ||
792
- thread.lastResult?.integrationApplied === true;
793
- continuationWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
794
- if (!ownsResumeReservation(runtime, thread, reservation)) {
795
- throw new Error(`Run #${runId} changed while its continuation worktree was being created.`);
796
- }
797
- seed = { worktree: continuationWorktree };
798
- if (previousSessionId && previousSessionDir) {
799
- clonedSession = await forkRetainedSession({
800
- cwd: previousExecutionCwd,
801
- targetCwd: continuationWorktree.cwd,
802
- sessionDir: previousSessionDir,
803
- sessionId: previousSessionId,
804
- targetRoot: sessionsRoot,
805
- });
806
- runtime.sessionDirs.add(clonedSession.sessionDir);
807
- if (!ownsResumeReservation(runtime, thread, reservation)) {
808
- throw new Error(`Run #${runId} changed while its retained session was being cloned.`);
809
- }
810
- seed.sessionId = clonedSession.sessionId;
811
- seed.sessionDir = clonedSession.sessionDir;
812
- }
813
- }
814
-
815
- const currentConfig = await loadConfig(runtime.configPath);
816
- if (!ownsResumeReservation(runtime, thread, reservation)) {
817
- throw new Error(`Run #${runId} changed while resume configuration was loading.`);
818
- }
819
- const currentAgents = discoverAgents(currentCtx.cwd, {
820
- scope: currentConfig.agentScope,
821
- enabledNames: currentConfig.enabledAgents,
822
- projectTrusted: currentCtx.isProjectTrusted?.() === true,
823
- }).agents;
824
- const nextTask = requestedObjective ?? thread.task;
825
- const pending = await startBackground(
826
- thread.agentName,
827
- nextTask,
828
- thread.cwd,
829
- thread.isolation,
830
- {
831
- existingThread: thread,
832
- appendedObjectiveOnResume: objective !== undefined,
833
- environment: {
834
- ctx: currentCtx,
835
- config: currentConfig,
836
- agents: currentAgents,
837
- },
838
- seed,
839
- resumeReservation: reservation,
840
- },
841
- );
842
- if (pending.exitCode !== -1) {
843
- if (clonedSession) {
844
- await cleanupTrackedSessionDir(
845
- clonedSession.sessionDir,
846
- `Could not discard failed resume session clone for run #${runId}`,
847
- );
848
- }
849
- await discardUnusedWorktree(continuationWorktree);
850
- if (ownsResumeReservation(runtime, thread, reservation)) thread.state = previousState;
851
- return pending;
852
- }
853
-
854
- // The cloned branch replaces the removed-worktree session for this
855
- // logical id. Keep an undeletable old dir in runtime cleanup if needed.
856
- if (clonedSession && previousSessionDir && previousSessionDir !== clonedSession.sessionDir) {
857
- try {
858
- await rm(previousSessionDir, { recursive: true, force: true });
859
- runtime.sessionDirs.delete(previousSessionDir);
860
- } catch {
861
- /* shutdown retries cleanup of the old retained branch */
862
- }
863
- }
864
- return pending;
865
- } catch (error) {
866
- if (clonedSession) {
867
- await cleanupTrackedSessionDir(
868
- clonedSession.sessionDir,
869
- `Could not discard interrupted resume session clone for run #${runId}`,
870
- );
871
- }
872
- await discardUnusedWorktree(continuationWorktree);
873
- if (ownsResumeReservation(runtime, thread, reservation)) {
874
- thread.state = previousState;
875
- thread.sessionId = previousSessionId;
876
- thread.sessionDir = previousSessionDir;
877
- thread.executionCwd = previousExecutionCwd;
878
- }
879
- return failedStartResult(
880
- thread.agentName,
881
- requestedObjective ?? thread.task,
882
- `Could not resume run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
883
- );
884
- } finally {
885
- finishPreflight();
886
- if (
887
- thread.lifecycleOperation === "resume" &&
888
- thread.lifecycleVersion === reservation.version
889
- ) {
890
- thread.lifecycleOperation = undefined;
891
- }
892
- }
893
- };
894
547
  }