/** * Disk persistence for goal harness state under `.pi/goal//`. * * Layout: * plan.md, plan.baseline.md, state.json, strategy.md * evidence/index.md * scratch/ * receipts// */ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { randomBytes } from "node:crypto"; import type { GoalOrchestration, GoalOwner, GoalOwnerToken } from "./types.ts"; /** Override root for tests. Default: `/.pi/goal`. */ let goalRootOverride: string | null = null; /** Owner activity is observable, but expiry never grants implicit takeover. */ export const GOAL_OWNER_LEASE_MS = 15 * 60_000; export type OwnedGoalResult = | { kind: "owned"; state: GoalOrchestration } | { kind: "foreign"; state: GoalOrchestration } | { kind: "unowned"; state: GoalOrchestration }; export type TakeOverGoalResult = | { kind: "taken_over"; state: GoalOrchestration } | { kind: "missing" }; export type ClearOwnedGoalResult = "cleared" | "missing" | "foreign" | "unowned"; /** Owner-validated critical section result. Artifact writes use this fence. */ export type OwnedMutationResult = | { kind: "owned"; state: GoalOrchestration; value: T } | { kind: "foreign"; state: GoalOrchestration } | { kind: "unowned"; state: GoalOrchestration } | { kind: "missing" }; /** Thrown when another process holds the goal ownership lock (never auto-reclaimed). */ export const GOAL_OWNERSHIP_LOCK_BUSY_MESSAGE = "goal ownership lock is busy"; export class GoalOwnershipLockBusyError extends Error { readonly code = "ownership_busy" as const; constructor(message = GOAL_OWNERSHIP_LOCK_BUSY_MESSAGE) { super(message); this.name = "GoalOwnershipLockBusyError"; } } export function isGoalOwnershipLockBusy(err: unknown): boolean { if (err instanceof GoalOwnershipLockBusyError) return true; if (err instanceof Error && err.message === GOAL_OWNERSHIP_LOCK_BUSY_MESSAGE) return true; return false; } export function createGoalOwner(sessionId: string, now = new Date()): GoalOwner { const normalized = sessionId.trim(); if (!normalized) throw new Error("goal owner session id must be non-empty"); return { sessionId: normalized, generation: 1, leaseExpiresAt: new Date(now.getTime() + GOAL_OWNER_LEASE_MS).toISOString(), }; } export function ownerTokenFor(owner: GoalOwner): GoalOwnerToken { return { sessionId: owner.sessionId, generation: owner.generation }; } function renewOwner(owner: GoalOwner, now: Date): GoalOwner { return { ...owner, leaseExpiresAt: new Date(now.getTime() + GOAL_OWNER_LEASE_MS).toISOString(), }; } function hasOwnerToken(owner: GoalOwner | undefined, token: GoalOwnerToken): boolean { return owner?.sessionId === token.sessionId && owner.generation === token.generation; } /** * Ownership lock path lives as a *sibling* of the goal directory under the goal * root — never inside the goal dir. clearOwnedGoalDir recursively deletes the * goal dir; an in-dir lock would be removed mid-critical-section, allowing a * second process to acquire a new lock that the first process's `finally` then * deletes (classic lost-lock interleave). */ export function resolveGoalOwnerLockPath(cwd: string, goalId: string): string { // Sanitize path segment: goal ids are hex-ish but keep lock names flat. const safe = goalId.replace(/[^A-Za-z0-9._-]/g, "_"); return join(resolveGoalRoot(cwd), `.owner.lock.${safe}`); } /** * Serialize ownership-sensitive reads/writes for one goal. * * Fail-closed: a pre-existing lock is never reclaimed based on age or local PID * liveness. Goal roots may live on shared filesystems where PID identity is not * host-global, and a live holder may be paused mid-write. Recovery is explicit * (operator removes the lock directory after confirming no live holder) — * normal ownership APIs never auto-delete a foreign lock. */ function withGoalLock(cwd: string, goalId: string, fn: () => T): T { const root = resolveGoalRoot(cwd); mkdirSync(root, { recursive: true }); // Acquire the sibling lock *before* ensureGoalDirs so a concurrent clear cannot // be undone by a losing contender recreating the goal directory pre-lock. const lockPath = resolveGoalOwnerLockPath(cwd, goalId); try { mkdirSync(lockPath); } catch (error) { const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: string }).code : undefined; if (code === "EEXIST") { throw new GoalOwnershipLockBusyError(); } throw error; } try { // Goal dir may be created under the lock for state/plan writes; clearOwnedGoalDir // deletes it while still holding the lock. ensureGoalDirs(cwd, goalId); return fn(); } finally { try { rmSync(lockPath, { recursive: true, force: true }); } catch { /* best-effort unlock of the lock we just created */ } } } /** * Run `fn` only while holding the goal lock and an exact owner-token match. * Validates `{sessionId, generation}` under the lock so a takeover cannot * interleave between the check and the mutation performed by `fn`. * On success, refreshes the owner lease and re-persists state. */ export function withOwnedGoalLock< T, >( cwd: string, goalId: string, token: GoalOwnerToken, fn: (state: GoalOrchestration) => T, now = new Date(), ): OwnedMutationResult { return withGoalLock(cwd, goalId, () => { const state = readState(cwd, goalId); if (!state) return { kind: "missing" }; if (!state.owner) return { kind: "unowned", state }; if (!hasOwnerToken(state.owner, token)) return { kind: "foreign", state }; const value = fn(state); const renewed = { ...state, owner: renewOwner(state.owner, now) }; writeState(cwd, renewed); return { kind: "owned", state: renewed, value }; }); } export function setGoalRootForTests(root: string | null): void { goalRootOverride = root; } export function resolveGoalRoot(cwd: string): string { if (process.env.PI_GOAL_ROOT) { return process.env.PI_GOAL_ROOT; } if (goalRootOverride) { return goalRootOverride; } return join(cwd, ".pi", "goal"); } export function resolveGoalDir(cwd: string, goalId: string): string { return join(resolveGoalRoot(cwd), goalId); } export function ensureGoalDirs(cwd: string, goalId: string): string { const dir = resolveGoalDir(cwd, goalId); for (const sub of ["", "evidence", "scratch", "receipts"]) { const p = sub ? join(dir, sub) : dir; mkdirSync(p, { recursive: true }); } return dir; } function isExdev(err: unknown): boolean { return ( typeof err === "object" && err !== null && "code" in err && (err as { code?: string }).code === "EXDEV" ); } /** * Atomic-ish write. Stage the temp file in the *same directory* as the * destination so `rename` never crosses mount points (tmpfs `/tmp` → home * is a common EXDEV on Linux). Falls back to copy+unlink if rename still * fails with EXDEV (e.g. weird bind mounts). */ function atomicWrite(filePath: string, content: string): void { const dir = dirname(filePath); mkdirSync(dir, { recursive: true }); const tmp = join(dir, `.pi-goal-${randomBytes(8).toString("hex")}.tmp`); try { writeFileSync(tmp, content, "utf8"); try { renameSync(tmp, filePath); } catch (err) { if (!isExdev(err)) throw err; // Cross-device fallback: copy then remove temp. copyFileSync(tmp, filePath); rmSync(tmp, { force: true }); } } catch (err) { try { rmSync(tmp, { force: true }); } catch { /* ignore */ } throw err; } } function safeRead(path: string): string | null { try { if (!existsSync(path)) return null; return readFileSync(path, "utf8"); } catch { return null; } } function writePlanUnlocked( cwd: string, goalId: string, planMarkdown: string, opts?: { refreshBaseline?: boolean }, ): { planPath: string; planBaselinePath: string } { ensureGoalDirs(cwd, goalId); const dir = resolveGoalDir(cwd, goalId); const planPath = join(dir, "plan.md"); atomicWrite(planPath, planMarkdown); const planBaselinePath = join(dir, "plan.baseline.md"); // Default: snapshot baseline only on first write. After planner expand, // pass refreshBaseline:true once so baseline matches the real contract. if (opts?.refreshBaseline || !existsSync(planBaselinePath)) { atomicWrite(planBaselinePath, planMarkdown); } return { planPath, planBaselinePath }; } /** Unfenced plan write — tests and pre-ownership bootstrap only. Prefer writeOwnedPlan. */ export function writePlan( cwd: string, goalId: string, planMarkdown: string, opts?: { refreshBaseline?: boolean }, ): { planPath: string; planBaselinePath: string } { return writePlanUnlocked(cwd, goalId, planMarkdown, opts); } /** * Write plan.md (and optionally baseline) only while the exact owner token still * owns the goal. Validation and the write share one lock so a concurrent * takeover cannot land between the check and the artifact mutation. */ export function writeOwnedPlan( cwd: string, goalId: string, planMarkdown: string, token: GoalOwnerToken, opts?: { refreshBaseline?: boolean }, now = new Date(), ): OwnedMutationResult<{ planPath: string; planBaselinePath: string }> { return withOwnedGoalLock( cwd, goalId, token, () => writePlanUnlocked(cwd, goalId, planMarkdown, opts), now, ); } export function readPlan(cwd: string, goalId: string): string | null { return safeRead(join(resolveGoalDir(cwd, goalId), "plan.md")); } export function writeState(cwd: string, state: GoalOrchestration): void { ensureGoalDirs(cwd, state.goalId); const path = join(resolveGoalDir(cwd, state.goalId), "state.json"); atomicWrite(path, JSON.stringify(state, null, 2) + "\n"); } export function readState(cwd: string, goalId: string): GoalOrchestration | null { const raw = safeRead(join(resolveGoalDir(cwd, goalId), "state.json")); if (!raw) return null; try { return JSON.parse(raw) as GoalOrchestration; } catch { return null; } } /** Restore only when this same Pi session owns the persisted goal. */ export function restoreGoalForOwner( cwd: string, goalId: string, sessionId: string, now = new Date(), ): OwnedGoalResult | null { return withGoalLock(cwd, goalId, () => { const state = readState(cwd, goalId); if (!state) return null; if (!state.owner) return { kind: "unowned", state }; if (state.owner.sessionId !== sessionId) return { kind: "foreign", state }; const renewed = { ...state, owner: renewOwner(state.owner, now) }; writeState(cwd, renewed); return { kind: "owned", state: renewed }; }); } /** Renew an already-loaded goal only when its exact fencing token still matches. */ export function renewGoalForOwnerToken( cwd: string, goalId: string, token: GoalOwnerToken, now = new Date(), ): OwnedGoalResult | null { return withGoalLock(cwd, goalId, () => { const state = readState(cwd, goalId); if (!state) return null; if (!state.owner) return { kind: "unowned", state }; if (!hasOwnerToken(state.owner, token)) return { kind: "foreign", state }; const renewed = { ...state, owner: renewOwner(state.owner, now) }; writeState(cwd, renewed); return { kind: "owned", state: renewed }; }); } /** Persist only when the same session/generation still owns the current disk state. */ export function writeOwnedGoalState( cwd: string, candidate: GoalOrchestration, token: GoalOwnerToken, now = new Date(), ): OwnedGoalResult { return withGoalLock(cwd, candidate.goalId, () => { const current = readState(cwd, candidate.goalId); if (!current?.owner) { return { kind: "unowned", state: current ?? candidate }; } if (!hasOwnerToken(current.owner, token)) return { kind: "foreign", state: current }; const next = { ...candidate, owner: renewOwner(current.owner, now) }; writeState(cwd, next); return { kind: "owned", state: next }; }); } /** Explicit cross-session transfer. It is the sole operation that bumps generation. */ export function takeOverGoal( cwd: string, goalId: string, sessionId: string, now = new Date(), ): TakeOverGoalResult { return withGoalLock(cwd, goalId, () => { const state = readState(cwd, goalId); if (!state) return { kind: "missing" }; const owner = createGoalOwner(sessionId, now); owner.generation = (state.owner?.generation ?? 0) + 1; const taken = { ...state, owner }; writeState(cwd, taken); return { kind: "taken_over", state: taken }; }); } /** * Test-only: runs after the goal directory is deleted while the ownership lock * is still held. Used to simulate a concurrent reacquire race. */ let clearOwnedGoalDirRaceHookForTests: (() => void) | null = null; export function setClearOwnedGoalDirRaceHookForTests(hook: (() => void) | null): void { clearOwnedGoalDirRaceHookForTests = hook; } /** * Remove a goal only when its persisted owner matches the caller fence token. * * `onCleared` runs under the same lock, after the token check and before the * directory is deleted, so lifecycle side effects (e.g. the `cleared` event) * are owner-fenced and cannot fire after a transfer refuses the clear. */ export function clearOwnedGoalDir( cwd: string, goalId: string, token: GoalOwnerToken, onCleared?: () => void, ): ClearOwnedGoalResult { return withGoalLock(cwd, goalId, () => { const state = readState(cwd, goalId); if (!state) return "missing"; if (!state.owner) return "unowned"; if (!hasOwnerToken(state.owner, token)) return "foreign"; // Side effects while still owner-validated and locked — never after release. try { onCleared?.(); } catch { /* non-fatal: clear still proceeds after a failed side effect */ } // Lock is a sibling of the goal dir, so recursive delete cannot drop it. clearGoalDir(cwd, goalId); try { clearOwnedGoalDirRaceHookForTests?.(); } catch { /* test hook must not break clear */ } return "cleared"; }); } const TERMINAL_STATUSES = new Set(["complete", "cleared"]); /** * Goals parked by startGoal replace must not be session-restored (B017). * Matches pauseMessage written in index.ts when superseding the prior active. */ function isSupersededGoal(state: GoalOrchestration): boolean { return /replaced by new goal/i.test(String(state.pauseMessage ?? "")); } /** Scan goal dirs for a non-complete/cleared goal. Prefers active; then newest createdAt. */ export function findActiveGoal(cwd: string): GoalOrchestration | null { const root = resolveGoalRoot(cwd); if (!existsSync(root)) return null; let active: GoalOrchestration | null = null; let fallback: GoalOrchestration | null = null; let entries: string[]; try { entries = readdirSync(root, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); } catch { return null; } for (const goalId of entries) { const state = readState(cwd, goalId); if (!state) continue; const status = String(state.status ?? ""); if (TERMINAL_STATUSES.has(status)) continue; if (isSupersededGoal(state)) continue; if (status === "active") { // Prefer newest active if two somehow exist (partial replace race). if (!active || String(state.createdAt) > String(active.createdAt)) { active = state; } continue; } if (!fallback || String(state.createdAt) > String(fallback.createdAt)) { fallback = state; } } return active ?? fallback; } export function writeEvidenceIndex(cwd: string, goalId: string, md: string): void { ensureGoalDirs(cwd, goalId); atomicWrite(join(resolveGoalDir(cwd, goalId), "evidence", "index.md"), md); } export function readEvidenceIndex(cwd: string, goalId: string): string | null { return safeRead(join(resolveGoalDir(cwd, goalId), "evidence", "index.md")); } export function writeStrategy(cwd: string, goalId: string, md: string): void { ensureGoalDirs(cwd, goalId); atomicWrite(join(resolveGoalDir(cwd, goalId), "strategy.md"), md); } export interface ReceiptInput { summary: string; plan: string; evidence: string; verifierNotes?: string; } function writeReceiptUnlocked(cwd: string, goalId: string, receipt: ReceiptInput): string { ensureGoalDirs(cwd, goalId); const iso = new Date().toISOString().replace(/[:.]/g, "-"); const dir = join(resolveGoalDir(cwd, goalId), "receipts", iso); mkdirSync(dir, { recursive: true }); atomicWrite(join(dir, "SUMMARY.md"), receipt.summary); atomicWrite(join(dir, "plan.md"), receipt.plan); atomicWrite(join(dir, "evidence.md"), receipt.evidence); if (receipt.verifierNotes) { atomicWrite(join(dir, "verifier-notes.md"), receipt.verifierNotes); } // Copy baseline if present const baseline = safeRead(join(resolveGoalDir(cwd, goalId), "plan.baseline.md")); if (baseline) { atomicWrite(join(dir, "plan.baseline.md"), baseline); } return dir; } /** Unfenced receipt write — tests only. Prefer writeOwnedReceipt. */ export function writeReceipt(cwd: string, goalId: string, receipt: ReceiptInput): string { return writeReceiptUnlocked(cwd, goalId, receipt); } /** * Write a completion receipt only while the exact owner token still owns the goal. * Shares the owner lock with the token check so transfer cannot race the write. */ export function writeOwnedReceipt( cwd: string, goalId: string, receipt: ReceiptInput, token: GoalOwnerToken, now = new Date(), ): OwnedMutationResult { return withOwnedGoalLock( cwd, goalId, token, () => writeReceiptUnlocked(cwd, goalId, receipt), now, ); } /** Best-effort remove of a goal directory. */ export function clearGoalDir(cwd: string, goalId: string): void { const dir = resolveGoalDir(cwd, goalId); try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } } /** Test helper: unique temp root under os.tmpdir(). */ export function makeTempGoalRoot(): string { const dir = join(tmpdir(), `pi-goal-${randomBytes(6).toString("hex")}`); mkdirSync(dir, { recursive: true }); return dir; }