pi-claude-supervisor 0.5.0 → 0.5.2
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 +30 -1
- package/README.cn.md +26 -9
- package/README.md +38 -18
- package/docs/architecture.md +71 -15
- package/docs/automation-hardening-plan.md +211 -0
- package/docs/engineering-plan.md +76 -3
- package/docs/recovery-review.md +24 -0
- package/docs/stability-matrix-2.1.270.md +32 -0
- package/docs/testing.md +90 -21
- package/package.json +1 -1
- package/src/cwd-lease.ts +95 -6
- package/src/decision-session-store.ts +343 -37
- package/src/decision-worker.ts +97 -13
- package/src/index.ts +189 -88
- package/src/redaction.ts +2 -1
- package/src/reviewer.ts +69 -16
- package/src/supervisor.ts +276 -45
- package/src/types.ts +6 -2
- package/src/verifier.ts +180 -22
- package/src/worker/process-adapter.ts +245 -47
- package/src/worker/tmux-adapter.ts +42 -3
package/src/index.ts
CHANGED
|
@@ -2,12 +2,13 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
|
+
import { accessSync, chmodSync, constants as fsConstants, lstatSync, mkdirSync } from "node:fs";
|
|
5
6
|
import { readFile, realpath } from "node:fs/promises";
|
|
6
7
|
import { EventLog } from "./events.ts";
|
|
7
8
|
import { redactSensitive } from "./redaction.ts";
|
|
8
9
|
import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
|
|
9
10
|
import { TmuxWorkerAdapter, attachCommand } from "./worker/tmux-adapter.ts";
|
|
10
|
-
import { Supervisor } from "./supervisor.ts";
|
|
11
|
+
import { Supervisor, type DecisionSessionClosedInfo, type SupervisorProgress } from "./supervisor.ts";
|
|
11
12
|
import { evaluateCommand } from "./policy.ts";
|
|
12
13
|
import { HumanWebhookNotifier } from "./notifications.ts";
|
|
13
14
|
import { loadSupervisorEnvironment } from "./config.ts";
|
|
@@ -15,7 +16,7 @@ import { DecisionSessionStore, type DecisionSessionRecord } from "./decision-ses
|
|
|
15
16
|
import { CwdLeaseStore, type CwdLeaseHandle, pathsOverlap, workerIdentity } from "./cwd-lease.ts";
|
|
16
17
|
import { normalizeTaskSpec } from "./acceptance.ts";
|
|
17
18
|
import { PiReadOnlyReviewer } from "./reviewer.ts";
|
|
18
|
-
import type { TaskSpec } from "./types.ts";
|
|
19
|
+
import type { TaskSpec, WorkerHandle } from "./types.ts";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Pi Claude Supervisor.
|
|
@@ -40,6 +41,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
40
41
|
if (transport === "tmux" && cgroupMode === "required") {
|
|
41
42
|
throw new Error("PI_CLAUDE_SUPERVISOR_CGROUP_MODE=required is unsupported with tmux; use process-pipe/jsonl or set cgroup mode to auto/off");
|
|
42
43
|
}
|
|
44
|
+
if (automation && transport === "process-pipe") {
|
|
45
|
+
throw new Error("automatic supervision requires PI_CLAUDE_SUPERVISOR_TRANSPORT=jsonl or tmux; explicit process-pipe is manual-only");
|
|
46
|
+
}
|
|
43
47
|
const adapter = transport === "tmux"
|
|
44
48
|
? new TmuxWorkerAdapter({ stateDir })
|
|
45
49
|
: new ProcessWorkerAdapter({
|
|
@@ -53,9 +57,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
53
57
|
format: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_FORMAT === "wecom" ? "wecom" : "generic",
|
|
54
58
|
secret: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_SECRET,
|
|
55
59
|
});
|
|
60
|
+
const leaseDir = process.env.PI_CLAUDE_SUPERVISOR_CWD_LEASE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor", "cwd-leases");
|
|
61
|
+
assertRuntimeDirectory(stateDir, "state");
|
|
62
|
+
assertRuntimeDirectory(leaseDir, "lease");
|
|
56
63
|
const events = new EventLog(join(stateDir, "events.jsonl"));
|
|
57
64
|
const decisionStore = new DecisionSessionStore(join(stateDir, "decision-sessions"));
|
|
58
|
-
const cwdLeaseStore = new CwdLeaseStore(
|
|
65
|
+
const cwdLeaseStore = new CwdLeaseStore(leaseDir);
|
|
59
66
|
const reviewer = automation ? new PiReadOnlyReviewer() : undefined;
|
|
60
67
|
const sessions = new Map<string, Supervisor>();
|
|
61
68
|
const cwdLeases = new Map<string, CwdLeaseHandle>();
|
|
@@ -69,9 +76,23 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
69
76
|
let shutdownPromise: Promise<void> | undefined;
|
|
70
77
|
let detachedLeaseSweepTimer: NodeJS.Timeout | undefined;
|
|
71
78
|
|
|
79
|
+
const handleDecisionSessionClosed = async (taskId: string, info: DecisionSessionClosedInfo): Promise<void> => {
|
|
80
|
+
const record = await decisionStore.load(taskId);
|
|
81
|
+
if (!record || record.state !== "active") return;
|
|
82
|
+
const terminal = info.reason === "completed" || info.reason === "human_stop";
|
|
83
|
+
if (terminal && info.cleanupConfirmed) {
|
|
84
|
+
await decisionStore.close(taskId);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
await decisionStore.markRecoveryInterrupted(taskId);
|
|
88
|
+
};
|
|
89
|
+
|
|
72
90
|
const notify = (ctx: ExtensionContext, message: string, type: "info" | "warning" = "info") => {
|
|
73
91
|
if (ctx.hasUI) ctx.ui.notify(redactText(message), type);
|
|
74
92
|
};
|
|
93
|
+
const progress = (ctx: ExtensionContext) => (info: SupervisorProgress) => {
|
|
94
|
+
notify(ctx, `Worker progress: task=${info.taskId} phase=${info.phase} ${info.message}`, info.phase === "human" || info.phase === "failed" ? "warning" : "info");
|
|
95
|
+
};
|
|
75
96
|
const activeSessions = () => [...sessions.entries()].filter(([, session]) =>
|
|
76
97
|
["starting", "running", "waiting", "paused"].includes(session.state));
|
|
77
98
|
const releaseLease = async (taskId: string): Promise<boolean> => {
|
|
@@ -121,7 +142,18 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
121
142
|
});
|
|
122
143
|
}, 1_000);
|
|
123
144
|
detachedLeaseSweepTimer.unref();
|
|
124
|
-
const
|
|
145
|
+
const workerCleanupConfirmed = async (handle: NonNullable<Supervisor["handle"]>): Promise<boolean> => {
|
|
146
|
+
try {
|
|
147
|
+
const status = await adapter.getStatus(handle);
|
|
148
|
+
return !status.running
|
|
149
|
+
&& status.processGroupCleaned === true
|
|
150
|
+
&& !status.cleanupError
|
|
151
|
+
&& (!status.cgroupError || status.cgroupRequired === false);
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const stopSession = async (session: Supervisor, reason: string, releasePersistent = false, preserveDecisionSession = false): Promise<void> => {
|
|
125
157
|
const handle = session.handle;
|
|
126
158
|
const persistent = adapter.capabilities().persistentSession && Boolean(handle);
|
|
127
159
|
const taskId = session.task?.taskId;
|
|
@@ -136,7 +168,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
136
168
|
if (!handle && ["failed", "completed", "stopped"].includes(session.state)) return;
|
|
137
169
|
let lifecycleError: unknown;
|
|
138
170
|
try {
|
|
139
|
-
await session.stop(reason);
|
|
171
|
+
await session.stop(reason, { preserveDecisionSession });
|
|
140
172
|
} catch (error) {
|
|
141
173
|
lifecycleError = error;
|
|
142
174
|
}
|
|
@@ -253,6 +285,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
253
285
|
try {
|
|
254
286
|
const handle = await session.start({
|
|
255
287
|
taskId,
|
|
288
|
+
onProgress: progress(ctx),
|
|
256
289
|
task: goal,
|
|
257
290
|
spec,
|
|
258
291
|
cwd: cwdKey,
|
|
@@ -273,26 +306,34 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
273
306
|
} : undefined,
|
|
274
307
|
sendInitialInput: !tmuxSession,
|
|
275
308
|
decisionSessionDir: decisionStore.directory,
|
|
276
|
-
onDecisionSessionReady: (info) =>
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
309
|
+
onDecisionSessionReady: async (info) => {
|
|
310
|
+
const current = await decisionStore.load(info.taskId);
|
|
311
|
+
await decisionStore.save({
|
|
312
|
+
taskId: info.taskId,
|
|
313
|
+
task: info.task,
|
|
314
|
+
spec: info.spec,
|
|
315
|
+
cwd: info.cwd,
|
|
316
|
+
command,
|
|
317
|
+
args: workerArgs,
|
|
318
|
+
approval,
|
|
319
|
+
decisionSessionFile: info.sessionFile,
|
|
320
|
+
maxTurns: info.maxTurns,
|
|
321
|
+
deadlineMs: info.deadlineMs,
|
|
322
|
+
noOutputTimeoutMs: info.noOutputTimeoutMs,
|
|
323
|
+
startedAt: info.startedAt,
|
|
324
|
+
turn: info.turn,
|
|
325
|
+
repairRound: info.repairRound,
|
|
326
|
+
...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}),
|
|
327
|
+
state: "active",
|
|
328
|
+
recoveryState: current?.recoveryState ?? "ready",
|
|
329
|
+
recoveryAttempt: current?.recoveryAttempt ?? 0,
|
|
330
|
+
...(current?.recoveryOwnerPid !== undefined ? { recoveryOwnerPid: current.recoveryOwnerPid } : {}),
|
|
331
|
+
...(current?.recoveryOwnerStartTime ? { recoveryOwnerStartTime: current.recoveryOwnerStartTime } : {}),
|
|
332
|
+
...(current?.recoveryWorker ? { recoveryWorker: current.recoveryWorker } : {}),
|
|
333
|
+
});
|
|
334
|
+
},
|
|
294
335
|
onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn, repairRound: info.repairRound, ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}) }),
|
|
295
|
-
onDecisionSessionClosed:
|
|
336
|
+
onDecisionSessionClosed: handleDecisionSessionClosed,
|
|
296
337
|
});
|
|
297
338
|
const startedTaskId = session.task?.taskId;
|
|
298
339
|
if (!startedTaskId) throw new Error("worker started without a task id");
|
|
@@ -318,6 +359,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
318
359
|
}
|
|
319
360
|
throw registrationError;
|
|
320
361
|
}
|
|
362
|
+
if (lease.replacedTaskId) await decisionStore.close(lease.replacedTaskId);
|
|
321
363
|
// Register immediately after spawn so shutdown can retry cleanup if
|
|
322
364
|
// the first stop attempt fails.
|
|
323
365
|
sessions.set(startedTaskId, session);
|
|
@@ -325,7 +367,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
325
367
|
activeTaskId = startedTaskId;
|
|
326
368
|
if (shuttingDown) {
|
|
327
369
|
try {
|
|
328
|
-
await stopSession(session, "Pi session shutdown during worker start", true);
|
|
370
|
+
await stopSession(session, "Pi session shutdown during worker start", true, true);
|
|
329
371
|
sessions.delete(startedTaskId);
|
|
330
372
|
reservedCwds.delete(startedTaskId);
|
|
331
373
|
} finally {
|
|
@@ -392,12 +434,15 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
392
434
|
pendingCwds.delete(cwdKey);
|
|
393
435
|
}
|
|
394
436
|
} else if (operation === "recover") {
|
|
395
|
-
const
|
|
396
|
-
|
|
437
|
+
const takeover = rest.includes("--takeover");
|
|
438
|
+
const taskId = rest.find((value) => value !== "--takeover");
|
|
439
|
+
if (!taskId || rest.some((value) => value !== "--takeover" && value !== taskId)) throw new Error("Usage: /supervise recover [--takeover] <task-id>");
|
|
397
440
|
if (shuttingDown) throw new Error("Pi session is shutting down");
|
|
398
441
|
if (sessions.has(taskId)) throw new Error(`Task session is already loaded: ${taskId}`);
|
|
399
442
|
const record = await decisionStore.load(taskId);
|
|
400
443
|
if (!record || record.state !== "active") throw new Error(`No recoverable Decision Worker session: ${taskId}`);
|
|
444
|
+
const staleRecovery = !["ready", "interrupted"].includes(record.recoveryState);
|
|
445
|
+
if (staleRecovery && !takeover) throw new Error(`Decision Worker recovery is stale (${record.recoveryState}); retry with --takeover only after verifying the old Worker is gone: ${taskId}`);
|
|
401
446
|
if (!await decisionStore.sessionFileExists(taskId)) throw new Error(`Decision Worker session file is missing or unsafe: ${taskId}`);
|
|
402
447
|
if (record.maxTurns > 0 && record.turn >= record.maxTurns) throw new Error(`Cannot recover task after its turn budget was exhausted: ${taskId}`);
|
|
403
448
|
if (record.deadlineMs > 0 && Date.now() - Date.parse(record.startedAt) >= record.deadlineMs) throw new Error(`Cannot recover task after its wall-clock deadline: ${taskId}`);
|
|
@@ -420,11 +465,28 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
420
465
|
if (!approved) throw new Error("Worker command not approved");
|
|
421
466
|
approval = { actor: "human", reason: policy.reason };
|
|
422
467
|
}
|
|
423
|
-
const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport
|
|
468
|
+
const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport, takeover
|
|
469
|
+
? {
|
|
470
|
+
takeover: {
|
|
471
|
+
taskId: record.taskId,
|
|
472
|
+
...(staleRecovery ? { beforeReplace: () => decisionStore.reconcileStaleRecovery(record.taskId) } : {}),
|
|
473
|
+
},
|
|
474
|
+
}
|
|
475
|
+
: {});
|
|
476
|
+
if (staleRecovery && lease.replacedTaskId !== record.taskId) {
|
|
477
|
+
await lease.release();
|
|
478
|
+
throw new Error(`Stale Decision Worker recovery has no verified old lease to take over: ${record.taskId}`);
|
|
479
|
+
}
|
|
424
480
|
cwdLeases.set(record.taskId, lease);
|
|
425
|
-
|
|
481
|
+
let recoveryClaimed = false;
|
|
482
|
+
try {
|
|
483
|
+
await decisionStore.beginRecovery(record.taskId);
|
|
484
|
+
recoveryClaimed = true;
|
|
485
|
+
if (shuttingDown) throw new Error("Pi session is shutting down");
|
|
486
|
+
} catch (error) {
|
|
487
|
+
if (recoveryClaimed) await decisionStore.markRecoveryInterrupted(record.taskId).catch(() => {});
|
|
426
488
|
await releaseLease(record.taskId);
|
|
427
|
-
throw
|
|
489
|
+
throw error;
|
|
428
490
|
}
|
|
429
491
|
pendingCwds.add(cwdKey);
|
|
430
492
|
const session = new Supervisor(adapter, events, {
|
|
@@ -440,11 +502,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
440
502
|
},
|
|
441
503
|
});
|
|
442
504
|
pendingStartSessions.add(session);
|
|
443
|
-
let recoveryCleanupCompleted = false;
|
|
444
505
|
const recoveryOperation = (async () => {
|
|
506
|
+
let startedHandle: WorkerHandle | undefined;
|
|
445
507
|
try {
|
|
446
508
|
const handle = await session.start({
|
|
447
509
|
taskId: record.taskId,
|
|
510
|
+
onProgress: progress(ctx),
|
|
448
511
|
// Claude session resume is not supported by this adapter. Start
|
|
449
512
|
// idle so recovery never replays the original task; the operator
|
|
450
513
|
// must explicitly send the next instruction.
|
|
@@ -467,72 +530,100 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
467
530
|
initialFindingSignature: record.lastFindingSignature,
|
|
468
531
|
decisionSessionFile: record.decisionSessionFile,
|
|
469
532
|
decisionSessionDir: decisionStore.directory,
|
|
470
|
-
onDecisionSessionReady: (info) =>
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
533
|
+
onDecisionSessionReady: async (info) => {
|
|
534
|
+
const current = await decisionStore.load(info.taskId);
|
|
535
|
+
if (!current || current.state !== "active") throw new Error("Decision Worker recovery record disappeared before session registration");
|
|
536
|
+
await decisionStore.save({
|
|
537
|
+
taskId: info.taskId,
|
|
538
|
+
task: info.task,
|
|
539
|
+
spec: info.spec,
|
|
540
|
+
cwd: info.cwd,
|
|
541
|
+
command: record.command,
|
|
542
|
+
args: record.args,
|
|
543
|
+
approval,
|
|
544
|
+
decisionSessionFile: info.sessionFile,
|
|
545
|
+
maxTurns: info.maxTurns,
|
|
546
|
+
deadlineMs: info.deadlineMs,
|
|
547
|
+
noOutputTimeoutMs: info.noOutputTimeoutMs,
|
|
548
|
+
startedAt: info.startedAt,
|
|
549
|
+
turn: info.turn,
|
|
550
|
+
repairRound: info.repairRound,
|
|
551
|
+
...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}),
|
|
552
|
+
state: "active",
|
|
553
|
+
recoveryState: current.recoveryState,
|
|
554
|
+
recoveryAttempt: current.recoveryAttempt,
|
|
555
|
+
...(current.recoveryOwnerPid !== undefined ? { recoveryOwnerPid: current.recoveryOwnerPid } : {}),
|
|
556
|
+
...(current.recoveryOwnerStartTime ? { recoveryOwnerStartTime: current.recoveryOwnerStartTime } : {}),
|
|
557
|
+
...(current.recoveryWorker ? { recoveryWorker: current.recoveryWorker } : {}),
|
|
558
|
+
});
|
|
559
|
+
},
|
|
488
560
|
onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn, repairRound: info.repairRound, ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}) }),
|
|
489
|
-
onDecisionSessionClosed:
|
|
561
|
+
onDecisionSessionClosed: handleDecisionSessionClosed,
|
|
490
562
|
});
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
Object.defineProperty(registrationError, "workerCleanupRequired", { value: true, enumerable: false });
|
|
508
|
-
}
|
|
509
|
-
throw registrationError;
|
|
563
|
+
startedHandle = handle;
|
|
564
|
+
await lease.updateWorker({
|
|
565
|
+
transport: adapter.capabilities().transport,
|
|
566
|
+
...(await workerIdentity(handle)),
|
|
567
|
+
sessionName: handle.sessionName,
|
|
568
|
+
tmuxSocket: handle.tmuxSocket,
|
|
569
|
+
ownership: handle.ownership,
|
|
570
|
+
});
|
|
571
|
+
await decisionStore.recordRecoveryWorker(record.taskId, {
|
|
572
|
+
id: handle.id,
|
|
573
|
+
pid: handle.pid,
|
|
574
|
+
startedAt: handle.startedAt,
|
|
575
|
+
});
|
|
576
|
+
const registered = await decisionStore.load(record.taskId);
|
|
577
|
+
if (!registered || registered.state !== "active" || registered.recoveryState !== "registered" || registered.recoveryWorker?.id !== handle.id) {
|
|
578
|
+
throw new Error("Recovered Worker registration was not durably confirmed");
|
|
510
579
|
}
|
|
511
580
|
sessions.set(record.taskId, session);
|
|
512
581
|
reservedCwds.set(record.taskId, cwdKey);
|
|
513
582
|
activeTaskId = record.taskId;
|
|
514
583
|
await session.takeover();
|
|
584
|
+
await decisionStore.markRecoveryIdle(record.taskId);
|
|
585
|
+
const idle = await decisionStore.load(record.taskId);
|
|
586
|
+
if (!idle || idle.state !== "active" || idle.recoveryState !== "recovered_idle" || idle.recoveryWorker?.id !== handle.id) {
|
|
587
|
+
throw new Error("Recovered idle state was not durably confirmed");
|
|
588
|
+
}
|
|
515
589
|
if (shuttingDown) {
|
|
516
|
-
await stopSession(session, "Pi session shutdown during recovery", true);
|
|
590
|
+
await stopSession(session, "Pi session shutdown during recovery", true, true);
|
|
517
591
|
throw new Error("Pi session shut down during recovery");
|
|
518
592
|
}
|
|
519
593
|
message = `Worker recovered idle: task=${record.taskId} worker=${handle.id}; original task was not replayed; send an explicit continuation, then use resume-auto`;
|
|
520
594
|
} catch (error) {
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
595
|
+
const handle = session.handle ?? startedHandle;
|
|
596
|
+
let cleanupConfirmed = !handle;
|
|
597
|
+
const retainedWorker = handle?.ownership === "adopted";
|
|
598
|
+
if (handle && !retainedWorker) {
|
|
599
|
+
try {
|
|
600
|
+
await stopSession(session, "Decision Worker recovery cleanup", false, true);
|
|
601
|
+
cleanupConfirmed = await workerCleanupConfirmed(handle);
|
|
602
|
+
} catch {
|
|
603
|
+
cleanupConfirmed = false;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (!cleanupConfirmed) cleanupRequiredTasks.add(record.taskId);
|
|
607
|
+
try {
|
|
608
|
+
await decisionStore.markRecoveryInterrupted(record.taskId);
|
|
609
|
+
} catch (stateError) {
|
|
610
|
+
cleanupConfirmed = false;
|
|
611
|
+
console.error(`pi-claude-supervisor recovery state update failed: ${redactText(stateError instanceof Error ? stateError.message : String(stateError))}`);
|
|
612
|
+
}
|
|
613
|
+
if (cleanupConfirmed && !retainedWorker) {
|
|
530
614
|
const released = await releaseLease(record.taskId);
|
|
531
|
-
if (
|
|
615
|
+
if (released) {
|
|
616
|
+
forgetSession(record.taskId);
|
|
617
|
+
} else {
|
|
618
|
+
cleanupRequiredTasks.add(record.taskId);
|
|
532
619
|
sessions.set(record.taskId, session);
|
|
533
620
|
reservedCwds.set(record.taskId, cwdKey);
|
|
534
621
|
activeTaskId = record.taskId;
|
|
535
622
|
}
|
|
623
|
+
} else {
|
|
624
|
+
sessions.set(record.taskId, session);
|
|
625
|
+
reservedCwds.set(record.taskId, cwdKey);
|
|
626
|
+
activeTaskId = record.taskId;
|
|
536
627
|
}
|
|
537
628
|
throw error;
|
|
538
629
|
}
|
|
@@ -595,7 +686,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
595
686
|
} else if (operation === "resume-auto") {
|
|
596
687
|
await session.resumeAutomation(); message = `Automatic decisions resumed: ${sessionId}.`;
|
|
597
688
|
} else {
|
|
598
|
-
throw new Error("Usage: /supervise start|adopt-tmux|recover|sessions|status|poll [all|taskId]|send [taskId]|pause [taskId]|resume [taskId]|stop [taskId]|verify [taskId]|approve [taskId] <allow|deny>|takeover [taskId]|resume-auto [taskId]|capabilities");
|
|
689
|
+
throw new Error("Usage: /supervise start|adopt-tmux|recover [--takeover]|sessions|status|poll [all|taskId]|send [taskId]|pause [taskId]|resume [taskId]|stop [taskId]|verify [taskId]|approve [taskId] <allow|deny>|takeover [taskId]|resume-auto [taskId]|capabilities");
|
|
599
690
|
}
|
|
600
691
|
}
|
|
601
692
|
notify(ctx, message);
|
|
@@ -629,7 +720,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
629
720
|
startupFailures.push(...results.filter((result): result is PromiseRejectedResult => result.status === "rejected"));
|
|
630
721
|
}
|
|
631
722
|
}
|
|
632
|
-
const results = await Promise.allSettled([...sessions.values()].map((session) => stopSession(session, "Pi session shutdown", true)));
|
|
723
|
+
const results = await Promise.allSettled([...sessions.values()].map((session) => stopSession(session, "Pi session shutdown", true, true)));
|
|
633
724
|
await releaseSettledReservations();
|
|
634
725
|
const failures = [...startupFailures, ...results.filter((result): result is PromiseRejectedResult => result.status === "rejected")];
|
|
635
726
|
if (failures.length > 0) {
|
|
@@ -640,11 +731,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
640
731
|
})();
|
|
641
732
|
return shutdownPromise;
|
|
642
733
|
};
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
process.once("SIGTERM", onSignal);
|
|
647
|
-
process.once("SIGINT", onSignal);
|
|
734
|
+
// Pi owns process signal handling and invokes session_shutdown. Installing a
|
|
735
|
+
// second extension-level process.exit() handler races Pi's terminal restore and
|
|
736
|
+
// other extension shutdown hooks.
|
|
648
737
|
pi.on("session_shutdown", async () => {
|
|
649
738
|
await shutdown();
|
|
650
739
|
});
|
|
@@ -682,7 +771,7 @@ function formatSessions(sessions: Map<string, Supervisor>, recoverable: Decision
|
|
|
682
771
|
.map(([taskId, session]) => `${taskId} state=${session.state} cwd=${session.task?.cwd ?? "-"} worker=${session.handle?.id ?? "-"}`);
|
|
683
772
|
const pending = recoverable
|
|
684
773
|
.filter((record) => !sessions.has(record.taskId))
|
|
685
|
-
.map((record) => `${record.taskId} state=recoverable cwd=${record.cwd} worker
|
|
774
|
+
.map((record) => `${record.taskId} state=recoverable recovery=${record.recoveryState} cwd=${record.cwd} worker=${record.recoveryWorker?.id ?? "-"}`);
|
|
686
775
|
return [...active, ...pending].join("\n") || "No task sessions.";
|
|
687
776
|
}
|
|
688
777
|
|
|
@@ -717,6 +806,18 @@ function delay(ms: number): Promise<void> {
|
|
|
717
806
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
718
807
|
}
|
|
719
808
|
|
|
809
|
+
function assertRuntimeDirectory(path: string, label: string): void {
|
|
810
|
+
try {
|
|
811
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
812
|
+
const info = lstatSync(path);
|
|
813
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`${label} directory is not a real directory`);
|
|
814
|
+
chmodSync(path, 0o700);
|
|
815
|
+
accessSync(path, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK);
|
|
816
|
+
} catch (error) {
|
|
817
|
+
throw new Error(`${label} directory preflight failed for ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
720
821
|
function redactText(value: string): string {
|
|
721
822
|
return String(redactSensitive(value));
|
|
722
823
|
}
|
package/src/redaction.ts
CHANGED
|
@@ -10,8 +10,9 @@ export function redactSensitive(value: unknown, key?: string): unknown {
|
|
|
10
10
|
.replace(/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/gu, "[REDACTED]")
|
|
11
11
|
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[REDACTED]")
|
|
12
12
|
.replace(/\b(Bearer\s+)[^\s]+/giu, "$1[REDACTED]")
|
|
13
|
+
.replace(/\b((?:authorization)\s*:\s*(?:[A-Za-z]+\s+)?)[^\s,;)}\]]+/giu, "$1[REDACTED]")
|
|
14
|
+
.replace(/\b((?:x-api-key|api[-_]?key|token|secret|password|credential)\s*[:=]\s*)[^\s,;)}\]]+/giu, "$1[REDACTED]")
|
|
13
15
|
.replace(/\b((?:AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)))=([^\s]+)/gu, "$1=[REDACTED]")
|
|
14
|
-
.replace(/\b((?:authorization|x-api-key|api-key)\s*:\s*)[^\s]+/giu, "$1[REDACTED]")
|
|
15
16
|
.replace(/(--?(?:token|api[-_]?key|secret|password|authorization)(?:=|\s+))[^\s]+/giu, "$1[REDACTED]");
|
|
16
17
|
}
|
|
17
18
|
if (Array.isArray(value)) return value.map((item) => redactSensitive(item, key));
|
package/src/reviewer.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface ReviewInput {
|
|
|
15
15
|
workerOutput?: string;
|
|
16
16
|
workerResult?: Record<string, unknown>;
|
|
17
17
|
round: number;
|
|
18
|
+
/** Abort a review when the operator stops or shuts down the Supervisor. */
|
|
19
|
+
signal?: AbortSignal;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export interface TaskReviewer {
|
|
@@ -38,6 +40,9 @@ export class PiReadOnlyReviewer implements TaskReviewer {
|
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
async review(input: ReviewInput): Promise<ReviewReport> {
|
|
43
|
+
if (input.evidence.complete === false || input.evidence.truncated === true) {
|
|
44
|
+
return invalidReview("repository evidence is incomplete or truncated", input.round, new Date().toISOString());
|
|
45
|
+
}
|
|
41
46
|
const resourceLoader = new DefaultResourceLoader({
|
|
42
47
|
cwd: input.cwd,
|
|
43
48
|
agentDir: getAgentDir(),
|
|
@@ -55,20 +60,40 @@ export class PiReadOnlyReviewer implements TaskReviewer {
|
|
|
55
60
|
thinkingLevel: "low",
|
|
56
61
|
tools: ["read", "grep", "find", "ls"],
|
|
57
62
|
});
|
|
58
|
-
let
|
|
59
|
-
let
|
|
63
|
+
let current = "";
|
|
64
|
+
let finalMessage = "";
|
|
65
|
+
let capturingAssistant = false;
|
|
66
|
+
let currentTooLarge = false;
|
|
67
|
+
let finalTooLarge = false;
|
|
60
68
|
const unsubscribe = session.subscribe((value) => {
|
|
61
|
-
const record = value as unknown as { type?: string; assistantMessageEvent?: { type?: string; delta?: string } };
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
const record = value as unknown as { type?: string; message?: { role?: string; content?: unknown }; assistantMessageEvent?: { type?: string; delta?: string } };
|
|
70
|
+
const role = record.message?.role;
|
|
71
|
+
if (record.type === "message_start" && role === "assistant") {
|
|
72
|
+
current = "";
|
|
73
|
+
currentTooLarge = false;
|
|
74
|
+
capturingAssistant = true;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta" && (capturingAssistant || role === "assistant" || role === undefined)) {
|
|
78
|
+
capturingAssistant = true;
|
|
79
|
+
const delta = record.assistantMessageEvent.delta ?? "";
|
|
80
|
+
if (Buffer.byteLength(current, "utf8") + Buffer.byteLength(delta, "utf8") > MAX_REVIEW_RESPONSE_BYTES) {
|
|
81
|
+
currentTooLarge = true;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
current += delta;
|
|
66
85
|
return;
|
|
67
86
|
}
|
|
68
|
-
|
|
87
|
+
if (record.type === "message_end" && role === "assistant") {
|
|
88
|
+
finalMessage = current || textFromMessage(record.message?.content);
|
|
89
|
+
finalTooLarge = currentTooLarge;
|
|
90
|
+
current = "";
|
|
91
|
+
currentTooLarge = false;
|
|
92
|
+
capturingAssistant = false;
|
|
93
|
+
}
|
|
69
94
|
});
|
|
70
95
|
try {
|
|
71
|
-
await withTimeout(session.prompt(reviewPrompt(input)), this.#timeoutMs, "independent Reviewer");
|
|
96
|
+
await withTimeout(session.prompt(reviewPrompt(input)), this.#timeoutMs, "independent Reviewer", input.signal);
|
|
72
97
|
} catch (error) {
|
|
73
98
|
await session.abort().catch(() => {});
|
|
74
99
|
throw error;
|
|
@@ -76,9 +101,9 @@ export class PiReadOnlyReviewer implements TaskReviewer {
|
|
|
76
101
|
unsubscribe();
|
|
77
102
|
session.dispose();
|
|
78
103
|
}
|
|
79
|
-
return
|
|
104
|
+
return finalTooLarge
|
|
80
105
|
? invalidReview(`Reviewer response exceeded ${MAX_REVIEW_RESPONSE_BYTES} bytes`, input.round, new Date().toISOString())
|
|
81
|
-
: parseReview(
|
|
106
|
+
: parseReview(finalMessage || current, input.round);
|
|
82
107
|
}
|
|
83
108
|
}
|
|
84
109
|
|
|
@@ -103,10 +128,17 @@ ACCEPTANCE REPORT:
|
|
|
103
128
|
${boundedJson(input.acceptance)}
|
|
104
129
|
|
|
105
130
|
REPOSITORY STATUS:
|
|
106
|
-
${boundText(input.evidence.status, 8_000)}
|
|
131
|
+
${boundText(redactText(input.evidence.status), 8_000)}
|
|
132
|
+
|
|
133
|
+
REPOSITORY DIFF (HEAD-RELATIVE, UNTRUSTED):
|
|
134
|
+
${boundText(redactText(input.evidence.diff), 16_000)}
|
|
107
135
|
|
|
108
|
-
|
|
109
|
-
${boundText(input.evidence.
|
|
136
|
+
UNTRACKED FILE EVIDENCE (UNTRUSTED):
|
|
137
|
+
${boundText(redactText(input.evidence.untracked ?? "(none)"), 16_000)}
|
|
138
|
+
|
|
139
|
+
EVIDENCE COMPLETE:
|
|
140
|
+
${String(input.evidence.complete !== false && input.evidence.truncated !== true)}
|
|
141
|
+
If repository evidence is incomplete or truncated, do not return pass; return human and explain which evidence is unavailable.
|
|
110
142
|
|
|
111
143
|
WORKER OUTPUT (UNTRUSTED):
|
|
112
144
|
${boundTailText(redactSensitive(input.workerOutput ?? "(none)"), 8_000)}
|
|
@@ -118,6 +150,15 @@ REVIEW ROUND:
|
|
|
118
150
|
${input.round}`;
|
|
119
151
|
}
|
|
120
152
|
|
|
153
|
+
function textFromMessage(content: unknown): string {
|
|
154
|
+
if (!Array.isArray(content)) return "";
|
|
155
|
+
return content
|
|
156
|
+
.filter((block): block is { type?: unknown; text?: unknown } => Boolean(block && typeof block === "object"))
|
|
157
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
158
|
+
.map((block) => block.text as string)
|
|
159
|
+
.join("");
|
|
160
|
+
}
|
|
161
|
+
|
|
121
162
|
export function parseReview(text: string, round: number): ReviewReport {
|
|
122
163
|
const checkedAt = new Date().toISOString();
|
|
123
164
|
if (Buffer.byteLength(text, "utf8") > MAX_REVIEW_RESPONSE_BYTES) return invalidReview(`Reviewer response exceeded ${MAX_REVIEW_RESPONSE_BYTES} bytes`, round, checkedAt);
|
|
@@ -179,16 +220,28 @@ function redactText(value: string): string {
|
|
|
179
220
|
return String(redactSensitive(value));
|
|
180
221
|
}
|
|
181
222
|
|
|
182
|
-
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
223
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal): Promise<T> {
|
|
183
224
|
let timer: NodeJS.Timeout | undefined;
|
|
225
|
+
let onAbort: (() => void) | undefined;
|
|
184
226
|
const timeout = new Promise<never>((_, reject) => {
|
|
185
227
|
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
186
228
|
timer.unref();
|
|
187
229
|
});
|
|
230
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
231
|
+
if (!signal) return;
|
|
232
|
+
onAbort = () => {
|
|
233
|
+
const error = new Error(`${label} aborted`);
|
|
234
|
+
error.name = "AbortError";
|
|
235
|
+
reject(error);
|
|
236
|
+
};
|
|
237
|
+
if (signal.aborted) onAbort();
|
|
238
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
239
|
+
});
|
|
188
240
|
try {
|
|
189
|
-
return await Promise.race([promise, timeout]);
|
|
241
|
+
return await Promise.race([promise, timeout, aborted]);
|
|
190
242
|
} finally {
|
|
191
243
|
if (timer) clearTimeout(timer);
|
|
244
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
192
245
|
}
|
|
193
246
|
}
|
|
194
247
|
|