// Seat-durable goal state persistence (Q7b: survives /new, /resume, restarts). // The state file lives beside the pi agent home: $PI_CODING_AGENT_DIR/goal-state.json // for fleet seats, ~/.pi/agent/goal-state.json for operator sessions. // // NG-7 INCARNATION FENCING (Mercer F1, board T129): the seat-level file was // ONE shared mutable object — two incarnations of a seat (marcie + marcie-2) // inherited and rewrote each other's goal state on session_start. State is // now FENCED per process-launch incarnation identity (mosaic-core // lib/incarnation.ts — the same identity the R6 journal keys on; NOT // PI_SESSION_ID, which is session identity): // // /goal-state..json // // Migration (NG-7 remediation F4): an existing unfenced goal-state.json has // an UNKNOWN OWNER — for the live canary case it is legacy AND ACTIVE (a // previous incarnation's focus), and velma B1 forbids a fresh incarnation // inheriting another incarnation's active focus. So the legacy file is // QUARANTINED, never claimed: atomically renamed aside with its bytes // preserved (goal-state.legacy.json, then .legacy.1.json, .legacy.2.json…), // and the resolving incarnation starts FRESH. Nobody inherits an active // focus; nobody loses the evidence. import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { initialState, type GoalState } from "./state.ts"; import { incarnationIdentity } from "../../mosaic-core/lib/incarnation.ts"; export const LEGACY_STATE_FILENAME = "goal-state.json"; /** Pi agent home for the current process (seat dir for fleet seats, ~/.pi/agent otherwise). */ export function agentStateDir(env: NodeJS.ProcessEnv = process.env): string { const dir = env.PI_CODING_AGENT_DIR; if (dir && dir.trim() !== "") return dir; return join(homedir(), ".pi", "agent"); } export function stateFilePath(dir: string = agentStateDir()): string { return join(dir, LEGACY_STATE_FILENAME); } /** The incarnation-fenced state file for a given agent dir + incarnation. */ export function fencedStateFilePath(dir: string, incarnationId: string): string { return join(dir, `goal-state.${incarnationId}.json`); } export interface ResolveIO { existsSync(path: string): boolean; renameSync(from: string, to: string): void; } function defaultResolveIO(): ResolveIO { return { existsSync, renameSync }; } /** First free quarantine slot for the unknown-owner legacy file. */ export function quarantinePath(dir: string, io: ResolveIO): string { const first = join(dir, "goal-state.legacy.json"); if (!io.existsSync(first)) return first; for (let n = 1; ; n++) { const candidate = join(dir, `goal-state.legacy.${n}.json`); if (!io.existsSync(candidate)) return candidate; } } /** * Resolve the state path for THIS process incarnation, quarantining any * unknown-owner legacy state (bytes preserved; the resolver starts fresh). * Inject `incarnationId` and `io` for tests; production callers take the * defaults. */ export function resolveStatePath( dir: string = agentStateDir(), opts: { incarnationId?: string; io?: ResolveIO } = {}, ): string { const io = opts.io ?? defaultResolveIO(); const incarnationId = opts.incarnationId ?? incarnationIdentity(); const fenced = fencedStateFilePath(dir, incarnationId); if (io.existsSync(fenced)) return fenced; const legacy = join(dir, LEGACY_STATE_FILENAME); if (io.existsSync(legacy)) { try { io.renameSync(legacy, quarantinePath(dir, io)); // atomic, bytes preserved } catch { // raced by a sibling incarnation or fs trouble: start fresh rather // than ever reading a file another incarnation may still rewrite return fenced; } } return fenced; } export function loadState(path: string = stateFilePath()): GoalState { try { const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; if (parsed?.version !== 1 || typeof parsed.text !== "string" || !parsed.status) { return initialState(); } return normalize(parsed as GoalState); } catch { return initialState(); } } export function saveState(state: GoalState, path: string = stateFilePath()): void { mkdirSync(dirname(path), { recursive: true }); const temporary = `${path}.${randomUUID()}.tmp`; try { writeFileSync(temporary, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", flag: "wx", mode: 0o600 }); renameSync(temporary, path); } finally { if (existsSync(temporary)) unlinkSync(temporary); } } function normalize(state: GoalState): GoalState { const defaults = initialState(); const maxChecks = Number.isInteger(state.maxChecks) && state.maxChecks >= 1 ? state.maxChecks : defaults.maxChecks; const checks = Number.isInteger(state.checks) && state.checks >= 0 ? state.checks : 0; const maxNoProgressReports = Number.isInteger(state.maxNoProgressReports) && state.maxNoProgressReports >= 1 ? state.maxNoProgressReports : defaults.maxNoProgressReports; const noProgressReports = Number.isInteger(state.noProgressReports) && state.noProgressReports >= 0 ? state.noProgressReports : 0; const normalized: GoalState = { ...defaults, ...state, maxChecks, checks, maxNoProgressReports, noProgressReports, workEventSinceReport: state.workEventSinceReport === true, }; const outcome = state.lastOutcome; delete normalized.lastOutcome; if (state.status === "none" && outcome?.status === "complete" && typeof outcome.text === "string" && outcome.text.length > 0 && typeof outcome.evidence === "string" && outcome.evidence.length > 0 && typeof outcome.at === "string" && Number.isFinite(Date.parse(outcome.at))) { normalized.lastOutcome = { text: outcome.text, status: "complete", evidence: outcome.evidence.slice(0, 1000), at: outcome.at }; } if (!["active", "paused", "blocked", "none"].includes(state.status)) { normalized.status = "paused"; normalized.pausedReason = "invalid persisted status; inspect before /goal resume"; } if (typeof normalized.pausedReason !== "string") delete normalized.pausedReason; if (state.waitTimeoutSeconds !== undefined) { const timeoutValid = Number.isInteger(state.waitTimeoutSeconds) && state.waitTimeoutSeconds >= 10 && state.waitTimeoutSeconds <= 86400; const wait = state.activeWait; const validTime = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= 8640000000000000; const deadlineValid = !wait || (validTime(wait.deadlineAt) && wait.deadlineAt <= Date.now() + 86400000); const flagsValid = [state.waitWakeUsed, wait?.wakeSent, wait?.wakeObserved].every(value => value === undefined || typeof value === "boolean"); const dispatchValid = !wait?.wakeSent || (validTime(wait.wakeDispatchedAt) && typeof wait.wakeRequestId === "string" && wait.wakeRequestId.length > 0 && state.waitWakeUsed === true); if (!timeoutValid || !deadlineValid || !dispatchValid || !flagsValid) { return { ...normalized, status: state.status === "none" ? "none" : "paused", pausedReason: "invalid persisted wait state; inspect before /goal resume" }; } } return normalized; }