@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.
- package/CHANGELOG.md +41 -0
- package/README.md +165 -117
- package/index.ts +2 -0
- package/package.json +12 -10
- package/src/configuration/setup.ts +16 -14
- package/src/delegation/agents.ts +2 -1
- package/src/delegation/dispatch.ts +189 -38
- package/src/delegation/phase-scope.ts +178 -0
- package/src/delegation/prompt.ts +46 -36
- package/src/delegation/risk.ts +168 -0
- package/src/execution/rpc-control.ts +2 -29
- package/src/execution/rpc-run.ts +29 -30
- package/src/execution/spawn.ts +21 -20
- package/src/isolation/temp-hygiene.ts +7 -9
- package/src/isolation/worktree.ts +13 -88
- package/src/lifecycle/durable.ts +24 -8
- package/src/lifecycle/runtime.ts +27 -63
- package/src/lifecycle/thread-lifecycle.ts +67 -414
- package/src/lifecycle/thread-restore.ts +16 -35
- package/src/lifecycle/thread-shared.ts +9 -63
- package/src/lifecycle/tools.ts +86 -241
- package/src/presentation/announcements.ts +1 -1
- package/src/presentation/format.ts +6 -16
- package/src/presentation/monitor.ts +0 -91
- package/src/presentation/widget.ts +7 -17
- package/src/execution/session-fork.ts +0 -86
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 =
|
|
88
|
+
const baseEnvironment = getEnvironment();
|
|
122
89
|
const runCtx = baseEnvironment.ctx;
|
|
123
90
|
const runConfig = baseEnvironment.config;
|
|
124
|
-
const
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
162
|
-
let
|
|
163
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
const
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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" &&
|
|
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
|
-
//
|
|
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
|
-
|
|
389
|
-
|
|
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
|
|
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
|
|
381
|
+
// isolated worktree. Do not expose a
|
|
487
382
|
// terminal monitor state before that owner records its outcome.
|
|
488
|
-
if (thread.lifecycleOperation === "stop"
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
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
|
}
|