pi-claude-supervisor 0.4.0 → 0.5.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 +23 -0
- package/README.cn.md +25 -1
- package/README.md +35 -5
- package/docs/architecture.md +31 -2
- package/docs/engineering-plan.md +63 -0
- package/docs/testing.md +73 -16
- package/package.json +2 -1
- package/src/acceptance.ts +84 -0
- package/src/decision-session-store.ts +17 -5
- package/src/decision-worker.ts +44 -6
- package/src/index.ts +64 -17
- package/src/reviewer.ts +205 -0
- package/src/supervisor.ts +204 -35
- package/src/types.ts +60 -0
- package/src/verifier.ts +99 -10
- package/src/worker/process-adapter.ts +103 -34
- package/src/worker/tmux-adapter.ts +12 -5
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
3
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import type {
|
|
@@ -25,6 +25,8 @@ export interface ProcessWorkerAdapterOptions {
|
|
|
25
25
|
maxOutputChunks?: number;
|
|
26
26
|
/** Maximum UTF-8 bytes of captured output retained per worker. */
|
|
27
27
|
maxOutputBytes?: number;
|
|
28
|
+
/** Maximum UTF-8 bytes retained for an incomplete Claude JSONL record. */
|
|
29
|
+
maxProtocolBufferBytes?: number;
|
|
28
30
|
/** Maximum time a blocked stdin write may hold lifecycle operations. */
|
|
29
31
|
inputWriteTimeoutMs?: number;
|
|
30
32
|
/** Linux descendant cleanup mode; auto uses cgroup v2 when available. */
|
|
@@ -43,7 +45,12 @@ interface ProcessRecord {
|
|
|
43
45
|
lastInputAt?: string;
|
|
44
46
|
activeRequests: number;
|
|
45
47
|
turnSequence: number;
|
|
48
|
+
requestSequence: number;
|
|
49
|
+
activeRequestSequence?: number;
|
|
50
|
+
seenResultIds: Set<string>;
|
|
51
|
+
seenPermissionRequestIds: Set<string>;
|
|
46
52
|
protocolBuffer: string;
|
|
53
|
+
discardProtocolLine: boolean;
|
|
47
54
|
exitCode?: number | null;
|
|
48
55
|
signal?: NodeJS.Signals;
|
|
49
56
|
spawnError?: Error;
|
|
@@ -83,6 +90,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
83
90
|
readonly #killGraceMs: number;
|
|
84
91
|
readonly #maxOutputChunks: number;
|
|
85
92
|
readonly #maxOutputBytes: number;
|
|
93
|
+
readonly #maxProtocolBufferBytes: number;
|
|
86
94
|
readonly #inputWriteTimeoutMs: number;
|
|
87
95
|
readonly #cgroupMode: "off" | "auto" | "required";
|
|
88
96
|
readonly #cgroupParentPath?: string;
|
|
@@ -93,6 +101,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
93
101
|
this.#killGraceMs = boundedDelay(options.killGraceMs ?? 500);
|
|
94
102
|
this.#maxOutputChunks = boundedPositiveInteger(options.maxOutputChunks ?? 10_000, "maxOutputChunks");
|
|
95
103
|
this.#maxOutputBytes = boundedPositiveInteger(options.maxOutputBytes ?? 8 * 1024 * 1024, "maxOutputBytes");
|
|
104
|
+
this.#maxProtocolBufferBytes = boundedPositiveInteger(options.maxProtocolBufferBytes ?? 256 * 1024, "maxProtocolBufferBytes");
|
|
96
105
|
this.#inputWriteTimeoutMs = boundedDelay(options.inputWriteTimeoutMs ?? 10_000);
|
|
97
106
|
this.#cgroupMode = options.cgroupMode ?? "auto";
|
|
98
107
|
this.#cgroupParentPath = options.cgroupParentPath;
|
|
@@ -140,7 +149,12 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
140
149
|
outputTruncated: false,
|
|
141
150
|
activeRequests: input.task ? 1 : 0,
|
|
142
151
|
turnSequence: 0,
|
|
152
|
+
requestSequence: input.task ? 1 : 0,
|
|
153
|
+
activeRequestSequence: input.task ? 1 : undefined,
|
|
154
|
+
seenResultIds: new Set(),
|
|
155
|
+
seenPermissionRequestIds: new Set(),
|
|
143
156
|
protocolBuffer: "",
|
|
157
|
+
discardProtocolLine: false,
|
|
144
158
|
exited,
|
|
145
159
|
resolveExit,
|
|
146
160
|
sentKeys: new Set(),
|
|
@@ -366,7 +380,10 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
366
380
|
if (record.stopping) throw new Error("worker is stopping");
|
|
367
381
|
if (record.sentKeys.has(idempotencyKey)) return;
|
|
368
382
|
if (record.exitCode !== undefined) throw new Error("worker is not running");
|
|
369
|
-
if (this.#mode === "claude-jsonl")
|
|
383
|
+
if (this.#mode === "claude-jsonl") {
|
|
384
|
+
record.activeRequests += 1;
|
|
385
|
+
record.activeRequestSequence = ++record.requestSequence;
|
|
386
|
+
}
|
|
370
387
|
try {
|
|
371
388
|
await this.#writeInput(record, this.#encodeMessage(message));
|
|
372
389
|
} catch (error) {
|
|
@@ -454,41 +471,76 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
454
471
|
}
|
|
455
472
|
|
|
456
473
|
#observeJsonl(record: ProcessRecord, chunk: string): void {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
474
|
+
let offset = 0;
|
|
475
|
+
while (offset < chunk.length) {
|
|
476
|
+
if (record.discardProtocolLine) {
|
|
477
|
+
const newline = chunk.indexOf("\n", offset);
|
|
478
|
+
if (newline < 0) return;
|
|
479
|
+
record.discardProtocolLine = false;
|
|
480
|
+
record.protocolBuffer = "";
|
|
481
|
+
offset = newline + 1;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const newline = chunk.indexOf("\n", offset);
|
|
485
|
+
if (newline < 0) {
|
|
486
|
+
const tail = chunk.slice(offset);
|
|
487
|
+
if (Buffer.byteLength(record.protocolBuffer, "utf8") + Buffer.byteLength(tail, "utf8") > this.#maxProtocolBufferBytes) {
|
|
488
|
+
record.protocolBuffer = "";
|
|
489
|
+
record.discardProtocolLine = true;
|
|
490
|
+
record.outputTruncated = true;
|
|
491
|
+
} else {
|
|
492
|
+
record.protocolBuffer += tail;
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const linePart = chunk.slice(offset, newline);
|
|
497
|
+
if (Buffer.byteLength(record.protocolBuffer, "utf8") + Buffer.byteLength(linePart, "utf8") > this.#maxProtocolBufferBytes) {
|
|
498
|
+
record.outputTruncated = true;
|
|
499
|
+
} else {
|
|
500
|
+
this.#processJsonlLine(record, `${record.protocolBuffer}${linePart}`.trim());
|
|
501
|
+
}
|
|
502
|
+
record.protocolBuffer = "";
|
|
503
|
+
offset = newline + 1;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
#processJsonlLine(record: ProcessRecord, line: string): void {
|
|
508
|
+
if (!line) return;
|
|
509
|
+
try {
|
|
510
|
+
const event = JSON.parse(line) as Record<string, unknown>;
|
|
511
|
+
this.#emit(record, { type: "jsonl", handle: record.handle, record: event });
|
|
512
|
+
if (event.type === "control_request") {
|
|
513
|
+
const request = event.request;
|
|
514
|
+
if (isPermissionRequest(event, request)) {
|
|
515
|
+
const requestId = String(event.request_id);
|
|
516
|
+
if (!record.seenPermissionRequestIds.has(requestId)) {
|
|
517
|
+
rememberBounded(record.seenPermissionRequestIds, requestId);
|
|
518
|
+
this.#emit(record, {
|
|
519
|
+
type: "permission_request",
|
|
520
|
+
handle: record.handle,
|
|
521
|
+
request: {
|
|
522
|
+
requestId,
|
|
523
|
+
toolUseId: request.tool_use_id,
|
|
524
|
+
toolName: request.tool_name,
|
|
525
|
+
input: request.input,
|
|
526
|
+
raw: event,
|
|
527
|
+
},
|
|
528
|
+
});
|
|
486
529
|
}
|
|
487
|
-
} catch {
|
|
488
|
-
// Keep raw output for diagnostics; malformed output is not a completion signal.
|
|
489
530
|
}
|
|
490
531
|
}
|
|
491
|
-
|
|
532
|
+
if (event.type === "result" && record.activeRequests > 0) {
|
|
533
|
+
const resultId = jsonlRecordId(event, record.activeRequestSequence);
|
|
534
|
+
if (!record.seenResultIds.has(resultId)) {
|
|
535
|
+
rememberBounded(record.seenResultIds, resultId);
|
|
536
|
+
record.activeRequests = Math.max(0, record.activeRequests - 1);
|
|
537
|
+
record.activeRequestSequence = undefined;
|
|
538
|
+
record.turnSequence += 1;
|
|
539
|
+
this.#emit(record, { type: "turn_completed", handle: record.handle, result: event, sequence: record.turnSequence });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} catch {
|
|
543
|
+
// Keep raw output for diagnostics; malformed output is not a completion signal.
|
|
492
544
|
}
|
|
493
545
|
}
|
|
494
546
|
|
|
@@ -665,6 +717,23 @@ async function cleanupCgroup(path: string, graceMs: number): Promise<void> {
|
|
|
665
717
|
throw new Error(`worker cgroup ${path} did not empty before cleanup deadline`);
|
|
666
718
|
}
|
|
667
719
|
|
|
720
|
+
function rememberBounded(values: Set<string>, value: string, limit = 2_000): void {
|
|
721
|
+
values.add(value);
|
|
722
|
+
while (values.size > limit) {
|
|
723
|
+
const first = values.values().next().value;
|
|
724
|
+
if (first === undefined) break;
|
|
725
|
+
values.delete(first);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function jsonlRecordId(record: Record<string, unknown>, requestSequence?: number): string {
|
|
730
|
+
for (const key of ["uuid", "request_id"]) {
|
|
731
|
+
if (typeof record[key] === "string" && record[key]) return `${key}:${record[key]}`;
|
|
732
|
+
}
|
|
733
|
+
const digest = createHash("sha256").update(JSON.stringify(record)).digest("hex").slice(0, 32);
|
|
734
|
+
return `request:${requestSequence ?? "unknown"}:${digest}`;
|
|
735
|
+
}
|
|
736
|
+
|
|
668
737
|
function isPermissionRequest(event: Record<string, unknown>, request: unknown): request is { subtype: "can_use_tool"; tool_use_id: string; tool_name: string; input: unknown } {
|
|
669
738
|
if (event.type !== "control_request" || typeof event.request_id !== "string" || !request || typeof request !== "object") return false;
|
|
670
739
|
const value = request as { subtype?: unknown; tool_use_id?: unknown; tool_name?: unknown; input?: unknown };
|
|
@@ -264,10 +264,16 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
264
264
|
const pane = await this.#paneStatus(record);
|
|
265
265
|
record.paneDead = pane.dead;
|
|
266
266
|
record.panePid = pane.pid;
|
|
267
|
-
if (
|
|
267
|
+
if (pane.dead) record.cleanupError = undefined;
|
|
268
|
+
else await this.#rememberPaneIdentity(record, pane.pid);
|
|
268
269
|
} catch (error) {
|
|
269
|
-
if (isMissingSession(error)
|
|
270
|
-
|
|
270
|
+
if (isMissingSession(error)) {
|
|
271
|
+
record.paneDead = true;
|
|
272
|
+
record.cleanupError = undefined;
|
|
273
|
+
} else if (isPaneIdentityError(error)) {
|
|
274
|
+
record.paneDead = false;
|
|
275
|
+
record.cleanupError = asError(error);
|
|
276
|
+
} else record.cleanupError = asError(error);
|
|
271
277
|
}
|
|
272
278
|
return this.#status(record, !record.paneDead);
|
|
273
279
|
}
|
|
@@ -284,10 +290,11 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
284
290
|
} catch (error) {
|
|
285
291
|
if (isMissingSession(error)) {
|
|
286
292
|
record.paneDead = true;
|
|
293
|
+
record.cleanupError = undefined;
|
|
287
294
|
if (!record.cleanupComplete && record.owned) await this.#cleanup(record, false);
|
|
288
295
|
} else {
|
|
289
296
|
record.cleanupError = asError(error);
|
|
290
|
-
if (isPaneIdentityError(error)) record.paneDead =
|
|
297
|
+
if (isPaneIdentityError(error)) record.paneDead = false;
|
|
291
298
|
}
|
|
292
299
|
}
|
|
293
300
|
return this.#status(record, !record.paneDead && !record.released);
|
|
@@ -485,7 +492,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
|
|
|
485
492
|
#startMonitor(record: TmuxRecord): void {
|
|
486
493
|
record.monitor = setInterval(() => {
|
|
487
494
|
void this.#monitor(record).catch((error) => {
|
|
488
|
-
if (isPaneIdentityError(error)) record.paneDead =
|
|
495
|
+
if (isPaneIdentityError(error)) record.paneDead = false;
|
|
489
496
|
if (!record.cleanupComplete && !isMissingSession(error)) record.cleanupError = asError(error);
|
|
490
497
|
});
|
|
491
498
|
}, this.#pollIntervalMs);
|