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/CHANGELOG.md +21 -2
- package/README.cn.md +6 -4
- package/README.md +11 -5
- package/docs/architecture.md +42 -7
- package/docs/releasing.md +2 -1
- package/docs/testing.md +35 -14
- package/docs/transport-spike-2026-09-12.md +8 -8
- package/package.json +3 -1
- package/src/config.ts +2 -0
- package/src/cwd-lease.ts +345 -0
- package/src/index.ts +206 -39
- package/src/supervisor.ts +105 -22
- package/src/types.ts +22 -1
- package/src/worker/process-adapter.ts +98 -26
- package/src/worker/tmux-adapter.ts +73 -30
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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:
|
|
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
|
|
204
|
-
if (!
|
|
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(
|
|
208
|
-
reservedCwds.set(
|
|
209
|
-
activeTaskId =
|
|
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(
|
|
214
|
-
reservedCwds.delete(
|
|
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(
|
|
219
|
-
reservedCwds.set(
|
|
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=${
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
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
|
|
481
|
-
return
|
|
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 {
|