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 (#5)
## What a user gets The Studio floor's finished-work list never steps back to an older answer once a newer one has been on screen. ## The race A live-state refresh is many `rig` calls: since #3, up to ten closing-note reads, each allowed six seconds. The companion started a refresh on a fixed 10-second clock (`setInterval`), so two refreshes of one output could be in flight at once, and `writeLiveState` wrote whichever finished last. An older, slower refresh could therefore overwrite a newer snapshot, putting a closed row back at its earlier `closedAt` and result. The timer and write weakness predates #3; #3's longer read path brought finished-work history into it. ## The fix, in two parts 1. **Newer wins.** `writeLiveState` numbers each refresh when it starts, per output file. A refresh that started before one already written there is dropped: it returns its state and writes nothing. 2. **One refresh at a time.** The new `startLiveState()` schedules the next refresh only after the current one finishes, with no fixed clock. `--once` (interval 0) still runs exactly once, and `stop()` cancels. The CLI entry uses it. What's unchanged: same-version note binding, unknown and retry behaviour, and cache bounds. `recentDone` and `buildLiveState` aren't touched. ## Tests Two fail-first tests in `test/live-state.test.mjs`: - an older refresh that finishes last never writes over a newer one; - the scheduler runs one refresh at a time, and `interval 0` runs once. Results: - **The test file:** 14 of 14 pass with the change; 12 of 12 on main. - **Against main's source:** the race test fails, with the older `closedAt` 22:00 shown instead of 22:02. - **The whole suite:** `node --test test/*.test.mjs` passes 316 of 317. The one failure is `observatory-p6.test.mjs` "run from stdin as documented…", which fails the same way on main `ef7dcea` and is unrelated to this change. ## Cost +77/-12 across 2 files: `live-state.mjs` +32/-11 and the test +45/-1. No new dependencies. ## Credit Follows #3. The finding, fix and tests are studio-impl@studio-build's, from the race studio-qa@studio-build found while reviewing the port of #3. studio-qa accepted it, and the commit keeps its original author line. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
1 parent
ef7dceab42
commit
8edd4b919e
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