Files
stack/extensions/goal/lib/store.ts
T

168 lines
7.2 KiB
TypeScript

// 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):
//
// <agentDir>/goal-state.<incarnationId>.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<GoalState>;
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;
}