pi-claude-supervisor 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -1
- package/README.cn.md +33 -3
- package/README.md +44 -7
- package/docs/architecture.md +57 -5
- package/docs/engineering-plan.md +117 -3
- package/docs/recovery-review.md +24 -0
- package/docs/stability-matrix-2.1.270.md +32 -0
- package/docs/testing.md +80 -6
- package/package.json +1 -1
- package/src/acceptance.ts +84 -0
- package/src/cwd-lease.ts +95 -6
- package/src/decision-session-store.ts +356 -38
- package/src/decision-worker.ts +85 -10
- package/src/index.ts +191 -82
- package/src/reviewer.ts +205 -0
- package/src/supervisor.ts +241 -47
- package/src/types.ts +60 -0
- package/src/verifier.ts +102 -10
- package/src/worker/process-adapter.ts +103 -34
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { AcceptanceCheck, TaskSpec } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
4
|
+
const DEFAULT_MAX_REPAIR_ROUNDS = 3;
|
|
5
|
+
|
|
6
|
+
/** Normalize legacy plain-text tasks into the structured acceptance model. */
|
|
7
|
+
export function normalizeTaskSpec(value: unknown, fallbackGoal: string): TaskSpec {
|
|
8
|
+
if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value))) {
|
|
9
|
+
throw new Error("task spec must be a JSON object");
|
|
10
|
+
}
|
|
11
|
+
const source = (value ?? {}) as Record<string, unknown>;
|
|
12
|
+
const goal = typeof source.goal === "string" && source.goal.trim() ? source.goal.trim() : fallbackGoal.trim();
|
|
13
|
+
if (!goal) throw new Error("task goal must not be empty");
|
|
14
|
+
return {
|
|
15
|
+
goal,
|
|
16
|
+
scope: stringList(source.scope, "scope"),
|
|
17
|
+
constraints: stringList(source.constraints, "constraints"),
|
|
18
|
+
forbidden: stringList(source.forbidden, "forbidden"),
|
|
19
|
+
acceptance: normalizeChecks(source.acceptance),
|
|
20
|
+
maxRepairRounds: normalizeRepairRounds(source.maxRepairRounds),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function defaultAcceptanceChecks(): AcceptanceCheck[] {
|
|
25
|
+
return [{
|
|
26
|
+
id: "diff-check",
|
|
27
|
+
name: "git diff check",
|
|
28
|
+
command: "git",
|
|
29
|
+
args: ["diff", "--check"],
|
|
30
|
+
required: true,
|
|
31
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
32
|
+
}];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeChecks(value: unknown): AcceptanceCheck[] {
|
|
36
|
+
if (value === undefined) return defaultAcceptanceChecks();
|
|
37
|
+
if (!Array.isArray(value)) throw new Error("task spec acceptance must be an array");
|
|
38
|
+
if (value.length === 0) return defaultAcceptanceChecks();
|
|
39
|
+
const checks = value.map((item, index) => normalizeCheck(item, index));
|
|
40
|
+
const ids = new Set<string>();
|
|
41
|
+
for (const check of checks) {
|
|
42
|
+
if (ids.has(check.id)) throw new Error(`duplicate acceptance check id: ${check.id}`);
|
|
43
|
+
ids.add(check.id);
|
|
44
|
+
}
|
|
45
|
+
return checks;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeCheck(value: unknown, index: number): AcceptanceCheck {
|
|
49
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`acceptance[${index}] must be an object`);
|
|
50
|
+
const source = value as Record<string, unknown>;
|
|
51
|
+
const id = typeof source.id === "string" && /^[A-Za-z0-9._-]+$/u.test(source.id.trim())
|
|
52
|
+
? source.id.trim()
|
|
53
|
+
: `check-${index + 1}`;
|
|
54
|
+
const name = typeof source.name === "string" && source.name.trim() ? source.name.trim() : id;
|
|
55
|
+
const command = typeof source.command === "string" ? source.command.trim() : "";
|
|
56
|
+
if (!command) throw new Error(`acceptance[${index}] command must not be empty`);
|
|
57
|
+
const args = source.args === undefined
|
|
58
|
+
? []
|
|
59
|
+
: Array.isArray(source.args) && source.args.every((arg) => typeof arg === "string")
|
|
60
|
+
? source.args.map((arg) => arg)
|
|
61
|
+
: undefined;
|
|
62
|
+
if (!args) throw new Error(`acceptance[${index}] args must be an array of strings`);
|
|
63
|
+
const timeoutValue = source.timeoutMs;
|
|
64
|
+
const timeoutMs = timeoutValue === undefined ? DEFAULT_TIMEOUT_MS : timeoutValue;
|
|
65
|
+
if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60 * 60_000) {
|
|
66
|
+
throw new Error(`acceptance[${index}] timeoutMs must be between 1 and 3600000`);
|
|
67
|
+
}
|
|
68
|
+
if (typeof source.required !== "undefined" && typeof source.required !== "boolean") {
|
|
69
|
+
throw new Error(`acceptance[${index}] required must be boolean`);
|
|
70
|
+
}
|
|
71
|
+
return { id, name, command, args, required: source.required !== false, timeoutMs };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function stringList(value: unknown, field: string): string[] {
|
|
75
|
+
if (value === undefined) return [];
|
|
76
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error(`task spec ${field} must be an array of strings`);
|
|
77
|
+
return value.map((item) => item.trim()).filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeRepairRounds(value: unknown): number {
|
|
81
|
+
if (value === undefined) return DEFAULT_MAX_REPAIR_ROUNDS;
|
|
82
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 10) throw new Error("maxRepairRounds must be between 0 and 10");
|
|
83
|
+
return value;
|
|
84
|
+
}
|
package/src/cwd-lease.ts
CHANGED
|
@@ -36,8 +36,25 @@ export interface CwdLeaseHandoff {
|
|
|
36
36
|
tmuxSocket?: string;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Explicit recovery takeover. This is intentionally separate from ordinary
|
|
41
|
+
* acquisition: an old Pi owner may have died while its Worker survived.
|
|
42
|
+
*/
|
|
43
|
+
export interface CwdLeaseTakeover {
|
|
44
|
+
taskId: string;
|
|
45
|
+
/** Run before the old lease is removed; failure leaves the old lease intact. */
|
|
46
|
+
beforeReplace?: (lease: CwdLeaseRecord) => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CwdLeaseAcquireOptions {
|
|
50
|
+
handoff?: CwdLeaseHandoff;
|
|
51
|
+
takeover?: CwdLeaseTakeover;
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
export interface CwdLeaseHandle {
|
|
40
55
|
readonly record: CwdLeaseRecord;
|
|
56
|
+
/** Task id whose lease was explicitly handed off/taken over, if any. */
|
|
57
|
+
readonly replacedTaskId?: string;
|
|
41
58
|
updateWorker(worker: CwdLeaseWorker): Promise<void>;
|
|
42
59
|
release(): Promise<void>;
|
|
43
60
|
}
|
|
@@ -63,7 +80,9 @@ export class CwdLeaseStore {
|
|
|
63
80
|
return this.#directory;
|
|
64
81
|
}
|
|
65
82
|
|
|
66
|
-
async acquire(cwd: string, taskId: string, transport: CwdLeaseTransport, options:
|
|
83
|
+
async acquire(cwd: string, taskId: string, transport: CwdLeaseTransport, options: CwdLeaseAcquireOptions = {}): Promise<CwdLeaseHandle> {
|
|
84
|
+
assertTaskId(taskId);
|
|
85
|
+
if (options.takeover) assertTaskId(options.takeover.taskId);
|
|
67
86
|
const canonicalCwd = await realpath(resolve(cwd));
|
|
68
87
|
const now = new Date().toISOString();
|
|
69
88
|
const lease: CwdLeaseRecord = {
|
|
@@ -77,6 +96,7 @@ export class CwdLeaseStore {
|
|
|
77
96
|
updatedAt: now,
|
|
78
97
|
worker: { transport },
|
|
79
98
|
};
|
|
99
|
+
let replacedTaskId: string | undefined;
|
|
80
100
|
await this.#withLock(async () => {
|
|
81
101
|
const leases = await this.#readAll();
|
|
82
102
|
let handoffLease: CwdLeaseRecord | undefined;
|
|
@@ -88,23 +108,35 @@ export class CwdLeaseStore {
|
|
|
88
108
|
continue;
|
|
89
109
|
}
|
|
90
110
|
if (!pathsOverlap(existing.cwd, canonicalCwd)) continue;
|
|
111
|
+
if (options.takeover?.taskId === existing.taskId
|
|
112
|
+
&& existing.cwd === canonicalCwd
|
|
113
|
+
&& await canTakeoverLease(existing)) {
|
|
114
|
+
await options.takeover.beforeReplace?.(existing);
|
|
115
|
+
replacedTaskId = existing.taskId;
|
|
116
|
+
await rm(this.#path(existing.leaseId), { force: true });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
91
119
|
throw new Error(`working-directory lease is held by task ${existing.taskId}: ${redactText(existing.cwd)}`);
|
|
92
120
|
}
|
|
93
121
|
await this.#write(lease);
|
|
94
|
-
if (handoffLease)
|
|
122
|
+
if (handoffLease) {
|
|
123
|
+
replacedTaskId = handoffLease.taskId;
|
|
124
|
+
await rm(this.#path(handoffLease.leaseId), { force: true });
|
|
125
|
+
}
|
|
95
126
|
});
|
|
96
|
-
return this.#handle(lease);
|
|
127
|
+
return this.#handle(lease, replacedTaskId);
|
|
97
128
|
}
|
|
98
129
|
|
|
99
130
|
async list(): Promise<CwdLeaseRecord[]> {
|
|
100
131
|
return this.#withLock(() => this.#readAll());
|
|
101
132
|
}
|
|
102
133
|
|
|
103
|
-
#handle(initial: CwdLeaseRecord): CwdLeaseHandle {
|
|
134
|
+
#handle(initial: CwdLeaseRecord, replacedTaskId?: string): CwdLeaseHandle {
|
|
104
135
|
let current = { ...initial, worker: initial.worker ? { ...initial.worker } : undefined };
|
|
105
136
|
let released = false;
|
|
106
137
|
return {
|
|
107
138
|
get record() { return current; },
|
|
139
|
+
get replacedTaskId() { return replacedTaskId; },
|
|
108
140
|
updateWorker: async (worker) => {
|
|
109
141
|
if (released) throw new Error("cwd lease has already been released");
|
|
110
142
|
assertWorker(worker);
|
|
@@ -157,8 +189,8 @@ export class CwdLeaseStore {
|
|
|
157
189
|
async #write(lease: CwdLeaseRecord): Promise<void> {
|
|
158
190
|
await this.#ensureDirectory();
|
|
159
191
|
const target = this.#path(lease.leaseId);
|
|
160
|
-
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
161
|
-
await writeFile(temporary, `${JSON.stringify(lease, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
192
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
193
|
+
await writeFile(temporary, `${JSON.stringify(lease, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
162
194
|
await rename(temporary, target);
|
|
163
195
|
await chmod(target, 0o600);
|
|
164
196
|
}
|
|
@@ -252,6 +284,10 @@ async function processIdentityLive(pid: number, expectedStartTime?: string): Pro
|
|
|
252
284
|
const currentStartTime = await processStartTime(pid);
|
|
253
285
|
if (currentStartTime && expectedStartTime) return currentStartTime === expectedStartTime;
|
|
254
286
|
if (currentStartTime) return true;
|
|
287
|
+
return processExists(pid);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function processExists(pid: number): Promise<boolean> {
|
|
255
291
|
try {
|
|
256
292
|
process.kill(pid, 0);
|
|
257
293
|
return true;
|
|
@@ -260,6 +296,55 @@ async function processIdentityLive(pid: number, expectedStartTime?: string): Pro
|
|
|
260
296
|
}
|
|
261
297
|
}
|
|
262
298
|
|
|
299
|
+
async function processGroupExists(pid: number): Promise<boolean> {
|
|
300
|
+
try {
|
|
301
|
+
process.kill(-pid, 0);
|
|
302
|
+
return true;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return error instanceof Error && /EPERM/u.test(error.message);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function cgroupHasProcesses(path: string): Promise<boolean> {
|
|
309
|
+
// Lease files are untrusted state. Never read an arbitrary path during
|
|
310
|
+
// takeover; only a real, canonical cgroup below the kernel cgroup root is
|
|
311
|
+
// eligible for this check. Missing/unreadable evidence is not proof of an
|
|
312
|
+
// empty cgroup: a descendant may have escaped before the cgroup disappeared.
|
|
313
|
+
const cgroupRoot = resolve("/sys/fs/cgroup");
|
|
314
|
+
const cgroupPath = resolve(path);
|
|
315
|
+
if (cgroupPath === cgroupRoot || !cgroupPath.startsWith(`${cgroupRoot}/`)) return true;
|
|
316
|
+
try {
|
|
317
|
+
const cgroupInfo = await lstat(cgroupPath);
|
|
318
|
+
if (!cgroupInfo.isDirectory() || cgroupInfo.isSymbolicLink()) return true;
|
|
319
|
+
const canonicalPath = await realpath(cgroupPath);
|
|
320
|
+
if (canonicalPath !== cgroupPath || !canonicalPath.startsWith(`${cgroupRoot}/`)) return true;
|
|
321
|
+
const procsPath = join(canonicalPath, "cgroup.procs");
|
|
322
|
+
const procsInfo = await lstat(procsPath);
|
|
323
|
+
if (!procsInfo.isFile() || procsInfo.isSymbolicLink()) return true;
|
|
324
|
+
const contents = await readFile(procsPath, "utf8");
|
|
325
|
+
return contents.split(/\s+/u).some((pid) => /^\d+$/u.test(pid));
|
|
326
|
+
} catch {
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function canTakeoverLease(lease: CwdLeaseRecord): Promise<boolean> {
|
|
332
|
+
// The old supervisor owner must be gone. A dead owner is not enough when
|
|
333
|
+
// the detached Worker itself is still alive.
|
|
334
|
+
if (await processExists(lease.ownerPid)) return false;
|
|
335
|
+
const worker = lease.worker;
|
|
336
|
+
if (!worker) return false;
|
|
337
|
+
if (worker.transport === "tmux") return false;
|
|
338
|
+
if (!worker.pid) return false;
|
|
339
|
+
if (await processExists(worker.pid)) return false;
|
|
340
|
+
if (await processGroupExists(worker.pid)) return false;
|
|
341
|
+
// A process-group check cannot see a setsid descendant. Explicit takeover
|
|
342
|
+
// therefore requires the verified cgroup boundary used by the adapter.
|
|
343
|
+
if (!worker.cgroupPath || !resolve(worker.cgroupPath).startsWith(`${resolve("/sys/fs/cgroup")}/`)) return false;
|
|
344
|
+
if (await cgroupHasProcesses(worker.cgroupPath)) return false;
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
|
|
263
348
|
async function processStartTime(pid: number): Promise<string | undefined> {
|
|
264
349
|
try {
|
|
265
350
|
const statText = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
@@ -288,6 +373,10 @@ async function matchesHandoff(lease: CwdLeaseRecord, handoff: CwdLeaseHandoff):
|
|
|
288
373
|
return !(await processIdentityLive(lease.ownerPid, lease.ownerStartTime));
|
|
289
374
|
}
|
|
290
375
|
|
|
376
|
+
function assertTaskId(taskId: string): void {
|
|
377
|
+
if (!/^[0-9a-f-]{36}$/iu.test(taskId)) throw new Error("invalid cwd lease task id");
|
|
378
|
+
}
|
|
379
|
+
|
|
291
380
|
function assertWorker(worker: CwdLeaseWorker): void {
|
|
292
381
|
if (!["process-pipe", "jsonl", "pty", "tmux"].includes(worker.transport)
|
|
293
382
|
|| (worker.pid !== undefined && (!Number.isSafeInteger(worker.pid) || worker.pid < 1))
|