@ferris1225/pi-subagents 4.3.4 → 4.3.5

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +59 -66
  3. package/agents/artisan.md +0 -1
  4. package/agents/steward.md +1 -2
  5. package/{src/index.ts → index.ts} +19 -19
  6. package/package.json +4 -3
  7. package/src/{config.ts → configuration/config.ts} +19 -24
  8. package/src/configuration/setup.ts +375 -0
  9. package/src/configuration/ui.ts +245 -0
  10. package/src/{agents.ts → delegation/agents.ts} +3 -3
  11. package/src/{dispatch.ts → delegation/dispatch.ts} +12 -18
  12. package/src/{prompt.ts → delegation/prompt.ts} +3 -8
  13. package/src/{background.ts → execution/background.ts} +3 -6
  14. package/src/execution/rpc-control.ts +200 -0
  15. package/src/{rpc-run.ts → execution/rpc-run.ts} +11 -199
  16. package/src/{session-fork.ts → execution/session-fork.ts} +1 -1
  17. package/src/{spawn.ts → execution/spawn.ts} +10 -8
  18. package/src/isolation/git-command.ts +147 -0
  19. package/src/{recovery.ts → isolation/recovery.ts} +1 -1
  20. package/src/{worktree.ts → isolation/worktree.ts} +10 -147
  21. package/src/{completion.ts → lifecycle/completion.ts} +2 -2
  22. package/src/{durable.ts → lifecycle/durable.ts} +3 -3
  23. package/src/{runtime.ts → lifecycle/runtime.ts} +7 -7
  24. package/src/{thread-lifecycle.ts → lifecycle/thread-lifecycle.ts} +25 -519
  25. package/src/lifecycle/thread-restore.ts +250 -0
  26. package/src/lifecycle/thread-shared.ts +269 -0
  27. package/src/{tools.ts → lifecycle/tools.ts} +8 -8
  28. package/src/{announcements.ts → presentation/announcements.ts} +4 -4
  29. package/src/{format.ts → presentation/format.ts} +3 -3
  30. package/src/{monitor.ts → presentation/monitor.ts} +2 -2
  31. package/src/{widget.ts → presentation/widget.ts} +1 -1
  32. package/agents/sentinel.md +0 -16
  33. package/src/setup.ts +0 -344
  34. package/src/ui.ts +0 -160
  35. /package/src/{models.ts → configuration/models.ts} +0 -0
  36. /package/src/{temp-hygiene.ts → isolation/temp-hygiene.ts} +0 -0
  37. /package/src/{status.ts → presentation/status.ts} +0 -0
@@ -10,330 +10,62 @@
10
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { existsSync } from "node:fs";
12
12
  import { rm } from "node:fs/promises";
13
- import { realpath } from "node:fs/promises";
14
13
  import { join, resolve } from "node:path";
15
14
  import {
16
15
  discoverAgents,
17
16
  isWriteCapableAgent,
18
17
  resolveAgentTools,
19
18
  type AgentConfig,
20
- } from "./agents.ts";
19
+ } from "../delegation/agents.ts";
21
20
  import { type CompletionMessageItem } from "./completion.ts";
22
- import {
23
- configuredModelForAgent,
24
- loadConfig,
25
- roleThinkingLevel,
26
- type SubagentsConfig,
27
- type ThinkingLevel,
28
- } from "./config.ts";
29
- import {
30
- isCurrentBoot,
31
- readThreadRecords,
32
- referencedDurablePaths,
33
- removeThreadRecord,
34
- pruneStaleProjectRoots,
35
- pruneThreadRecords,
36
- restoredResultFromSummary,
37
- threadRecordFromThread,
38
- type ThreadRecord,
39
- upsertThreadRecord,
40
- } from "./durable.ts";
21
+ import { loadConfig } from "../configuration/config.ts";
41
22
  import {
42
23
  dispatchFailedResult,
43
24
  failedStartResult,
44
25
  formatCompletionBlock,
45
26
  modelLevelTakeoverNote,
46
27
  queuedResult,
47
- } from "./format.ts";
48
- import {
49
- availableModelsInScope,
50
- currentModelRef,
51
- findModelByRef,
52
- modelRef,
53
- resolveAgentModelRoute,
54
- resolveThinkingLevel,
55
- } from "./models.ts";
56
- import { monitor } from "./monitor.ts";
57
- import { findDuplicateActiveDispatch } from "./prompt.ts";
58
- import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
59
- import { emptyUsage } from "./rpc-run.ts";
28
+ } from "../presentation/format.ts";
29
+ import { monitor } from "../presentation/monitor.ts";
30
+ import { findDuplicateActiveDispatch } from "../delegation/prompt.ts";
31
+ import { persistRecoveryRecords, recoveryRecordFromFinalization } from "../isolation/recovery.ts";
60
32
  import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
61
- import { forkRetainedSession } from "./session-fork.ts";
33
+ import { forkRetainedSession } from "../execution/session-fork.ts";
62
34
  import {
63
35
  buildResumePrompt,
64
36
  getProjectRoot,
65
- getSubagentsRoot,
66
37
  RpcRunControl,
67
38
  isFailedResult,
68
39
  isModelLevelFailure,
69
40
  runSingleAgentWithMainFallback,
70
- sessionExists,
71
- sweepProjectResultArtifacts,
72
41
  type SingleResult,
73
42
  type SubagentDetails,
74
43
  type SubagentLiveEvent,
75
- } from "./spawn.ts";
44
+ } from "../execution/spawn.ts";
76
45
  import {
77
- isProcessAlive,
78
- killProcessTree,
79
- sweepProjectDurableDirs,
80
- sweepProjectTempDirs,
81
- } from "./temp-hygiene.ts";
46
+ beginRuntimePreflight,
47
+ isWorktreeCapableAgent,
48
+ ownsResumeReservation,
49
+ persistThreadCheckpoint,
50
+ projectResultsRoot,
51
+ quiesced,
52
+ resolveDispatchModelRoute,
53
+ runInManagedRepositoryLane,
54
+ withWorktreeSystemPrompt,
55
+ type DispatchEnvironment,
56
+ type ResumeReservation,
57
+ type SessionSeed,
58
+ type StartBackgroundInternal,
59
+ type StartBackgroundOptions,
60
+ type ThreadLifecycleDeps,
61
+ } from "./thread-shared.ts";
82
62
  import {
83
63
  createWorktreeIsolation,
84
- isPathInside,
85
- resolveRepositoryRoot,
86
- restoreWorktreeIsolation,
87
64
  worktreeGroupId,
88
65
  type IsolationMode,
89
66
  type WorktreeFinalization,
90
67
  type WorktreeIsolation,
91
- } from "./worktree.ts";
92
-
93
- /** Control operations must never wait forever on a settling generation: the
94
- * queue task can legitimately spend minutes in worktree finalization (bounded
95
- * per-Git-command timeouts) or wait behind the managed repository lane. After
96
- * this deadline the control path owns the lifecycle synchronously and proceeds
97
- * while the stuck tail settles silently in the background. */
98
- export const CONTROL_QUIESCE_TIMEOUT_MS = 20_000;
99
-
100
- /** Resolve true when the promise settles, or false after the bounded deadline. */
101
- export function quiesced(promise: Promise<unknown>, timeoutMs: number = CONTROL_QUIESCE_TIMEOUT_MS): Promise<boolean> {
102
- return Promise.race([
103
- promise.then(() => true, () => true),
104
- new Promise<boolean>((resolve) => {
105
- const timer = setTimeout(() => resolve(false), timeoutMs);
106
- if (typeof timer.unref === "function") timer.unref();
107
- }),
108
- ]);
109
- }
110
-
111
- const managedRepositoryRootTails = new Map<string, Promise<void>>();
112
-
113
- async function canonicalManagedRepositoryRoot(cwd: string): Promise<string> {
114
- try {
115
- // Repository identity does not depend on HEAD: empty repositories must
116
- // serialize root and nested cwd requests under the same lane too.
117
- return await resolveRepositoryRoot(cwd);
118
- } catch {
119
- try {
120
- return await realpath(resolve(cwd));
121
- } catch {
122
- return resolve(cwd);
123
- }
124
- }
125
- }
126
-
127
- /** Run one operation under the canonical original-repository lane.
128
- *
129
- * Shared write-capable generations use the abortable overload for their whole
130
- * run. Isolated generations use the non-abortable overload only for their
131
- * final worktree apply, so model work remains parallel while the original
132
- * checkout mutation cannot race a shared writer.
133
- */
134
- export async function runInManagedRepositoryLane<T>(
135
- cwd: string,
136
- task: () => Promise<T>,
137
- ): Promise<T>;
138
- export async function runInManagedRepositoryLane<T>(
139
- cwd: string,
140
- task: () => Promise<T>,
141
- signal: AbortSignal,
142
- ): Promise<T | undefined>;
143
- export async function runInManagedRepositoryLane<T>(
144
- cwd: string,
145
- task: () => Promise<T>,
146
- signal?: AbortSignal,
147
- ): Promise<T | undefined> {
148
- if (signal?.aborted) return undefined;
149
- const root = await canonicalManagedRepositoryRoot(cwd);
150
- const key = process.platform === "win32" ? root.toLowerCase() : root;
151
- const previous = managedRepositoryRootTails.get(key) ?? Promise.resolve();
152
- let release!: () => void;
153
- const gate = new Promise<void>((resolveGate) => {
154
- release = resolveGate;
155
- });
156
- const tail = previous.catch(() => undefined).then(() => gate);
157
- managedRepositoryRootTails.set(key, tail);
158
- let onAbort: (() => void) | undefined;
159
- try {
160
- if (signal) {
161
- await Promise.race([
162
- previous.catch(() => undefined),
163
- new Promise<void>((resolveAborted) => {
164
- if (signal.aborted) resolveAborted();
165
- else {
166
- onAbort = resolveAborted;
167
- signal.addEventListener("abort", onAbort, { once: true });
168
- }
169
- }),
170
- ]);
171
- } else {
172
- await previous.catch(() => undefined);
173
- }
174
- if (signal?.aborted) return undefined;
175
- return await task();
176
- } finally {
177
- if (signal && onAbort) signal.removeEventListener("abort", onAbort);
178
- release();
179
- // An aborted waiter may finish before the prior owner. Keep its chained
180
- // tail installed until that owner also settles, otherwise a newcomer could
181
- // observe an empty map and race the still-running workflow.
182
- void tail.then(() => {
183
- if (managedRepositoryRootTails.get(key) === tail) managedRepositoryRootTails.delete(key);
184
- });
185
- }
186
- }
187
-
188
- /** Track resume setup that has claimed a thread but has not yet enqueued
189
- * its next generation. Shutdown invalidates these claims and waits for cleanup. */
190
- export function beginRuntimePreflight(runtime: SubagentRuntime): () => void {
191
- let resolvePreflight!: () => void;
192
- const preflight = new Promise<void>((resolve) => {
193
- resolvePreflight = resolve;
194
- });
195
- runtime.preflightOperations.add(preflight);
196
- return () => {
197
- runtime.preflightOperations.delete(preflight);
198
- resolvePreflight();
199
- };
200
- }
201
-
202
- /** Synchronous CAS used by lifecycle controls across their async preflight. */
203
- export function ownsResumeReservation(
204
- runtime: SubagentRuntime,
205
- thread: SubagentThread,
206
- reservation: { version: number; generation: number; sessionId?: string; sessionDir?: string },
207
- ): boolean {
208
- return (
209
- runtime.sessionActive &&
210
- runtime.threads.get(thread.id) === thread &&
211
- !thread.retired &&
212
- thread.lifecycleOperation === "resume" &&
213
- thread.lifecycleVersion === reservation.version &&
214
- thread.generation === reservation.generation &&
215
- thread.sessionId === reservation.sessionId &&
216
- thread.sessionDir === reservation.sessionDir
217
- );
218
- }
219
-
220
- /** Fire-and-forget durable checkpoint. Parked threads stay resumable across
221
- * reloads; a settled thread drops its record so the manifest only exists
222
- * while unfinished work needs it. The live session keeps working when the
223
- * manifest is unwritable; only cross-reload resume is degraded. */
224
- export function persistThreadCheckpoint(
225
- runtime: SubagentRuntime,
226
- thread: SubagentThread,
227
- state: "parked" | "completed" | "failed",
228
- ): void {
229
- const write = state === "parked"
230
- ? upsertThreadRecord(runtime.configPath, threadRecordFromThread(thread, state))
231
- : removeThreadRecord(runtime.configPath, thread.id, thread.cwd);
232
- void write.catch(() => undefined);
233
- }
234
-
235
- const WORKTREE_ISOLATION_INSTRUCTIONS =
236
- "You are running in a temporary detached Git worktree. Work only in the current cwd; do not create another worktree or manually copy/apply changes to the original checkout. The parent dispatcher will integrate your tracked, deleted, and untracked changes when this thread finally settles.";
237
-
238
- export function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
239
- return {
240
- ...agent,
241
- systemPrompt: `${agent.systemPrompt.trimEnd()}\n\n${WORKTREE_ISOLATION_INSTRUCTIONS}`.trim(),
242
- };
243
- }
244
-
245
- /** Sentinel must inspect the caller's uncommitted diff, never a detached copy. */
246
- export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
247
- return agent.name !== "sentinel" && isWriteCapableAgent(agent);
248
- }
249
-
250
- export interface DispatchEnvironment {
251
- ctx: ExtensionContext;
252
- config: SubagentsConfig;
253
- agents: AgentConfig[];
254
- }
255
-
256
- export interface SessionSeed {
257
- sessionId?: string;
258
- sessionDir?: string;
259
- prompt?: string;
260
- worktree?: WorktreeIsolation;
261
- }
262
-
263
- export interface ResumeReservation {
264
- version: number;
265
- generation: number;
266
- sessionId?: string;
267
- sessionDir?: string;
268
- }
269
-
270
- /** The dispatcher's full internal entry point; the public tool surface only
271
- * uses the first four parameters. */
272
- export interface StartBackgroundOptions {
273
- /** Resume path only: the thread whose retained context continues. */
274
- existingThread?: SubagentThread;
275
- appendedObjectiveOnResume?: boolean;
276
- environment?: DispatchEnvironment;
277
- seed?: SessionSeed;
278
- resumeReservation?: ResumeReservation;
279
- /** Chosen by the tool call before the queue can start a fast child. */
280
- deliveryRoute?: "background" | "await";
281
- }
282
-
283
- export type StartBackgroundInternal = (
284
- agentName: string,
285
- task: string,
286
- cwd: string | undefined,
287
- isolation?: IsolationMode,
288
- options?: StartBackgroundOptions,
289
- ) => Promise<SingleResult>;
290
-
291
- export interface ThreadLifecycleDeps {
292
- runtime: SubagentRuntime;
293
- /** Fallback context when a control caller supplies none; restored threads
294
- * install without one and rely on the per-call context. */
295
- runCtx?: ExtensionContext;
296
- /** Fresh dispatch passes the live dispatcher; restored threads resolve it
297
- * from the runtime at call time so they never pin a stale closure. */
298
- startBackground: StartBackgroundInternal;
299
- }
300
-
301
- interface DispatchModelRoute {
302
- agent: AgentConfig;
303
- mainFallbackRef?: string;
304
- thinkingLevel: ThinkingLevel;
305
- thinkingLevelForModel: (ref?: string) => ThinkingLevel;
306
- }
307
-
308
- export function resolveDispatchModelRoute(
309
- agent: AgentConfig,
310
- config: SubagentsConfig,
311
- ctx: ExtensionContext,
312
- ): DispatchModelRoute {
313
- const availableModels = availableModelsInScope(ctx);
314
- const mainRef = currentModelRef(ctx);
315
- const route = resolveAgentModelRoute({
316
- selectedRef: configuredModelForAgent(config.agentModels, agent.name),
317
- mainRef,
318
- availableRefs: availableModels.map(modelRef),
319
- });
320
- // A `/subagents-setup` override wins; otherwise the role default. No
321
- // per-call or frontmatter thinking.
322
- const preferred =
323
- config.agentThinkingLevels[agent.name] ?? roleThinkingLevel(agent.name);
324
- const thinkingLevelForModel = (ref?: string): ThinkingLevel => {
325
- const model = ref === mainRef && ctx.model
326
- ? ctx.model
327
- : findModelByRef(availableModels, ref);
328
- return resolveThinkingLevel(model, preferred);
329
- };
330
- return {
331
- agent: { ...agent, model: route.primaryRef },
332
- mainFallbackRef: route.mainFallbackRef,
333
- thinkingLevel: thinkingLevelForModel(route.primaryRef),
334
- thinkingLevelForModel,
335
- };
336
- }
68
+ } from "../isolation/worktree.ts";
337
69
 
338
70
  interface BackgroundDispatcherOptions {
339
71
  runtime: SubagentRuntime;
@@ -973,10 +705,6 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
973
705
  });
974
706
  };
975
707
 
976
- const persistElapsedTime = (): void => {
977
- thread.elapsedMs = monitor.getElapsedMs(runId) ?? thread.elapsedMs;
978
- };
979
-
980
708
  thread.resume = async (objective?: string, resumeCtx?: ExtensionContext): Promise<SingleResult> => {
981
709
  const requestedObjective = objective?.trim();
982
710
  if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
@@ -1151,225 +879,3 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
1151
879
  }
1152
880
  };
1153
881
  }
1154
-
1155
- /** Dropped restored record: no session means no context to resume, so its
1156
- * artifacts go away with the record. */
1157
- async function discardRestoredRecord(runtime: SubagentRuntime, record: ThreadRecord): Promise<void> {
1158
- if (record.sessionDir) {
1159
- await rm(record.sessionDir, { recursive: true, force: true }).catch(() => undefined);
1160
- runtime.sessionDirs.delete(record.sessionDir);
1161
- }
1162
- if (record.worktree && (record.worktree.state === "active" || record.worktree.state === "retained")) {
1163
- const worktree = await restoreWorktreeIsolation(record.worktree).catch(() => undefined);
1164
- await worktree?.discard().catch(() => undefined);
1165
- }
1166
- await removeThreadRecord(runtime.configPath, record.runId, record.cwd).catch(() => undefined);
1167
- }
1168
-
1169
- /** Project-scoped <projectRoot>/results for a completion's artifacts. */
1170
- export function projectResultsRoot(configPath: string, cwd: string | undefined): string {
1171
- return join(getProjectRoot(configPath, cwd), "results");
1172
- }
1173
-
1174
- function createRestoredThread(
1175
- runtime: SubagentRuntime,
1176
- record: ThreadRecord,
1177
- worktree: WorktreeIsolation | undefined,
1178
- state: ThreadState,
1179
- ): SubagentThread {
1180
- const thread: SubagentThread = {
1181
- id: record.runId,
1182
- generation: record.generation,
1183
- agentName: record.agentName,
1184
- task: record.task,
1185
- cwd: record.cwd,
1186
- executionCwd: record.executionCwd,
1187
- ...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel as ThinkingLevel } : {}),
1188
- isolation: record.isolation,
1189
- worktree,
1190
- state,
1191
- control: new RpcRunControl(record.task, record.generation),
1192
- generationCompletion: Promise.resolve(),
1193
- lifecycleVersion: 0,
1194
- elapsedMs: record.elapsedMs,
1195
- sessionId: record.sessionId,
1196
- sessionDir: record.sessionDir,
1197
- lastResult: restoredResultFromSummary(record),
1198
- resume: async () => failedStartResult(record.agentName, record.task, "Thread resume was not initialized."),
1199
- finalizeIsolation: async () => undefined,
1200
- };
1201
- installThreadLifecycle(thread, {
1202
- runtime,
1203
- startBackground: (...args) => {
1204
- const dispatcher = runtime.dispatcher;
1205
- if (!dispatcher) {
1206
- return Promise.resolve(failedStartResult(
1207
- record.agentName,
1208
- record.task,
1209
- `Run #${record.runId} cannot continue: no dispatch context is available yet. Dispatch any subagent once, then retry.`,
1210
- ));
1211
- }
1212
- return dispatcher(...args);
1213
- },
1214
- });
1215
- return thread;
1216
- }
1217
-
1218
- /** Rebuild interrupted (parked) threads from the durable manifest after a
1219
- * reload or restart. Orphaned children recorded by the previous process are
1220
- * killed first; records whose retained session vanished — and settled records
1221
- * left by older versions, which hold no work worth resuming — drop out with
1222
- * their artifacts. Returns the restored run ids. */
1223
- export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<number[]> {
1224
- const records = await readThreadRecords(runtime.configPath);
1225
- const restoredIds: number[] = [];
1226
- for (const record of records) {
1227
- if (runtime.threads.has(record.runId) || monitor.findRun(record.runId)) continue;
1228
- if (record.state !== "parked") {
1229
- await discardRestoredRecord(runtime, record);
1230
- continue;
1231
- }
1232
- // A child orphaned by reload/crash may still hold the retained session.
1233
- // The on-disk session checkpoint is what survives; kill the writer — but
1234
- // only while the recorded pids are still ours. Across a reboot the same
1235
- // numbers belong to unrelated processes.
1236
- if (isCurrentBoot(record)) {
1237
- for (const pid of record.childPids) {
1238
- if (isProcessAlive(pid)) killProcessTree(pid);
1239
- }
1240
- }
1241
- const sessionValid =
1242
- record.sessionId !== undefined &&
1243
- record.sessionDir !== undefined &&
1244
- sessionExists(record.sessionDir, record.sessionId);
1245
- if (!sessionValid) {
1246
- await discardRestoredRecord(runtime, record);
1247
- continue;
1248
- }
1249
- const worktree = record.worktree
1250
- ? await restoreWorktreeIsolation(record.worktree).catch(() => undefined)
1251
- : undefined;
1252
- const restorationFailed = record.isolation === "worktree" && record.worktree !== undefined && !worktree;
1253
- const thread = createRestoredThread(runtime, record, worktree, restorationFailed ? "failed" : "parked");
1254
- runtime.threads.set(record.runId, thread);
1255
- runtime.sessionDirs.add(record.sessionDir!);
1256
- if (restorationFailed) {
1257
- const reason = `Run #${record.runId}'s recorded worktree could not be restored; isolated edits may be unavailable. The retained session and durable record were kept, but this thread cannot be resumed.`;
1258
- thread.resumeUnavailableReason = reason;
1259
- thread.restorationRecord = record;
1260
- const previous = restoredResultFromSummary(record);
1261
- const failed: SingleResult = {
1262
- agent: record.agentName,
1263
- task: record.task,
1264
- exitCode: 1,
1265
- messages: previous?.messages ?? [],
1266
- stderr: reason,
1267
- usage: previous?.usage ?? emptyUsage(),
1268
- model: previous?.model,
1269
- thinking: previous?.thinking,
1270
- stopReason: "error",
1271
- errorMessage: reason,
1272
- dispatchFailed: true,
1273
- sessionId: record.sessionId,
1274
- sessionDir: record.sessionDir,
1275
- projectCwd: record.cwd,
1276
- runId: record.runId,
1277
- isolation: "worktree",
1278
- integrationStatus: "retained",
1279
- };
1280
- thread.lastResult = failed;
1281
- runtime.registerRunResult(record.runId, failed);
1282
- monitor.restoreRun({
1283
- id: record.runId,
1284
- agent: record.agentName,
1285
- task: record.task,
1286
- status: "failed",
1287
- elapsedMs: record.elapsedMs,
1288
- isolation: "worktree",
1289
- integrationStatus: "retained",
1290
- });
1291
- runtime.claimRunDelivery(record.runId, "background");
1292
- runtime.publishRunCompletion(record.runId, {
1293
- agent: record.agentName,
1294
- block: `### Subagent restoration failed: #${record.runId} ${record.agentName}\n\n${reason}`,
1295
- usage: failed.usage,
1296
- }, true);
1297
- continue;
1298
- }
1299
- monitor.restoreRun({
1300
- id: record.runId,
1301
- agent: record.agentName,
1302
- task: record.task,
1303
- status: "parked",
1304
- elapsedMs: record.elapsedMs,
1305
- isolation: record.isolation,
1306
- ...(record.worktree
1307
- ? {
1308
- integrationStatus: record.worktree.state === "active"
1309
- ? ("pending" as const)
1310
- : record.worktree.state,
1311
- ...(worktree ? { worktreeId: worktreeGroupId(worktree) } : {}),
1312
- }
1313
- : {}),
1314
- });
1315
- restoredIds.push(record.runId);
1316
- }
1317
- return restoredIds;
1318
- }
1319
-
1320
- /** Session-start durable bootstrap: restore threads, age out expired records, and
1321
- * sweep leaked temp/state directories. Every stage is best-effort so a broken
1322
- * manifest never blocks the session.
1323
- *
1324
- * Restore is published on the runtime as `durableRestore` before this returns,
1325
- * so callers that must see restored threads await that pass alone and never the
1326
- * hygiene sweeps behind it. Hygiene still runs after restore: pruning decides
1327
- * what to delete from the records restore has already claimed. */
1328
- export function bootstrapDurableState(runtime: SubagentRuntime): Promise<void> {
1329
- const restore = (async () => {
1330
- try {
1331
- runtime.restoredRunIds = await restoreDurableThreads(runtime);
1332
- } catch {
1333
- /* restore is best-effort */
1334
- }
1335
- })();
1336
- runtime.durableRestore = restore;
1337
- return (async () => {
1338
- await restore;
1339
- try {
1340
- await pruneThreadRecords(runtime.configPath);
1341
- } catch {
1342
- /* retention is best-effort */
1343
- }
1344
- const projectRoots = getSubagentsRoot(runtime.configPath);
1345
- try {
1346
- sweepProjectTempDirs(projectRoots);
1347
- } catch {
1348
- /* temp hygiene is best-effort */
1349
- }
1350
- try {
1351
- // Sessions and worktrees outlive their process on purpose, so only
1352
- // ownership separates state a live pi still resumes from state a crash
1353
- // abandoned. Parked work is claimed by the manifest and always kept.
1354
- const records = await readThreadRecords(runtime.configPath);
1355
- const referenced = [...referencedDurablePaths(records)];
1356
- sweepProjectDurableDirs(projectRoots, {
1357
- keep: (path) => referenced.some((claimed) => isPathInside(path, claimed)),
1358
- });
1359
- } catch {
1360
- /* durable-state hygiene is best-effort */
1361
- }
1362
- try {
1363
- // Result excerpts are bounded on write, which never reaches a project
1364
- // that has stopped producing them.
1365
- sweepProjectResultArtifacts(projectRoots);
1366
- } catch {
1367
- /* result retention is best-effort */
1368
- }
1369
- try {
1370
- await pruneStaleProjectRoots(runtime.configPath);
1371
- } catch {
1372
- /* project-root hygiene is best-effort */
1373
- }
1374
- })();
1375
- }