pi-claude-supervisor 0.2.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.
@@ -0,0 +1,626 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { EventLog, type SupervisorEvent } from "./events.ts";
4
+ import { SupervisorStateMachine } from "./state.ts";
5
+ import { evaluatePermission } from "./policy.ts";
6
+ import { PiDecisionWorker, type DecisionAction } from "./decision-worker.ts";
7
+ import { verify, type VerificationCommand } from "./verifier.ts";
8
+ import type {
9
+ TaskContext,
10
+ VerificationResult,
11
+ WorkerAdapter,
12
+ WorkerHandle,
13
+ WorkerEvent,
14
+ WorkerOutputChunk,
15
+ WorkerPermissionRequest,
16
+ WorkerStartInput,
17
+ WorkerStatus,
18
+ } from "./types.ts";
19
+
20
+ export interface DecisionSessionReadyInfo {
21
+ taskId: string;
22
+ task: string;
23
+ cwd: string;
24
+ sessionFile: string;
25
+ sessionId: string;
26
+ restored: boolean;
27
+ maxTurns: number;
28
+ deadlineMs: number;
29
+ noOutputTimeoutMs: number;
30
+ startedAt: string;
31
+ turn: number;
32
+ }
33
+
34
+ export interface SupervisorStartOptions {
35
+ /** Reuse an existing task id when explicitly recovering after a Pi restart. */
36
+ taskId?: string;
37
+ task: string;
38
+ /** Override the first worker message; recovery uses an empty message to avoid replay. */
39
+ initialInput?: string;
40
+ cwd: string;
41
+ command: string;
42
+ args?: string[];
43
+ env?: NodeJS.ProcessEnv;
44
+ maxTurns?: number;
45
+ /** Maximum wall-clock runtime; defaults to 4 hours for long development tasks. Set to 0 to disable. */
46
+ deadlineMs?: number;
47
+ /** Maximum time without worker output; defaults to 20 minutes. Set to 0 to disable. */
48
+ noOutputTimeoutMs?: number;
49
+ /** Human approval for a review-level worker command. */
50
+ approval?: { actor: "human"; reason: string };
51
+ /** Enable the event-driven Pi Decision Worker. Requires claude-jsonl. */
52
+ automation?: boolean;
53
+ /** Persistent Pi session location for the Decision Worker. */
54
+ decisionSessionFile?: string;
55
+ decisionSessionDir?: string;
56
+ /** Internal recovery values; elapsed wall time remains cumulative. */
57
+ startedAt?: string;
58
+ initialTurn?: number;
59
+ onDecisionSessionReady?: (info: DecisionSessionReadyInfo) => Promise<void> | void;
60
+ onDecisionSessionProgress?: (info: { taskId: string; turn: number }) => Promise<void> | void;
61
+ onDecisionSessionClosed?: (taskId: string) => Promise<void> | void;
62
+ onHumanRequired?: (notice: HumanInterventionNotice) => Promise<void> | void;
63
+ }
64
+
65
+ export interface HumanInterventionNotice {
66
+ taskId: string;
67
+ workerId?: string;
68
+ cwd: string;
69
+ task: string;
70
+ reason: string;
71
+ question?: string;
72
+ permission?: { requestId: string; toolUseId: string; toolName: string; input: unknown };
73
+ }
74
+
75
+ export class Supervisor {
76
+ readonly #adapter: WorkerAdapter;
77
+ readonly #events: EventLog;
78
+ readonly #machine = new SupervisorStateMachine();
79
+ #task?: TaskContext;
80
+ #handle?: WorkerHandle;
81
+ #lastVerification?: VerificationResult;
82
+ #turn = 0;
83
+ #watchdog?: NodeJS.Timeout;
84
+ #lifecycleTail: Promise<void> = Promise.resolve();
85
+ #pendingEvents: Array<Omit<SupervisorEvent, "seq" | "at">> = [];
86
+ #preemptiveStop?: Promise<void>;
87
+ #deadlineMs = 4 * 60 * 60_000;
88
+ #noOutputTimeoutMs = 20 * 60_000;
89
+ #automation = false;
90
+ #decision?: PiDecisionWorker;
91
+ #onHumanRequired?: (notice: HumanInterventionNotice) => Promise<void> | void;
92
+ #handledEvents = new Set<string>();
93
+ #pendingPermissions = new Map<string, WorkerPermissionRequest>();
94
+ #humanRequired = false;
95
+ #onDecisionSessionProgress?: (info: { taskId: string; turn: number }) => Promise<void> | void;
96
+ #onDecisionSessionClosed?: (taskId: string) => Promise<void> | void;
97
+
98
+ constructor(adapter: WorkerAdapter, events = new EventLog(), hooks: { onHumanRequired?: (notice: HumanInterventionNotice) => Promise<void> | void } = {}) {
99
+ this.#adapter = adapter;
100
+ this.#events = events;
101
+ this.#onHumanRequired = hooks.onHumanRequired;
102
+ }
103
+
104
+ get state() { return this.#machine.state; }
105
+ get task() { return this.#task; }
106
+ get handle() { return this.#handle; }
107
+ get lastVerification() { return this.#lastVerification; }
108
+ get humanRequired() { return this.#humanRequired; }
109
+
110
+ async start(options: SupervisorStartOptions): Promise<WorkerHandle> {
111
+ return this.#exclusive(() => this.#startInternal(options));
112
+ }
113
+
114
+ async #startInternal(options: SupervisorStartOptions): Promise<WorkerHandle> {
115
+ await this.#flushPendingEvents();
116
+ if (this.#machine.state === "completed" || this.#machine.state === "failed" || this.#machine.state === "stopped") this.#machine.reset();
117
+ if (this.#machine.state !== "idle") throw new Error(`cannot start from ${this.#machine.state}`);
118
+ const taskId = options.taskId ?? randomUUID();
119
+ this.#handle = undefined;
120
+ this.#lastVerification = undefined;
121
+ this.#preemptiveStop = undefined;
122
+ this.#automation = options.automation ?? false;
123
+ this.#onDecisionSessionProgress = options.onDecisionSessionProgress;
124
+ this.#onDecisionSessionClosed = options.onDecisionSessionClosed;
125
+ this.#handledEvents.clear();
126
+ this.#pendingPermissions.clear();
127
+ this.#humanRequired = false;
128
+ this.#task = { taskId, task: options.task, cwd: options.cwd, maxTurns: options.maxTurns ?? 100, startedAt: options.startedAt ?? new Date().toISOString() };
129
+ this.#turn = options.initialTurn ?? 0;
130
+ this.#deadlineMs = options.deadlineMs ?? 4 * 60 * 60_000;
131
+ this.#noOutputTimeoutMs = options.noOutputTimeoutMs ?? 20 * 60_000;
132
+ this.#clearWatchdog();
133
+ this.#machine.transition("starting");
134
+ try {
135
+ await this.#appendEvent({
136
+ type: "task_started",
137
+ taskId,
138
+ data: {
139
+ cwd: options.cwd,
140
+ command: options.command,
141
+ ...(options.approval ? { approval: options.approval } : {}),
142
+ },
143
+ });
144
+ if (this.#automation && this.#adapter.capabilities().transport !== "jsonl") {
145
+ throw new Error("automatic supervision requires claude-jsonl transport");
146
+ }
147
+ if (this.#automation) {
148
+ this.#decision = new PiDecisionWorker({
149
+ context: { taskId, task: options.task, cwd: options.cwd, state: this.#machine.state, turn: this.#turn, maxTurns: this.#task.maxTurns },
150
+ sessionFile: options.decisionSessionFile,
151
+ sessionDir: options.decisionSessionDir ? join(options.decisionSessionDir, taskId) : undefined,
152
+ onSessionReady: (info) => options.onDecisionSessionReady?.({
153
+ taskId,
154
+ task: options.task,
155
+ cwd: options.cwd,
156
+ ...info,
157
+ maxTurns: this.#task!.maxTurns,
158
+ deadlineMs: this.#deadlineMs,
159
+ noOutputTimeoutMs: this.#noOutputTimeoutMs,
160
+ startedAt: this.#task!.startedAt,
161
+ turn: this.#turn,
162
+ }),
163
+ onAction: (action, event) => this.#applyDecision(action, event),
164
+ onFailure: (event, error) => this.#decisionFailure(event, error),
165
+ onStartupFailure: (error) => this.#decisionStartupFailure(error),
166
+ });
167
+ await this.#decision.start();
168
+ }
169
+ const input: WorkerStartInput = {
170
+ task: options.initialInput ?? options.task,
171
+ cwd: options.cwd,
172
+ command: options.command,
173
+ args: options.args,
174
+ env: options.env,
175
+ approval: options.approval,
176
+ eventListener: (event) => this.#receiveWorkerEvent(event),
177
+ };
178
+ this.#handle = await this.#adapter.start(input);
179
+ this.#machine.transition("running");
180
+ await this.#appendEvent({ type: "worker_started", taskId, workerId: this.#handle.id, data: { pid: this.#handle.pid } });
181
+ this.#armWatchdog();
182
+ return this.#handle;
183
+ } catch (error) {
184
+ const startFailureHandle = (error as { workerHandle?: WorkerHandle }).workerHandle;
185
+ if (!this.#handle && startFailureHandle?.pid) this.#handle = startFailureHandle;
186
+ const handle = this.#handle;
187
+ if (handle) {
188
+ try {
189
+ await this.#adapter.stop(handle, "startup failed");
190
+ } catch {
191
+ try { await this.#adapter.killProcessGroup(handle, "startup cleanup"); } catch { /* preserve startup error */ }
192
+ }
193
+ }
194
+ if (["starting", "running"].includes(this.#machine.state)) this.#machine.transition("failed");
195
+ try {
196
+ await this.#appendEvent({ type: "worker_start_failed", taskId, data: { error: safeMessage(error) } });
197
+ } catch { /* logging failure must not hide the startup failure */ }
198
+ await this.#decision?.close().catch(() => {});
199
+ this.#decision = undefined;
200
+ await Promise.resolve(this.#onDecisionSessionClosed?.(taskId)).catch(() => {});
201
+ throw error;
202
+ }
203
+ }
204
+
205
+ async poll(): Promise<{ status: WorkerStatus; output: WorkerOutputChunk[] }> {
206
+ return this.#exclusive(() => this.#pollInternal());
207
+ }
208
+
209
+ async #pollInternal(intentionalVerification = false): Promise<{ status: WorkerStatus; output: WorkerOutputChunk[] }> {
210
+ await this.#flushPendingEvents();
211
+ const taskId = this.#task?.taskId;
212
+ const handle = this.#handle;
213
+ if (!taskId || !handle) throw new Error("no active task");
214
+ const output = await this.#adapter.readOutput(handle);
215
+ const status = await this.#adapter.getStatus(handle);
216
+ if (output.length) {
217
+ try {
218
+ await this.#events.append({ type: "worker_output", taskId, workerId: handle.id, data: { chunks: output } });
219
+ } catch (error) {
220
+ if (this.#adapter.restoreOutput) {
221
+ try { await this.#adapter.restoreOutput(handle, output); } catch { /* preserve append failure */ }
222
+ }
223
+ throw error;
224
+ }
225
+ }
226
+ if (status.running && status.activeRequests === 0 && this.#machine.state === "running") {
227
+ this.#machine.transition("waiting");
228
+ await this.#appendEvent({ type: "worker_waiting", taskId, workerId: handle.id });
229
+ }
230
+ if (!status.running && ["running", "waiting", "paused"].includes(this.#machine.state)) {
231
+ this.#clearWatchdog();
232
+ const cleanupSafe = !status.cleanupError && status.processGroupCleaned === true;
233
+ if ((status.exitReason === "completed" || intentionalVerification) && cleanupSafe) this.#machine.transition("verifying");
234
+ else this.#machine.transition("failed");
235
+ await this.#appendEvent({ type: "worker_exited", taskId, workerId: handle.id, data: { exitCode: status.exitCode, signal: status.signal, reason: status.exitReason, cleanupSafe, cleanupError: status.cleanupError } });
236
+ if (this.#machine.state === "failed") {
237
+ await this.#decision?.close().catch(() => {});
238
+ this.#decision = undefined;
239
+ await Promise.resolve(this.#onDecisionSessionClosed?.(taskId)).catch(() => {});
240
+ }
241
+ }
242
+ return { status, output };
243
+ }
244
+
245
+ #receiveWorkerEvent(event: WorkerEvent): void {
246
+ if (event.type === "output" || event.type === "jsonl") return;
247
+ void this.#exclusive(() => this.#processWorkerEvent(event)).catch((error) => {
248
+ void this.#appendEvent({ type: "worker_event_error", taskId: this.#task?.taskId, workerId: event.handle.id, data: { error: safeMessage(error), eventType: event.type } }).catch(() => {});
249
+ });
250
+ }
251
+
252
+ async #processWorkerEvent(event: WorkerEvent): Promise<void> {
253
+ const taskId = this.#task?.taskId;
254
+ const handle = this.#handle;
255
+ if (!taskId || !handle || event.handle.id !== handle.id) return;
256
+ const key = workerEventKey(event);
257
+ if (this.#handledEvents.has(key)) return;
258
+ this.#handledEvents.add(key);
259
+ if (event.type === "turn_completed" || event.type === "exited") await this.#pollInternal();
260
+ if (event.type === "permission_request") {
261
+ this.#pendingPermissions.set(event.request.requestId, event.request);
262
+ await this.#appendEvent({
263
+ type: "permission_requested",
264
+ taskId,
265
+ workerId: handle.id,
266
+ data: { requestId: event.request.requestId, toolUseId: event.request.toolUseId, toolName: event.request.toolName, input: event.request.input },
267
+ });
268
+ }
269
+ if (this.#decision && (event.type === "permission_request" || event.type === "turn_completed" || event.type === "exited")) {
270
+ this.#decision.updateContext({ state: this.#machine.state, turn: this.#turn });
271
+ this.#decision.notify(event);
272
+ }
273
+ }
274
+
275
+ async #decisionStartupFailure(error: unknown): Promise<void> {
276
+ const task = this.#task;
277
+ if (!task) return;
278
+ const reason = `Decision Worker API failed during initialization: ${safeMessage(error)}`;
279
+ try {
280
+ await this.#appendEvent({ type: "decision_worker_failed", taskId: task.taskId, data: { eventType: "startup", error: safeMessage(error) } });
281
+ } catch (auditError) {
282
+ console.error(`pi-claude-supervisor decision startup audit failed: ${safeMessage(auditError)}`);
283
+ }
284
+ const notice: HumanInterventionNotice = { taskId: task.taskId, cwd: task.cwd, task: task.task, reason };
285
+ try {
286
+ await this.#appendEvent({ type: "human_intervention_required", taskId: task.taskId, data: notice as unknown as Record<string, unknown> });
287
+ } catch (auditError) {
288
+ console.error(`pi-claude-supervisor human intervention audit failed: ${safeMessage(auditError)}`);
289
+ }
290
+ try {
291
+ if (this.#onHumanRequired) await this.#onHumanRequired(notice);
292
+ else console.error(`pi-claude-supervisor human intervention required: ${reason}`);
293
+ } catch (notifyError) {
294
+ console.error(`pi-claude-supervisor human intervention notification failed: ${safeMessage(notifyError)}`);
295
+ }
296
+ }
297
+
298
+ async #decisionFailure(event: WorkerEvent, error: unknown): Promise<void> {
299
+ return this.#exclusive(async () => {
300
+ let auditError: unknown;
301
+ try {
302
+ await this.#appendEvent({
303
+ type: "decision_worker_failed",
304
+ taskId: this.#task?.taskId,
305
+ workerId: event.handle.id,
306
+ data: { eventType: event.type, error: safeMessage(error) },
307
+ });
308
+ } catch (failure) {
309
+ auditError = failure;
310
+ }
311
+ let noticeError: unknown;
312
+ try {
313
+ await this.#requestHuman(`Decision Worker API failed: ${safeMessage(error)}`, event);
314
+ } catch (failure) {
315
+ noticeError = failure;
316
+ }
317
+ if (auditError || noticeError) throw new AggregateError([auditError, noticeError].filter(Boolean), "Decision Worker failure handling failed");
318
+ });
319
+ }
320
+
321
+ async #applyDecision(action: DecisionAction, event: WorkerEvent): Promise<void> {
322
+ return this.#exclusive(async () => {
323
+ const task = this.#task;
324
+ const handle = this.#handle;
325
+ if (!task || !handle || !this.#automation || this.#humanRequired) return;
326
+ const actionKey = `${workerEventKey(event)}:${action.action}`;
327
+ if (this.#handledEvents.has(actionKey)) return;
328
+ this.#handledEvents.add(actionKey);
329
+ await this.#appendEvent({ type: "decision_made", taskId: task.taskId, workerId: handle.id, data: { action: action.action, reason: action.reason, confidence: action.confidence } });
330
+ if (action.action === "allow_permission" || action.action === "deny_permission") {
331
+ if (event.type !== "permission_request" || !this.#adapter.respondPermission) {
332
+ await this.#requestHuman(`Permission response is unavailable for ${event.type}`, event);
333
+ return;
334
+ }
335
+ const policy = evaluatePermission(event.request.toolName, event.request.input);
336
+ if (policy.decision === "review" && !(event.request.toolName === "AskUserQuestion" && action.action === "deny_permission")) {
337
+ await this.#requestHuman(policy.reason, event);
338
+ return;
339
+ }
340
+ const behavior = policy.decision === "deny" ? "deny" : action.action === "allow_permission" ? "allow" : "deny";
341
+ await this.#adapter.respondPermission(handle, event.request.requestId, event.request.toolUseId, {
342
+ behavior,
343
+ message: behavior === "deny" ? `${policy.reason}; denied by supervisor` : undefined,
344
+ }, behavior === "allow" ? event.request.input : undefined);
345
+ await this.#appendEvent({ type: "permission_decision", taskId: task.taskId, workerId: handle.id, data: { requestId: event.request.requestId, toolName: event.request.toolName, behavior, policy: policy.decision } });
346
+ return;
347
+ }
348
+ if (action.action === "continue" || action.action === "redirect" || action.action === "answer") {
349
+ await this.#sendInternal(action.message);
350
+ return;
351
+ }
352
+ if (action.action === "verify") {
353
+ if (this.#machine.state === "waiting") {
354
+ await this.#adapter.stop(handle, "Decision Worker requested verification");
355
+ await this.#pollInternal(true);
356
+ }
357
+ if (this.#machine.state === "verifying") await this.#verifyInternal();
358
+ else await this.#requestHuman(`Decision Worker requested verification from state ${this.#machine.state}`, event);
359
+ return;
360
+ }
361
+ if (action.action === "ask_human") {
362
+ await this.#requestHuman(action.reason, event, action.question);
363
+ return;
364
+ }
365
+ if (action.action === "stop") {
366
+ await this.#stopInternal(`Decision Worker: ${action.reason}`);
367
+ return;
368
+ }
369
+ if (action.action === "retry") {
370
+ await this.#requestHuman(`Retry requires a concrete corrective instruction: ${action.reason}`, event);
371
+ }
372
+ });
373
+ }
374
+
375
+ async #requestHuman(reason: string, event: WorkerEvent, question?: string): Promise<void> {
376
+ const task = this.#task;
377
+ const handle = this.#handle;
378
+ if (!task) return;
379
+ const permission = event.type === "permission_request" ? {
380
+ requestId: event.request.requestId,
381
+ toolUseId: event.request.toolUseId,
382
+ toolName: event.request.toolName,
383
+ input: event.request.input,
384
+ } : undefined;
385
+ this.#humanRequired = true;
386
+ const notice: HumanInterventionNotice = { taskId: task.taskId, workerId: handle?.id, cwd: task.cwd, task: task.task, reason, question, permission };
387
+ let logError: unknown;
388
+ try {
389
+ await this.#appendEvent({ type: "human_intervention_required", taskId: task.taskId, workerId: handle?.id, data: notice as unknown as Record<string, unknown> });
390
+ } catch (error) {
391
+ logError = error;
392
+ }
393
+ // Alert delivery is independent from event-log persistence: a broken audit
394
+ // path must not suppress the operator notification.
395
+ if (this.#onHumanRequired) await this.#onHumanRequired(notice);
396
+ else console.error(`pi-claude-supervisor human intervention required: ${reason}`);
397
+ if (logError) throw logError;
398
+ }
399
+
400
+ async approvePermission(behavior: "allow" | "deny", requestId?: string): Promise<void> {
401
+ return this.#exclusive(async () => {
402
+ const task = this.#task;
403
+ const handle = this.#handle;
404
+ if (!task || !handle || !this.#adapter.respondPermission) throw new Error("permission responses are unavailable");
405
+ const request = requestId ? this.#pendingPermissions.get(requestId) : [...this.#pendingPermissions.values()].at(-1);
406
+ if (!request) throw new Error("no pending permission request");
407
+ const policy = evaluatePermission(request.toolName, request.input);
408
+ if (policy.decision === "deny" && behavior === "allow") throw new Error(`permission denied by policy: ${policy.reason}`);
409
+ await this.#adapter.respondPermission(handle, request.requestId, request.toolUseId, { behavior: policy.decision === "deny" ? "deny" : behavior }, behavior === "allow" ? request.input : undefined);
410
+ this.#pendingPermissions.delete(request.requestId);
411
+ this.#humanRequired = false;
412
+ await this.#appendEvent({ type: "permission_decision", taskId: task.taskId, workerId: handle.id, data: { requestId: request.requestId, toolName: request.toolName, behavior: policy.decision === "deny" ? "deny" : behavior, actor: "human", policy: policy.decision } });
413
+ });
414
+ }
415
+
416
+ async takeover(): Promise<void> {
417
+ return this.#exclusive(async () => {
418
+ this.#humanRequired = true;
419
+ await this.#appendEvent({ type: "human_takeover", taskId: this.#task?.taskId, workerId: this.#handle?.id });
420
+ });
421
+ }
422
+
423
+ async resumeAutomation(): Promise<void> {
424
+ return this.#exclusive(async () => {
425
+ if (!this.#automation) throw new Error("automatic mode is not enabled");
426
+ this.#humanRequired = false;
427
+ await this.#appendEvent({ type: "automation_resumed", taskId: this.#task?.taskId, workerId: this.#handle?.id });
428
+ });
429
+ }
430
+
431
+ async send(message: string): Promise<void> {
432
+ return this.#exclusive(() => this.#sendInternal(message));
433
+ }
434
+
435
+ async #sendInternal(message: string): Promise<void> {
436
+ await this.#flushPendingEvents();
437
+ const taskId = this.#task?.taskId;
438
+ const handle = this.#handle;
439
+ if (!taskId || !handle) throw new Error("no active task");
440
+ if (!["running", "waiting"].includes(this.#machine.state)) throw new Error(`cannot send from ${this.#machine.state}`);
441
+ const status = await this.#adapter.getStatus(handle);
442
+ if (status.activeRequests !== undefined && status.activeRequests > 0) {
443
+ throw new Error("worker has an active JSONL request; poll until its result before sending another turn");
444
+ }
445
+ if (status.activeRequests === 0 && this.#machine.state === "running") {
446
+ this.#machine.transition("waiting");
447
+ await this.#appendEvent({ type: "worker_waiting", taskId, workerId: handle.id });
448
+ }
449
+ if (++this.#turn > (this.#task?.maxTurns ?? 100)) throw new Error("supervisor turn budget exhausted");
450
+ await this.#adapter.send(handle, message, `${taskId}:turn:${this.#turn}`);
451
+ if (this.#machine.state === "waiting") this.#machine.transition("running");
452
+ await this.#appendEvent({ type: "worker_message_sent", taskId, workerId: handle.id, idempotencyKey: `${taskId}:turn:${this.#turn}`, data: { message } });
453
+ await Promise.resolve(this.#onDecisionSessionProgress?.({ taskId, turn: this.#turn })).catch(() => {});
454
+ }
455
+
456
+ async pause(): Promise<void> {
457
+ return this.#exclusive(() => this.#pauseInternal());
458
+ }
459
+
460
+ async #pauseInternal(): Promise<void> {
461
+ await this.#flushPendingEvents();
462
+ if (!this.#handle || this.#machine.state !== "running") throw new Error("worker is not running");
463
+ await this.#adapter.pause(this.#handle);
464
+ this.#machine.transition("paused");
465
+ await this.#appendEvent({ type: "worker_paused", taskId: this.#task?.taskId, workerId: this.#handle.id });
466
+ }
467
+
468
+ async resume(): Promise<void> {
469
+ return this.#exclusive(() => this.#resumeInternal());
470
+ }
471
+
472
+ async #resumeInternal(): Promise<void> {
473
+ await this.#flushPendingEvents();
474
+ if (!this.#handle || this.#machine.state !== "paused") throw new Error("worker is not paused");
475
+ await this.#adapter.resume(this.#handle);
476
+ this.#machine.transition("running");
477
+ await this.#appendEvent({ type: "worker_resumed", taskId: this.#task?.taskId, workerId: this.#handle.id });
478
+ }
479
+
480
+ async abortStart(reason = "startup aborted"): Promise<void> {
481
+ // This path intentionally bypasses #exclusive(): start() may be blocked in
482
+ // a Decision Worker model call and shutdown must still dispose that session.
483
+ await this.#decision?.close().catch(() => {});
484
+ this.#decision = undefined;
485
+ if (this.#handle) await this.#adapter.stop(this.#handle, reason).catch(() => {});
486
+ }
487
+
488
+ async stop(reason = "human requested stop"): Promise<void> {
489
+ // Start the adapter stop immediately so a queued/hung send cannot delay
490
+ // process termination. State/event changes still remain serialized below.
491
+ if (!this.#preemptiveStop && this.#handle && ["starting", "running", "waiting", "paused"].includes(this.#machine.state)) {
492
+ this.#preemptiveStop = this.#adapter.stop(this.#handle, reason);
493
+ void this.#preemptiveStop.catch(() => { /* consumed by serialized stop */ });
494
+ }
495
+ return this.#exclusive(() => this.#stopInternal(reason));
496
+ }
497
+
498
+ async #stopInternal(reason: string, flushPendingEvents = true): Promise<void> {
499
+ if (flushPendingEvents) await this.#flushPendingEvents();
500
+ if (!this.#handle) throw new Error("no active task");
501
+ if (this.#machine.state === "stopped") return;
502
+ if (this.#machine.state === "failed") {
503
+ // A failed startup or cleanup attempt may still retain a live handle.
504
+ // Retry group termination during shutdown instead of treating the state
505
+ // as fully reclaimed.
506
+ if (this.#handle) await this.#adapter.stop(this.#handle, reason);
507
+ await this.#decision?.close().catch(() => {});
508
+ this.#decision = undefined;
509
+ await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task?.taskId ?? "")).catch(() => {});
510
+ return;
511
+ }
512
+ if (!["running", "waiting", "paused", "starting"].includes(this.#machine.state)) return;
513
+ this.#clearWatchdog();
514
+ const preemptiveStop = this.#preemptiveStop;
515
+ this.#preemptiveStop = undefined;
516
+ if (preemptiveStop) await preemptiveStop;
517
+ else await this.#adapter.stop(this.#handle, reason);
518
+ this.#machine.transition("stopped");
519
+ await this.#appendEvent({ type: "worker_stopped", taskId: this.#task?.taskId, workerId: this.#handle.id, data: { reason } });
520
+ await this.#decision?.close().catch(() => {});
521
+ this.#decision = undefined;
522
+ await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task?.taskId ?? "")).catch(() => {});
523
+ }
524
+
525
+ async verify(command?: VerificationCommand): Promise<VerificationResult> {
526
+ return this.#exclusive(() => this.#verifyInternal(command));
527
+ }
528
+
529
+ async #verifyInternal(command?: VerificationCommand): Promise<VerificationResult> {
530
+ await this.#flushPendingEvents();
531
+ if (!this.#task) throw new Error("no active task");
532
+ if (this.#machine.state !== "verifying") throw new Error(`cannot verify from ${this.#machine.state}`);
533
+ const result = await verify(this.#task.cwd, command);
534
+ this.#lastVerification = result;
535
+ this.#machine.transition(result.ok ? "completed" : "failed");
536
+ await this.#appendEvent({ type: result.ok ? "verification_passed" : "verification_failed", taskId: this.#task.taskId, workerId: this.#handle?.id, data: { ...result } });
537
+ await this.#decision?.close().catch(() => {});
538
+ this.#decision = undefined;
539
+ await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task.taskId)).catch(() => {});
540
+ return result;
541
+ }
542
+
543
+ #armWatchdog(): void {
544
+ if (this.#deadlineMs <= 0 && this.#noOutputTimeoutMs <= 0) return;
545
+ this.#watchdog = setInterval(() => { void this.#checkWatchdog().catch(() => { /* lifecycle state is retained for the next explicit operation */ }); }, 1_000);
546
+ this.#watchdog.unref();
547
+ }
548
+
549
+ #clearWatchdog(): void {
550
+ if (this.#watchdog) clearInterval(this.#watchdog);
551
+ this.#watchdog = undefined;
552
+ }
553
+
554
+ async #checkWatchdog(): Promise<void> {
555
+ return this.#exclusive(() => this.#checkWatchdogInternal());
556
+ }
557
+
558
+ async #checkWatchdogInternal(): Promise<void> {
559
+ if (!this.#task || !this.#handle || !["running", "waiting", "paused"].includes(this.#machine.state)) return;
560
+ const status = await this.#adapter.getStatus(this.#handle);
561
+ if (!status.running) return;
562
+ const now = Date.now();
563
+ const startedAt = Date.parse(this.#task.startedAt);
564
+ const lastOutputAt = status.lastOutputAt ? Date.parse(status.lastOutputAt) : startedAt;
565
+ const reason = this.#deadlineMs > 0 && now - startedAt >= this.#deadlineMs
566
+ ? "worker deadline exceeded"
567
+ : this.#noOutputTimeoutMs > 0 && now - lastOutputAt >= this.#noOutputTimeoutMs
568
+ ? "worker produced no output before timeout"
569
+ : undefined;
570
+ if (!reason) return;
571
+ let timeoutEventError: unknown;
572
+ try {
573
+ await this.#appendEvent({ type: "worker_watchdog_timeout", taskId: this.#task.taskId, workerId: this.#handle.id, data: { reason } });
574
+ } catch (error) {
575
+ timeoutEventError = error;
576
+ }
577
+ // Termination must not wait for a persistently failing event log. The
578
+ // timeout event remains queued and is retried after the adapter stop.
579
+ await this.#stopInternal(reason, false);
580
+ if (timeoutEventError) throw timeoutEventError;
581
+ }
582
+
583
+ async #appendEvent(event: Omit<SupervisorEvent, "seq" | "at">): Promise<void> {
584
+ try {
585
+ await this.#flushPendingEvents();
586
+ await this.#events.append(event);
587
+ } catch (error) {
588
+ this.#pendingEvents.push(event);
589
+ throw error;
590
+ }
591
+ }
592
+
593
+ async #flushPendingEvents(): Promise<void> {
594
+ while (this.#pendingEvents.length > 0) {
595
+ const event = this.#pendingEvents[0];
596
+ await this.#events.append(event);
597
+ this.#pendingEvents.shift();
598
+ }
599
+ }
600
+
601
+ #exclusive<T>(operation: () => Promise<T>): Promise<T> {
602
+ const previous = this.#lifecycleTail;
603
+ let release!: () => void;
604
+ const gate = new Promise<void>((resolve) => { release = resolve; });
605
+ this.#lifecycleTail = previous.then(() => gate);
606
+ return previous.then(async () => {
607
+ try {
608
+ return await operation();
609
+ } finally {
610
+ release();
611
+ }
612
+ });
613
+ }
614
+ }
615
+
616
+ function workerEventKey(event: WorkerEvent): string {
617
+ if (event.type === "permission_request") return `${event.handle.id}:permission:${event.request.requestId}`;
618
+ if (event.type === "turn_completed") return `${event.handle.id}:result:${event.sequence}`;
619
+ if (event.type === "exited") return `${event.handle.id}:exit`;
620
+ if (event.type === "jsonl") return `${event.handle.id}:jsonl:${String(event.record.uuid ?? event.record.request_id ?? JSON.stringify(event.record))}`;
621
+ return `${event.handle.id}:output:${event.chunk.at}:${event.chunk.text.slice(0, 80)}`;
622
+ }
623
+
624
+ function safeMessage(error: unknown): string {
625
+ return error instanceof Error ? error.message : String(error);
626
+ }