pi-claude-supervisor 0.3.0 → 0.4.1

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/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join, relative, sep } from "node:path";
4
+ import { join } from "node:path";
4
5
  import { realpath } from "node:fs/promises";
5
6
  import { EventLog } from "./events.ts";
6
7
  import { redactSensitive } from "./redaction.ts";
@@ -11,6 +12,7 @@ import { evaluateCommand } from "./policy.ts";
11
12
  import { HumanWebhookNotifier } from "./notifications.ts";
12
13
  import { loadSupervisorEnvironment } from "./config.ts";
13
14
  import { DecisionSessionStore, type DecisionSessionRecord } from "./decision-session-store.ts";
15
+ import { CwdLeaseStore, type CwdLeaseHandle, pathsOverlap, workerIdentity } from "./cwd-lease.ts";
14
16
 
15
17
  /**
16
18
  * Pi Claude Supervisor.
@@ -25,15 +27,23 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
25
27
  const stateDir = process.env.PI_CLAUDE_SUPERVISOR_STATE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor");
26
28
  const configuredTransport = process.env.PI_CLAUDE_SUPERVISOR_TRANSPORT;
27
29
  const transport = configuredTransport ?? (automation ? "jsonl" : "process-pipe");
30
+ const cgroupMode = process.env.PI_CLAUDE_SUPERVISOR_CGROUP_MODE ?? "auto";
28
31
  if (!(["process-pipe", "jsonl", "tmux"] as string[]).includes(transport)) {
29
32
  throw new Error(`Unsupported PI_CLAUDE_SUPERVISOR_TRANSPORT: ${transport}; expected process-pipe, jsonl, or tmux`);
30
33
  }
34
+ if (!["off", "auto", "required"].includes(cgroupMode)) {
35
+ throw new Error(`Unsupported PI_CLAUDE_SUPERVISOR_CGROUP_MODE: ${cgroupMode}; expected off, auto, or required`);
36
+ }
37
+ if (transport === "tmux" && cgroupMode === "required") {
38
+ throw new Error("PI_CLAUDE_SUPERVISOR_CGROUP_MODE=required is unsupported with tmux; use process-pipe/jsonl or set cgroup mode to auto/off");
39
+ }
31
40
  const adapter = transport === "tmux"
32
41
  ? new TmuxWorkerAdapter({ stateDir })
33
42
  : new ProcessWorkerAdapter({
34
43
  // Automatic decisions require Claude's structured event stream. The pipe
35
44
  // transport remains available for manual/compatibility sessions.
36
45
  mode: automation || transport === "jsonl" ? "claude-jsonl" : "process-pipe",
46
+ cgroupMode: cgroupMode as "off" | "auto" | "required",
37
47
  });
38
48
  const humanWebhook = new HumanWebhookNotifier({
39
49
  url: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_URL,
@@ -42,7 +52,10 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
42
52
  });
43
53
  const events = new EventLog(join(stateDir, "events.jsonl"));
44
54
  const decisionStore = new DecisionSessionStore(join(stateDir, "decision-sessions"));
55
+ const cwdLeaseStore = new CwdLeaseStore(process.env.PI_CLAUDE_SUPERVISOR_CWD_LEASE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor", "cwd-leases"));
45
56
  const sessions = new Map<string, Supervisor>();
57
+ const cwdLeases = new Map<string, CwdLeaseHandle>();
58
+ const cleanupRequiredTasks = new Set<string>();
46
59
  const reservedCwds = new Map<string, string>();
47
60
  const pendingCwds = new Set<string>();
48
61
  const pendingStarts = new Set<Promise<void>>();
@@ -50,36 +63,73 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
50
63
  let activeTaskId: string | undefined;
51
64
  let shuttingDown = false;
52
65
  let shutdownPromise: Promise<void> | undefined;
66
+ let detachedLeaseSweepTimer: NodeJS.Timeout | undefined;
53
67
 
54
68
  const notify = (ctx: ExtensionContext, message: string, type: "info" | "warning" = "info") => {
55
69
  if (ctx.hasUI) ctx.ui.notify(redactText(message), type);
56
70
  };
57
71
  const activeSessions = () => [...sessions.entries()].filter(([, session]) =>
58
72
  ["starting", "running", "waiting", "paused"].includes(session.state));
73
+ const releaseLease = async (taskId: string): Promise<boolean> => {
74
+ const lease = cwdLeases.get(taskId);
75
+ if (!lease) return false;
76
+ try {
77
+ await lease.release();
78
+ cwdLeases.delete(taskId);
79
+ return true;
80
+ } catch (error) {
81
+ console.error(`pi-claude-supervisor cwd lease release failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
82
+ return false;
83
+ }
84
+ };
85
+ const forgetSession = (taskId: string): void => {
86
+ sessions.delete(taskId);
87
+ reservedCwds.delete(taskId);
88
+ cleanupRequiredTasks.delete(taskId);
89
+ if (activeTaskId === taskId) activeTaskId = undefined;
90
+ };
59
91
  const releaseSettledReservations = async (): Promise<void> => {
60
92
  for (const [taskId, session] of sessions) {
61
- if (!["completed", "stopped", "failed"].includes(session.state)) continue;
93
+ const terminal = ["completed", "stopped", "failed"].includes(session.state);
94
+ const detached = session.released;
95
+ if (!terminal && !detached) continue;
62
96
  if (!session.handle) {
63
- reservedCwds.delete(taskId);
97
+ if (await releaseLease(taskId)) forgetSession(taskId);
64
98
  continue;
65
99
  }
66
100
  try {
67
101
  const status = await adapter.getStatus(session.handle);
68
- if (!status.running && status.processGroupCleaned === true && !status.cleanupError) reservedCwds.delete(taskId);
102
+ const adoptedDetachConfirmed = detached
103
+ && session.handle.ownership === "adopted"
104
+ && !status.running;
105
+ const cleanupConfirmed = status.processGroupCleaned === true || adoptedDetachConfirmed;
106
+ if (!status.running && cleanupConfirmed && !status.cleanupError && (!status.cgroupError || status.cgroupRequired === false)) {
107
+ if (await releaseLease(taskId)) forgetSession(taskId);
108
+ }
69
109
  } catch {
70
110
  // Keep the reservation when cleanup status cannot be confirmed.
71
111
  }
72
112
  }
73
113
  };
74
- const stopSession = async (session: Supervisor, reason: string): Promise<void> => {
114
+ detachedLeaseSweepTimer = setInterval(() => {
115
+ void releaseSettledReservations().catch((error) => {
116
+ console.error(`pi-claude-supervisor detached lease sweep failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
117
+ });
118
+ }, 1_000);
119
+ detachedLeaseSweepTimer.unref();
120
+ const stopSession = async (session: Supervisor, reason: string, releasePersistent = false): Promise<void> => {
75
121
  const handle = session.handle;
76
122
  const persistent = adapter.capabilities().persistentSession && Boolean(handle);
123
+ const taskId = session.task?.taskId;
77
124
  const adoptedPersistent = persistent && handle?.ownership === "adopted";
78
125
  const healthyPersistent = persistent && !["failed", "completed", "stopped"].includes(session.state);
79
- if (adoptedPersistent || healthyPersistent) {
126
+ const cleanupRequired = taskId ? cleanupRequiredTasks.has(taskId) : false;
127
+ const markCleanupRequired = () => { if (taskId) cleanupRequiredTasks.add(taskId); };
128
+ if (adoptedPersistent || (releasePersistent && healthyPersistent && !cleanupRequired)) {
80
129
  await session.release(reason);
81
130
  return;
82
131
  }
132
+ if (!handle && ["failed", "completed", "stopped"].includes(session.state)) return;
83
133
  let lifecycleError: unknown;
84
134
  try {
85
135
  await session.stop(reason);
@@ -88,7 +138,10 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
88
138
  }
89
139
 
90
140
  if (!handle) {
91
- if (lifecycleError) throw lifecycleError;
141
+ if (lifecycleError) {
142
+ markCleanupRequired();
143
+ throw lifecycleError;
144
+ }
92
145
  return;
93
146
  }
94
147
 
@@ -97,8 +150,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
97
150
  while (Date.now() <= deadline) {
98
151
  try {
99
152
  const status = await adapter.getStatus(handle);
100
- if (!status.running && status.processGroupCleaned === true && !status.cleanupError) {
101
- if (lifecycleError) throw lifecycleError;
153
+ if (!status.running && status.processGroupCleaned === true && !status.cleanupError && (!status.cgroupError || status.cgroupRequired === false)) {
154
+ if (lifecycleError) {
155
+ markCleanupRequired();
156
+ throw lifecycleError;
157
+ }
158
+ if (taskId) cleanupRequiredTasks.delete(taskId);
102
159
  return;
103
160
  }
104
161
  } catch (error) {
@@ -112,6 +169,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
112
169
  await delay(25);
113
170
  }
114
171
 
172
+ markCleanupRequired();
115
173
  if (lifecycleError) throw lifecycleError;
116
174
  throw cleanupError instanceof Error
117
175
  ? cleanupError
@@ -152,6 +210,15 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
152
210
  if (reservedPaths.some((reserved) => pathsOverlap(reserved, cwdKey))) {
153
211
  throw new Error("An active, starting, or unreaped worker uses an overlapping cwd; use a separate worktree for concurrent sessions");
154
212
  }
213
+ const taskId = randomUUID();
214
+ const lease = await cwdLeaseStore.acquire(cwdKey, taskId, adapter.capabilities().transport, tmuxSession
215
+ ? { handoff: { sessionName: tmuxSession, tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET } }
216
+ : {});
217
+ cwdLeases.set(taskId, lease);
218
+ if (shuttingDown) {
219
+ await releaseLease(taskId);
220
+ throw new Error("Pi session is shutting down");
221
+ }
155
222
  pendingCwds.add(cwdKey);
156
223
  const session = new Supervisor(adapter, events, {
157
224
  onHumanRequired: async (notice) => {
@@ -168,11 +235,13 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
168
235
  },
169
236
  });
170
237
  pendingStartSessions.add(session);
238
+ let startupCleanupCompleted = false;
171
239
  const startOperation = (async () => {
172
240
  try {
173
241
  const handle = await session.start({
242
+ taskId,
174
243
  task,
175
- cwd: ctx.cwd,
244
+ cwd: cwdKey,
176
245
  command,
177
246
  args: workerArgs,
178
247
  env: selectedWorkerEnvironment(),
@@ -180,6 +249,14 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
180
249
  automation,
181
250
  tmuxSession,
182
251
  tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET,
252
+ tmuxExpectedIdentity: tmuxSession && lease.record.worker ? {
253
+ pid: lease.record.worker.pid,
254
+ startTime: lease.record.worker.startTime,
255
+ tmuxTarget: lease.record.worker.tmuxTarget,
256
+ tmuxPaneId: lease.record.worker.tmuxPaneId,
257
+ paneStartTime: lease.record.worker.paneStartTime,
258
+ paneCommand: lease.record.worker.paneCommand,
259
+ } : undefined,
183
260
  sendInitialInput: !tmuxSession,
184
261
  decisionSessionDir: decisionStore.directory,
185
262
  onDecisionSessionReady: (info) => decisionStore.save({
@@ -200,37 +277,70 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
200
277
  onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn }),
201
278
  onDecisionSessionClosed: (taskId) => decisionStore.close(taskId),
202
279
  });
203
- const taskId = session.task?.taskId;
204
- if (!taskId) throw new Error("worker started without a task id");
280
+ const startedTaskId = session.task?.taskId;
281
+ if (!startedTaskId) throw new Error("worker started without a task id");
282
+ try {
283
+ await lease.updateWorker({
284
+ transport: adapter.capabilities().transport,
285
+ ...(await workerIdentity(handle)),
286
+ sessionName: handle.sessionName,
287
+ tmuxSocket: handle.tmuxSocket,
288
+ ownership: handle.ownership,
289
+ });
290
+ } catch (error) {
291
+ const registrationError = error instanceof Error ? error : new Error(String(error));
292
+ if (handle.ownership !== "adopted") {
293
+ try {
294
+ await stopSession(session, "cwd lease metadata registration failed");
295
+ startupCleanupCompleted = await releaseLease(taskId);
296
+ } catch (cleanupError) {
297
+ const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
298
+ registrationError.message = `${registrationError.message}; worker cleanup failed: ${message}`;
299
+ Object.defineProperty(registrationError, "workerCleanupRequired", { value: true, enumerable: false });
300
+ }
301
+ }
302
+ throw registrationError;
303
+ }
205
304
  // Register immediately after spawn so shutdown can retry cleanup if
206
305
  // the first stop attempt fails.
207
- sessions.set(taskId, session);
208
- reservedCwds.set(taskId, cwdKey);
209
- activeTaskId = taskId;
306
+ sessions.set(startedTaskId, session);
307
+ reservedCwds.set(startedTaskId, cwdKey);
308
+ activeTaskId = startedTaskId;
210
309
  if (shuttingDown) {
211
310
  try {
212
- await stopSession(session, "Pi session shutdown during worker start");
213
- sessions.delete(taskId);
214
- reservedCwds.delete(taskId);
311
+ await stopSession(session, "Pi session shutdown during worker start", true);
312
+ sessions.delete(startedTaskId);
313
+ reservedCwds.delete(startedTaskId);
215
314
  } finally {
216
315
  if (session.state !== "stopped") {
217
316
  // Keep the session registered for the shutdown retry below.
218
- sessions.set(taskId, session);
219
- reservedCwds.set(taskId, cwdKey);
317
+ sessions.set(startedTaskId, session);
318
+ reservedCwds.set(startedTaskId, cwdKey);
220
319
  }
221
320
  }
222
321
  throw new Error("Pi session shut down during worker start");
223
322
  }
224
323
  const attach = handle.sessionName ? ` attach=${attachCommand(handle)}` : "";
225
- message = `${tmuxSession ? "Tmux worker adopted" : "Worker started"}: task=${taskId} worker=${handle.id} (pid ${handle.pid ?? "unknown"}); transport=${adapter.capabilities().transport}${attach}`;
324
+ message = `${tmuxSession ? "Tmux worker adopted" : "Worker started"}: task=${startedTaskId} worker=${handle.id} (pid ${handle.pid ?? "unknown"}); transport=${adapter.capabilities().transport}${attach}`;
226
325
  } catch (error) {
227
326
  // Register failed starts before the promise settles, so shutdown
228
327
  // cannot snapshot sessions before a returned handle is retained.
229
328
  const failedTaskId = session.task?.taskId;
230
- if (failedTaskId && session.handle) {
329
+ const cleanupRequired = requiresWorkerCleanup(error);
330
+ if (cleanupRequired && failedTaskId) cleanupRequiredTasks.add(failedTaskId);
331
+ const retainHandle = !startupCleanupCompleted && failedTaskId && session.handle
332
+ && (cleanupRequired || session.handle.ownership === "adopted");
333
+ if (retainHandle) {
231
334
  sessions.set(failedTaskId, session);
232
335
  reservedCwds.set(failedTaskId, cwdKey);
233
336
  activeTaskId = failedTaskId;
337
+ } else if (!cleanupRequired && !startupCleanupCompleted) {
338
+ const released = await releaseLease(taskId);
339
+ if (!released && failedTaskId) {
340
+ sessions.set(failedTaskId, session);
341
+ reservedCwds.set(failedTaskId, cwdKey);
342
+ activeTaskId = failedTaskId;
343
+ }
234
344
  }
235
345
  throw error;
236
346
  }
@@ -242,10 +352,21 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
242
352
  // Preserve a failed startup in the registry when the adapter
243
353
  // returned a handle but lifecycle/event setup failed.
244
354
  const failedTaskId = session.task?.taskId;
245
- if (failedTaskId && session.handle) {
355
+ const cleanupRequired = requiresWorkerCleanup(error);
356
+ if (cleanupRequired && failedTaskId) cleanupRequiredTasks.add(failedTaskId);
357
+ const retainHandle = !startupCleanupCompleted && failedTaskId && session.handle
358
+ && (cleanupRequired || session.handle.ownership === "adopted");
359
+ if (retainHandle) {
246
360
  sessions.set(failedTaskId, session);
247
361
  reservedCwds.set(failedTaskId, cwdKey);
248
362
  activeTaskId = failedTaskId;
363
+ } else if (!cleanupRequired && !startupCleanupCompleted) {
364
+ const released = await releaseLease(taskId);
365
+ if (!released && failedTaskId) {
366
+ sessions.set(failedTaskId, session);
367
+ reservedCwds.set(failedTaskId, cwdKey);
368
+ activeTaskId = failedTaskId;
369
+ }
249
370
  }
250
371
  throw error;
251
372
  } finally {
@@ -282,6 +403,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
282
403
  if (!approved) throw new Error("Worker command not approved");
283
404
  approval = { actor: "human", reason: policy.reason };
284
405
  }
406
+ const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport);
407
+ cwdLeases.set(record.taskId, lease);
408
+ if (shuttingDown) {
409
+ await releaseLease(record.taskId);
410
+ throw new Error("Pi session is shutting down");
411
+ }
285
412
  pendingCwds.add(cwdKey);
286
413
  const session = new Supervisor(adapter, events, {
287
414
  onHumanRequired: async (notice) => {
@@ -295,6 +422,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
295
422
  },
296
423
  });
297
424
  pendingStartSessions.add(session);
425
+ let recoveryCleanupCompleted = false;
298
426
  const recoveryOperation = (async () => {
299
427
  try {
300
428
  const handle = await session.start({
@@ -305,7 +433,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
305
433
  task: record.task,
306
434
  initialInput: "",
307
435
  sendInitialInput: false,
308
- cwd: record.cwd,
436
+ cwd: cwdKey,
309
437
  command: record.command,
310
438
  args: record.args,
311
439
  env: selectedWorkerEnvironment(),
@@ -336,20 +464,51 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
336
464
  onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn }),
337
465
  onDecisionSessionClosed: (closedTaskId) => decisionStore.close(closedTaskId),
338
466
  });
467
+ try {
468
+ await lease.updateWorker({
469
+ transport: adapter.capabilities().transport,
470
+ ...(await workerIdentity(handle)),
471
+ sessionName: handle.sessionName,
472
+ tmuxSocket: handle.tmuxSocket,
473
+ ownership: handle.ownership,
474
+ });
475
+ } catch (error) {
476
+ const registrationError = error instanceof Error ? error : new Error(String(error));
477
+ try {
478
+ await stopSession(session, "cwd lease metadata registration failed");
479
+ recoveryCleanupCompleted = await releaseLease(record.taskId);
480
+ } catch (cleanupError) {
481
+ const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
482
+ registrationError.message = `${registrationError.message}; worker cleanup failed: ${message}`;
483
+ Object.defineProperty(registrationError, "workerCleanupRequired", { value: true, enumerable: false });
484
+ }
485
+ throw registrationError;
486
+ }
339
487
  sessions.set(record.taskId, session);
340
488
  reservedCwds.set(record.taskId, cwdKey);
341
489
  activeTaskId = record.taskId;
342
490
  await session.takeover();
343
491
  if (shuttingDown) {
344
- await stopSession(session, "Pi session shutdown during recovery");
492
+ await stopSession(session, "Pi session shutdown during recovery", true);
345
493
  throw new Error("Pi session shut down during recovery");
346
494
  }
347
495
  message = `Worker recovered idle: task=${record.taskId} worker=${handle.id}; original task was not replayed; send an explicit continuation, then use resume-auto`;
348
496
  } catch (error) {
349
- if (session.handle) {
497
+ const cleanupRequired = requiresWorkerCleanup(error);
498
+ if (cleanupRequired) cleanupRequiredTasks.add(record.taskId);
499
+ const retainHandle = !recoveryCleanupCompleted && session.handle
500
+ && (cleanupRequired || session.handle.ownership === "adopted");
501
+ if (retainHandle) {
350
502
  sessions.set(record.taskId, session);
351
503
  reservedCwds.set(record.taskId, cwdKey);
352
504
  activeTaskId = record.taskId;
505
+ } else if (!cleanupRequired && !recoveryCleanupCompleted) {
506
+ const released = await releaseLease(record.taskId);
507
+ if (!released) {
508
+ sessions.set(record.taskId, session);
509
+ reservedCwds.set(record.taskId, cwdKey);
510
+ activeTaskId = record.taskId;
511
+ }
353
512
  }
354
513
  throw error;
355
514
  }
@@ -395,9 +554,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
395
554
  } else if (operation === "resume") {
396
555
  await session.resume(); message = `Worker resumed: ${sessionId}.`;
397
556
  } else if (operation === "stop") {
398
- await session.stop(remaining.join(" ") || "human requested stop"); message = `Worker stopped: ${sessionId}.`;
557
+ await stopSession(session, remaining.join(" ") || "human requested stop");
558
+ await releaseSettledReservations();
559
+ message = `Worker stopped: ${sessionId}.`;
399
560
  } else if (operation === "verify") {
400
561
  const result = await session.verify();
562
+ await releaseSettledReservations();
401
563
  message = `${result.ok ? "Verification passed" : "Verification failed"}: ${result.command}\n${result.output}`.trim();
402
564
  } else if (operation === "approve") {
403
565
  const behavior = remaining[0];
@@ -423,18 +585,28 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
423
585
  if (shutdownPromise) return shutdownPromise;
424
586
  shuttingDown = true;
425
587
  shutdownPromise = (async () => {
588
+ if (detachedLeaseSweepTimer) {
589
+ clearInterval(detachedLeaseSweepTimer);
590
+ detachedLeaseSweepTimer = undefined;
591
+ }
426
592
  const pending = [...pendingStarts];
427
593
  let startupFailures: PromiseRejectedResult[] = [];
428
594
  await Promise.race([Promise.allSettled(pending), delay(5_000)]);
429
595
  if (pendingStarts.size > 0) {
430
596
  const abortResults = await Promise.allSettled([...pendingStartSessions].map((session) => session.abortStart("Pi session shutdown during startup")));
431
597
  startupFailures = abortResults.filter((result): result is PromiseRejectedResult => result.status === "rejected");
432
- // Startup adapters own their cleanup and expose bounded cancellation;
433
- // do not exit while one of those cleanups is still in flight. A model
434
- // provider can still fail to honor disposal, so retain a final bound.
435
- await Promise.race([Promise.allSettled([...pendingStarts]), delay(30_000)]);
598
+ // Do not snapshot sessions or release leases until every startup has
599
+ // settled. Otherwise a provider that resolves after a fixed timeout
600
+ // could register a live handle after shutdown cleanup had completed.
601
+ // Adapters expose bounded cancellation; an uncooperative provider must
602
+ // keep shutdown fail-closed rather than allowing a late worker escape.
603
+ while (pendingStarts.size > 0) {
604
+ const results = await Promise.allSettled([...pendingStarts]);
605
+ startupFailures.push(...results.filter((result): result is PromiseRejectedResult => result.status === "rejected"));
606
+ }
436
607
  }
437
- const results = await Promise.allSettled([...sessions.values()].map((session) => stopSession(session, "Pi session shutdown")));
608
+ const results = await Promise.allSettled([...sessions.values()].map((session) => stopSession(session, "Pi session shutdown", true)));
609
+ await releaseSettledReservations();
438
610
  const failures = [...startupFailures, ...results.filter((result): result is PromiseRejectedResult => result.status === "rejected")];
439
611
  if (failures.length > 0) {
440
612
  for (const failure of failures) console.error(`pi-claude-supervisor shutdown cleanup failed: ${redactText(failure.reason instanceof Error ? failure.reason.message : String(failure.reason))}`);
@@ -477,13 +649,8 @@ async function canonicalCwd(cwd: string): Promise<string> {
477
649
  return realpath(cwd);
478
650
  }
479
651
 
480
- function pathsOverlap(first: string, second: string): boolean {
481
- return isWithin(first, second) || isWithin(second, first);
482
- }
483
-
484
- function isWithin(parent: string, child: string): boolean {
485
- const childRelative = relative(parent, child);
486
- return childRelative === "" || (childRelative !== ".." && !childRelative.startsWith(`..${sep}`) && !isAbsolute(childRelative));
652
+ function requiresWorkerCleanup(error: unknown): boolean {
653
+ return Boolean(error && typeof error === "object" && (error as { workerCleanupRequired?: unknown }).workerCleanupRequired === true);
487
654
  }
488
655
 
489
656
  function formatSessions(sessions: Map<string, Supervisor>, recoverable: DecisionSessionRecord[] = []): string {