pi-claude-supervisor 0.6.0 → 0.7.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/src/cwd-lease.ts CHANGED
@@ -276,6 +276,16 @@ export class CwdLeaseStore {
276
276
  return this.#withLock(() => this.#readAll());
277
277
  }
278
278
 
279
+ /** Sorted quarantined lease file names, for operator inspection/cleanup. */
280
+ async quarantined(): Promise<string[]> {
281
+ try {
282
+ return (await readdir(this.#quarantineDirectory())).sort();
283
+ } catch (error) {
284
+ if (error instanceof Error && /ENOENT/u.test(error.message)) return [];
285
+ throw error;
286
+ }
287
+ }
288
+
279
289
  #handle(initial: CwdLeaseRecord, replacedTaskId?: string): CwdLeaseHandle {
280
290
  let current = { ...initial, worker: initial.worker ? { ...initial.worker } : undefined };
281
291
  let released = false;
@@ -326,46 +336,101 @@ export class CwdLeaseStore {
326
336
  const leases: CwdLeaseRecord[] = [];
327
337
  for (const name of names.filter((item) => item.endsWith(".json"))) {
328
338
  const leasePath = join(this.#directory, name);
329
- const leaseInfo = await lstat(leasePath);
330
- if (!leaseInfo.isFile()) throw new Error(`cwd lease entry is not a regular file: ${redactText(leasePath)}`);
331
- const value = JSON.parse(await readFile(leasePath, "utf8")) as Partial<CwdLeaseRecord>;
332
- let lease = normalizeLease(value);
333
- if (lease.pendingCleanup) {
334
- // A takeover transaction is not usable until the old cgroup/session
335
- // and any socket marker have been independently confirmed clean.
336
- const cgroupCleanedBefore = lease.pendingCleanup.cgroupCleaned === true;
337
- let cleanupComplete = await cleanupPendingLease(lease.pendingCleanup);
338
- // Reconciliation may have removed the cgroup successfully but failed
339
- // on a later marker operation. Persist that stage before returning a
340
- // still-pending record, so the next reader can continue fail-closed.
341
- if (!cgroupCleanedBefore && lease.pendingCleanup.cgroupCleaned === true) {
342
- await this.#write({ ...lease, updatedAt: new Date().toISOString() });
343
- // The first pass deliberately stops before socket-marker removal;
344
- // the cleanup stage must be durable before that second proof.
345
- cleanupComplete = await cleanupPendingLease(lease.pendingCleanup);
339
+ let lease: CwdLeaseRecord;
340
+ try {
341
+ const leaseInfo = await lstat(leasePath);
342
+ if (!leaseInfo.isFile()) {
343
+ // A directory or symlink is never renamed or followed as part of
344
+ // quarantine; just skip it in place and let an operator inspect it.
345
+ console.error(`pi-claude-supervisor skipped a non-regular cwd lease entry: ${redactText(name)}`);
346
+ continue;
346
347
  }
347
- if (cleanupComplete) {
348
- if (lease.pendingCleanup.phase === "replacement") {
349
- // The replacement is the live lease after recovery. Clear only
350
- // its transaction marker; do not delete the cwd reservation.
351
- delete lease.pendingCleanup;
352
- await this.#write(lease);
353
- } else {
354
- // The old record was still in the preparation phase, so no new
355
- // Worker lease exists to retain after cleanup.
356
- await rm(leasePath, { force: true });
357
- continue;
348
+ const value = JSON.parse(await readFile(leasePath, "utf8")) as Partial<CwdLeaseRecord>;
349
+ lease = normalizeLease(value);
350
+ } catch (error) {
351
+ if (isTransientIoError(error)) throw error;
352
+ // One unreadable or incompatible record (disk full, truncated write,
353
+ // a schema this build no longer accepts) must not block every other
354
+ // cwd lease in the registry. Quarantine it out of the directory this
355
+ // scan reads instead, and keep going.
356
+ await this.#quarantine(name, leasePath, error);
357
+ continue;
358
+ }
359
+ if (lease.pendingCleanup) {
360
+ try {
361
+ // A takeover transaction is not usable until the old cgroup/session
362
+ // and any socket marker have been independently confirmed clean.
363
+ const cgroupCleanedBefore = lease.pendingCleanup.cgroupCleaned === true;
364
+ let cleanupComplete = await cleanupPendingLease(lease.pendingCleanup);
365
+ // Reconciliation may have removed the cgroup successfully but failed
366
+ // on a later marker operation. Persist that stage before returning a
367
+ // still-pending record, so the next reader can continue fail-closed.
368
+ if (!cgroupCleanedBefore && lease.pendingCleanup.cgroupCleaned === true) {
369
+ await this.#write({ ...lease, updatedAt: new Date().toISOString() });
370
+ // The first pass deliberately stops before socket-marker removal;
371
+ // the cleanup stage must be durable before that second proof.
372
+ cleanupComplete = await cleanupPendingLease(lease.pendingCleanup);
358
373
  }
374
+ if (cleanupComplete) {
375
+ if (lease.pendingCleanup.phase === "replacement") {
376
+ // The replacement is the live lease after recovery. Clear only
377
+ // its transaction marker; do not delete the cwd reservation.
378
+ delete lease.pendingCleanup;
379
+ await this.#write(lease);
380
+ } else {
381
+ // The old record was still in the preparation phase, so no new
382
+ // Worker lease exists to retain after cleanup.
383
+ await rm(leasePath, { force: true });
384
+ continue;
385
+ }
386
+ }
387
+ } catch (error) {
388
+ if (isTransientIoError(error)) throw error;
389
+ // A reconciliation failure is not proof the record itself is
390
+ // corrupt, and quarantining it would discard a durable
391
+ // pendingCleanup transaction a later read could still finish. Skip
392
+ // it for this read only and leave the file in place.
393
+ console.error(`pi-claude-supervisor skipped a cwd lease record with a failed pendingCleanup reconciliation: ${redactText(name)}: ${errorMessage(error)}`);
394
+ continue;
359
395
  }
360
396
  }
361
- // A missing or replaced cwd invalidates the registry evidence. Do not
362
- // silently drop that record and allow a second worker to start.
363
- lease.cwd = await realpath(lease.cwd);
397
+ // A missing cwd is registry evidence that this lease's directory is
398
+ // gone, but it must not be treated the same as an unreadable record:
399
+ // silently dropping the lease would let a second worker start against
400
+ // the same path. Callers only ever compare `existing.cwd` against a
401
+ // freshly canonicalized candidate cwd, so keeping the raw (already
402
+ // `resolve`d by normalizeLease) path here still fails closed for that
403
+ // same/overlapping path without blocking every unrelated cwd lookup
404
+ // just because one lease's directory happened to disappear.
405
+ try {
406
+ lease.cwd = await realpath(lease.cwd);
407
+ } catch {
408
+ lease.cwd = resolve(lease.cwd);
409
+ }
364
410
  leases.push(lease);
365
411
  }
366
412
  return leases;
367
413
  }
368
414
 
415
+ async #quarantine(name: string, leasePath: string, error: unknown): Promise<void> {
416
+ const message = errorMessage(error);
417
+ const quarantineDir = this.#quarantineDirectory();
418
+ try {
419
+ await mkdir(quarantineDir, { recursive: true, mode: 0o700 });
420
+ await rename(leasePath, join(quarantineDir, `${name}.${Date.now()}`));
421
+ } catch {
422
+ // If the rename itself fails, leave the file in place and skip it for
423
+ // this read; the next read will retry rather than lose it silently.
424
+ console.error(`pi-claude-supervisor could not quarantine an unreadable cwd lease record: ${redactText(name)}: ${message}`);
425
+ return;
426
+ }
427
+ console.error(`pi-claude-supervisor quarantined an unreadable cwd lease record: ${redactText(name)}: ${message}`);
428
+ }
429
+
430
+ #quarantineDirectory(): string {
431
+ return join(this.#directory, "quarantine");
432
+ }
433
+
369
434
  async #write(lease: CwdLeaseRecord): Promise<void> {
370
435
  await this.#ensureDirectory();
371
436
  const target = this.#path(lease.leaseId);
@@ -570,8 +635,8 @@ async function cgroupHasProcesses(path: string): Promise<boolean> {
570
635
  try {
571
636
  const cgroupInfo = await lstat(cgroupPath);
572
637
  if (!cgroupInfo.isDirectory() || cgroupInfo.isSymbolicLink()) return true;
573
- const canonicalPath = await realpath(cgroupPath);
574
- if (canonicalPath !== cgroupPath || !canonicalPath.startsWith(`${cgroupRoot}/`)) return true;
638
+ const canonicalPath = await canonicalCgroupPath(cgroupPath);
639
+ if (!canonicalPath.startsWith(`${cgroupRoot}/`)) return true;
575
640
  const eventsPath = join(canonicalPath, "cgroup.events");
576
641
  const eventsInfo = await lstat(eventsPath);
577
642
  if (!eventsInfo.isFile() || eventsInfo.isSymbolicLink()) return true;
@@ -840,6 +905,24 @@ function isCgroupPath(path: string): boolean {
840
905
  return candidate !== root && candidate.startsWith(`${root}/`);
841
906
  }
842
907
 
908
+ /**
909
+ * Bun's realpath implementation decodes systemd's literal `\\x2d` cgroup
910
+ * escapes and then reports ENOENT. Verify every cgroup component with lstat
911
+ * instead; this also rejects symlinked components without relying on realpath.
912
+ */
913
+ async function canonicalCgroupPath(path: string): Promise<string> {
914
+ const root = resolve("/sys/fs/cgroup");
915
+ const candidate = resolve(path);
916
+ if (candidate === root || !candidate.startsWith(`${root}/`)) throw new Error("invalid cgroup path");
917
+ let current = root;
918
+ for (const component of candidate.slice(root.length + 1).split("/")) {
919
+ current = join(current, component);
920
+ const info = await lstat(current);
921
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("cgroup path is not a real directory");
922
+ }
923
+ return candidate;
924
+ }
925
+
843
926
  function matchesGeneratedCgroupName(path: string, transport: CwdLeaseTransport, workerId: string): boolean {
844
927
  const prefix = transport === "tmux" ? "pi-claude-supervisor-tmux-" : "pi-claude-supervisor-";
845
928
  return basename(resolve(path)) === `${prefix}${workerId}`;
@@ -847,11 +930,8 @@ function matchesGeneratedCgroupName(path: string, transport: CwdLeaseTransport,
847
930
 
848
931
  async function readCgroupIdentity(path: string): Promise<{ device: string; inode: string }> {
849
932
  if (!isCgroupPath(path)) throw new Error("invalid cgroup path");
850
- const candidate = resolve(path);
933
+ const candidate = await canonicalCgroupPath(path);
851
934
  const info = await lstat(candidate);
852
- if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("cgroup path is not a real directory");
853
- const canonicalPath = await realpath(candidate);
854
- if (canonicalPath !== candidate || !isCgroupPath(canonicalPath)) throw new Error("cgroup path is not canonical");
855
935
  return { device: String(info.dev), inode: String(info.ino) };
856
936
  }
857
937
 
@@ -864,11 +944,46 @@ async function cgroupIdentityMatches(path: string, expected: { device: string; i
864
944
  }
865
945
  }
866
946
 
947
+ /**
948
+ * systemd/claude can create nested cgroups under a Worker's own cgroup, so an
949
+ * empty parent (`populated 0`) can still contain an empty child directory
950
+ * that plain `rmdir` leaves behind with ENOTEMPTY. Remove bottom-up so the
951
+ * parent is only ever asked to `rmdir` once every descendant is gone.
952
+ * `hasProcesses` is injectable because `cgroupHasProcesses` refuses to
953
+ * inspect any path outside `/sys/fs/cgroup`, which this function's own
954
+ * (already `/sys/fs/cgroup`-scoped) recursion does not need to re-derive.
955
+ */
956
+ export async function removeEmptyChildCgroups(path: string, hasProcesses: (candidate: string) => Promise<boolean> = cgroupHasProcesses): Promise<boolean> {
957
+ let entries;
958
+ try {
959
+ entries = await readdir(path, { withFileTypes: true });
960
+ } catch {
961
+ // A directory that has already been removed out from under us (e.g. by a
962
+ // concurrent guardian teardown) is not proof it is safe to remove; every
963
+ // neighboring check in this file (cgroupIdentityMatches, cgroupHasProcesses,
964
+ // the final rmdir) also fails closed on an unexpected fs error.
965
+ return false;
966
+ }
967
+ for (const entry of entries) {
968
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
969
+ const child = join(path, entry.name);
970
+ if (!await removeEmptyChildCgroups(child, hasProcesses)) return false;
971
+ if (await hasProcesses(child)) return false;
972
+ try {
973
+ await rmdir(child);
974
+ } catch {
975
+ return false;
976
+ }
977
+ }
978
+ return true;
979
+ }
980
+
867
981
  async function removeEmptyCgroup(path: string, expected: { device: string; inode: string }): Promise<boolean> {
868
982
  // Re-check both the persisted directory identity and emptiness immediately
869
983
  // before removal. The initial checks only authorize attempting takeover;
870
984
  // they must not be reused after the pre-replacement hook has run.
871
985
  if (!await cgroupIdentityMatches(path, expected) || await cgroupHasProcesses(path)) return false;
986
+ if (!await removeEmptyChildCgroups(resolve(path))) return false;
872
987
  try {
873
988
  await rmdir(resolve(path));
874
989
  return true;
@@ -1116,6 +1231,20 @@ function redactText(value: string): string {
1116
1231
  return String(redactSensitive(value));
1117
1232
  }
1118
1233
 
1234
+ function errorMessage(error: unknown): string {
1235
+ return error instanceof Error ? error.message : String(error);
1236
+ }
1237
+
1238
+ /**
1239
+ * A transient condition (resource exhaustion, a concurrent lock) is not
1240
+ * evidence a lease record is corrupt. Rethrow it instead of quarantining or
1241
+ * skipping the record on its account.
1242
+ */
1243
+ function isTransientIoError(error: unknown): boolean {
1244
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
1245
+ return code === "EAGAIN" || code === "EBUSY";
1246
+ }
1247
+
1119
1248
  function delay(ms: number): Promise<void> {
1120
1249
  return new Promise((resolve) => setTimeout(resolve, ms));
1121
1250
  }
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { chmod, lstat, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { redactSensitive } from "./redaction.ts";
5
5
  import { normalizeTaskSpec } from "./acceptance.ts";
6
6
  import type { TaskSpec } from "./types.ts";
@@ -34,6 +34,8 @@ export interface DecisionSessionRecord {
34
34
  turn: number;
35
35
  repairRound?: number;
36
36
  lastFindingSignature?: string;
37
+ /** Cumulative Claude Worker API cost so far; recovery seeds the next Worker's budget accounting with it. */
38
+ workerCostUsd?: number;
37
39
  state: "active" | "closed";
38
40
  recoveryState: DecisionRecoveryState;
39
41
  recoveryAttempt: number;
@@ -92,7 +94,44 @@ export class DecisionSessionStore {
92
94
  });
93
95
  }
94
96
 
95
- async update(taskId: string, patch: Partial<Pick<DecisionSessionRecord, "turn" | "repairRound" | "lastFindingSignature" | "updatedAt">>): Promise<void> {
97
+ /**
98
+ * Remove closed records (and their session directories) whose last update is
99
+ * older than maxAgeMs. Active records are never touched. A record whose
100
+ * directory cannot be safely identified as its own task directory (moved,
101
+ * missing, or replaced by a symlink) is left in place and logged, not removed.
102
+ */
103
+ async prune(options: { maxAgeMs: number; now?: number }): Promise<{ removed: string[] }> {
104
+ if (options.maxAgeMs <= 0) return { removed: [] };
105
+ const now = options.now ?? Date.now();
106
+ return this.#withLock(async () => {
107
+ const removed: string[] = [];
108
+ for (const record of await this.list()) {
109
+ if (record.state !== "closed") continue;
110
+ const updatedAtMs = Date.parse(record.updatedAt);
111
+ if (!Number.isFinite(updatedAtMs) || now - updatedAtMs < options.maxAgeMs) continue;
112
+ try {
113
+ const directory = resolve(dirname(record.decisionSessionFile));
114
+ if (directory !== this.sessionDirectory(record.taskId)) {
115
+ throw new Error("session directory does not match its task id");
116
+ }
117
+ const relativeToStore = relative(this.#directory, directory);
118
+ if (isAbsolute(relativeToStore) || relativeToStore === ".." || relativeToStore.startsWith(`..${sep}`)) {
119
+ throw new Error("session directory escaped the store directory");
120
+ }
121
+ const info = await lstat(directory);
122
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("session directory is not a real directory");
123
+ await rm(directory, { recursive: true, force: true });
124
+ await rm(this.#recordPath(record.taskId), { force: true });
125
+ removed.push(record.taskId);
126
+ } catch (error) {
127
+ console.error(`pi-claude-supervisor could not prune Decision Worker session ${record.taskId}: ${error instanceof Error ? error.message : String(error)}`);
128
+ }
129
+ }
130
+ return { removed };
131
+ });
132
+ }
133
+
134
+ async update(taskId: string, patch: Partial<Pick<DecisionSessionRecord, "turn" | "repairRound" | "lastFindingSignature" | "workerCostUsd" | "updatedAt">>): Promise<void> {
96
135
  assertTaskId(taskId);
97
136
  await this.#withLock(async () => {
98
137
  const record = await this.#loadUnlocked(taskId);
@@ -366,6 +405,7 @@ function normalizeRecord(value: Partial<DecisionSessionRecord>, directory: strin
366
405
  || (value.recoveryOwnerStartTime !== undefined && (typeof value.recoveryOwnerStartTime !== "string" || !/^\d+$/u.test(value.recoveryOwnerStartTime)))
367
406
  || (value.recoveryState !== undefined && !isRecoveryState(value.recoveryState))
368
407
  || (value.lastFindingSignature !== undefined && (typeof value.lastFindingSignature !== "string" || value.lastFindingSignature.length > 128))
408
+ || (value.workerCostUsd !== undefined && (typeof value.workerCostUsd !== "number" || !Number.isFinite(value.workerCostUsd) || value.workerCostUsd < 0))
369
409
  || (value.baseCommit !== undefined && (typeof value.baseCommit !== "string" || !/^[0-9a-f]{40,64}$/iu.test(value.baseCommit)))
370
410
  || (value.baseBranch !== undefined && (typeof value.baseBranch !== "string" || !/^[A-Za-z0-9._/-]+$/u.test(value.baseBranch)))
371
411
  || (value.resolvedExecutable !== undefined && (typeof value.resolvedExecutable !== "string" || !isAbsolute(value.resolvedExecutable) || value.resolvedExecutable.length > 4_096))
@@ -403,6 +443,7 @@ function normalizeRecord(value: Partial<DecisionSessionRecord>, directory: strin
403
443
  turn: value.turn ?? 0,
404
444
  repairRound: value.repairRound ?? 0,
405
445
  ...(typeof value.lastFindingSignature === "string" ? { lastFindingSignature: value.lastFindingSignature } : {}),
446
+ ...(typeof value.workerCostUsd === "number" ? { workerCostUsd: value.workerCostUsd } : {}),
406
447
  state: value.state,
407
448
  recoveryState: value.recoveryState ?? "ready",
408
449
  recoveryAttempt: value.recoveryAttempt ?? 0,