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.
@@ -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: { handoff?: CwdLeaseHandoff } = {}): Promise<CwdLeaseHandle> {
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) await rm(this.#path(handoffLease.leaseId), { force: true });
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))