diff --git a/LifeOS/install/LIFEOS/TOOLS/Inference.autostate.test.ts b/LifeOS/install/LIFEOS/TOOLS/Inference.autostate.test.ts new file mode 100644 index 0000000000..59ccdf740f --- /dev/null +++ b/LifeOS/install/LIFEOS/TOOLS/Inference.autostate.test.ts @@ -0,0 +1,133 @@ +/** + * Inference.autostate.test.ts — session-resolution contract for + * synthesizeAdvisorState() (the --mode advisor --auto-state path). + * + * Runs in-process: the function resolves everything from $HOME, so each test + * points HOME at a throwaway tree. No inference call is ever made — only the + * state-synthesis step is exercised. Run with: bun test Inference.autostate.test.ts + */ + +import { test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { synthesizeAdvisorState } from "./Inference"; + +const MIN = 60 * 1000; +const iso = (agoMs: number) => new Date(Date.now() - agoMs).toISOString(); + +const UUID_A = "aaaaaaaa-0000-0000-0000-000000000001"; +const UUID_B = "aaaaaaaa-0000-0000-0000-000000000002"; + +let savedHome: string | undefined; +let savedUserProfile: string | undefined; +let savedSessionId: string | undefined; + +beforeEach(() => { + savedHome = process.env.HOME; + savedUserProfile = process.env.USERPROFILE; + savedSessionId = process.env.CLAUDE_SESSION_ID; + delete process.env.CLAUDE_SESSION_ID; +}); + +afterEach(() => { + if (savedHome !== undefined) process.env.HOME = savedHome; else delete process.env.HOME; + if (savedUserProfile !== undefined) process.env.USERPROFILE = savedUserProfile; else delete process.env.USERPROFILE; + if (savedSessionId !== undefined) process.env.CLAUDE_SESSION_ID = savedSessionId; else delete process.env.CLAUDE_SESSION_ID; +}); + +/** Build a throwaway $HOME with a registry and one ISA.md per session slug. */ +function fixture(sessions: Record): void { + const home = mkdtempSync(join(tmpdir(), "inference-test-")); + const memory = join(home, ".claude", "LIFEOS", "MEMORY"); + mkdirSync(join(memory, "STATE"), { recursive: true }); + writeFileSync(join(memory, "STATE", "work.json"), JSON.stringify({ sessions }, null, 2)); + for (const slug of Object.keys(sessions)) { + if (slug === "__pulse_strip") continue; + mkdirSync(join(memory, "WORK", slug), { recursive: true }); + writeFileSync(join(memory, "WORK", slug, "ISA.md"), `# ISA for ${slug}\nbody\n`); + } + process.env.HOME = home; + process.env.USERPROFILE = home; +} + +function row(overrides: Record = {}) { + return { mode: "interactive", currentMode: "algorithm", phase: "build", updatedAt: iso(1 * MIN), ...overrides }; +} + +test("CLAUDE_SESSION_ID match wins, including its phase:complete row", async () => { + fixture({ + "session-a": row({ sessionUUID: UUID_A, phase: "complete", updatedAt: iso(2 * MIN) }), + "session-b": row({ sessionUUID: UUID_B, phase: "build" }), + }); + process.env.CLAUDE_SESSION_ID = UUID_A; + const state = await synthesizeAdvisorState(); + expect(state).toContain("ISA: session-a"); +}); + +test("CLAUDE_SESSION_ID set but unmatched: no state, never a guess", async () => { + fixture({ "session-a": row({ sessionUUID: UUID_A }) }); + process.env.CLAUDE_SESSION_ID = "aaaaaaaa-0000-0000-0000-00000000dead"; + const state = await synthesizeAdvisorState(); + expect(state).toContain("ADVISOR STATE UNAVAILABLE"); +}); + +test("no env, single fresh live session: uses its ISA", async () => { + fixture({ "session-a": row({ sessionUUID: UUID_A }) }); + const state = await synthesizeAdvisorState(); + expect(state).toContain("ISA: session-a"); +}); + +test("no env, two fresh live sessions: no state", async () => { + fixture({ + "session-a": row({ sessionUUID: UUID_A }), + "session-b": row({ sessionUUID: UUID_B }), + }); + const state = await synthesizeAdvisorState(); + expect(state).toContain("ADVISOR STATE UNAVAILABLE"); +}); + +test("completion boundary: complete row from 2min ago, no live rows: uses it", async () => { + fixture({ "session-a": row({ sessionUUID: UUID_A, phase: "complete", updatedAt: iso(2 * MIN) }) }); + const state = await synthesizeAdvisorState(); + expect(state).toContain("ISA: session-a"); +}); + +test("two recent completes, no live: most recently updated wins", async () => { + fixture({ + "session-a": row({ sessionUUID: UUID_A, phase: "complete", updatedAt: iso(4 * MIN) }), + "session-b": row({ sessionUUID: UUID_B, phase: "complete", updatedAt: iso(1 * MIN) }), + }); + const state = await synthesizeAdvisorState(); + expect(state).toContain("ISA: session-b"); +}); + +test("recent complete AND a live row: ambiguous, no state", async () => { + fixture({ + "session-a": row({ sessionUUID: UUID_A, phase: "complete", updatedAt: iso(2 * MIN) }), + "session-b": row({ sessionUUID: UUID_B, phase: "build" }), + }); + const state = await synthesizeAdvisorState(); + expect(state).toContain("ADVISOR STATE UNAVAILABLE"); +}); + +test("old complete (1h ago) is outside the completion window: no state", async () => { + fixture({ "session-a": row({ sessionUUID: UUID_A, phase: "complete", updatedAt: iso(60 * MIN) }) }); + const state = await synthesizeAdvisorState(); + expect(state).toContain("ADVISOR STATE UNAVAILABLE"); +}); + +test("stale live row (2h idle) is not guessable: no state", async () => { + fixture({ "session-a": row({ sessionUUID: UUID_A, updatedAt: iso(120 * MIN) }) }); + const state = await synthesizeAdvisorState(); + expect(state).toContain("ADVISOR STATE UNAVAILABLE"); +}); + +test("native placeholder rows are never candidates", async () => { + fixture({ + "session-native": row({ sessionUUID: UUID_B, mode: "native", phase: "native" }), + "session-a": row({ sessionUUID: UUID_A }), + }); + const state = await synthesizeAdvisorState(); + expect(state).toContain("ISA: session-a"); +}); diff --git a/LifeOS/install/LIFEOS/TOOLS/Inference.ts b/LifeOS/install/LIFEOS/TOOLS/Inference.ts index f13b5cea0a..67b286efdf 100755 --- a/LifeOS/install/LIFEOS/TOOLS/Inference.ts +++ b/LifeOS/install/LIFEOS/TOOLS/Inference.ts @@ -408,10 +408,31 @@ export async function inference(options: InferenceOptions): Promise { const workDir = path.join(home, ".claude", "LIFEOS", "MEMORY", "WORK"); const stateFile = path.join(home, ".claude", "LIFEOS", "MEMORY", "STATE", "work.json"); - // Try to read active session from work.json + // Resolve the current session's slug from the work.json sessions registry. let activeSlug: string | undefined; + let sessions: Record = {}; try { const stateRaw = await fs.readFile(stateFile, "utf-8"); - const state = JSON.parse(stateRaw); - activeSlug = state?.active || state?.current || state?.activeSession; + sessions = JSON.parse(stateRaw)?.sessions ?? {}; } catch { - // work.json may not exist — fall back to most recent ISA + // work.json missing/unreadable — handled below as zero candidates. } - // Fall back: find most recently updated ISA in WORK/ + const rows = Object.entries(sessions).filter(([slug]) => slug !== "__pulse_strip"); + const updatedMs = (s: any) => { + const t = new Date(s?.updatedAt || s?.started || 0).getTime(); + return Number.isFinite(t) ? t : 0; + }; + const nowMs = Date.now(); + // Freshness horizons — see the resolution-order doc comment above. + const LIVE_FRESH_MS = 60 * 60 * 1000; + const COMPLETE_RECENT_MS = 5 * 60 * 1000; + + const unavailable = (detail: string): string => { + const warning = + `advisor --auto-state could not resolve the current session deterministically (${detail}); ` + + `proceeding WITHOUT auto-synthesized state. Pass explicit state or set CLAUDE_SESSION_ID.`; + console.error(`WARN: ${warning}`); + return `ADVISOR STATE UNAVAILABLE: ${warning}`; + }; + + // 1. Current session wins: row whose sessionUUID matches CLAUDE_SESSION_ID. + // A phase:complete row still belongs to THIS session, so it stays eligible; + // live rows are preferred, most recently updated breaks ties. Set-but- + // unmatched never falls through to a guess. + const envUUID = process.env.CLAUDE_SESSION_ID; + if (envUUID) { + const mine = rows + .filter(([, s]) => s?.sessionUUID === envUUID) + .sort((a, b) => + (Number(a[1]?.phase === "complete") - Number(b[1]?.phase === "complete")) + || updatedMs(b[1]) - updatedMs(a[1])); + if (mine.length === 0) { + return unavailable(`CLAUDE_SESSION_ID matches no session row in work.json`); + } + activeSlug = mine[0][0]; + } + + // 2/3. No env: completion-boundary path first, then the single-fresh-live + // convenience path; anything else proceeds with NO state rather than guess. if (!activeSlug) { - try { - const entries = await fs.readdir(workDir, { withFileTypes: true }); - const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); - if (dirs.length === 0) { - return "No active ISA found. Advisor state unavailable."; - } - // Sort by mtime - const statted = await Promise.all( - dirs.map(async (d) => { - const s = await fs.stat(path.join(workDir, d)); - return { name: d, mtime: s.mtimeMs }; - }), - ); - statted.sort((a, b) => b.mtime - a.mtime); - activeSlug = statted[0].name; - } catch (err) { - return `Unable to locate active ISA: ${(err as Error).message}`; + const live = rows.filter(([, s]) => + s?.phase && s.phase !== "complete" && s.phase !== "native" + && nowMs - updatedMs(s) <= LIVE_FRESH_MS); + const recentComplete = rows + .filter(([, s]) => s?.phase === "complete" && nowMs - updatedMs(s) <= COMPLETE_RECENT_MS) + .sort((a, b) => updatedMs(b[1]) - updatedMs(a[1])); + + if (recentComplete.length > 0 && live.length > 0) { + return unavailable( + `both just-completed [${recentComplete.map(([slug]) => slug).join(", ")}] and live ` + + `[${live.map(([slug]) => slug).join(", ")}] sessions exist — cannot tell which is calling`); + } + if (recentComplete.length > 0) { + activeSlug = recentComplete[0][0]; + } else if (live.length === 1) { + activeSlug = live[0][0]; + } else { + const detail = live.length === 0 + ? "no live session rows in work.json (rows idle >60min are not guessable)" + : `${live.length} live sessions [${live.map(([slug]) => slug).join(", ")}]`; + return unavailable(detail); } }