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.
@@ -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 this.#writeInput(record, this.#encodeMessage(input.task));
213
+ await record.spawned;
214
+ this.#assertNotAborted(record);
215
+ await this.#attachCgroup(record);
216
+ this.#assertNotAborted(record);
202
217
  } catch (error) {
203
- await this.stop(handle, "initial worker input failed");
204
- throw error;
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" || process.platform !== "linux" || !record.handle.pid) return;
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));