mirror of
https://github.com/mvschwarz/openrig-studio-apps.git
synced 2026-11-07 05:56:21 -10:00
live-state: an older refresh never writes over a newer one; one refresh at a time
A refresh is many rig calls (up to ten closing-note reads), so two could be in flight for one output and the older could finish last, stepping the floor back to an older answer. Each refresh is now numbered when it starts, per output file, and one that started before a refresh already written there is dropped. The companion schedules the next refresh only after the current one finishes, instead of on a fixed clock. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ef7dceab42
commit
5c0b817e62
2 files changed
+78
-13
No files matched your search
@@ -226,12 +226,24 @@ async function recentDone(runRig, ourSeats, cache) {
|
||||
// Safe by construction rather than by validation: the directory comes from the
|
||||
// studio's own configuration, never from a request, and the filename is fixed
|
||||
// here. There is no caller-supplied path to sanitise because none is accepted.
|
||||
//
|
||||
// NEWER WINS. A refresh is many rig calls (up to ten closing-note reads, each
|
||||
// allowed six seconds), so two refreshes of one output can be in flight at
|
||||
// once, and the older one can finish last. Each refresh is numbered when it
|
||||
// STARTS, per output file; one that started before a refresh already written
|
||||
// there is dropped, never written over it, or the floor would step back to an
|
||||
// older answer after a newer one was on screen.
|
||||
const STARTED = new Map(), WRITTEN = new Map();
|
||||
export async function writeLiveState(outDir, options = {}) {
|
||||
if (!outDir) throw new Error("live-state: --out <dir> is required");
|
||||
const dir = path.resolve(outDir);
|
||||
const state = await buildLiveState(options);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const target = path.join(dir, "factory-state.json");
|
||||
const gen = (STARTED.get(target) || 0) + 1;
|
||||
STARTED.set(target, gen);
|
||||
const state = await buildLiveState(options);
|
||||
if ((WRITTEN.get(target) || 0) > gen) return state;
|
||||
WRITTEN.set(target, gen);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const next = JSON.stringify(state, null, 2) + "\n";
|
||||
// Only write on change: an unchanged rewrite fires the runtime's file watcher
|
||||
// and makes the floor look busier than the rig is.
|
||||
@@ -240,21 +252,30 @@ export async function writeLiveState(outDir, options = {}) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
// ONE REFRESH AT A TIME. The next refresh is scheduled when the current one
|
||||
// has finished, never on a fixed clock: a clock starts a new refresh while a
|
||||
// slow one is still reading, which is how an older answer could land last.
|
||||
// `interval` 0 runs once. `schedule`/`cancel` are the timer, swappable in tests.
|
||||
export function startLiveState(outDir, options = {}, { interval = INTERVAL, schedule = setTimeout, cancel = clearTimeout, log = console } = {}) {
|
||||
let stopped = false, timer = null;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const s = await writeLiveState(OUT, RIG ? { rig: RIG } : {});
|
||||
if (!s.attached) console.log(`live-state: ${s.reason} — ${s.detail}`);
|
||||
else if (s.reason === "ambiguous-rig") console.log(`live-state: ${s.detail} — pass --rig <name> to scope the floor`);
|
||||
const s = await writeLiveState(outDir, options);
|
||||
if (!s.attached) log.log(`live-state: ${s.reason} — ${s.detail}`);
|
||||
else if (s.reason === "ambiguous-rig") log.log(`live-state: ${s.detail} — pass --rig <name> to scope the floor`);
|
||||
} catch (e) {
|
||||
// Fail loudly and keep going: a state generator that dies silently
|
||||
// leaves the last-written state on screen looking current.
|
||||
console.error(`live-state: ${e.message}`);
|
||||
log.error(`live-state: ${e.message}`);
|
||||
}
|
||||
if (!stopped && interval > 0) timer = schedule(tick, interval);
|
||||
};
|
||||
await tick();
|
||||
if (!ONCE) {
|
||||
console.log(`live-state: ${path.resolve(OUT)} refreshed every ${INTERVAL}ms`);
|
||||
setInterval(tick, INTERVAL);
|
||||
}
|
||||
const first = tick();
|
||||
return { first, stop() { stopped = true; if (timer !== null) cancel(timer); } };
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const run = startLiveState(OUT, RIG ? { rig: RIG } : {}, { interval: ONCE ? 0 : INTERVAL });
|
||||
await run.first;
|
||||
if (!ONCE) console.log(`live-state: ${path.resolve(OUT)} refreshed ${INTERVAL}ms after each refresh finishes`);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildLiveState } from "../providers/studio-host/live-state.mjs";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { buildLiveState, writeLiveState, startLiveState } from "../providers/studio-host/live-state.mjs";
|
||||
|
||||
test("live state distinguishes a missing rig CLI from a rig command failure", async () => {
|
||||
const missing = await buildLiveState({
|
||||
@@ -197,3 +200,44 @@ test("a done-list answer that isn't a list is unreadable, not 'no finished work'
|
||||
assert.deepEqual(s.done, { rows: [], coverage: { looked: 0, shown: 0, unreadable: "the queue's answer wasn't a list" } }, JSON.stringify(value));
|
||||
}
|
||||
});
|
||||
|
||||
// NEWER WINS: a refresh that started earlier and finishes later never replaces one already written. Invented rows.
|
||||
const raceRow = { qitemId: "qitem-20261024220000-race", state: "done", summary: "Race", sourceSession: "lab-qa@lab", destinationSession: "lab-impl@lab",
|
||||
tsUpdated: "2026-10-24T22:00:00.000Z", closureReason: "no-follow-on", closureTarget: null, resolution: "Old answer" };
|
||||
const raceCLI = (row, show) => async (a) => {
|
||||
if (a[0] === "ps") return { ok: true, value: SEATS }; if (a[0] === "whoami") return { ok: true, value: { identity: { rigName: "lab" } } };
|
||||
if (a[1] === "list") return { ok: true, value: a.includes("done") ? [row] : [] }; if (a[1] === "show") return show(); return { ok: true, value: [] };
|
||||
};
|
||||
test("an older refresh that finishes last never writes over a newer one already written", async () => {
|
||||
const out = fs.mkdtempSync(path.join(os.tmpdir(), "live-state-race-"));
|
||||
let entered, release; const inShow = new Promise((r) => (entered = r)), held = new Promise((r) => (release = r));
|
||||
try {
|
||||
const older = writeLiveState(out, { resultCache: new Map(), runRig: raceCLI(raceRow, async () => { entered(); await held; return { ok: true, value: { ...raceRow, closingTransition: { transitionNote: "earlier" } } }; }) });
|
||||
await inShow;
|
||||
const newer = { ...raceRow, tsUpdated: "2026-10-24T22:02:00.000Z", resolution: "New answer" };
|
||||
await writeLiveState(out, { resultCache: new Map(), runRig: raceCLI(newer, async () => ({ ok: true, value: { ...newer, closingTransition: { transitionNote: "later" } } })) });
|
||||
release(); await older;
|
||||
const shown = JSON.parse(fs.readFileSync(path.join(out, "factory-state.json"), "utf8")).done.rows[0];
|
||||
assert.equal(shown.closedAt, "2026-10-24T22:02:00.000Z"); assert.equal(shown.resolution, "New answer"); assert.equal(shown.result, "later");
|
||||
} finally { release(); fs.rmSync(out, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("the companion's scheduler runs one refresh at a time: the next is scheduled only after the current one finishes", async () => {
|
||||
const out = fs.mkdtempSync(path.join(os.tmpdir(), "live-state-tick-"));
|
||||
const timers = []; let builds = 0, release; const held = new Promise((r) => (release = r));
|
||||
const log = { log() {}, error() {} };
|
||||
try {
|
||||
const run = startLiveState(out, { resultCache: new Map(), runRig: async (a) => { if (a[0] === "ps") { builds++; if (builds === 1) await held; } return raceCLI(raceRow, async () => ({ ok: true, value: { ...raceRow, closingTransition: null } }))(a); } },
|
||||
{ interval: 10000, schedule: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, cancel() {}, log });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(timers.length, 0, "nothing is scheduled while the first refresh is still reading");
|
||||
assert.equal(builds, 1);
|
||||
release(); await run.first;
|
||||
assert.deepEqual(timers.map((t) => t.ms), [10000], "the next refresh is scheduled once, after the first finished");
|
||||
await timers[0].fn();
|
||||
assert.equal(builds, 2); assert.equal(timers.length, 2);
|
||||
run.stop(); await timers[1].fn(); assert.equal(timers.length, 2, "stopped: nothing more is scheduled");
|
||||
const once = startLiveState(out, { resultCache: new Map(), runRig: raceCLI(raceRow, async () => ({ ok: false })) }, { interval: 0, schedule: () => assert.fail("interval 0 runs once"), log });
|
||||
await once.first;
|
||||
} finally { release(); fs.rmSync(out, { recursive: true, force: true }); }
|
||||
});
|
||||
Reference in new issue
Block a user