168 lines
7.2 KiB
TypeScript
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;
|
|
}
|