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:
Mike Schwarz authored and GitHub committed 2026-10-24 16:33:44 -07:00
1 parent ef7dceab42
commit 8edd4b919e
2 files changed
+78 -13

No files matched your search

+33 -12
View File
@@ -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`);
}
+45 -1
View File
@@ -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 }); }
});