pi-claude-supervisor 0.3.0 → 0.4.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 +21 -2
- package/README.cn.md +6 -4
- package/README.md +11 -5
- package/docs/architecture.md +42 -7
- package/docs/releasing.md +2 -1
- package/docs/testing.md +35 -14
- package/docs/transport-spike-2026-09-12.md +8 -8
- package/package.json +3 -1
- package/src/config.ts +2 -0
- package/src/cwd-lease.ts +345 -0
- package/src/index.ts +206 -39
- package/src/supervisor.ts +105 -22
- package/src/types.ts +22 -1
- package/src/worker/process-adapter.ts +98 -26
- package/src/worker/tmux-adapter.ts +73 -30
package/src/cwd-lease.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile, rename } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { redactSensitive } from "./redaction.ts";
|
|
5
|
+
|
|
6
|
+
export type CwdLeaseTransport = "process-pipe" | "jsonl" | "pty" | "tmux";
|
|
7
|
+
|
|
8
|
+
export interface CwdLeaseWorker {
|
|
9
|
+
transport: CwdLeaseTransport;
|
|
10
|
+
pid?: number;
|
|
11
|
+
startTime?: string;
|
|
12
|
+
sessionName?: string;
|
|
13
|
+
tmuxSocket?: string;
|
|
14
|
+
ownership?: "owned" | "adopted";
|
|
15
|
+
cgroupPath?: string;
|
|
16
|
+
tmuxTarget?: string;
|
|
17
|
+
tmuxPaneId?: string;
|
|
18
|
+
paneStartTime?: string;
|
|
19
|
+
paneCommand?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CwdLeaseRecord {
|
|
23
|
+
version: 1;
|
|
24
|
+
leaseId: string;
|
|
25
|
+
taskId: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
ownerPid: number;
|
|
28
|
+
ownerStartTime?: string;
|
|
29
|
+
acquiredAt: string;
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
worker?: CwdLeaseWorker;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CwdLeaseHandoff {
|
|
35
|
+
sessionName: string;
|
|
36
|
+
tmuxSocket?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CwdLeaseHandle {
|
|
40
|
+
readonly record: CwdLeaseRecord;
|
|
41
|
+
updateWorker(worker: CwdLeaseWorker): Promise<void>;
|
|
42
|
+
release(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const LOCK_TIMEOUT_MS = 10_000;
|
|
46
|
+
const STALE_LOCK_MS = 5_000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Cross-process working-directory lease registry.
|
|
50
|
+
*
|
|
51
|
+
* A lease is deliberately not reclaimed merely because its owner process died:
|
|
52
|
+
* a worker may have survived the Pi process. The operator must clean up an
|
|
53
|
+
* unconfirmed lease explicitly after verifying the old worker is gone.
|
|
54
|
+
*/
|
|
55
|
+
export class CwdLeaseStore {
|
|
56
|
+
readonly #directory: string;
|
|
57
|
+
|
|
58
|
+
constructor(directory: string) {
|
|
59
|
+
this.#directory = resolve(directory);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get directory(): string {
|
|
63
|
+
return this.#directory;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async acquire(cwd: string, taskId: string, transport: CwdLeaseTransport, options: { handoff?: CwdLeaseHandoff } = {}): Promise<CwdLeaseHandle> {
|
|
67
|
+
const canonicalCwd = await realpath(resolve(cwd));
|
|
68
|
+
const now = new Date().toISOString();
|
|
69
|
+
const lease: CwdLeaseRecord = {
|
|
70
|
+
version: 1,
|
|
71
|
+
leaseId: randomUUID(),
|
|
72
|
+
taskId,
|
|
73
|
+
cwd: canonicalCwd,
|
|
74
|
+
ownerPid: process.pid,
|
|
75
|
+
ownerStartTime: await processStartTime(process.pid),
|
|
76
|
+
acquiredAt: now,
|
|
77
|
+
updatedAt: now,
|
|
78
|
+
worker: { transport },
|
|
79
|
+
};
|
|
80
|
+
await this.#withLock(async () => {
|
|
81
|
+
const leases = await this.#readAll();
|
|
82
|
+
let handoffLease: CwdLeaseRecord | undefined;
|
|
83
|
+
for (const existing of leases) {
|
|
84
|
+
if (options.handoff && existing.cwd === canonicalCwd && await matchesHandoff(existing, options.handoff)) {
|
|
85
|
+
if (handoffLease) throw new Error("multiple matching tmux cwd leases; refusing ambiguous handoff");
|
|
86
|
+
handoffLease = existing;
|
|
87
|
+
lease.worker = { ...existing.worker!, transport };
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!pathsOverlap(existing.cwd, canonicalCwd)) continue;
|
|
91
|
+
throw new Error(`working-directory lease is held by task ${existing.taskId}: ${redactText(existing.cwd)}`);
|
|
92
|
+
}
|
|
93
|
+
await this.#write(lease);
|
|
94
|
+
if (handoffLease) await rm(this.#path(handoffLease.leaseId), { force: true });
|
|
95
|
+
});
|
|
96
|
+
return this.#handle(lease);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async list(): Promise<CwdLeaseRecord[]> {
|
|
100
|
+
return this.#withLock(() => this.#readAll());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#handle(initial: CwdLeaseRecord): CwdLeaseHandle {
|
|
104
|
+
let current = { ...initial, worker: initial.worker ? { ...initial.worker } : undefined };
|
|
105
|
+
let released = false;
|
|
106
|
+
return {
|
|
107
|
+
get record() { return current; },
|
|
108
|
+
updateWorker: async (worker) => {
|
|
109
|
+
if (released) throw new Error("cwd lease has already been released");
|
|
110
|
+
assertWorker(worker);
|
|
111
|
+
const next = { ...current, worker: { ...worker }, updatedAt: new Date().toISOString() };
|
|
112
|
+
await this.#withLock(async () => {
|
|
113
|
+
const leases = await this.#readAll();
|
|
114
|
+
const existing = leases.find((lease) => lease.leaseId === current.leaseId);
|
|
115
|
+
if (!existing) throw new Error("cwd lease disappeared before worker identity could be recorded");
|
|
116
|
+
if (existing.cwd !== current.cwd || existing.taskId !== current.taskId) throw new Error("cwd lease identity changed; refusing update");
|
|
117
|
+
await this.#write(next);
|
|
118
|
+
});
|
|
119
|
+
current = next;
|
|
120
|
+
},
|
|
121
|
+
release: async () => {
|
|
122
|
+
if (released) return;
|
|
123
|
+
await this.#withLock(async () => {
|
|
124
|
+
const leases = await this.#readAll();
|
|
125
|
+
const existing = leases.find((lease) => lease.leaseId === current.leaseId);
|
|
126
|
+
if (!existing) throw new Error("cwd lease disappeared before release");
|
|
127
|
+
await rm(this.#path(existing.leaseId), { force: true });
|
|
128
|
+
});
|
|
129
|
+
released = true;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async #readAll(): Promise<CwdLeaseRecord[]> {
|
|
135
|
+
let names: string[];
|
|
136
|
+
try {
|
|
137
|
+
names = await readdir(this.#directory);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof Error && /ENOENT/u.test(error.message)) return [];
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
const leases: CwdLeaseRecord[] = [];
|
|
143
|
+
for (const name of names.filter((item) => item.endsWith(".json"))) {
|
|
144
|
+
const leasePath = join(this.#directory, name);
|
|
145
|
+
const leaseInfo = await lstat(leasePath);
|
|
146
|
+
if (!leaseInfo.isFile()) throw new Error(`cwd lease entry is not a regular file: ${redactText(leasePath)}`);
|
|
147
|
+
const value = JSON.parse(await readFile(leasePath, "utf8")) as Partial<CwdLeaseRecord>;
|
|
148
|
+
const lease = normalizeLease(value);
|
|
149
|
+
// A missing or replaced cwd invalidates the registry evidence. Do not
|
|
150
|
+
// silently drop that record and allow a second worker to start.
|
|
151
|
+
lease.cwd = await realpath(lease.cwd);
|
|
152
|
+
leases.push(lease);
|
|
153
|
+
}
|
|
154
|
+
return leases;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async #write(lease: CwdLeaseRecord): Promise<void> {
|
|
158
|
+
await this.#ensureDirectory();
|
|
159
|
+
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 });
|
|
162
|
+
await rename(temporary, target);
|
|
163
|
+
await chmod(target, 0o600);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#path(leaseId: string): string {
|
|
167
|
+
return join(this.#directory, `${leaseId}.json`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async #withLock<T>(operation: () => Promise<T>): Promise<T> {
|
|
171
|
+
await this.#ensureDirectory();
|
|
172
|
+
const lockPath = join(this.#directory, ".lock");
|
|
173
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
174
|
+
while (true) {
|
|
175
|
+
try {
|
|
176
|
+
await mkdir(lockPath);
|
|
177
|
+
await writeFile(join(lockPath, "owner.json"), JSON.stringify({
|
|
178
|
+
pid: process.pid,
|
|
179
|
+
startTime: await processStartTime(process.pid),
|
|
180
|
+
at: new Date().toISOString(),
|
|
181
|
+
}), { mode: 0o600 });
|
|
182
|
+
break;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (!(error instanceof Error) || !/EEXIST/u.test(error.message)) throw error;
|
|
185
|
+
if (await removeStaleLock(lockPath)) continue;
|
|
186
|
+
if (Date.now() >= deadline) throw new Error(`cwd lease lock timeout: ${redactText(lockPath)}`);
|
|
187
|
+
await delay(25);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
return await operation();
|
|
192
|
+
} finally {
|
|
193
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async #ensureDirectory(): Promise<void> {
|
|
198
|
+
await mkdir(this.#directory, { recursive: true, mode: 0o700 });
|
|
199
|
+
const info = await lstat(this.#directory);
|
|
200
|
+
if (!info.isDirectory()) throw new Error(`cwd lease registry is not a directory: ${redactText(this.#directory)}`);
|
|
201
|
+
await chmod(this.#directory, 0o700);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function pathsOverlap(first: string, second: string): boolean {
|
|
206
|
+
const left = resolve(first);
|
|
207
|
+
const right = resolve(second);
|
|
208
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function workerIdentity(worker: Pick<CwdLeaseWorker, "pid" | "cgroupPath" | "tmuxTarget" | "tmuxPaneId" | "paneStartTime" | "paneCommand">): Promise<Pick<CwdLeaseWorker, "pid" | "startTime" | "cgroupPath" | "tmuxTarget" | "tmuxPaneId" | "paneStartTime" | "paneCommand">> {
|
|
212
|
+
return {
|
|
213
|
+
pid: worker.pid,
|
|
214
|
+
startTime: worker.pid ? await processStartTime(worker.pid) : undefined,
|
|
215
|
+
cgroupPath: worker.cgroupPath,
|
|
216
|
+
tmuxTarget: worker.tmuxTarget,
|
|
217
|
+
tmuxPaneId: worker.tmuxPaneId,
|
|
218
|
+
paneStartTime: worker.paneStartTime,
|
|
219
|
+
paneCommand: worker.paneCommand,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function removeStaleLock(lockPath: string): Promise<boolean> {
|
|
224
|
+
try {
|
|
225
|
+
const lockInfo = await lstat(lockPath);
|
|
226
|
+
if (!lockInfo.isDirectory()) throw new Error("cwd lease lock is not a directory");
|
|
227
|
+
const ownerPath = join(lockPath, "owner.json");
|
|
228
|
+
const ownerInfo = await lstat(ownerPath);
|
|
229
|
+
if (!ownerInfo.isFile()) throw new Error("cwd lease lock owner is not a regular file");
|
|
230
|
+
const info = await stat(ownerPath);
|
|
231
|
+
if (Date.now() - info.mtimeMs < STALE_LOCK_MS) return false;
|
|
232
|
+
const owner = JSON.parse(await readFile(ownerPath, "utf8")) as { pid?: unknown; startTime?: unknown };
|
|
233
|
+
if (typeof owner.pid === "number") {
|
|
234
|
+
const currentStart = await processStartTime(owner.pid);
|
|
235
|
+
if (currentStart && typeof owner.startTime === "string" && currentStart === owner.startTime) return false;
|
|
236
|
+
try {
|
|
237
|
+
process.kill(owner.pid, 0);
|
|
238
|
+
return false;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error instanceof Error && /EPERM/u.test(error.message)) return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
244
|
+
return true;
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error instanceof Error && /ENOENT/u.test(error.message)) return true;
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function processIdentityLive(pid: number, expectedStartTime?: string): Promise<boolean> {
|
|
252
|
+
const currentStartTime = await processStartTime(pid);
|
|
253
|
+
if (currentStartTime && expectedStartTime) return currentStartTime === expectedStartTime;
|
|
254
|
+
if (currentStartTime) return true;
|
|
255
|
+
try {
|
|
256
|
+
process.kill(pid, 0);
|
|
257
|
+
return true;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return error instanceof Error && /EPERM/u.test(error.message);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function processStartTime(pid: number): Promise<string | undefined> {
|
|
264
|
+
try {
|
|
265
|
+
const statText = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
266
|
+
const closeParen = statText.lastIndexOf(")");
|
|
267
|
+
const fields = closeParen >= 0 ? statText.slice(closeParen + 2).trim().split(/\s+/u) : [];
|
|
268
|
+
const startTime = fields[19];
|
|
269
|
+
return startTime && /^\d+$/u.test(startTime) ? startTime : undefined;
|
|
270
|
+
} catch {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function matchesHandoff(lease: CwdLeaseRecord, handoff: CwdLeaseHandoff): Promise<boolean> {
|
|
276
|
+
const worker = lease.worker;
|
|
277
|
+
if (worker?.transport !== "tmux"
|
|
278
|
+
|| worker.sessionName !== handoff.sessionName
|
|
279
|
+
|| worker.tmuxSocket !== handoff.tmuxSocket
|
|
280
|
+
|| worker.tmuxTarget !== handoff.sessionName
|
|
281
|
+
|| !worker.pid
|
|
282
|
+
|| !worker.startTime
|
|
283
|
+
|| !worker.tmuxPaneId
|
|
284
|
+
|| !worker.paneStartTime
|
|
285
|
+
|| !worker.paneCommand) return false;
|
|
286
|
+
// A live owner is still an active supervisor. Do not let another Pi consume
|
|
287
|
+
// its lease merely because the tmux session name is known.
|
|
288
|
+
return !(await processIdentityLive(lease.ownerPid, lease.ownerStartTime));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function assertWorker(worker: CwdLeaseWorker): void {
|
|
292
|
+
if (!["process-pipe", "jsonl", "pty", "tmux"].includes(worker.transport)
|
|
293
|
+
|| (worker.pid !== undefined && (!Number.isSafeInteger(worker.pid) || worker.pid < 1))
|
|
294
|
+
|| (worker.startTime !== undefined && !/^\d+$/u.test(worker.startTime))
|
|
295
|
+
|| (worker.tmuxPaneId !== undefined && !/^%\d+$/u.test(worker.tmuxPaneId))
|
|
296
|
+
|| (worker.cgroupPath !== undefined && !worker.cgroupPath.startsWith("/"))) {
|
|
297
|
+
throw new Error("invalid cwd lease worker identity");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function normalizeLease(value: Partial<CwdLeaseRecord>): CwdLeaseRecord {
|
|
302
|
+
if (value.version !== 1 || typeof value.leaseId !== "string" || !/^[0-9a-f-]{36}$/iu.test(value.leaseId)
|
|
303
|
+
|| typeof value.taskId !== "string" || !/^[0-9a-f-]{36}$/iu.test(value.taskId)
|
|
304
|
+
|| typeof value.cwd !== "string" || !value.cwd.startsWith("/")
|
|
305
|
+
|| !Number.isSafeInteger(value.ownerPid) || (value.ownerPid ?? 0) < 1
|
|
306
|
+
|| typeof value.acquiredAt !== "string" || !Number.isFinite(Date.parse(value.acquiredAt))
|
|
307
|
+
|| typeof value.updatedAt !== "string" || !Number.isFinite(Date.parse(value.updatedAt))
|
|
308
|
+
|| (value.ownerStartTime !== undefined && (typeof value.ownerStartTime !== "string" || !/^\d+$/u.test(value.ownerStartTime)))) {
|
|
309
|
+
throw new Error("invalid cwd lease record");
|
|
310
|
+
}
|
|
311
|
+
const worker = value.worker;
|
|
312
|
+
if (worker && (typeof worker !== "object" || !["process-pipe", "jsonl", "pty", "tmux"].includes(worker.transport as string))) throw new Error("invalid cwd lease worker identity");
|
|
313
|
+
const ownerPid = value.ownerPid!;
|
|
314
|
+
return {
|
|
315
|
+
version: 1,
|
|
316
|
+
leaseId: value.leaseId,
|
|
317
|
+
taskId: value.taskId,
|
|
318
|
+
cwd: resolve(value.cwd),
|
|
319
|
+
ownerPid,
|
|
320
|
+
ownerStartTime: typeof value.ownerStartTime === "string" ? value.ownerStartTime : undefined,
|
|
321
|
+
acquiredAt: value.acquiredAt,
|
|
322
|
+
updatedAt: value.updatedAt,
|
|
323
|
+
worker: worker ? {
|
|
324
|
+
transport: worker.transport!,
|
|
325
|
+
pid: Number.isSafeInteger(worker.pid) && worker.pid! > 0 ? worker.pid : undefined,
|
|
326
|
+
startTime: typeof worker.startTime === "string" && /^\d+$/u.test(worker.startTime) ? worker.startTime : undefined,
|
|
327
|
+
sessionName: typeof worker.sessionName === "string" ? worker.sessionName : undefined,
|
|
328
|
+
tmuxSocket: typeof worker.tmuxSocket === "string" ? worker.tmuxSocket : undefined,
|
|
329
|
+
ownership: worker.ownership === "owned" || worker.ownership === "adopted" ? worker.ownership : undefined,
|
|
330
|
+
cgroupPath: typeof worker.cgroupPath === "string" ? worker.cgroupPath : undefined,
|
|
331
|
+
tmuxTarget: typeof worker.tmuxTarget === "string" ? worker.tmuxTarget : undefined,
|
|
332
|
+
tmuxPaneId: typeof worker.tmuxPaneId === "string" && /^%\d+$/u.test(worker.tmuxPaneId) ? worker.tmuxPaneId : undefined,
|
|
333
|
+
paneStartTime: typeof worker.paneStartTime === "string" ? worker.paneStartTime : undefined,
|
|
334
|
+
paneCommand: typeof worker.paneCommand === "string" ? worker.paneCommand : undefined,
|
|
335
|
+
} : undefined,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function redactText(value: string): string {
|
|
340
|
+
return String(redactSensitive(value));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function delay(ms: number): Promise<void> {
|
|
344
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
345
|
+
}
|