mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 17:30:37 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd352cf44d | ||
|
|
98b3f3cb15 |
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");
|
||||
|
||||
Reference in new issue
Block a user