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/supervisor.ts CHANGED
@@ -53,6 +53,8 @@ export interface SupervisorStartOptions {
53
53
  tmuxSession?: string;
54
54
  /** Optional socket path for an existing non-default tmux server. */
55
55
  tmuxSocket?: string;
56
+ /** Persisted identity required when handing off an existing tmux lease. */
57
+ tmuxExpectedIdentity?: WorkerStartInput["tmuxExpectedIdentity"];
56
58
  /** Do not replay the task when adopting an existing interactive session. */
57
59
  sendInitialInput?: boolean;
58
60
  /** Enable the event-driven Pi Decision Worker. Requires claude-jsonl or tmux. */
@@ -101,6 +103,12 @@ export class Supervisor {
101
103
  #humanRequired = false;
102
104
  #onDecisionSessionProgress?: (info: { taskId: string; turn: number }) => Promise<void> | void;
103
105
  #onDecisionSessionClosed?: (taskId: string) => Promise<void> | void;
106
+ #startAbortController?: AbortController;
107
+ #startToken?: string;
108
+ #startStopReason?: string;
109
+ #startAbortError?: unknown;
110
+ #startAbortCompletion?: Promise<void>;
111
+ #released = false;
104
112
 
105
113
  constructor(adapter: WorkerAdapter, events = new EventLog(), hooks: { onHumanRequired?: (notice: HumanInterventionNotice) => Promise<void> | void } = {}) {
106
114
  this.#adapter = adapter;
@@ -113,6 +121,8 @@ export class Supervisor {
113
121
  get handle() { return this.#handle; }
114
122
  get lastVerification() { return this.#lastVerification; }
115
123
  get humanRequired() { return this.#humanRequired; }
124
+ /** True after the persistent worker was detached from this Supervisor. */
125
+ get released() { return this.#released; }
116
126
 
117
127
  async start(options: SupervisorStartOptions): Promise<WorkerHandle> {
118
128
  return this.#exclusive(() => this.#startInternal(options));
@@ -132,12 +142,18 @@ export class Supervisor {
132
142
  this.#handledEvents.clear();
133
143
  this.#pendingPermissions.clear();
134
144
  this.#humanRequired = false;
145
+ this.#released = false;
135
146
  this.#task = { taskId, task: options.task, cwd: options.cwd, maxTurns: options.maxTurns ?? 100, startedAt: options.startedAt ?? new Date().toISOString() };
136
147
  this.#turn = options.initialTurn ?? 0;
137
148
  this.#deadlineMs = options.deadlineMs ?? 4 * 60 * 60_000;
138
149
  this.#noOutputTimeoutMs = options.noOutputTimeoutMs ?? 20 * 60_000;
139
150
  this.#clearWatchdog();
140
151
  this.#machine.transition("starting");
152
+ const startAbortController = new AbortController();
153
+ this.#startAbortController = startAbortController;
154
+ this.#startToken = randomUUID();
155
+ this.#startStopReason = undefined;
156
+ this.#startAbortError = undefined;
141
157
  try {
142
158
  await this.#appendEvent({
143
159
  type: "task_started",
@@ -151,6 +167,7 @@ export class Supervisor {
151
167
  if (this.#automation && !["jsonl", "tmux"].includes(this.#adapter.capabilities().transport)) {
152
168
  throw new Error("automatic supervision requires claude-jsonl or tmux transport");
153
169
  }
170
+ this.#assertStartNotAborted(startAbortController.signal);
154
171
  if (this.#automation) {
155
172
  this.#decision = new PiDecisionWorker({
156
173
  context: { taskId, task: options.task, cwd: options.cwd, state: this.#machine.state, turn: this.#turn, maxTurns: this.#task.maxTurns },
@@ -173,6 +190,7 @@ export class Supervisor {
173
190
  });
174
191
  await this.#decision.start();
175
192
  }
193
+ this.#assertStartNotAborted(startAbortController.signal);
176
194
  const input: WorkerStartInput = {
177
195
  task: options.initialInput ?? options.task,
178
196
  cwd: options.cwd,
@@ -182,37 +200,66 @@ export class Supervisor {
182
200
  approval: options.approval,
183
201
  tmuxSession: options.tmuxSession,
184
202
  tmuxSocket: options.tmuxSocket,
203
+ tmuxExpectedIdentity: options.tmuxExpectedIdentity,
185
204
  sendInitialInput: options.sendInitialInput,
186
205
  eventListener: (event) => this.#receiveWorkerEvent(event),
206
+ abortSignal: startAbortController.signal,
207
+ startupToken: this.#startToken,
187
208
  };
188
209
  this.#handle = await this.#adapter.start(input);
210
+ this.#assertStartNotAborted(startAbortController.signal);
189
211
  this.#machine.transition("running");
190
212
  await this.#appendEvent({ type: "worker_started", taskId, workerId: this.#handle.id, data: { pid: this.#handle.pid } });
191
213
  this.#armWatchdog();
214
+ this.#startAbortController = undefined;
215
+ this.#startToken = undefined;
216
+ this.#startStopReason = undefined;
217
+ this.#startAbortError = undefined;
192
218
  return this.#handle;
193
219
  } catch (error) {
194
- const startFailure = error as { workerHandle?: WorkerHandle; workerCleanupRequired?: boolean };
220
+ const startupError = error instanceof Error ? error : new Error(String(error));
221
+ const startFailure = startupError as Error & { workerHandle?: WorkerHandle; workerCleanupRequired?: boolean };
195
222
  const startFailureHandle = startFailure.workerHandle;
196
223
  if (!this.#handle && startFailureHandle && (startFailure.workerCleanupRequired || startFailureHandle.ownership || startFailureHandle.sessionName)) this.#handle = startFailureHandle;
197
224
  const handle = this.#handle;
225
+ const startupCancelled = Boolean(this.#startStopReason || startAbortController.signal.aborted);
226
+ let startupCleanupError: unknown = this.#startAbortError;
227
+ const abortCompletion = this.#startAbortCompletion;
228
+ if (abortCompletion) {
229
+ try { await abortCompletion; }
230
+ catch (error) { startupCleanupError ??= error; }
231
+ }
198
232
  if (handle) {
199
233
  try {
200
- await this.#adapter.stop(handle, "startup failed");
201
- } catch {
202
- try { await this.#adapter.killProcessGroup(handle, "startup cleanup"); } catch { /* preserve startup error */ }
234
+ await this.#adapter.stop(handle, startupCancelled ? (this.#startStopReason ?? "startup aborted") : "startup failed");
235
+ } catch (error) {
236
+ startupCleanupError = error;
237
+ try { await this.#adapter.killProcessGroup(handle, "startup cleanup"); }
238
+ catch (cleanupError) { startupCleanupError ??= cleanupError; }
203
239
  }
204
240
  }
205
- if (["starting", "running"].includes(this.#machine.state)) this.#machine.transition("failed");
241
+ if (startupCleanupError && !startFailure.workerCleanupRequired) {
242
+ Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
243
+ }
244
+ if (["starting", "running"].includes(this.#machine.state)) this.#machine.transition(startupCancelled && !startupCleanupError ? "stopped" : "failed");
206
245
  try {
207
- await this.#appendEvent({ type: "worker_start_failed", taskId, data: { error: safeMessage(error) } });
246
+ await this.#appendEvent({ type: "worker_start_failed", taskId, data: { error: safeMessage(startupError) } });
208
247
  } catch { /* logging failure must not hide the startup failure */ }
209
248
  await this.#decision?.close().catch(() => {});
210
249
  this.#decision = undefined;
211
250
  await Promise.resolve(this.#onDecisionSessionClosed?.(taskId)).catch(() => {});
212
- throw error;
251
+ this.#startAbortController = undefined;
252
+ this.#startToken = undefined;
253
+ this.#startStopReason = undefined;
254
+ this.#startAbortError = undefined;
255
+ throw startupError;
213
256
  }
214
257
  }
215
258
 
259
+ #assertStartNotAborted(signal: AbortSignal): void {
260
+ if (signal.aborted) throw new Error(`worker startup aborted: ${this.#startStopReason ?? "startup cancellation requested"}`);
261
+ }
262
+
216
263
  async poll(): Promise<{ status: WorkerStatus; output: WorkerOutputChunk[] }> {
217
264
  return this.#exclusive(() => this.#pollInternal());
218
265
  }
@@ -496,21 +543,35 @@ export class Supervisor {
496
543
  }
497
544
 
498
545
  async abortStart(reason = "startup aborted"): Promise<void> {
546
+ if (this.#machine.state !== "starting" && !this.#startAbortController) return;
499
547
  // This path intentionally bypasses #exclusive(): start() may be blocked in
500
548
  // a Decision Worker model call and shutdown must still dispose that session.
501
- let cleanupError: unknown;
502
- const abort = this.#adapter.abortStart?.(reason);
503
- if (abort) {
504
- try { await abort; }
505
- catch (error) { cleanupError = error; }
506
- }
507
- await this.#decision?.close().catch(() => {});
508
- this.#decision = undefined;
509
- if (this.#handle) {
510
- try { await this.#adapter.stop(this.#handle, reason); }
511
- catch (error) { cleanupError ??= error; }
549
+ this.#startStopReason = reason;
550
+ const completion = (async () => {
551
+ this.#startAbortController?.abort(reason);
552
+ let cleanupError: unknown;
553
+ const abort = this.#adapter.abortStart?.(reason, this.#startToken);
554
+ if (abort) {
555
+ try { await abort; }
556
+ catch (error) { cleanupError = error; }
557
+ }
558
+ await this.#decision?.close().catch(() => {});
559
+ this.#decision = undefined;
560
+ if (this.#handle) {
561
+ try { await this.#adapter.stop(this.#handle, reason); }
562
+ catch (error) { cleanupError ??= error; }
563
+ }
564
+ if (cleanupError) throw cleanupError;
565
+ })();
566
+ this.#startAbortCompletion = completion;
567
+ try {
568
+ await completion;
569
+ } catch (error) {
570
+ this.#startAbortError = error;
571
+ throw error;
572
+ } finally {
573
+ if (this.#startAbortCompletion === completion) this.#startAbortCompletion = undefined;
512
574
  }
513
- if (cleanupError) throw cleanupError;
514
575
  }
515
576
 
516
577
  async release(reason = "Pi session disconnected"): Promise<void> {
@@ -519,17 +580,30 @@ export class Supervisor {
519
580
  ? this.#adapter.release
520
581
  ? this.#adapter.release(handle, reason)
521
582
  : this.#adapter.stop(handle, reason)
522
- : this.#adapter.abortStart?.(reason) ?? Promise.resolve();
583
+ : this.#machine.state === "starting" || this.#startAbortController
584
+ ? this.abortStart(reason)
585
+ : Promise.resolve();
523
586
  await this.#decision?.close().catch(() => {});
524
587
  this.#decision = undefined;
525
588
  await withTimeout(this.#exclusive(async () => {
526
589
  this.#clearWatchdog();
527
590
  await preemptiveRelease;
591
+ this.#released = true;
528
592
  if (handle) await this.#appendEvent({ type: "worker_released", taskId: this.#task?.taskId, workerId: handle.id, data: { reason } });
529
593
  }), 15_000, "persistent worker release");
530
594
  }
531
595
 
532
596
  async stop(reason = "human requested stop"): Promise<void> {
597
+ // A stop must be able to preempt startup rather than waiting behind a
598
+ // startup operation that is blocked in a provider or adapter call.
599
+ if (this.#machine.state === "starting") {
600
+ this.#startStopReason = reason;
601
+ this.#startAbortController?.abort(reason);
602
+ if (!this.#handle) {
603
+ await withTimeout(this.abortStart(reason), 15_000, "worker startup cancellation");
604
+ return;
605
+ }
606
+ }
533
607
  // Start the adapter stop immediately so a queued/hung send cannot delay
534
608
  // process termination. State/event changes still remain serialized below.
535
609
  if (!this.#preemptiveStop && this.#handle && ["starting", "running", "waiting", "paused"].includes(this.#machine.state)) {
@@ -542,12 +616,20 @@ export class Supervisor {
542
616
  async #stopInternal(reason: string, flushPendingEvents = true): Promise<void> {
543
617
  if (flushPendingEvents) await this.#flushPendingEvents();
544
618
  if (!this.#handle) throw new Error("no active task");
545
- if (this.#machine.state === "stopped") return;
619
+ if (this.#machine.state === "stopped") {
620
+ const preemptiveStop = this.#preemptiveStop;
621
+ this.#preemptiveStop = undefined;
622
+ if (preemptiveStop) await preemptiveStop;
623
+ return;
624
+ }
546
625
  if (this.#machine.state === "failed") {
547
626
  // A failed startup or cleanup attempt may still retain a live handle.
548
627
  // Retry group termination during shutdown instead of treating the state
549
628
  // as fully reclaimed.
550
- if (this.#handle) await this.#adapter.stop(this.#handle, reason);
629
+ const preemptiveStop = this.#preemptiveStop;
630
+ this.#preemptiveStop = undefined;
631
+ if (preemptiveStop) await preemptiveStop;
632
+ else if (this.#handle) await this.#adapter.stop(this.#handle, reason);
551
633
  await this.#decision?.close().catch(() => {});
552
634
  this.#decision = undefined;
553
635
  await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task?.taskId ?? "")).catch(() => {});
@@ -615,6 +697,7 @@ export class Supervisor {
615
697
  await this.#drainOutputAfterStop(this.#handle);
616
698
  const cleanup = await this.#adapter.getStatus(this.#handle);
617
699
  if (cleanup.cleanupError) throw new Error(`worker cleanup failed after verification: ${cleanup.cleanupError}`);
700
+ if (cleanup.cgroupError && cleanup.cgroupRequired !== false) throw new Error(`worker cgroup cleanup failed after verification: ${cleanup.cgroupError}`);
618
701
  if (this.#handle.ownership === "owned" && (cleanup.running || cleanup.processGroupCleaned !== true)) {
619
702
  throw new Error("owned worker cleanup was not confirmed after verification");
620
703
  }
package/src/types.ts CHANGED
@@ -40,8 +40,21 @@ export interface WorkerStartInput {
40
40
  tmuxSession?: string;
41
41
  /** Optional tmux socket path; omitted means the user's default server. */
42
42
  tmuxSocket?: string;
43
+ /** Persisted identity required when handing off an existing tmux lease. */
44
+ tmuxExpectedIdentity?: {
45
+ pid?: number;
46
+ startTime?: string;
47
+ tmuxTarget?: string;
48
+ tmuxPaneId?: string;
49
+ paneStartTime?: string;
50
+ paneCommand?: string;
51
+ };
43
52
  /** Adopted sessions must not replay the task as a new user message. */
44
53
  sendInitialInput?: boolean;
54
+ /** Cancel startup before a worker is fully returned to the supervisor. */
55
+ abortSignal?: AbortSignal;
56
+ /** Internal token that scopes out-of-band startup cancellation. */
57
+ startupToken?: string;
45
58
  }
46
59
 
47
60
  export interface WorkerHandle {
@@ -53,6 +66,12 @@ export interface WorkerHandle {
53
66
  sessionName?: string;
54
67
  tmuxSocket?: string;
55
68
  ownership?: "owned" | "adopted";
69
+ /** Verified process-boundary metadata persisted in the cwd lease registry. */
70
+ cgroupPath?: string;
71
+ tmuxTarget?: string;
72
+ tmuxPaneId?: string;
73
+ paneStartTime?: string;
74
+ paneCommand?: string;
56
75
  }
57
76
 
58
77
  export interface WorkerStatus {
@@ -68,6 +87,8 @@ export interface WorkerStatus {
68
87
  processGroupCleaned?: boolean;
69
88
  /** Whether a Linux cgroup provided descendant cleanup for this worker. */
70
89
  cgroupCleaned?: boolean;
90
+ /** Whether cgroup attachment was required for this worker. */
91
+ cgroupRequired?: boolean;
71
92
  /** cgroup attachment was unavailable and a fallback may have been used. */
72
93
  cgroupError?: string;
73
94
  /** Cleanup failure is diagnostic and must be treated as a safety failure. */
@@ -85,7 +106,7 @@ export interface WorkerAdapter {
85
106
  capabilities(): WorkerCapabilities;
86
107
  start(input: WorkerStartInput): Promise<WorkerHandle>;
87
108
  /** Cancel adapter-owned startup work before a WorkerHandle is returned. */
88
- abortStart?(reason: string): Promise<void>;
109
+ abortStart?(reason: string, startupToken?: string): Promise<void>;
89
110
  getStatus(handle: WorkerHandle): Promise<WorkerStatus>;
90
111
  readOutput(handle: WorkerHandle): Promise<WorkerOutputChunk[]>;
91
112
  /** Restore chunks when diagnostic event persistence fails before acknowledgement. */
@@ -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,37 +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
- if (record.cleanupError) {
196
- startupError.message = `${startupError.message}; startup cleanup failed: ${record.cleanupError.message}`;
197
- Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
198
- Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
199
- }
200
- throw startupError;
201
- }
202
- if (input.task) {
203
- record.lastInputAt = new Date().toISOString();
204
212
  try {
205
- 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);
206
217
  } catch (error) {
207
- let cleanupError: unknown;
208
- try { await this.stop(handle, "initial worker input failed"); }
209
- catch (stopError) { cleanupError = stopError; }
210
- if (cleanupError) {
211
- const startupError = error instanceof Error ? error : new Error(String(error));
212
- startupError.message = `${startupError.message}; startup cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
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}`;
213
226
  Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
214
227
  Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
215
- throw startupError;
216
228
  }
217
- throw error;
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)}`); }
218
273
  }
219
274
  }
220
- return handle;
221
275
  }
222
276
 
223
277
  async getStatus(handle: WorkerHandle): Promise<WorkerStatus> {
@@ -241,6 +295,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
241
295
  exitReason: running ? undefined : record.signal ? "crashed" : record.exitCode === 0 ? "completed" : "failed",
242
296
  processGroupCleaned: record.groupCleanupComplete,
243
297
  cgroupCleaned: record.cgroupPath ? record.groupCleanupComplete : undefined,
298
+ cgroupRequired: this.#cgroupMode === "required",
244
299
  cgroupError: record.cgroupError?.message,
245
300
  cleanupError: record.cleanupError?.message,
246
301
  runtimeError: record.runtimeError?.message,
@@ -356,6 +411,10 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
356
411
  throw new Error("process-pipe transport does not support session resume");
357
412
  }
358
413
 
414
+ #assertNotAborted(record: ProcessRecord): void {
415
+ if (record.abortRequested) throw new Error("worker startup aborted");
416
+ }
417
+
359
418
  async #writeInput(record: ProcessRecord, message: string): Promise<void> {
360
419
  const stdin = record.child.stdin;
361
420
  if (record.stdinError) throw new Error(`worker stdin is unavailable: ${record.stdinError.message}`);
@@ -464,14 +523,27 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
464
523
  }
465
524
 
466
525
  async #attachCgroup(record: ProcessRecord): Promise<void> {
467
- 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
+ }
468
539
  let path: string | undefined;
469
540
  try {
470
- const parent = await currentCgroupPath();
541
+ const parent = this.#cgroupParentPath ?? await currentCgroupPath();
471
542
  path = `${parent}/pi-claude-supervisor-${record.handle.id}`;
472
543
  await mkdir(path);
473
544
  await writeFile(`${path}/cgroup.procs`, `${record.handle.pid}\n`);
474
545
  record.cgroupPath = path;
546
+ record.handle.cgroupPath = path;
475
547
  } catch (error) {
476
548
  if (path) await rm(path, { recursive: true, force: true }).catch(() => {});
477
549
  record.cgroupError = error instanceof Error ? error : new Error(String(error));