import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { randomBytes } from "node:crypto"; import { getProcessStartIdentity, isProcessAlive, ownerToken, proveDeath, type LockOwner, } from "../proof-of-death.ts"; export interface SessionLeaseOwner extends LockOwner { runId: string; token: string; } export type SessionLeaseResult = | { ok: true; path: string; owner: SessionLeaseOwner; reclaimedFrom?: SessionLeaseOwner; reclaimReason?: string } | { ok: false; path: string; holder?: SessionLeaseOwner; reason: string }; export interface LeaseDeps { pid?: number; hostname?: string; now?: number; identity?: string; alive?: (pid: number) => boolean; currentIdentity?: (pid: number) => string | undefined; token?: string; } export function sessionLeasePath(sessionFile: string): string { return `${sessionFile}.agi-lease`; } function ownerFile(lockDir: string): string { return path.join(lockDir, "owner.json"); } export function readSessionLease(lockDir: string): SessionLeaseOwner | undefined { try { const record = JSON.parse(fs.readFileSync(ownerFile(lockDir), "utf8")) as Record; if ( typeof record.pid !== "number" || typeof record.hostname !== "string" || typeof record.sessionId !== "string" || typeof record.claimedAt !== "string" || typeof record.runId !== "string" || typeof record.token !== "string" ) return undefined; return { pid: record.pid, hostname: record.hostname, sessionId: record.sessionId, claimedAt: record.claimedAt, runId: record.runId, token: record.token, ...(typeof record.processStartIdentity === "string" ? { processStartIdentity: record.processStartIdentity } : {}), }; } catch { return undefined; } } function tryClaim(lockDir: string, owner: SessionLeaseOwner): boolean { const candidate = `${lockDir}.candidate-${owner.token}`; try { fs.mkdirSync(candidate, { recursive: false, mode: 0o700 }); fs.writeFileSync(ownerFile(candidate), `${JSON.stringify(owner, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); fs.renameSync(candidate, lockDir); return true; } catch { try { fs.rmSync(candidate, { recursive: true, force: true }); } catch {} return false; } } export function acquireSessionLease(sessionFile: string, runId: string, sessionId: string, deps: LeaseDeps = {}): SessionLeaseResult { const lockDir = sessionLeasePath(sessionFile); fs.mkdirSync(path.dirname(lockDir), { recursive: true, mode: 0o700 }); const pid = deps.pid ?? process.pid; const hostname = deps.hostname ?? os.hostname(); const owner: SessionLeaseOwner = { pid, hostname, sessionId, runId, token: deps.token ?? randomBytes(12).toString("hex"), claimedAt: new Date(deps.now ?? Date.now()).toISOString(), processStartIdentity: deps.identity ?? getProcessStartIdentity(pid), }; if (tryClaim(lockDir, owner)) return { ok: true, path: lockDir, owner }; const holder = readSessionLease(lockDir); if (holder === undefined) return { ok: false, path: lockDir, reason: `${lockDir} exists but owner.json is missing or unreadable; refusing to reclaim` }; if (holder.pid === pid && holder.hostname === hostname && holder.runId === runId) { return { ok: true, path: lockDir, owner: holder }; } const alive = deps.alive ?? isProcessAlive; const identity = deps.currentIdentity ?? getProcessStartIdentity; const verdict = proveDeath(holder, { hostname, alive: alive(holder.pid), currentIdentity: identity(holder.pid), now: deps.now, }); if (!verdict.reclaimable) return { ok: false, path: lockDir, holder, reason: verdict.reason }; const tombstone = `${lockDir}.stale-${ownerToken(holder)}`; try { fs.renameSync(lockDir, tombstone); } catch { // Another contender may have reclaimed the same observed owner. } if (tryClaim(lockDir, owner)) { try { fs.rmSync(tombstone, { recursive: true, force: true }); } catch {} return { ok: true, path: lockDir, owner, reclaimedFrom: holder, reclaimReason: verdict.reason }; } const winner = readSessionLease(lockDir); return { ok: false, path: lockDir, holder: winner, reason: `another run claimed the session lease first (${winner?.runId ?? "unknown"})` }; } /** * R-CTRL-29/30. Release only ever destroys the lease this token owns. * * Reading `owner.json`, checking the token, then `rmSync`-ing the shared final path * is a TOCTOU: a successor can CAS its own lease into that path between the two * steps, and the old owner then deletes a *live* lease, letting a third contender * in while the successor believes it holds the session. * * So the directory is first `rename`d to a per-owner path — an atomic move that can * only succeed for whoever currently occupies the source — and the token is verified * *again* in the moved copy before anything is deleted. If a successor had already * replaced the path, the move takes the successor's directory, the second check * fails, and it is moved straight back. */ export function releaseSessionLease(lockDir: string, token: string): void { const holder = readSessionLease(lockDir); if (holder === undefined || holder.token !== token) return; const claimed = `${lockDir}.released-${token}`; try { fs.rmSync(claimed, { recursive: true, force: true }); } catch { // A leftover from an interrupted release; the rename below will report it. } try { fs.renameSync(lockDir, claimed); } catch { // Nothing of ours to release: already gone, or replaced by a successor that // owns the path now. Either way this token must not delete it. return; } const moved = readSessionLease(claimed); if (moved === undefined || moved.token !== token) { // We moved a lease that is not ours — a successor claimed the path between the // read and the rename. Put it back exactly where it was. try { fs.renameSync(claimed, lockDir); } catch { // The successor has already re-created its lease at the final path; deleting // our stolen copy would be wrong, so it is left as an inert artefact. } return; } try { fs.rmSync(claimed, { recursive: true, force: true }); } catch { // The moved copy is no longer at the lock path, so it blocks nobody. A stale // lease is only ever reclaimed with proof of death anyway. } } /** Transfer the pre-spawn CAS claim from the orchestrator pid to the worker pid. */ export function transferSessionLease(lockDir: string, token: string, pid: number, processStartIdentity: string | undefined): boolean { const holder = readSessionLease(lockDir); if (holder === undefined || holder.token !== token) return false; const next: SessionLeaseOwner = { ...holder, pid, claimedAt: new Date().toISOString(), ...(processStartIdentity === undefined ? {} : { processStartIdentity }), }; if (processStartIdentity === undefined) delete next.processStartIdentity; const tmp = `${ownerFile(lockDir)}.tmp-${randomBytes(8).toString("hex")}`; try { fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); fs.renameSync(tmp, ownerFile(lockDir)); return true; } catch { try { fs.rmSync(tmp, { force: true }); } catch {} return false; } }