@ferris1225/pi-subagents 4.3.9 → 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,42 +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";
31
- import {
32
- findWriterLeaseScopeOverlap,
33
- mergePhaseScopes,
34
- normalizePhaseId,
35
- normalizePhaseScope,
36
- } from "../delegation/phase-scope.ts";
17
+ import { findWriterLeaseScopeOverlap, normalizePhaseId, normalizePhaseScope } from "../delegation/phase-scope.ts";
37
18
  import { persistRecoveryRecords, recoveryRecordFromFinalization } from "../isolation/recovery.ts";
38
19
  import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
39
- import { forkRetainedSession } from "../execution/session-fork.ts";
40
20
  import {
41
- buildAppendedObjectivePrompt,
42
- buildResumePrompt,
43
21
  getProjectRoot,
44
22
  RpcRunControl,
45
23
  isFailedResult,
@@ -50,18 +28,13 @@ import {
50
28
  type SubagentLiveEvent,
51
29
  } from "../execution/spawn.ts";
52
30
  import {
53
- beginRuntimePreflight,
54
31
  isWorktreeCapableAgent,
55
- ownsResumeReservation,
56
32
  persistThreadCheckpoint,
57
33
  projectResultsRoot,
58
- quiesced,
59
34
  resolveDispatchModelRoute,
60
35
  runInManagedRepositoryLane,
61
36
  withWorktreeSystemPrompt,
62
37
  type DispatchEnvironment,
63
- type ResumeReservation,
64
- type SessionSeed,
65
38
  type StartBackgroundInternal,
66
39
  type StartBackgroundOptions,
67
40
  type ThreadLifecycleDeps,
@@ -76,8 +49,7 @@ import {
76
49
 
77
50
  interface BackgroundDispatcherOptions {
78
51
  runtime: SubagentRuntime;
79
- /** Live dispatch environment; resolved lazily so control operations work
80
- * before the first dispatch of a process (restored threads). */
52
+ /** Current parent context, config, and role catalog for a fresh dispatch. */
81
53
  getEnvironment: () => DispatchEnvironment;
82
54
  finishRun: (
83
55
  runId: number,
@@ -110,135 +82,67 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
110
82
  isolation: IsolationMode = "shared",
111
83
  startOptions: StartBackgroundOptions = {},
112
84
  ): Promise<SingleResult> => {
113
- const {
114
- phaseId: requestedPhaseId,
115
- scope: requestedScope,
116
- writeCapable: requestedWriteCapable,
117
- existingThread,
118
- appendedObjectiveOnResume = false,
119
- environment,
120
- seed,
121
- resumeReservation,
122
- deliveryRoute = "background",
123
- } = startOptions;
124
85
  if (!runtime.sessionActive) {
125
- return failedStartResult(agentName, task, "Parent session shut down before this subagent generation could start.");
86
+ return failedStartResult(agentName, task, "Parent session shut down before this subagent run could start.");
126
87
  }
127
- if (existingThread && (!resumeReservation || !ownsResumeReservation(runtime, existingThread, resumeReservation))) {
128
- return failedStartResult(agentName, task, `Run #${existingThread.id} changed while resume was preparing; no new generation was started.`);
129
- }
130
- const baseEnvironment = environment ?? getEnvironment();
88
+ const baseEnvironment = getEnvironment();
131
89
  const runCtx = baseEnvironment.ctx;
132
90
  const runConfig = baseEnvironment.config;
133
- const runAgents = baseEnvironment.agents;
134
- const discoveredAgent = runAgents.find((candidate) => candidate.name === agentName);
91
+ const discoveredAgent = baseEnvironment.agents.find((candidate) => candidate.name === agentName);
135
92
  if (!discoveredAgent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
136
93
  const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
137
94
  resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
138
95
  const agent = resolveLiveAgentTools(discoveredAgent);
139
-
140
96
  const originalCwd = resolve(cwd ?? runCtx.cwd);
141
97
  let phaseId: string | undefined;
142
98
  let scope: ReturnType<typeof normalizePhaseScope>;
143
99
  try {
144
- phaseId = normalizePhaseId(existingThread ? existingThread.phaseId : requestedPhaseId);
145
- const requested = normalizePhaseScope(requestedScope, originalCwd);
146
- scope = existingThread ? mergePhaseScopes(existingThread.scope, requested) : requested;
100
+ phaseId = normalizePhaseId(startOptions.phaseId);
101
+ scope = normalizePhaseScope(startOptions.scope, originalCwd);
147
102
  } catch (error) {
148
103
  return failedStartResult(agentName, task, error instanceof Error ? error.message : String(error));
149
104
  }
150
- const currentWriteCapable = isWriteCapableAgent(agent);
151
- const priorWriteCapable = existingThread
152
- ? (existingThread.writeCapable ?? existingThread.agentName !== "scout")
153
- : Boolean(requestedWriteCapable);
154
- const writeCapable = priorWriteCapable || currentWriteCapable;
105
+ const writeCapable = Boolean(startOptions.writeCapable) || isWriteCapableAgent(agent);
155
106
  if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
156
107
  return {
157
108
  ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as artisan.`),
158
109
  isolation,
159
110
  };
160
111
  }
161
- if (!existingThread) {
162
- const duplicate = findDuplicateDispatch(runtime.threads.values(), task, originalCwd, phaseId);
163
- if (duplicate?.kind === "active") {
164
- return failedStartResult(
165
- agentName,
166
- task,
167
- `Duplicate active dispatch matches run #${duplicate.source.id} (${duplicate.source.agentName}). Use that logical thread instead; resume #${duplicate.source.id} when it is eligible.`,
168
- );
169
- }
170
- if (duplicate?.kind === "settled") {
171
- // The same brief on the same tree would re-buy work whose result main
172
- // already holds; the retained session continues it for a fraction.
173
- return failedStartResult(
174
- agentName,
175
- task,
176
- `Run #${duplicate.source.id} (${duplicate.source.agentName}) already ${duplicate.source.state} this logical phase and kept its context; its result was delivered. Resume #${duplicate.source.id} with an appended objective instead of paying for a second run${phaseId ? "; keep using the same phaseId on that thread" : ", or restate the brief with what changed"}.`,
177
- );
178
- }
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.`);
179
116
  }
180
117
  if (writeCapable && scope) {
181
- const conflict = findWriterLeaseScopeOverlap(scope, runtime.threads.values(), existingThread?.id);
118
+ const conflict = findWriterLeaseScopeOverlap(scope, runtime.threads.values());
182
119
  if (conflict) {
183
- return failedStartResult(
184
- agentName,
185
- task,
186
- `Declared writer scope ${conflict.overlap.left} overlaps active run #${conflict.lease.id} scope ${conflict.overlap.right}; no new generation was started.`,
187
- );
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.`);
188
122
  }
189
123
  }
190
124
  const projectRoot = getProjectRoot(runtime.configPath, originalCwd);
191
125
  const sessionsRoot = join(projectRoot, "sessions");
192
126
  const worktreesRoot = join(projectRoot, "worktrees");
193
127
  const scratchRoot = join(projectRoot, "tmp");
194
- const previousWorktree = existingThread?.worktree;
195
- let worktree = seed?.worktree ?? previousWorktree;
196
- if (isolation === "worktree" && worktree && worktree.state !== "active") {
197
- return {
198
- ...failedStartResult(agentName, task, `Run #${existingThread?.id ?? "?"} has no active continuation worktree.`),
199
- isolation,
200
- integrationStatus: worktree.state === "finalizing" ? "pending" : worktree.state,
201
- };
202
- }
203
- let executionCwd = worktree?.cwd ?? originalCwd;
204
- let worktreeGroup = worktree ? worktreeGroupId(worktree) : undefined;
205
- // A resume re-runs at the strength its dispatch asked for, so the retained
206
- // request survives generations (and, via the durable record, restarts).
128
+ let worktree: WorktreeIsolation | undefined;
129
+ let executionCwd = originalCwd;
130
+ let worktreeGroup: string | undefined;
207
131
  const resolvedRoute = resolveDispatchModelRoute(agent, runConfig, runCtx);
208
- // Isolation is a persistent system-level invariant, not a one-shot task
209
- // prefix: resumes and main-model
210
- // handoffs all keep the same worktree boundary.
211
132
  const route = isolation === "worktree"
212
133
  ? { ...resolvedRoute, agent: withWorktreeSystemPrompt(resolvedRoute.agent) }
213
134
  : resolvedRoute;
214
135
  const thinkingLevel = route.thinkingLevel;
215
- const priorTask = existingThread?.task;
216
- const priorSessionId = seed?.sessionId ?? existingThread?.sessionId;
217
- const priorSessionDir = seed?.sessionDir ?? existingThread?.sessionDir;
218
- const runId = existingThread?.id ?? monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
219
- isolation,
220
- ...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
221
- });
222
- runtime.claimRunDelivery(runId, deliveryRoute);
223
- 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;
224
139
  const pending: SingleResult = {
225
140
  ...queuedResult(route.agent, task, thinkingLevel),
226
141
  runId,
227
142
  projectCwd: originalCwd,
228
143
  isolation,
229
144
  ...(isolation === "worktree" ? { integrationStatus: "pending" as const } : {}),
230
- ...(seed?.sessionId && seed.sessionDir
231
- ? { sessionId: seed.sessionId, sessionDir: seed.sessionDir }
232
- : {}),
233
145
  };
234
- if (existingThread) {
235
- monitor.restartRun(runId, agent.name, task, route.agent.model, thinkingLevel, isolation, {
236
- elapsedMs: existingThread.elapsedMs,
237
- continuationKind: appendedObjectiveOnResume ? "resume-appended" : "resume-retained",
238
- ...(worktreeGroup ? { worktreeId: worktreeGroup } : {}),
239
- });
240
- runtime.settledRuns.delete(runId);
241
- }
242
146
 
243
147
  let thread!: SubagentThread;
244
148
  const control = new RpcRunControl(task, generation, (phase) => {
@@ -257,62 +161,29 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
257
161
  else if (state === "running") monitor.setStatus(runId, "running");
258
162
  });
259
163
 
260
-
261
- if (existingThread) {
262
- thread = existingThread;
263
- thread.generation = generation;
264
- thread.agentName = agent.name;
265
- thread.task = task;
266
- thread.phaseId = phaseId;
267
- thread.scope = scope;
268
- thread.writeCapable = writeCapable;
269
- thread.cwd = originalCwd;
270
- thread.executionCwd = executionCwd;
271
- thread.thinkingLevel = thinkingLevel;
272
- thread.isolation = isolation;
273
- thread.worktree = worktree;
274
- thread.state = "queued";
275
- thread.control = control;
276
- // A newly admitted generation owns no output yet. Keeping the prior
277
- // generation here would make a queued stop publish stale task,
278
- // session metadata as this generation's partial.
279
- thread.lastResult = undefined;
280
- if (seed?.sessionId && seed.sessionDir) {
281
- thread.sessionId = seed.sessionId;
282
- thread.sessionDir = seed.sessionDir;
283
- }
284
- thread.retireOnSettle = false;
285
- thread.isolationFailureNotified = false;
286
- } else {
287
- thread = {
288
- id: runId,
289
- generation,
290
- agentName: agent.name,
291
- task,
292
- phaseId,
293
- scope,
294
- writeCapable,
295
- cwd: originalCwd,
296
- executionCwd,
297
- thinkingLevel,
298
- isolation,
299
- worktree,
300
- state: "queued",
301
- control,
302
- generationCompletion: Promise.resolve(),
303
- lifecycleVersion: 0,
304
- elapsedMs: 0,
305
- sessionId: seed?.sessionId,
306
- sessionDir: seed?.sessionDir,
307
- resume: async () => failedStartResult(agent.name, task, "Thread resume was not initialized."),
308
- finalizeIsolation: async () => undefined,
309
- };
310
- runtime.threads.set(runId, thread);
311
- }
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);
312
184
  const installCurrentLifecycle = (): void => installThreadLifecycle(thread, {
313
185
  runtime,
314
186
  runCtx,
315
- startBackground: (...args) => startBackground(...args),
316
187
  });
317
188
  if (isolation === "shared" || worktree) installCurrentLifecycle();
318
189
 
@@ -378,15 +249,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
378
249
  idleTimeoutMs: activeIdleTimeoutMs,
379
250
  sessionRoot: sessionsRoot,
380
251
  scratchRoot,
381
- ...(priorSessionId && priorSessionDir
382
- ? {
383
- sessionId: priorSessionId,
384
- sessionDir: priorSessionDir,
385
- stdinText: appendedObjectiveOnResume
386
- ? buildAppendedObjectivePrompt(priorTask ?? task, task)
387
- : buildResumePrompt(priorTask ?? task, "the retained thread was resumed"),
388
- }
389
- : {}),
390
252
  },
391
253
  activeRoute.mainFallbackRef,
392
254
  );
@@ -403,8 +265,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
403
265
  };
404
266
  }
405
267
 
406
- // A stale process/generation may finish after a superseded resume. It owns
407
- // no monitor mutation, result registration, or completion delivery.
268
+ // Stale work from a retired parent owns no publication or monitor updates.
408
269
  if (runtime.threads.get(runId)?.generation !== generation) return;
409
270
  result.runId = runId;
410
271
  result.projectCwd = originalCwd;
@@ -423,14 +284,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
423
284
  }
424
285
 
425
286
  const lifecycleInterrupted = (): boolean =>
426
- thread.lifecycleOperation === "stop" ||
427
- thread.lifecycleOperation === "park" ||
428
- thread.state === "stopped";
429
- // Destructive stop and park own publication once they have
430
- // synchronously claimed the lifecycle. Leave the partial result/session
431
- // on the thread; stop waits for this queue task, finalizes isolation,
432
- // and emits exactly one aborted result, while park records the
433
- // 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.
434
290
  if (lifecycleInterrupted()) return;
435
291
 
436
292
  // A shutdown can win in the microtask gap after the child RPC
@@ -484,7 +340,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
484
340
  const completion: CompletionMessageItem = {
485
341
  agent: result.agent,
486
342
  block: modelLevel
487
- ? `${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)}`
488
344
  : formatCompletionBlock(result, runConfig.maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, result.projectCwd ?? originalCwd) }),
489
345
  usage: result.usage,
490
346
  };
@@ -522,9 +378,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
522
378
  () => {
523
379
  if (runtime.threads.get(runId)?.generation !== generation) return;
524
380
  // A destructive stop owns publication and may still be finalizing an
525
- // isolated worktree; a park owns the checkpoint. Do not expose a
381
+ // isolated worktree. Do not expose a
526
382
  // terminal monitor state before that owner records its outcome.
527
- if (thread.lifecycleOperation === "stop" || thread.lifecycleOperation === "park") return;
383
+ if (thread.lifecycleOperation === "stop") return;
528
384
  runtime.runControllers.delete(runId);
529
385
  thread.queueController = undefined;
530
386
  thread.state = "stopped";
@@ -538,10 +394,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
538
394
  async (error) => {
539
395
  if (runtime.threads.get(runId)?.generation !== generation) return;
540
396
  // Queue-level crashes use the same settlement reservation as ordinary
541
- // results. A concurrent destructive stop or park may supersede it while
397
+ // results. A concurrent destructive stop may supersede it while
542
398
  // slow worktree finalization is running, in which case that owner
543
399
  // publishes once.
544
- if (thread.lifecycleOperation === "stop" || thread.lifecycleOperation === "park") return;
400
+ if (thread.lifecycleOperation === "stop") return;
545
401
  const settlementVersion = ++thread.lifecycleVersion;
546
402
  thread.lifecycleOperation = "settle";
547
403
  const ownsSettlement = (): boolean =>
@@ -608,16 +464,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
608
464
  return startBackground;
609
465
  }
610
466
 
611
- /** Install resume/finalize control surfaces on a thread. Called for
612
- * every fresh generation (closures refresh with the current dispatch context)
613
- * and for threads restored from the durable manifest, whose startBackground
614
- * resolves the live dispatcher at call time. */
467
+ /** Install the shared settlement hook for a fresh run or recovered worktree. */
615
468
  export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifecycleDeps): void {
616
- const { runtime, startBackground } = deps;
469
+ const { runtime } = deps;
617
470
  const runId = thread.id;
618
- const projectRoot = getProjectRoot(runtime.configPath, thread.cwd);
619
- const sessionsRoot = join(projectRoot, "sessions");
620
- const worktreesRoot = join(projectRoot, "worktrees");
621
471
 
622
472
  thread.notifyIsolationFailure = (finalization) => {
623
473
  const paths = [finalization.worktreePath, finalization.patchPath].filter(Boolean).join(" · ");
@@ -694,257 +544,4 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
694
544
  return finalization;
695
545
  };
696
546
 
697
- const cleanupTrackedSessionDir = async (sessionDir: string, action: string): Promise<void> => {
698
- try {
699
- await rm(sessionDir, { recursive: true, force: true });
700
- runtime.sessionDirs.delete(sessionDir);
701
- } catch (error) {
702
- // Keep ownership so shutdown can retry; losing the path here leaks a
703
- // cloned session containing retained model context on Windows locks.
704
- try {
705
- deps.runCtx?.ui.notify(
706
- `✗ ${action}; retained ${sessionDir} for shutdown cleanup: ${error instanceof Error ? error.message : String(error)}`,
707
- "error",
708
- );
709
- } catch {
710
- /* cleanup ownership remains tracked even if the UI is unavailable */
711
- }
712
- }
713
- };
714
-
715
- const discardUnusedWorktree = async (candidate: WorktreeIsolation | undefined): Promise<void> => {
716
- if (!candidate) return;
717
- try {
718
- await candidate.discard();
719
- } catch (error) {
720
- const retainedPath = existsSync(candidate.worktreePath)
721
- ? candidate.worktreePath
722
- : existsSync(candidate.tempDir)
723
- ? candidate.tempDir
724
- : undefined;
725
- const finalization: WorktreeFinalization = {
726
- status: "retained",
727
- integrated: false,
728
- hadChanges: false,
729
- originalRoot: candidate.originalRoot,
730
- ...(retainedPath ? { worktreePath: retainedPath } : {}),
731
- ...(existsSync(candidate.patchPath) ? { patchPath: candidate.patchPath } : {}),
732
- error: `Discarding unused continuation failed: ${error instanceof Error ? error.message : String(error)}`,
733
- };
734
- await persistRecoveryRecords(runtime.configPath, [
735
- recoveryRecordFromFinalization(runId, finalization),
736
- ]).catch(() => undefined);
737
- try {
738
- thread.notifyIsolationFailure?.(finalization);
739
- } catch {
740
- /* parent UI may already be shutting down */
741
- }
742
- }
743
- };
744
-
745
- const createContinuationWorktree = async (
746
- source: WorktreeIsolation,
747
- seedIsIntegrated: boolean,
748
- ): Promise<WorktreeIsolation> => {
749
- if (source.state === "finalizing") {
750
- throw new Error(`Run #${runId}'s worktree is still finalizing.`);
751
- }
752
- const seedCheckpoint = await source.snapshotCheckpoint();
753
- return createWorktreeIsolation(thread.cwd, {
754
- seedCheckpoint,
755
- seedIsIntegrated,
756
- tempBaseDir: worktreesRoot,
757
- });
758
- };
759
-
760
- thread.resume = async (
761
- objective?: string,
762
- resumeCtx?: ExtensionContext,
763
- metadata?: { scope?: Parameters<typeof normalizePhaseScope>[0] },
764
- ): Promise<SingleResult> => {
765
- const requestedObjective = objective?.trim();
766
- if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
767
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
768
- }
769
- if (objective !== undefined && !requestedObjective) {
770
- return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
771
- }
772
- const continuationPhaseId = thread.phaseId;
773
- let continuationScope: ReturnType<typeof normalizePhaseScope>;
774
- try {
775
- const additionalScope = normalizePhaseScope(metadata?.scope, thread.cwd);
776
- continuationScope = mergePhaseScopes(thread.scope, additionalScope);
777
- } catch (error) {
778
- return failedStartResult(thread.agentName, thread.task, error instanceof Error ? error.message : String(error));
779
- }
780
- if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
781
- if (thread.resumeUnavailableReason) {
782
- return failedStartResult(thread.agentName, thread.task, thread.resumeUnavailableReason);
783
- }
784
- if (thread.lifecycleOperation) {
785
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is already resuming.`);
786
- }
787
- if (!["parked", "completed", "failed"].includes(thread.state)) {
788
- return failedStartResult(thread.agentName, thread.task, `Run #${runId} is ${thread.state}; it must be parked or settled before resume.`);
789
- }
790
-
791
- // Lifecycle CAS: claim synchronously before the first await, then cancel
792
- // and fully quiesce any superseded queue/process before cloning or
793
- // reusing its session. A second resume sees this claim immediately.
794
- const previousState = thread.state;
795
- const previousSessionId = thread.sessionId;
796
- const previousSessionDir = thread.sessionDir;
797
- const previousExecutionCwd = thread.executionCwd;
798
- const reservation: ResumeReservation = {
799
- version: ++thread.lifecycleVersion,
800
- generation: thread.generation,
801
- sessionId: previousSessionId,
802
- sessionDir: previousSessionDir,
803
- };
804
- thread.admissionScope = continuationScope;
805
- thread.lifecycleOperation = "resume";
806
- thread.state = "resuming";
807
- const finishPreflight = beginRuntimePreflight(runtime);
808
- const supersededController = thread.queueController;
809
- runtime.backgroundQueue.cancel(supersededController);
810
- runtime.runControllers.delete(runId);
811
-
812
- let continuationWorktree: WorktreeIsolation | undefined;
813
- let clonedSession: Awaited<ReturnType<typeof forkRetainedSession>> | undefined;
814
- try {
815
- // Never wait forever on a previous generation that is still settling
816
- // (e.g. blocked behind the managed repository lane in finalization).
817
- if (!(await quiesced(thread.generationCompletion))) {
818
- if (ownsResumeReservation(runtime, thread, reservation)) thread.state = previousState;
819
- return failedStartResult(
820
- thread.agentName,
821
- thread.task,
822
- `Run #${runId}'s previous generation is still settling; retry the resume shortly.`,
823
- );
824
- }
825
- if (!ownsResumeReservation(runtime, thread, reservation)) {
826
- return failedStartResult(
827
- thread.agentName,
828
- thread.task,
829
- thread.retired
830
- ? `Run #${runId} was retired by subagent_stop; no new generation was started.`
831
- : `Run #${runId} changed while resume was preparing; no new generation was started.`,
832
- );
833
- }
834
- thread.state = "resuming";
835
- const currentCtx = resumeCtx ?? deps.runCtx;
836
- if (!currentCtx) {
837
- throw new Error(`Run #${runId} has no dispatch context for resume.`);
838
- }
839
- let seed: SessionSeed | undefined;
840
- if (thread.isolation === "worktree" && thread.worktree?.state !== "active") {
841
- if (!thread.worktree) throw new Error(`Run #${runId} has no isolated worktree checkpoint.`);
842
- const seedAlreadyIntegrated =
843
- thread.worktree.state === "integrated" ||
844
- thread.worktree.state === "no_changes" ||
845
- thread.lastResult?.integrationApplied === true;
846
- continuationWorktree = await createContinuationWorktree(thread.worktree, seedAlreadyIntegrated);
847
- if (!ownsResumeReservation(runtime, thread, reservation)) {
848
- throw new Error(`Run #${runId} changed while its continuation worktree was being created.`);
849
- }
850
- seed = { worktree: continuationWorktree };
851
- if (previousSessionId && previousSessionDir) {
852
- clonedSession = await forkRetainedSession({
853
- cwd: previousExecutionCwd,
854
- targetCwd: continuationWorktree.cwd,
855
- sessionDir: previousSessionDir,
856
- sessionId: previousSessionId,
857
- targetRoot: sessionsRoot,
858
- });
859
- runtime.sessionDirs.add(clonedSession.sessionDir);
860
- if (!ownsResumeReservation(runtime, thread, reservation)) {
861
- throw new Error(`Run #${runId} changed while its retained session was being cloned.`);
862
- }
863
- seed.sessionId = clonedSession.sessionId;
864
- seed.sessionDir = clonedSession.sessionDir;
865
- }
866
- }
867
-
868
- const currentConfig = await loadConfig(runtime.configPath);
869
- if (!ownsResumeReservation(runtime, thread, reservation)) {
870
- throw new Error(`Run #${runId} changed while resume configuration was loading.`);
871
- }
872
- const currentAgents = discoverAgents(currentCtx.cwd, {
873
- scope: currentConfig.agentScope,
874
- enabledNames: currentConfig.enabledAgents,
875
- projectTrusted: currentCtx.isProjectTrusted?.() === true,
876
- }).agents;
877
- const nextTask = requestedObjective ?? thread.task;
878
- const pending = await startBackground(
879
- thread.agentName,
880
- nextTask,
881
- thread.cwd,
882
- thread.isolation,
883
- {
884
- existingThread: thread,
885
- phaseId: continuationPhaseId,
886
- scope: continuationScope,
887
- appendedObjectiveOnResume: objective !== undefined,
888
- environment: {
889
- ctx: currentCtx,
890
- config: currentConfig,
891
- agents: currentAgents,
892
- },
893
- seed,
894
- resumeReservation: reservation,
895
- },
896
- );
897
- if (pending.exitCode !== -1) {
898
- if (clonedSession) {
899
- await cleanupTrackedSessionDir(
900
- clonedSession.sessionDir,
901
- `Could not discard failed resume session clone for run #${runId}`,
902
- );
903
- }
904
- await discardUnusedWorktree(continuationWorktree);
905
- if (ownsResumeReservation(runtime, thread, reservation)) thread.state = previousState;
906
- return pending;
907
- }
908
-
909
- // The cloned branch replaces the removed-worktree session for this
910
- // logical id. Keep an undeletable old dir in runtime cleanup if needed.
911
- if (clonedSession && previousSessionDir && previousSessionDir !== clonedSession.sessionDir) {
912
- try {
913
- await rm(previousSessionDir, { recursive: true, force: true });
914
- runtime.sessionDirs.delete(previousSessionDir);
915
- } catch {
916
- /* shutdown retries cleanup of the old retained branch */
917
- }
918
- }
919
- return pending;
920
- } catch (error) {
921
- if (clonedSession) {
922
- await cleanupTrackedSessionDir(
923
- clonedSession.sessionDir,
924
- `Could not discard interrupted resume session clone for run #${runId}`,
925
- );
926
- }
927
- await discardUnusedWorktree(continuationWorktree);
928
- if (ownsResumeReservation(runtime, thread, reservation)) {
929
- thread.state = previousState;
930
- thread.sessionId = previousSessionId;
931
- thread.sessionDir = previousSessionDir;
932
- thread.executionCwd = previousExecutionCwd;
933
- }
934
- return failedStartResult(
935
- thread.agentName,
936
- requestedObjective ?? thread.task,
937
- `Could not resume run #${runId}: ${error instanceof Error ? error.message : String(error)}`,
938
- );
939
- } finally {
940
- finishPreflight();
941
- if (thread.lifecycleVersion === reservation.version) thread.admissionScope = undefined;
942
- if (
943
- thread.lifecycleOperation === "resume" &&
944
- thread.lifecycleVersion === reservation.version
945
- ) {
946
- thread.lifecycleOperation = undefined;
947
- }
948
- }
949
- };
950
547
  }