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
package/src/supervisor.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { SupervisorStateMachine } from "./state.ts";
|
|
|
5
5
|
import { evaluatePermission } from "./policy.ts";
|
|
6
6
|
import { PiDecisionWorker, type DecisionAction } from "./decision-worker.ts";
|
|
7
7
|
import { verify, type VerificationCommand } from "./verifier.ts";
|
|
8
|
+
import { redactSensitive } from "./redaction.ts";
|
|
8
9
|
import type {
|
|
9
10
|
TaskContext,
|
|
10
11
|
VerificationResult,
|
|
@@ -48,7 +49,15 @@ export interface SupervisorStartOptions {
|
|
|
48
49
|
noOutputTimeoutMs?: number;
|
|
49
50
|
/** Human approval for a review-level worker command. */
|
|
50
51
|
approval?: { actor: "human"; reason: string };
|
|
51
|
-
/**
|
|
52
|
+
/** Adopt an existing tmux session instead of starting a new worker. */
|
|
53
|
+
tmuxSession?: string;
|
|
54
|
+
/** Optional socket path for an existing non-default tmux server. */
|
|
55
|
+
tmuxSocket?: string;
|
|
56
|
+
/** Persisted identity required when handing off an existing tmux lease. */
|
|
57
|
+
tmuxExpectedIdentity?: WorkerStartInput["tmuxExpectedIdentity"];
|
|
58
|
+
/** Do not replay the task when adopting an existing interactive session. */
|
|
59
|
+
sendInitialInput?: boolean;
|
|
60
|
+
/** Enable the event-driven Pi Decision Worker. Requires claude-jsonl or tmux. */
|
|
52
61
|
automation?: boolean;
|
|
53
62
|
/** Persistent Pi session location for the Decision Worker. */
|
|
54
63
|
decisionSessionFile?: string;
|
|
@@ -94,6 +103,11 @@ export class Supervisor {
|
|
|
94
103
|
#humanRequired = false;
|
|
95
104
|
#onDecisionSessionProgress?: (info: { taskId: string; turn: number }) => Promise<void> | void;
|
|
96
105
|
#onDecisionSessionClosed?: (taskId: string) => Promise<void> | void;
|
|
106
|
+
#startAbortController?: AbortController;
|
|
107
|
+
#startToken?: string;
|
|
108
|
+
#startStopReason?: string;
|
|
109
|
+
#startAbortError?: unknown;
|
|
110
|
+
#startAbortCompletion?: Promise<void>;
|
|
97
111
|
|
|
98
112
|
constructor(adapter: WorkerAdapter, events = new EventLog(), hooks: { onHumanRequired?: (notice: HumanInterventionNotice) => Promise<void> | void } = {}) {
|
|
99
113
|
this.#adapter = adapter;
|
|
@@ -131,6 +145,11 @@ export class Supervisor {
|
|
|
131
145
|
this.#noOutputTimeoutMs = options.noOutputTimeoutMs ?? 20 * 60_000;
|
|
132
146
|
this.#clearWatchdog();
|
|
133
147
|
this.#machine.transition("starting");
|
|
148
|
+
const startAbortController = new AbortController();
|
|
149
|
+
this.#startAbortController = startAbortController;
|
|
150
|
+
this.#startToken = randomUUID();
|
|
151
|
+
this.#startStopReason = undefined;
|
|
152
|
+
this.#startAbortError = undefined;
|
|
134
153
|
try {
|
|
135
154
|
await this.#appendEvent({
|
|
136
155
|
type: "task_started",
|
|
@@ -141,9 +160,10 @@ export class Supervisor {
|
|
|
141
160
|
...(options.approval ? { approval: options.approval } : {}),
|
|
142
161
|
},
|
|
143
162
|
});
|
|
144
|
-
if (this.#automation && this.#adapter.capabilities().transport
|
|
145
|
-
throw new Error("automatic supervision requires claude-jsonl transport");
|
|
163
|
+
if (this.#automation && !["jsonl", "tmux"].includes(this.#adapter.capabilities().transport)) {
|
|
164
|
+
throw new Error("automatic supervision requires claude-jsonl or tmux transport");
|
|
146
165
|
}
|
|
166
|
+
this.#assertStartNotAborted(startAbortController.signal);
|
|
147
167
|
if (this.#automation) {
|
|
148
168
|
this.#decision = new PiDecisionWorker({
|
|
149
169
|
context: { taskId, task: options.task, cwd: options.cwd, state: this.#machine.state, turn: this.#turn, maxTurns: this.#task.maxTurns },
|
|
@@ -166,6 +186,7 @@ export class Supervisor {
|
|
|
166
186
|
});
|
|
167
187
|
await this.#decision.start();
|
|
168
188
|
}
|
|
189
|
+
this.#assertStartNotAborted(startAbortController.signal);
|
|
169
190
|
const input: WorkerStartInput = {
|
|
170
191
|
task: options.initialInput ?? options.task,
|
|
171
192
|
cwd: options.cwd,
|
|
@@ -173,35 +194,68 @@ export class Supervisor {
|
|
|
173
194
|
args: options.args,
|
|
174
195
|
env: options.env,
|
|
175
196
|
approval: options.approval,
|
|
197
|
+
tmuxSession: options.tmuxSession,
|
|
198
|
+
tmuxSocket: options.tmuxSocket,
|
|
199
|
+
tmuxExpectedIdentity: options.tmuxExpectedIdentity,
|
|
200
|
+
sendInitialInput: options.sendInitialInput,
|
|
176
201
|
eventListener: (event) => this.#receiveWorkerEvent(event),
|
|
202
|
+
abortSignal: startAbortController.signal,
|
|
203
|
+
startupToken: this.#startToken,
|
|
177
204
|
};
|
|
178
205
|
this.#handle = await this.#adapter.start(input);
|
|
206
|
+
this.#assertStartNotAborted(startAbortController.signal);
|
|
179
207
|
this.#machine.transition("running");
|
|
180
208
|
await this.#appendEvent({ type: "worker_started", taskId, workerId: this.#handle.id, data: { pid: this.#handle.pid } });
|
|
181
209
|
this.#armWatchdog();
|
|
210
|
+
this.#startAbortController = undefined;
|
|
211
|
+
this.#startToken = undefined;
|
|
212
|
+
this.#startStopReason = undefined;
|
|
213
|
+
this.#startAbortError = undefined;
|
|
182
214
|
return this.#handle;
|
|
183
215
|
} catch (error) {
|
|
184
|
-
const
|
|
185
|
-
|
|
216
|
+
const startupError = error instanceof Error ? error : new Error(String(error));
|
|
217
|
+
const startFailure = startupError as Error & { workerHandle?: WorkerHandle; workerCleanupRequired?: boolean };
|
|
218
|
+
const startFailureHandle = startFailure.workerHandle;
|
|
219
|
+
if (!this.#handle && startFailureHandle && (startFailure.workerCleanupRequired || startFailureHandle.ownership || startFailureHandle.sessionName)) this.#handle = startFailureHandle;
|
|
186
220
|
const handle = this.#handle;
|
|
221
|
+
const startupCancelled = Boolean(this.#startStopReason || startAbortController.signal.aborted);
|
|
222
|
+
let startupCleanupError: unknown = this.#startAbortError;
|
|
223
|
+
const abortCompletion = this.#startAbortCompletion;
|
|
224
|
+
if (abortCompletion) {
|
|
225
|
+
try { await abortCompletion; }
|
|
226
|
+
catch (error) { startupCleanupError ??= error; }
|
|
227
|
+
}
|
|
187
228
|
if (handle) {
|
|
188
229
|
try {
|
|
189
|
-
await this.#adapter.stop(handle, "startup failed");
|
|
190
|
-
} catch {
|
|
191
|
-
|
|
230
|
+
await this.#adapter.stop(handle, startupCancelled ? (this.#startStopReason ?? "startup aborted") : "startup failed");
|
|
231
|
+
} catch (error) {
|
|
232
|
+
startupCleanupError = error;
|
|
233
|
+
try { await this.#adapter.killProcessGroup(handle, "startup cleanup"); }
|
|
234
|
+
catch (cleanupError) { startupCleanupError ??= cleanupError; }
|
|
192
235
|
}
|
|
193
236
|
}
|
|
194
|
-
if (
|
|
237
|
+
if (startupCleanupError && !startFailure.workerCleanupRequired) {
|
|
238
|
+
Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
|
|
239
|
+
}
|
|
240
|
+
if (["starting", "running"].includes(this.#machine.state)) this.#machine.transition(startupCancelled && !startupCleanupError ? "stopped" : "failed");
|
|
195
241
|
try {
|
|
196
|
-
await this.#appendEvent({ type: "worker_start_failed", taskId, data: { error: safeMessage(
|
|
242
|
+
await this.#appendEvent({ type: "worker_start_failed", taskId, data: { error: safeMessage(startupError) } });
|
|
197
243
|
} catch { /* logging failure must not hide the startup failure */ }
|
|
198
244
|
await this.#decision?.close().catch(() => {});
|
|
199
245
|
this.#decision = undefined;
|
|
200
246
|
await Promise.resolve(this.#onDecisionSessionClosed?.(taskId)).catch(() => {});
|
|
201
|
-
|
|
247
|
+
this.#startAbortController = undefined;
|
|
248
|
+
this.#startToken = undefined;
|
|
249
|
+
this.#startStopReason = undefined;
|
|
250
|
+
this.#startAbortError = undefined;
|
|
251
|
+
throw startupError;
|
|
202
252
|
}
|
|
203
253
|
}
|
|
204
254
|
|
|
255
|
+
#assertStartNotAborted(signal: AbortSignal): void {
|
|
256
|
+
if (signal.aborted) throw new Error(`worker startup aborted: ${this.#startStopReason ?? "startup cancellation requested"}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
205
259
|
async poll(): Promise<{ status: WorkerStatus; output: WorkerOutputChunk[] }> {
|
|
206
260
|
return this.#exclusive(() => this.#pollInternal());
|
|
207
261
|
}
|
|
@@ -289,7 +343,7 @@ export class Supervisor {
|
|
|
289
343
|
}
|
|
290
344
|
try {
|
|
291
345
|
if (this.#onHumanRequired) await this.#onHumanRequired(notice);
|
|
292
|
-
else console.error(`pi-claude-supervisor human intervention required: ${reason}`);
|
|
346
|
+
else console.error(`pi-claude-supervisor human intervention required: ${safeMessage(reason)}`);
|
|
293
347
|
} catch (notifyError) {
|
|
294
348
|
console.error(`pi-claude-supervisor human intervention notification failed: ${safeMessage(notifyError)}`);
|
|
295
349
|
}
|
|
@@ -350,6 +404,11 @@ export class Supervisor {
|
|
|
350
404
|
return;
|
|
351
405
|
}
|
|
352
406
|
if (action.action === "verify") {
|
|
407
|
+
if (this.#machine.state === "waiting" && this.#adapter.capabilities().persistentSession) {
|
|
408
|
+
this.#machine.transition("verifying");
|
|
409
|
+
await this.#verifyInternal();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
353
412
|
if (this.#machine.state === "waiting") {
|
|
354
413
|
await this.#adapter.stop(handle, "Decision Worker requested verification");
|
|
355
414
|
await this.#pollInternal(true);
|
|
@@ -393,7 +452,7 @@ export class Supervisor {
|
|
|
393
452
|
// Alert delivery is independent from event-log persistence: a broken audit
|
|
394
453
|
// path must not suppress the operator notification.
|
|
395
454
|
if (this.#onHumanRequired) await this.#onHumanRequired(notice);
|
|
396
|
-
else console.error(`pi-claude-supervisor human intervention required: ${reason}`);
|
|
455
|
+
else console.error(`pi-claude-supervisor human intervention required: ${safeMessage(reason)}`);
|
|
397
456
|
if (logError) throw logError;
|
|
398
457
|
}
|
|
399
458
|
|
|
@@ -440,7 +499,9 @@ export class Supervisor {
|
|
|
440
499
|
if (!["running", "waiting"].includes(this.#machine.state)) throw new Error(`cannot send from ${this.#machine.state}`);
|
|
441
500
|
const status = await this.#adapter.getStatus(handle);
|
|
442
501
|
if (status.activeRequests !== undefined && status.activeRequests > 0) {
|
|
443
|
-
throw new Error(
|
|
502
|
+
throw new Error(this.#adapter.capabilities().transport === "jsonl"
|
|
503
|
+
? "worker has an active JSONL request; poll until its result before sending the next turn"
|
|
504
|
+
: "worker has an active turn; wait until its interactive prompt or structured result is ready before sending another turn");
|
|
444
505
|
}
|
|
445
506
|
if (status.activeRequests === 0 && this.#machine.state === "running") {
|
|
446
507
|
this.#machine.transition("waiting");
|
|
@@ -478,14 +539,66 @@ export class Supervisor {
|
|
|
478
539
|
}
|
|
479
540
|
|
|
480
541
|
async abortStart(reason = "startup aborted"): Promise<void> {
|
|
542
|
+
if (this.#machine.state !== "starting" && !this.#startAbortController) return;
|
|
481
543
|
// This path intentionally bypasses #exclusive(): start() may be blocked in
|
|
482
544
|
// a Decision Worker model call and shutdown must still dispose that session.
|
|
545
|
+
this.#startStopReason = reason;
|
|
546
|
+
const completion = (async () => {
|
|
547
|
+
this.#startAbortController?.abort(reason);
|
|
548
|
+
let cleanupError: unknown;
|
|
549
|
+
const abort = this.#adapter.abortStart?.(reason, this.#startToken);
|
|
550
|
+
if (abort) {
|
|
551
|
+
try { await abort; }
|
|
552
|
+
catch (error) { cleanupError = error; }
|
|
553
|
+
}
|
|
554
|
+
await this.#decision?.close().catch(() => {});
|
|
555
|
+
this.#decision = undefined;
|
|
556
|
+
if (this.#handle) {
|
|
557
|
+
try { await this.#adapter.stop(this.#handle, reason); }
|
|
558
|
+
catch (error) { cleanupError ??= error; }
|
|
559
|
+
}
|
|
560
|
+
if (cleanupError) throw cleanupError;
|
|
561
|
+
})();
|
|
562
|
+
this.#startAbortCompletion = completion;
|
|
563
|
+
try {
|
|
564
|
+
await completion;
|
|
565
|
+
} catch (error) {
|
|
566
|
+
this.#startAbortError = error;
|
|
567
|
+
throw error;
|
|
568
|
+
} finally {
|
|
569
|
+
if (this.#startAbortCompletion === completion) this.#startAbortCompletion = undefined;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async release(reason = "Pi session disconnected"): Promise<void> {
|
|
574
|
+
const handle = this.#handle;
|
|
575
|
+
const preemptiveRelease = handle
|
|
576
|
+
? this.#adapter.release
|
|
577
|
+
? this.#adapter.release(handle, reason)
|
|
578
|
+
: this.#adapter.stop(handle, reason)
|
|
579
|
+
: this.#machine.state === "starting" || this.#startAbortController
|
|
580
|
+
? this.abortStart(reason)
|
|
581
|
+
: Promise.resolve();
|
|
483
582
|
await this.#decision?.close().catch(() => {});
|
|
484
583
|
this.#decision = undefined;
|
|
485
|
-
|
|
584
|
+
await withTimeout(this.#exclusive(async () => {
|
|
585
|
+
this.#clearWatchdog();
|
|
586
|
+
await preemptiveRelease;
|
|
587
|
+
if (handle) await this.#appendEvent({ type: "worker_released", taskId: this.#task?.taskId, workerId: handle.id, data: { reason } });
|
|
588
|
+
}), 15_000, "persistent worker release");
|
|
486
589
|
}
|
|
487
590
|
|
|
488
591
|
async stop(reason = "human requested stop"): Promise<void> {
|
|
592
|
+
// A stop must be able to preempt startup rather than waiting behind a
|
|
593
|
+
// startup operation that is blocked in a provider or adapter call.
|
|
594
|
+
if (this.#machine.state === "starting") {
|
|
595
|
+
this.#startStopReason = reason;
|
|
596
|
+
this.#startAbortController?.abort(reason);
|
|
597
|
+
if (!this.#handle) {
|
|
598
|
+
await withTimeout(this.abortStart(reason), 15_000, "worker startup cancellation");
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
489
602
|
// Start the adapter stop immediately so a queued/hung send cannot delay
|
|
490
603
|
// process termination. State/event changes still remain serialized below.
|
|
491
604
|
if (!this.#preemptiveStop && this.#handle && ["starting", "running", "waiting", "paused"].includes(this.#machine.state)) {
|
|
@@ -498,30 +611,63 @@ export class Supervisor {
|
|
|
498
611
|
async #stopInternal(reason: string, flushPendingEvents = true): Promise<void> {
|
|
499
612
|
if (flushPendingEvents) await this.#flushPendingEvents();
|
|
500
613
|
if (!this.#handle) throw new Error("no active task");
|
|
501
|
-
if (this.#machine.state === "stopped")
|
|
614
|
+
if (this.#machine.state === "stopped") {
|
|
615
|
+
const preemptiveStop = this.#preemptiveStop;
|
|
616
|
+
this.#preemptiveStop = undefined;
|
|
617
|
+
if (preemptiveStop) await preemptiveStop;
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
502
620
|
if (this.#machine.state === "failed") {
|
|
503
621
|
// A failed startup or cleanup attempt may still retain a live handle.
|
|
504
622
|
// Retry group termination during shutdown instead of treating the state
|
|
505
623
|
// as fully reclaimed.
|
|
506
|
-
|
|
624
|
+
const preemptiveStop = this.#preemptiveStop;
|
|
625
|
+
this.#preemptiveStop = undefined;
|
|
626
|
+
if (preemptiveStop) await preemptiveStop;
|
|
627
|
+
else if (this.#handle) await this.#adapter.stop(this.#handle, reason);
|
|
507
628
|
await this.#decision?.close().catch(() => {});
|
|
508
629
|
this.#decision = undefined;
|
|
509
630
|
await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task?.taskId ?? "")).catch(() => {});
|
|
510
631
|
return;
|
|
511
632
|
}
|
|
633
|
+
if (this.#machine.state === "completed") {
|
|
634
|
+
if (this.#handle) {
|
|
635
|
+
await this.#adapter.stop(this.#handle, reason);
|
|
636
|
+
await this.#drainOutputAfterStop(this.#handle);
|
|
637
|
+
}
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
512
640
|
if (!["running", "waiting", "paused", "starting"].includes(this.#machine.state)) return;
|
|
513
641
|
this.#clearWatchdog();
|
|
514
642
|
const preemptiveStop = this.#preemptiveStop;
|
|
515
643
|
this.#preemptiveStop = undefined;
|
|
516
644
|
if (preemptiveStop) await preemptiveStop;
|
|
517
645
|
else await this.#adapter.stop(this.#handle, reason);
|
|
646
|
+
let outputError: unknown;
|
|
647
|
+
try {
|
|
648
|
+
await this.#drainOutputAfterStop(this.#handle);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
outputError = error;
|
|
651
|
+
}
|
|
518
652
|
this.#machine.transition("stopped");
|
|
519
653
|
await this.#appendEvent({ type: "worker_stopped", taskId: this.#task?.taskId, workerId: this.#handle.id, data: { reason } });
|
|
654
|
+
if (outputError) throw outputError;
|
|
520
655
|
await this.#decision?.close().catch(() => {});
|
|
521
656
|
this.#decision = undefined;
|
|
522
657
|
await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task?.taskId ?? "")).catch(() => {});
|
|
523
658
|
}
|
|
524
659
|
|
|
660
|
+
async #drainOutputAfterStop(handle: WorkerHandle): Promise<void> {
|
|
661
|
+
const output = await this.#adapter.readOutput(handle);
|
|
662
|
+
if (!output.length) return;
|
|
663
|
+
try {
|
|
664
|
+
await this.#appendEvent({ type: "worker_output", taskId: this.#task?.taskId, workerId: handle.id, data: { chunks: output } });
|
|
665
|
+
} catch (error) {
|
|
666
|
+
if (this.#adapter.restoreOutput) await this.#adapter.restoreOutput(handle, output).catch(() => {});
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
525
671
|
async verify(command?: VerificationCommand): Promise<VerificationResult> {
|
|
526
672
|
return this.#exclusive(() => this.#verifyInternal(command));
|
|
527
673
|
}
|
|
@@ -529,17 +675,71 @@ export class Supervisor {
|
|
|
529
675
|
async #verifyInternal(command?: VerificationCommand): Promise<VerificationResult> {
|
|
530
676
|
await this.#flushPendingEvents();
|
|
531
677
|
if (!this.#task) throw new Error("no active task");
|
|
678
|
+
if (this.#machine.state === "waiting" && this.#adapter.capabilities().persistentSession) this.#machine.transition("verifying");
|
|
532
679
|
if (this.#machine.state !== "verifying") throw new Error(`cannot verify from ${this.#machine.state}`);
|
|
533
|
-
|
|
680
|
+
let result: VerificationResult;
|
|
681
|
+
try {
|
|
682
|
+
result = await verify(this.#task.cwd, command);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
await this.#failVerification(error);
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
this.#clearWatchdog();
|
|
688
|
+
let cleanupError: unknown;
|
|
689
|
+
if (this.#handle) {
|
|
690
|
+
try {
|
|
691
|
+
await this.#adapter.stop(this.#handle, result.ok ? "verification passed" : "verification failed");
|
|
692
|
+
await this.#drainOutputAfterStop(this.#handle);
|
|
693
|
+
const cleanup = await this.#adapter.getStatus(this.#handle);
|
|
694
|
+
if (cleanup.cleanupError) throw new Error(`worker cleanup failed after verification: ${cleanup.cleanupError}`);
|
|
695
|
+
if (cleanup.cgroupError && cleanup.cgroupRequired !== false) throw new Error(`worker cgroup cleanup failed after verification: ${cleanup.cgroupError}`);
|
|
696
|
+
if (this.#handle.ownership === "owned" && (cleanup.running || cleanup.processGroupCleaned !== true)) {
|
|
697
|
+
throw new Error("owned worker cleanup was not confirmed after verification");
|
|
698
|
+
}
|
|
699
|
+
} catch (error) {
|
|
700
|
+
cleanupError = error;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
534
703
|
this.#lastVerification = result;
|
|
535
|
-
|
|
536
|
-
|
|
704
|
+
const verificationSucceeded = result.ok && !cleanupError;
|
|
705
|
+
this.#machine.transition(verificationSucceeded ? "completed" : "failed");
|
|
706
|
+
await this.#appendEvent({ type: verificationSucceeded ? "verification_passed" : "verification_failed", taskId: this.#task.taskId, workerId: this.#handle?.id, data: { ...result, ...(cleanupError ? { cleanupError: safeMessage(cleanupError) } : {}) } });
|
|
537
707
|
await this.#decision?.close().catch(() => {});
|
|
538
708
|
this.#decision = undefined;
|
|
539
709
|
await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task.taskId)).catch(() => {});
|
|
710
|
+
if (cleanupError) throw cleanupError;
|
|
540
711
|
return result;
|
|
541
712
|
}
|
|
542
713
|
|
|
714
|
+
async #failVerification(error: unknown): Promise<void> {
|
|
715
|
+
this.#clearWatchdog();
|
|
716
|
+
let cleanupError: unknown;
|
|
717
|
+
if (this.#handle) {
|
|
718
|
+
try {
|
|
719
|
+
await this.#adapter.stop(this.#handle, "verification failed");
|
|
720
|
+
await this.#drainOutputAfterStop(this.#handle);
|
|
721
|
+
} catch (stopError) {
|
|
722
|
+
cleanupError = stopError;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (this.#machine.state === "verifying") this.#machine.transition("failed");
|
|
726
|
+
if (this.#task) {
|
|
727
|
+
try {
|
|
728
|
+
await this.#appendEvent({
|
|
729
|
+
type: "verification_failed",
|
|
730
|
+
taskId: this.#task.taskId,
|
|
731
|
+
workerId: this.#handle?.id,
|
|
732
|
+
data: { error: safeMessage(error), ...(cleanupError ? { cleanupError: safeMessage(cleanupError) } : {}) },
|
|
733
|
+
});
|
|
734
|
+
} catch {
|
|
735
|
+
// Preserve the verifier error; the event remains a pending lifecycle record.
|
|
736
|
+
}
|
|
737
|
+
await this.#decision?.close().catch(() => {});
|
|
738
|
+
this.#decision = undefined;
|
|
739
|
+
await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task.taskId)).catch(() => {});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
543
743
|
#armWatchdog(): void {
|
|
544
744
|
if (this.#deadlineMs <= 0 && this.#noOutputTimeoutMs <= 0) return;
|
|
545
745
|
this.#watchdog = setInterval(() => { void this.#checkWatchdog().catch(() => { /* lifecycle state is retained for the next explicit operation */ }); }, 1_000);
|
|
@@ -618,6 +818,19 @@ export class Supervisor {
|
|
|
618
818
|
}
|
|
619
819
|
}
|
|
620
820
|
|
|
821
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
822
|
+
let timer: NodeJS.Timeout | undefined;
|
|
823
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
824
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
825
|
+
timer.unref();
|
|
826
|
+
});
|
|
827
|
+
try {
|
|
828
|
+
return await Promise.race([promise, timeout]);
|
|
829
|
+
} finally {
|
|
830
|
+
if (timer) clearTimeout(timer);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
621
834
|
function workerEventKey(event: WorkerEvent): string {
|
|
622
835
|
if (event.type === "permission_request") return `${event.handle.id}:permission:${event.request.requestId}`;
|
|
623
836
|
if (event.type === "turn_completed") return `${event.handle.id}:result:${event.sequence}`;
|
|
@@ -627,5 +840,5 @@ function workerEventKey(event: WorkerEvent): string {
|
|
|
627
840
|
}
|
|
628
841
|
|
|
629
842
|
function safeMessage(error: unknown): string {
|
|
630
|
-
return error instanceof Error ? error.message : String(error);
|
|
843
|
+
return String(redactSensitive(error instanceof Error ? error.message : String(error)));
|
|
631
844
|
}
|
package/src/types.ts
CHANGED
|
@@ -36,6 +36,25 @@ export interface WorkerStartInput {
|
|
|
36
36
|
env?: NodeJS.ProcessEnv;
|
|
37
37
|
approval?: { actor: "human"; reason: string };
|
|
38
38
|
eventListener?: WorkerEventListener;
|
|
39
|
+
/** Attach to an existing tmux session instead of starting a new worker. */
|
|
40
|
+
tmuxSession?: string;
|
|
41
|
+
/** Optional tmux socket path; omitted means the user's default server. */
|
|
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
|
+
};
|
|
52
|
+
/** Adopted sessions must not replay the task as a new user message. */
|
|
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;
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
export interface WorkerHandle {
|
|
@@ -44,6 +63,15 @@ export interface WorkerHandle {
|
|
|
44
63
|
startedAt: string;
|
|
45
64
|
cwd: string;
|
|
46
65
|
sessionId?: string;
|
|
66
|
+
sessionName?: string;
|
|
67
|
+
tmuxSocket?: string;
|
|
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;
|
|
47
75
|
}
|
|
48
76
|
|
|
49
77
|
export interface WorkerStatus {
|
|
@@ -59,6 +87,8 @@ export interface WorkerStatus {
|
|
|
59
87
|
processGroupCleaned?: boolean;
|
|
60
88
|
/** Whether a Linux cgroup provided descendant cleanup for this worker. */
|
|
61
89
|
cgroupCleaned?: boolean;
|
|
90
|
+
/** Whether cgroup attachment was required for this worker. */
|
|
91
|
+
cgroupRequired?: boolean;
|
|
62
92
|
/** cgroup attachment was unavailable and a fallback may have been used. */
|
|
63
93
|
cgroupError?: string;
|
|
64
94
|
/** Cleanup failure is diagnostic and must be treated as a safety failure. */
|
|
@@ -75,6 +105,8 @@ export interface PermissionDecision {
|
|
|
75
105
|
export interface WorkerAdapter {
|
|
76
106
|
capabilities(): WorkerCapabilities;
|
|
77
107
|
start(input: WorkerStartInput): Promise<WorkerHandle>;
|
|
108
|
+
/** Cancel adapter-owned startup work before a WorkerHandle is returned. */
|
|
109
|
+
abortStart?(reason: string, startupToken?: string): Promise<void>;
|
|
78
110
|
getStatus(handle: WorkerHandle): Promise<WorkerStatus>;
|
|
79
111
|
readOutput(handle: WorkerHandle): Promise<WorkerOutputChunk[]>;
|
|
80
112
|
/** Restore chunks when diagnostic event persistence fails before acknowledgement. */
|
|
@@ -87,6 +119,8 @@ export interface WorkerAdapter {
|
|
|
87
119
|
pause(handle: WorkerHandle): Promise<void>;
|
|
88
120
|
resume(handle: WorkerHandle): Promise<void>;
|
|
89
121
|
stop(handle: WorkerHandle, reason: string): Promise<void>;
|
|
122
|
+
/** Disconnect the supervisor without stopping a persistent worker, if supported. */
|
|
123
|
+
release?(handle: WorkerHandle, reason: string): Promise<void>;
|
|
90
124
|
killProcessGroup(handle: WorkerHandle, reason: string): Promise<void>;
|
|
91
125
|
resumeSession(sessionId: string): Promise<WorkerHandle>;
|
|
92
126
|
}
|
|
@@ -98,11 +132,13 @@ export interface WorkerOutputChunk {
|
|
|
98
132
|
}
|
|
99
133
|
|
|
100
134
|
export interface WorkerCapabilities {
|
|
101
|
-
transport: "process-pipe" | "pty" | "jsonl";
|
|
135
|
+
transport: "process-pipe" | "pty" | "jsonl" | "tmux";
|
|
102
136
|
interactiveInput: boolean;
|
|
103
137
|
pause: boolean;
|
|
104
138
|
resumeSession: boolean;
|
|
105
139
|
processGroupControl: boolean;
|
|
140
|
+
/** The worker can remain alive while Pi disconnects from it. */
|
|
141
|
+
persistentSession?: boolean;
|
|
106
142
|
}
|
|
107
143
|
|
|
108
144
|
export interface TaskContext {
|