Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions LifeOS/install/LIFEOS/TOOLS/Inference.autostate.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>): 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<string, any> = {}) {
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");
});
113 changes: 87 additions & 26 deletions LifeOS/install/LIFEOS/TOOLS/Inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,10 +408,31 @@ export async function inference(options: InferenceOptions): Promise<InferenceRes
* that might have missed the problem decides what the reviewer sees. Auto-synthesis
* reads the ISA directly so the reviewer gets the unfiltered state.
*
* Reads:
* - Current ISA content (resolved from MEMORY/STATE/work.json active session, or
* the most recently-updated ISA in MEMORY/WORK/)
* - Recent session activity if available
* Session resolution (deterministic, fail-safe):
* - work.json is a `{sessions: {...}}` registry. The pre-fix code looked for
* `active`/`current`/`activeSession` keys that never existed, so it ALWAYS
* fell through to "most recently modified dir in MEMORY/WORK/" — usually
* right by accident, but with no session identity it could attach an
* unrelated (often completed) session's ISA as the advisor state blob.
* - Resolution order:
* 1. Row matching CLAUDE_SESSION_ID, any phase — a phase:complete row of
* the SAME session is still this session, and the advisor fires at the
* completion boundary itself. Env set but unmatched → no state (never
* fall through to a guess). Claude Code does not export
* CLAUDE_SESSION_ID to tool subprocesses today; this path is
* forward-looking defense-in-depth, local-trust only — not an auth
* boundary.
* 2. No env: a COMPLETE row updated within the last 5 minutes is almost
* certainly the just-finished caller making its mandatory
* completion-boundary advisor call — prefer the most recent such row,
* UNLESS any fresh live row also exists (then we cannot tell which
* session is calling → ambiguous → no state).
* 3. No env, no recent-complete: exactly ONE live (non-complete,
* non-native) row updated within the last 60 minutes → use it. Rows
* idle longer are crash leftovers (the registry stale sweep only
* prunes them after ~7 days) and are never guessable.
* 4. Anything else → return a state-unavailable marker and warn loudly.
* Wrong state is worse than no state for a reviewer.
*
* Returns a state string suitable for passing to advisor().
*/
Expand All @@ -422,35 +443,75 @@ export async function synthesizeAdvisorState(): Promise<string> {
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<string, any> = {};
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);
}
}

Expand Down
Loading