Compare commits

...
3 changed files with 411 additions and 109 deletions

No files matched your search

@@ -0,0 +1,103 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
function processExists(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (error.code === "ESRCH") return false;
throw error;
}
}
function acquire(stateFile) {
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
const base = `${stateFile}.guard`;
const token = `${process.pid}-${randomUUID()}`;
const until = performance.now() + 200;
const wait = new Int32Array(new SharedArrayBuffer(4));
let lock = base;
do {
try {
fs.symlinkSync(token, lock);
return () => {
try {
if (fs.readlinkSync(lock) === token) fs.unlinkSync(lock);
} catch {}
};
} catch (error) {
if (error.code !== "EEXIST") throw error;
}
let owner;
try {
owner = fs.readlinkSync(lock);
} catch (error) {
if (error.code === "ENOENT") continue;
throw error;
}
const match = /^([1-9][0-9]*)-([0-9a-f-]{36})$/.exec(owner);
if (!match) throw new Error("unrecognized state guard owner");
if (!processExists(Number(match[1]))) {
// The owner might have released the path before exiting. Recheck its
// token after proving death; that same owner can no longer remove it.
try {
if (fs.readlinkSync(lock) === owner) lock = `${base}.${match[2]}`;
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
// Keep the dead link. Contenders follow it to one shared successor
// without ever removing a guard another process might have acquired.
continue;
}
Atomics.wait(wait, 0, 0, 10);
} while (performance.now() < until);
throw new Error("state guard still busy");
}
function openRefocusState(stateFile) {
const report = (operation, error) =>
process.stderr.write(`refocus: could not ${operation} (${error.code || error.message})\n`);
let release;
try {
release = acquire(stateFile);
} catch (error) {
report("coordinate state. Continuing with available evidence", error);
}
function read(file) {
try {
const state = JSON.parse(fs.readFileSync(file, "utf8"));
if (state && typeof state === "object" && !Array.isArray(state)) return state;
throw new Error("expected a state object");
} catch (error) {
if (error.code !== "ENOENT") report("read state", error);
return null;
}
}
function write(file, state) {
const tmp = `${file}.${randomUUID()}.tmp`;
try {
fs.writeFileSync(tmp, JSON.stringify(state), { flag: "wx", mode: 0o600 });
fs.renameSync(tmp, file);
return true;
} catch (error) {
try { fs.unlinkSync(tmp); } catch {}
report("record state", error);
return false;
}
}
return {
exclusive: Boolean(release),
close: release || (() => {}),
read: () => read(stateFile),
write: (state) => write(stateFile, state),
readDeferred: (kind) => read(`${stateFile}.${kind}-pending`),
writeDeferred: (kind, pending) => write(`${stateFile}.${kind}-pending`, pending),
};
}
module.exports = { openRefocusState, processExists };
@@ -12,6 +12,7 @@ const path = require("node:path");
const crypto = require("node:crypto");
const { spawnSync } = require("node:child_process");
const { recordRefocusResult } = require("./refocus-health.cjs");
const { openRefocusState, processExists } = require("./refocus-state.cjs");
// Keep the operator's command lookup order; env-node CLI scripts can also use this hook's Node.
const childEnv = { ...process.env, PATH: [process.env.PATH, path.dirname(process.execPath)].filter(Boolean).join(path.delimiter) };
@@ -45,6 +46,18 @@ const lookupTimeout = (reserve = 0) => SELECTED_BUDGET ? Math.min(LOOKUP_CAP_MS,
// Normal managed restore advances on ~30 s polls with a 10 s idle wait.
// A lost daemon stage must not suppress this occupant's refocus indefinitely.
const MANAGED_RESTORE_HOLD_MS = 10 * 60_000;
const DELIVERY_CLAIM_MS = 60_000;
function liveDelivery(claim) {
if (!claim) return false;
const age = Date.now() - claim.at;
try {
if (Number.isSafeInteger(claim.pid) && claim.pid > 0
&& age >= 0 && age < DELIVERY_CLAIM_MS && processExists(claim.pid)) return true;
} catch {}
process.stderr.write("refocus: prior delivery owner could not be confirmed. Attempting the pending refocus\n");
return false;
}
function runtime() {
const index = process.argv.indexOf("--runtime");
@@ -373,8 +386,6 @@ function runsForThisSeatsProject(env = process.env) {
const seat = process.env.OPENRIG_SESSION_NAME || "unknown-seat";
const home = process.env.OPENRIG_HOME || path.join(process.env.HOME || "/tmp", ".openrig");
const transcriptPath = input.transcript_path || input.transcriptPath || "";
let size = 0;
try { size = fs.statSync(transcriptPath).size; } catch {}
const stateDir = path.join(home, "refocus");
@@ -434,80 +445,139 @@ function runsForThisSeatsProject(env = process.env) {
} catch {}
const stateFile = path.join(stateDir, `${seatKey}__${keyFor(identity)}.json`);
let state = null;
try { state = JSON.parse(fs.readFileSync(stateFile, "utf8")) || null; } catch {}
const persist = () => {
try {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(stateFile, JSON.stringify(state));
} catch {}
};
if (state === null) {
// First observation for this occupant: the baseline is its OWN current size —
// growth accumulates from here; nothing is inherited. A zero-size read means
// the transcript is absent/unreadable, which is instrument absence, not a
// baseline: record zero only when that is what was genuinely measured.
state = { lastBytes: size, baselineAt: new Date().toISOString() };
persist();
} else if (size > 0 && size < Number(state.lastBytes || 0)) {
// Shrink invalidates growth-based due state, not an exact PostCompact event
// awaiting its actionable restore turn.
if (state.pendingOn !== "PostCompact") {
delete state.pendingOn;
delete state.pendingAt;
delete state.pendingFailure;
}
state.lastReset = { at: new Date().toISOString(), fromBytes: Number(state.lastBytes || 0), toBytes: size };
state.lastBytes = size;
persist();
process.stderr.write(`refocus: transcript shrank for ${seat} — baseline reset; PostCompact pending retained if present\n`);
}
const lastBytes = Number(state.lastBytes || 0);
const grown = size > lastBytes ? size - lastBytes : 0;
// Only the two state transitions need exclusive access. Trace rendering and
// stdout run outside the guard so an arriving PostCompact can record its work.
const start = openRefocusState(stateFile);
const promptId = firstString(input.prompt_id);
const onDemand = enabled(process.env.OPENRIG_REFOCUS_NOW, false);
const thresholdDue = harness === "claude" && grown >= threshold();
const due = onDemand
|| event === "PostCompact"
|| Boolean(state.pendingOn)
|| thresholdDue;
if (!due) process.exit(0);
if (event !== "UserPromptSubmit") {
if (event === "PostCompact" && harness === "claude") {
// PreCompact records whether THIS compact was initiated by the enforcer.
// A manual /compact overwrites the marker with false; other occupants
// cannot hold this session. Read the early sentinel first if it survives.
state.managedRestorePending = false;
for (const suffix of [".expected.json", ".json"]) {
try {
const marker = JSON.parse(fs.readFileSync(path.join(home, "compaction", "restore-pending", seatKey + suffix), "utf8"));
if (marker.sessionName !== seat || (marker.sessionId ? marker.sessionId !== identity : !transcriptPath || marker.transcriptPath !== transcriptPath)) continue;
state.managedRestorePending = marker.managedRefocusPending === true;
break;
} catch {}
let state, size = 0, grown, claimed;
const claim = { id: crypto.randomUUID(), pid: process.pid, at: Date.now(), promptId };
try {
state = start.read();
try { size = fs.statSync(transcriptPath).size; } catch {}
if (state === null) {
// First observation for this occupant: the baseline is its OWN current size —
// growth accumulates from here; nothing is inherited. A zero-size read means
// the transcript is absent/unreadable, which is instrument absence, not a
// baseline: record zero only when that is what was genuinely measured.
state = { lastBytes: size, baselineAt: new Date().toISOString() };
if (start.exclusive) start.write(state);
} else if (size > 0 && size < Number(state.lastBytes || 0)) {
// Shrink invalidates growth-based due state, not an exact PostCompact event
// awaiting its actionable restore turn.
if (state.pendingOn !== "PostCompact") {
delete state.pendingOn;
delete state.pendingAt;
delete state.pendingFailure;
delete state.pendingRevision;
}
if (state.managedRestorePending) state.managedRestorePendingAt = new Date().toISOString();
else delete state.managedRestorePendingAt;
state.lastReset = { at: new Date().toISOString(), fromBytes: Number(state.lastBytes || 0), toBytes: size };
state.lastBytes = size;
if (start.exclusive) start.write(state);
process.stderr.write(`refocus: transcript shrank for ${seat} — baseline reset; PostCompact pending retained if present\n`);
}
// A new compaction is a fresh obligation, not a retry of the refocus before it.
if (event === "PostCompact") delete state.pendingFailure;
if (event === "PostCompact" || !state.pendingOn) state.pendingOn = event;
state.pendingAt ||= new Date().toISOString();
persist();
process.exit(0);
}
if (harness === "claude" && state.managedRestorePending) {
const heldFor = Date.now() - Date.parse(state.managedRestorePendingAt);
// Missing/invalid timestamps from older state, or a clock moving backwards,
// cannot establish a live hold. No timer or extra turn is created here.
if (heldFor >= 0 && heldFor < MANAGED_RESTORE_HOLD_MS
&& !prompt.startsWith("Please respond to this normal user message now by restoring this Claude session after compaction.")) process.exit(0);
delete state.managedRestorePending;
delete state.managedRestorePendingAt;
persist();
// An event that could not take the guard records its pending work separately.
// The delivery callback never writes these slots.
for (const kind of ["other", "compact"]) {
const deferred = start.readDeferred(kind);
if (!deferred?.pendingRevision || state.seenDeferred?.[kind] === deferred.pendingRevision) continue;
state.pendingRevision = deferred.pendingRevision;
if (kind === "compact" || !state.pendingOn) {
state.pendingOn = deferred.pendingOn;
state.pendingAt = deferred.pendingAt;
}
if (kind === "compact") {
delete state.pendingFailure;
state.managedRestorePending = deferred.managedRestorePending;
state.managedRestorePendingAt = deferred.managedRestorePendingAt;
}
state.seenDeferred = { ...state.seenDeferred, [kind]: deferred.pendingRevision };
if (start.exclusive) start.write(state);
}
const lastBytes = Number(state.lastBytes || 0);
grown = size > lastBytes ? size - lastBytes : 0;
const growthThreshold = threshold();
const thresholdDue = harness === "claude" && grown >= growthThreshold;
const due = onDemand
|| event === "PostCompact"
|| Boolean(state.pendingOn)
|| thresholdDue;
if (start.exclusive && event === "UserPromptSubmit" && promptId && state.lastPromptId === promptId) {
process.stderr.write("refocus: another handler already wrote refocus output for this prompt\n");
return;
}
if (!due) return;
if (event !== "UserPromptSubmit") {
if (event === "PostCompact" && harness === "claude") {
// PreCompact records whether THIS compact was initiated by the enforcer.
// A manual /compact overwrites the marker with false; other occupants
// cannot hold this session. Read the early sentinel first if it survives.
state.managedRestorePending = false;
for (const suffix of [".expected.json", ".json"]) {
try {
const marker = JSON.parse(fs.readFileSync(path.join(home, "compaction", "restore-pending", seatKey + suffix), "utf8"));
if (marker.sessionName !== seat || (marker.sessionId ? marker.sessionId !== identity : !transcriptPath || marker.transcriptPath !== transcriptPath)) continue;
state.managedRestorePending = marker.managedRefocusPending === true;
break;
} catch {}
}
if (state.managedRestorePending) state.managedRestorePendingAt = new Date().toISOString();
else delete state.managedRestorePendingAt;
}
// A new compaction is a fresh obligation, not a retry of the refocus before it.
const compact = event === "PostCompact";
if (compact) delete state.pendingFailure;
const newWork = compact || !state.pendingOn || onDemand
|| (thresholdDue && size - (state.deliveryClaim?.bytes ?? lastBytes) >= growthThreshold);
if (newWork) state.pendingRevision = crypto.randomUUID();
if (compact || !state.pendingOn) state.pendingOn = event;
state.pendingAt ||= new Date().toISOString();
if ((!start.exclusive || !start.write(state)) && newWork) {
start.writeDeferred(compact ? "compact" : "other", {
pendingOn: event,
pendingRevision: state.pendingRevision,
pendingAt: state.pendingAt,
...(compact ? {
managedRestorePending: state.managedRestorePending,
managedRestorePendingAt: state.managedRestorePendingAt,
} : {}),
});
}
return;
}
if (start.exclusive && liveDelivery(state.deliveryClaim)) {
if (onDemand && promptId && state.deliveryClaim.promptId && promptId !== state.deliveryClaim.promptId) {
const pending = {
pendingOn: state.pendingOn || event,
pendingAt: state.pendingAt || new Date().toISOString(),
pendingRevision: crypto.randomUUID(),
};
Object.assign(state, pending);
if (!start.write(state)) start.writeDeferred("other", pending);
}
process.stderr.write("refocus: another hook is delivering. Any later pending work stays queued\n");
return;
}
if (harness === "claude" && state.managedRestorePending) {
const heldFor = Date.now() - Date.parse(state.managedRestorePendingAt);
// Missing/invalid timestamps from older state, or a clock moving backwards,
// cannot establish a live hold. No timer or extra turn is created here.
if (heldFor >= 0 && heldFor < MANAGED_RESTORE_HOLD_MS
&& !prompt.startsWith("Please respond to this normal user message now by restoring this Claude session after compaction.")) return;
delete state.managedRestorePending;
delete state.managedRestorePendingAt;
}
state.pendingOn ||= event;
state.pendingAt ||= new Date().toISOString();
state.deliveryClaim = { ...claim, bytes: size };
claimed = start.exclusive && start.write(state);
} finally {
start.close();
}
// Run the public trace before resolving a context ref. Besides keeping the
@@ -571,22 +641,36 @@ function runsForThisSeatsProject(env = process.env) {
// their fixed caps, so a default attempt can run past its 4.5 s budget).
recordRefocusResult({ home, seat, identity, failed: failed || Boolean(error), env: childEnv,
deadlineMs: Math.max(BUDGET_MS, Math.floor(process.uptime() * 1_000)) + REPORT_MS });
if (failed || error) {
state.pendingOn ||= event;
state.pendingAt ||= new Date().toISOString();
const failure = rendered.failure
|| (configured.failure ? `content ref ${configured.contentRef}: ${configured.failure}` : null)
|| `the refocus could not be written: ${String(error?.message ?? error).replace(/\s+/g, " ").trim()}`;
state.pendingFailure = failure.length > PENDING_FAILURE_MAX ? `${failure.slice(0, PENDING_FAILURE_MAX - 1)}…` : failure;
persist();
return;
if (!claimed) return;
const finish = openRefocusState(stateFile);
try {
if (!finish.exclusive) return;
const latest = finish.read();
if (latest?.deliveryClaim?.id !== claim.id) return;
delete latest.deliveryClaim;
if (!error && promptId) latest.lastPromptId = promptId;
const samePending = latest.pendingRevision === state.pendingRevision;
if (failed || error) {
if (samePending) {
const failure = rendered.failure
|| (configured.failure ? `content ref ${configured.contentRef}: ${configured.failure}` : null)
|| `the refocus could not be written: ${String(error?.message ?? error).replace(/\s+/g, " ").trim()}`;
latest.pendingFailure = failure.length > PENDING_FAILURE_MAX ? `${failure.slice(0, PENDING_FAILURE_MAX - 1)}…` : failure;
}
} else {
if (size > 0 && latest.lastReset?.at === state.lastReset?.at) latest.lastBytes = size;
latest.firedAt = new Date().toISOString();
latest.firedOn = event;
if (samePending) {
delete latest.pendingOn;
delete latest.pendingAt;
delete latest.pendingFailure;
delete latest.pendingRevision;
}
}
finish.write(latest);
} finally {
finish.close();
}
if (size > 0) state.lastBytes = size;
state.firedAt = new Date().toISOString();
state.firedOn = event;
delete state.pendingOn;
delete state.pendingAt;
delete state.pendingFailure;
persist();
});
})();
@@ -1,11 +1,10 @@
// Refocus: the hook's time budget against the harness's outer kill, its failure report, and (characterized, unresolved)
// overlapping events for one occupant.
// Refocus: the hook's time budget, its failure report, and overlapping events for one occupant.
// Each case runs the real hook as a harness does (JSON on stdin, killed at the registration's outer bound), with a stub
// `rig` and a stub trace script whose delays are set per case. The overlaps are hook processes overlapping; whether a
// harness overlaps these events natively is not established here.
import { afterEach, describe, expect, it, vi } from "vitest";
import { spawn } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -62,7 +61,7 @@ echo "TRACE OK: work and topology"
interface Run { killed: boolean; ms: number; stdout: string; stderr: string }
// Run the hook as the harness does, killed at `outerMs` (both registrations' bound for refocus: 60 s).
function hook(f: Fixture, event: string, session: string, extra: Record<string, string> = {}, outerMs = 60_000): Promise<Run> {
function hook(f: Fixture, event: string, session: string, extra: Record<string, string> = {}, outerMs = 60_000, promptId?: string): Promise<Run> {
const transcript = join(root!, `${session}.jsonl`);
if (!existsSync(transcript)) writeFileSync(transcript, "");
const started = Date.now();
@@ -81,7 +80,7 @@ function hook(f: Fixture, event: string, session: string, extra: Record<string,
child.stderr.on("data", (chunk) => { stderr += chunk; });
const timer = setTimeout(() => { killed = true; child.kill("SIGKILL"); }, outerMs);
child.on("close", () => { clearTimeout(timer); done({ killed, ms: Date.now() - started, stdout, stderr }); });
child.stdin.end(JSON.stringify({ hook_event_name: event, session_id: session, transcript_path: transcript, prompt: "next" }));
child.stdin.end(JSON.stringify({ hook_event_name: event, session_id: session, transcript_path: transcript, prompt: "next", prompt_id: promptId }));
});
}
@@ -90,7 +89,6 @@ const stateOf = (f: Fixture, session: string) =>
JSON.parse(readFileSync(join(f.home, "refocus", `${SEAT}__${session}.json`), "utf8")) as Record<string, unknown>;
const healthOf = (f: Fixture) => readdirSync(join(f.home, "refocus")).filter((n) => n.endsWith(".health.json"))
.map((n) => JSON.parse(readFileSync(join(f.home, "refocus", n), "utf8")) as { failures?: number });
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
describe("refocus: the registrations' outer bound", () => {
it("allows refocus.cjs 60 s on both harnesses and leaves every other hook at 5 s", () => {
@@ -205,12 +203,8 @@ describe("refocus: the time budget", () => {
}, 60_000);
});
describe("refocus: overlapping events for one occupant (UNRESOLVED; characterized, not fixed)", () => {
// These describe main's behaviour, which this change keeps: two hook runs for one occupant are not serialized, so
// overlapping hook processes can lose a just-recorded compaction or deliver one refocus twice. Whether a harness
// overlaps these events natively is NOT ESTABLISHED; the gap is open, not shown safe. If a later change serializes
// the state, these flip and should then assert the fixed behaviour.
it("can lose a PostCompact that lands while an earlier refocus is being delivered", async () => {
describe("refocus: overlapping events for one occupant", () => {
it("preserves a PostCompact that lands while an earlier refocus is being delivered", async () => {
const f = fixture();
await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_NOW: "1" });
const file = join(f.home, "refocus", `${SEAT}__s1.json`);
@@ -220,7 +214,7 @@ describe("refocus: overlapping events for one occupant (UNRESOLVED; characterize
// Hold delivery after it has read the state; process start-up time cannot choose the interleaving.
const delivering = hook(f, "UserPromptSubmit", "s1", {
OPENRIG_REFOCUS_BUDGET_MS: "52000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
});
}, 60_000, "prompt-1");
try {
await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 10000 });
await hook(f, "PostCompact", "s1");
@@ -229,21 +223,142 @@ describe("refocus: overlapping events for one occupant (UNRESOLVED; characterize
writeFileSync(release, "");
}
expect(context(await delivering)).toContain("REFOCUS");
// The delivery wrote back the state it read before the PostCompact: the compaction's pending is gone.
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-1"))).toBe("");
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-2"))).toContain("REFOCUS (just compacted");
expect(stateOf(f, "s1")["pendingOn"]).toBeUndefined();
}, 20_000);
it("delivers a pending refocus once when two handlers overlap", async () => {
const f = fixture();
await hook(f, "PostCompact", "s1");
const ready = join(root!, "trace-ready");
const release = join(root!, "trace-release");
const first = hook(f, "UserPromptSubmit", "s1", {
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
}, 60_000, "prompt-A");
try {
await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5000 });
const revision = stateOf(f, "s1").pendingRevision;
const second = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_NOW: "1" }, 60_000, "prompt-A");
expect(context(second)).toBe("");
expect(second.stderr).toContain("another hook is delivering");
expect(stateOf(f, "s1").pendingRevision).toBe(revision);
} finally {
writeFileSync(release, "");
expect(context(await first)).toContain("REFOCUS");
}
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
}, 20_000);
it("can deliver one pending refocus twice when two prompts overlap", async () => {
it("retains a distinct identified on-demand prompt while an earlier prompt is rendering", async () => {
const f = fixture();
await hook(f, "PostCompact", "s1");
const first = hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "1000" });
await wait(200);
const second = hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "1000" });
const delivered = (await Promise.all([first, second])).filter((r) => context(r).includes("REFOCUS"));
expect(delivered).toHaveLength(2);
const ready = join(root!, "trace-ready");
const release = join(root!, "trace-release");
const first = hook(f, "UserPromptSubmit", "s1", {
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
}, 60_000, "prompt-A");
try {
await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5000 });
const second = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_NOW: "1" }, 60_000, "prompt-B");
expect(context(second)).toBe("");
} finally {
writeFileSync(release, "");
expect(context(await first)).toContain("REFOCUS");
}
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: expect.any(String) });
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-C"))).toContain("REFOCUS");
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-D"))).toBe("");
}, 20_000);
it("delivers once per identified prompt even if the second handler starts after completion", async () => {
const f = fixture();
const env = { OPENRIG_REFOCUS_NOW: "1" };
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-1"))).toContain("REFOCUS");
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-1"))).toBe("");
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-2"))).toContain("REFOCUS");
});
it("recovers pending work after the rendering hook is killed", async () => {
const f = fixture();
await hook(f, "PostCompact", "s1");
const ready = join(root!, "trace-ready");
const release = join(root!, "trace-release");
const interrupted = hook(f, "UserPromptSubmit", "s1", {
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
}, 1000);
try {
await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5000 });
expect(await interrupted).toMatchObject({ killed: true, stdout: "" });
} finally {
writeFileSync(release, "");
}
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact", deliveryClaim: { pid: expect.any(Number) } });
const resumed = await hook(f, "UserPromptSubmit", "s1");
expect(resumed.stderr).toContain("prior delivery owner could not be confirmed");
expect(context(resumed)).toContain("REFOCUS (just compacted");
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
});
it("uses a shared successor guard after its former owner exits", async () => {
const f = fixture();
await hook(f, "PostCompact", "s1");
const guard = join(f.home, "refocus", `${SEAT}__s1.json.guard`);
const owner = spawnSync(process.execPath, ["-e", ""]).pid;
const token = `${owner}-00000000-0000-4000-8000-000000000001`;
symlinkSync(token, guard);
const first = hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "200" });
const second = hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "200" });
const runs = await Promise.all([first, second]);
expect(runs.filter((run) => context(run).includes("REFOCUS"))).toHaveLength(1);
expect(readlinkSync(guard)).toBe(token);
});
it("does not take a successor when the old guard was released before the owner exited", async () => {
const f = fixture();
await hook(f, "PostCompact", "s1");
const stateFile = join(f.home, "refocus", `${SEAT}__s1.json`);
const guard = stateFile + ".guard";
const oldPid = spawnSync(process.execPath, ["-e", ""]).pid;
symlinkSync(`${oldPid}-00000000-0000-4000-8000-000000000001`, guard);
const liveToken = `${process.pid}-00000000-0000-4000-8000-000000000002`;
const result = spawnSync(process.execPath, ["-e", `
const fs = require("node:fs");
const [helper, stateFile, liveToken, oldPid] = process.argv.slice(1);
const kill = process.kill;
process.kill = (pid, signal) => {
if (pid === Number(oldPid)) {
fs.unlinkSync(stateFile + ".guard");
fs.symlinkSync(liveToken, stateFile + ".guard");
}
return kill(pid, signal);
};
const access = require(helper).openRefocusState(stateFile);
console.log(access.exclusive);
access.close();
`, join(dirname(HOOK), "refocus-state.cjs"), stateFile, liveToken, String(oldPid)], { encoding: "utf8" });
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("false");
expect(readlinkSync(guard)).toBe(liveToken);
});
it("reports an unreadable guard and leaves the pending compaction available", async () => {
const f = fixture();
await hook(f, "UserPromptSubmit", "s1");
const guard = join(f.home, "refocus", `${SEAT}__s1.json.guard`);
mkdirSync(guard);
const compact = await hook(f, "PostCompact", "s1");
expect(compact.stderr).toContain("could not coordinate state");
const uncoordinated = await hook(f, "UserPromptSubmit", "s1");
expect(uncoordinated.stderr).toContain("could not coordinate state");
expect(context(uncoordinated)).toContain("REFOCUS (just compacted");
rmSync(guard, { recursive: true });
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toContain("REFOCUS (just compacted");
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
});
it("still delivers to each of two occupants of one seat when they overlap", async () => {
const f = fixture();
await hook(f, "PostCompact", "s1");