pi-claude-supervisor 0.2.2 → 0.4.0
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 +22 -2
- package/README.cn.md +36 -3
- package/README.md +43 -0
- package/docs/architecture.md +72 -2
- package/docs/releasing.md +2 -1
- package/docs/testing.md +31 -6
- package/docs/transport-spike-2026-09-12.md +8 -8
- package/package.json +2 -1
- package/src/config.ts +5 -1
- package/src/cwd-lease.ts +345 -0
- package/src/decision-session-store.ts +38 -8
- package/src/decision-worker.ts +51 -15
- package/src/events.ts +2 -16
- package/src/index.ts +233 -58
- package/src/notifications.ts +15 -16
- package/src/redaction.ts +20 -0
- package/src/supervisor.ts +234 -21
- package/src/types.ts +37 -1
- package/src/worker/process-adapter.ts +101 -16
- package/src/worker/tmux-adapter.ts +1053 -0
|
@@ -29,6 +29,8 @@ export interface ProcessWorkerAdapterOptions {
|
|
|
29
29
|
inputWriteTimeoutMs?: number;
|
|
30
30
|
/** Linux descendant cleanup mode; auto uses cgroup v2 when available. */
|
|
31
31
|
cgroupMode?: "off" | "auto" | "required";
|
|
32
|
+
/** Override the detected cgroup parent for controlled integration tests. */
|
|
33
|
+
cgroupParentPath?: string;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
interface ProcessRecord {
|
|
@@ -61,6 +63,10 @@ interface ProcessRecord {
|
|
|
61
63
|
listeners: Set<WorkerEventListener>;
|
|
62
64
|
permissionResponses: Set<string>;
|
|
63
65
|
stopping?: boolean;
|
|
66
|
+
starting: boolean;
|
|
67
|
+
abortRequested: boolean;
|
|
68
|
+
startupToken?: string;
|
|
69
|
+
abortListener?: () => void;
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
/**
|
|
@@ -79,6 +85,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
79
85
|
readonly #maxOutputBytes: number;
|
|
80
86
|
readonly #inputWriteTimeoutMs: number;
|
|
81
87
|
readonly #cgroupMode: "off" | "auto" | "required";
|
|
88
|
+
readonly #cgroupParentPath?: string;
|
|
82
89
|
|
|
83
90
|
constructor(options: ProcessWorkerAdapterOptions = {}) {
|
|
84
91
|
this.#mode = options.mode ?? "process-pipe";
|
|
@@ -88,6 +95,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
88
95
|
this.#maxOutputBytes = boundedPositiveInteger(options.maxOutputBytes ?? 8 * 1024 * 1024, "maxOutputBytes");
|
|
89
96
|
this.#inputWriteTimeoutMs = boundedDelay(options.inputWriteTimeoutMs ?? 10_000);
|
|
90
97
|
this.#cgroupMode = options.cgroupMode ?? "auto";
|
|
98
|
+
this.#cgroupParentPath = options.cgroupParentPath;
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
capabilities(): WorkerCapabilities {
|
|
@@ -101,6 +109,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
async start(input: WorkerStartInput): Promise<WorkerHandle> {
|
|
112
|
+
if (input.abortSignal?.aborted) throw new Error("worker startup aborted before spawn");
|
|
104
113
|
const args = this.#mode === "claude-jsonl" ? claudeJsonlArgs(input.args) : (input.args ?? []);
|
|
105
114
|
assertSafeWorkerCommand(input.command, args, input.approval);
|
|
106
115
|
const handle: WorkerHandle = {
|
|
@@ -141,8 +150,21 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
141
150
|
inputTail: Promise.resolve(),
|
|
142
151
|
listeners: new Set(input.eventListener ? [input.eventListener] : []),
|
|
143
152
|
permissionResponses: new Set(),
|
|
153
|
+
starting: true,
|
|
154
|
+
abortRequested: false,
|
|
155
|
+
startupToken: input.startupToken,
|
|
144
156
|
};
|
|
157
|
+
const abortListener = () => {
|
|
158
|
+
record.abortRequested = true;
|
|
159
|
+
record.stopping = true;
|
|
160
|
+
if (record.exitCode === undefined) {
|
|
161
|
+
try { record.child.kill("SIGTERM"); } catch { /* cleanup below remains authoritative */ }
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
record.abortListener = abortListener;
|
|
165
|
+
input.abortSignal?.addEventListener("abort", abortListener, { once: true });
|
|
145
166
|
this.#records.set(handle.id, record);
|
|
167
|
+
if (input.abortSignal?.aborted) abortListener();
|
|
146
168
|
const capture = (stream: "stdout" | "stderr") => (chunk: Buffer | string) => {
|
|
147
169
|
const text = String(chunk);
|
|
148
170
|
record.lastOutputAt = new Date().toISOString();
|
|
@@ -187,24 +209,69 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
187
209
|
});
|
|
188
210
|
});
|
|
189
211
|
try {
|
|
190
|
-
await record.spawned;
|
|
191
|
-
await this.#attachCgroup(record);
|
|
192
|
-
} catch (error) {
|
|
193
|
-
try { await this.#ensureGroupCleanup(record); } catch (cleanupError) { record.cleanupError = cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)); }
|
|
194
|
-
const startupError = error instanceof Error ? error : new Error(String(error));
|
|
195
|
-
Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
|
|
196
|
-
throw startupError;
|
|
197
|
-
}
|
|
198
|
-
if (input.task) {
|
|
199
|
-
record.lastInputAt = new Date().toISOString();
|
|
200
212
|
try {
|
|
201
|
-
await
|
|
213
|
+
await record.spawned;
|
|
214
|
+
this.#assertNotAborted(record);
|
|
215
|
+
await this.#attachCgroup(record);
|
|
216
|
+
this.#assertNotAborted(record);
|
|
202
217
|
} catch (error) {
|
|
203
|
-
|
|
204
|
-
|
|
218
|
+
try {
|
|
219
|
+
await this.#ensureGroupCleanup(record);
|
|
220
|
+
} catch (cleanupError) {
|
|
221
|
+
record.cleanupError = cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError));
|
|
222
|
+
}
|
|
223
|
+
const startupError = error instanceof Error ? error : new Error(String(error));
|
|
224
|
+
if (record.cleanupError) {
|
|
225
|
+
startupError.message = `${startupError.message}; startup cleanup failed: ${record.cleanupError.message}`;
|
|
226
|
+
Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
|
|
227
|
+
Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
|
|
228
|
+
}
|
|
229
|
+
throw startupError;
|
|
230
|
+
}
|
|
231
|
+
if (input.task) {
|
|
232
|
+
this.#assertNotAborted(record);
|
|
233
|
+
record.lastInputAt = new Date().toISOString();
|
|
234
|
+
try {
|
|
235
|
+
await this.#writeInput(record, this.#encodeMessage(input.task));
|
|
236
|
+
} catch (error) {
|
|
237
|
+
let cleanupError: unknown;
|
|
238
|
+
try { await this.stop(handle, "initial worker input failed"); }
|
|
239
|
+
catch (stopError) { cleanupError = stopError; }
|
|
240
|
+
if (cleanupError) {
|
|
241
|
+
const startupError = error instanceof Error ? error : new Error(String(error));
|
|
242
|
+
startupError.message = `${startupError.message}; startup cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
|
|
243
|
+
Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
|
|
244
|
+
Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
|
|
245
|
+
throw startupError;
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return handle;
|
|
251
|
+
} finally {
|
|
252
|
+
record.starting = false;
|
|
253
|
+
if (record.abortListener) input.abortSignal?.removeEventListener("abort", record.abortListener);
|
|
254
|
+
record.abortListener = undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async abortStart(reason = "startup aborted", startupToken?: string): Promise<void> {
|
|
259
|
+
const records = [...this.#records.values()].filter((record) => record.starting && (startupToken === undefined || record.startupToken === startupToken));
|
|
260
|
+
for (const record of records) {
|
|
261
|
+
record.abortRequested = true;
|
|
262
|
+
record.stopping = true;
|
|
263
|
+
if (record.exitCode === undefined) {
|
|
264
|
+
try { record.child.kill("SIGTERM"); } catch { /* cleanup below remains authoritative */ }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const deadline = Date.now() + Math.max(this.#terminationGraceMs, 1_000);
|
|
268
|
+
while (records.some((record) => record.starting) && Date.now() < deadline) await delay(10);
|
|
269
|
+
for (const record of records) {
|
|
270
|
+
if (record.exitCode === undefined) {
|
|
271
|
+
try { await this.#ensureGroupCleanup(record); }
|
|
272
|
+
catch (error) { record.cleanupError = error instanceof Error ? error : new Error(`${reason}: ${String(error)}`); }
|
|
205
273
|
}
|
|
206
274
|
}
|
|
207
|
-
return handle;
|
|
208
275
|
}
|
|
209
276
|
|
|
210
277
|
async getStatus(handle: WorkerHandle): Promise<WorkerStatus> {
|
|
@@ -228,6 +295,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
228
295
|
exitReason: running ? undefined : record.signal ? "crashed" : record.exitCode === 0 ? "completed" : "failed",
|
|
229
296
|
processGroupCleaned: record.groupCleanupComplete,
|
|
230
297
|
cgroupCleaned: record.cgroupPath ? record.groupCleanupComplete : undefined,
|
|
298
|
+
cgroupRequired: this.#cgroupMode === "required",
|
|
231
299
|
cgroupError: record.cgroupError?.message,
|
|
232
300
|
cleanupError: record.cleanupError?.message,
|
|
233
301
|
runtimeError: record.runtimeError?.message,
|
|
@@ -343,6 +411,10 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
343
411
|
throw new Error("process-pipe transport does not support session resume");
|
|
344
412
|
}
|
|
345
413
|
|
|
414
|
+
#assertNotAborted(record: ProcessRecord): void {
|
|
415
|
+
if (record.abortRequested) throw new Error("worker startup aborted");
|
|
416
|
+
}
|
|
417
|
+
|
|
346
418
|
async #writeInput(record: ProcessRecord, message: string): Promise<void> {
|
|
347
419
|
const stdin = record.child.stdin;
|
|
348
420
|
if (record.stdinError) throw new Error(`worker stdin is unavailable: ${record.stdinError.message}`);
|
|
@@ -451,14 +523,27 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
451
523
|
}
|
|
452
524
|
|
|
453
525
|
async #attachCgroup(record: ProcessRecord): Promise<void> {
|
|
454
|
-
if (this.#cgroupMode === "off"
|
|
526
|
+
if (this.#cgroupMode === "off") return;
|
|
527
|
+
if (!record.handle.pid) {
|
|
528
|
+
const error = new Error("worker pid was unavailable for cgroup attachment");
|
|
529
|
+
record.cgroupError = error;
|
|
530
|
+
if (this.#cgroupMode === "required") throw new Error(`unable to attach worker to a cgroup: ${error.message}`, { cause: error });
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
if (process.platform !== "linux") {
|
|
534
|
+
const error = new Error("cgroups are unavailable on this platform");
|
|
535
|
+
record.cgroupError = error;
|
|
536
|
+
if (this.#cgroupMode === "required") throw new Error(`unable to attach worker to a cgroup: ${error.message}`, { cause: error });
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
455
539
|
let path: string | undefined;
|
|
456
540
|
try {
|
|
457
|
-
const parent = await currentCgroupPath();
|
|
541
|
+
const parent = this.#cgroupParentPath ?? await currentCgroupPath();
|
|
458
542
|
path = `${parent}/pi-claude-supervisor-${record.handle.id}`;
|
|
459
543
|
await mkdir(path);
|
|
460
544
|
await writeFile(`${path}/cgroup.procs`, `${record.handle.pid}\n`);
|
|
461
545
|
record.cgroupPath = path;
|
|
546
|
+
record.handle.cgroupPath = path;
|
|
462
547
|
} catch (error) {
|
|
463
548
|
if (path) await rm(path, { recursive: true, force: true }).catch(() => {});
|
|
464
549
|
record.cgroupError = error instanceof Error ? error : new Error(String(error));
|