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/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
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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.#
|
|
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")
|
|
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
|
-
|
|
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
|
|
213
|
+
await record.spawned;
|
|
214
|
+
this.#assertNotAborted(record);
|
|
215
|
+
await this.#attachCgroup(record);
|
|
216
|
+
this.#assertNotAborted(record);
|
|
206
217
|
} catch (error) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
catch (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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"
|
|
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));
|