Fix: stop the direction page hanging forever after a re-roll (#469) (#530)

* Fix: stop the direction page hanging forever after a re-roll (#469)

The re-roll leg of the decision-page protocol was documented only in
serve-question.mjs's own header, so agents never ran --update and the
open tab polled a round that could never arrive. Compounding failure
modes: the page poll swallowed every error, the daemon's --timeout was
an absolute guillotine that killed the server under a still-open tab,
a choice posted to a dead server confirmed nothing, and refresh or
Reload on an unresolved round resurrected heartbeats that held the
daemon alive indefinitely.

- new-work.md documents the re-roll leg: rerun concept-seed with
  --from/--reroll, deliver with --update on the same key, never --start
  a second server.
- The page poll terminates and says why: eight consecutive fetch
  failures means the server is gone; the delivery deadline (the
  server's own --idle-grace, inlined into the page) passing means the
  hand never arrived. Both stop heartbeating.
- The daemon's --timeout bounds only the wait for a page to open; once
  the page heartbeats, the server lives while the page does and exits
  after --idle-grace (default 600s) without a beat, including under
  --timeout 0.
- Build this and Re-roll against a dead server fail loudly instead of
  silently swallowing the click.
- The server tracks the window between a collected re-roll answer and
  the --update that replaces the round, and serves the page in waiting
  mode there, so a native refresh re-enters the same bounded wait
  instead of resurrecting dead cards; the in-page Reload button only
  revives a delivered hand.
- --update is exempt from the headless gate and its liveness probe
  trusts a fresh heartbeat over a failed kill probe (sandbox EPERM is
  not death).

Squash of the six review-round commits on this branch, rebased onto
main after the decision-page revamp.

AI assistance: prepared with an AI agent operating under maintainer
instruction (abdulwahabone).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix review findings: persist the replacement deadline, refuse unloadable hands

A browser-native refresh of the waiting page re-entered the bounded wait
with a fresh delivery deadline and an immediate heartbeat, so refreshing
before each deadline expired could hold the daemon alive and keep --wait
on WAITING indefinitely. The server now records when the re-roll or
followup answer was collected, each served waiting page inherits only
what remains of that one allowance, and a page served after the deadline
renders stalled immediately and never starts its heartbeat.

And a next hand the round could not load used to reload-loop the tab:
GET /'s catch kept the file on disk, so /next-status stayed ready:true
forever. --update now refuses a payload without a non-empty options
array at the sender, and GET / discards an unloadable next file so the
bounded wait resumes.

AI-assisted (Cursor agent) under maintainer instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix review finding: a stalled page recovers a late hand without a click

The stall silenced heartbeats so the idle grace could reclaim the
daemon, but that silence read as a closed tab: after a late --update,
--wait saw the stale beat and reported PAGE CLOSED while the user sat
on the Reload screen, so the agent abandoned the browser path the
recovery UI exists for. The stall screen now keeps a beat-free
/next-status watch that reloads into a delivered hand on its own
(GET never beats, so an abandoned flow is still reclaimed), and --wait
no longer concludes closure from a stale beat while an undelivered
next hand sits on disk.

AI-assisted (Cursor agent) under maintainer instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix review finding: a delivered hand must not mask a closed page

The mid-delivery suppression keyed on the next file existing, but a
closed tab never claims that file, so an unconsumed delivery held
--wait on WAITING indefinitely instead of reporting the closed flow.
The suppression is now age-bound: a stalled page's watch reclaims a
delivered hand within seconds, so a file still unclaimed after a 10s
grace means no page is coming back and the stale beat reads as the
closed page it is.

AI-assisted (Cursor agent) under maintainer instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix review finding: stamp the delivery clock at --update, not the copy

--wait's mid-delivery grace reads the next file's mtime, but
copyFileSync's timestamp behavior is the platform's business: a copy
that preserves the source payload's older mtime would start the grace
already spent and report PAGE CLOSED under a live stalled tab. --update
now touches the delivered file itself, so delivery time is delivery
time everywhere.

AI-assisted (Cursor agent) under maintainer instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix review findings: disable canon during the wait, validate --timeout

The waiting and stall screens disabled only the re-roll buttons; the
footer canon action stayed clickable, and a canon pick posted after
--wait had consumed the re-roll could never be collected: it overwrote
the answer, marked the table closed, and exited the daemon under the
agent. Both disable sites now take the canon exit down with the re-roll
buttons; a delivered hand reloads the page and serves it live again.

And --timeout reached the lifetime timer unvalidated: NaN or a negative
value disarmed the no-page exit and the daemon leaked. It now takes the
default unless the value is a finite non-negative number, keeping 0 as
the explicit wait-forever.

AI-assisted (Cursor agent) under maintainer instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix review finding: a second click must not renew the delivery deadline

dealAgain left the re-roll and canon controls live through the answer
POST and the 700ms fly-out, so a second click posted another re-roll
and the server restamped awaitingNextSince, renewing the deadline this
PR made non-renewable on refresh and on the stall screen. The controls
now go quiet at the click itself, in dealAgain and in answer(), and the
server stamps the allowance only on the transition into the wait, so a
duplicate answer racing the disable keeps the first stamp.

Regression coverage on both sides: the unit deadline test posts a
duplicate re-roll mid-allowance and asserts the budget shrank instead
of resetting, and the e2e stall test asserts both controls are disabled
immediately after the click, before the fly-out.

AI-assisted (Cursor agent) under maintainer instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix review finding: a late delivery must survive its claim window

--update could land a replacement hand after the stalled page went
silent but moments before the daemon's idle deadline: the daemon exited
before the page's 1.5s watch could claim the hand, orphaning a delivery
--update had confirmed, and the next --wait reported a server failure.
The idle exit now defers while an unclaimed next hand is younger than
the claim grace --wait already reads (extracted as one shared
constant), so the page's watch deals it and heartbeats resume; a file
unclaimed past the grace still ends the daemon, bounded as before.

Regression test: deliver at idle-deadline-minus-a-beat, assert the
daemon survives past the deadline and serves the late hand.

AI-assisted (Cursor agent) under maintainer instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix review finding: the claim itself must hold the daemon

The idle-exit hold read only the next file's freshness, but GET /
deletes that file when it serves the claimed round, before the
reloading page can post its first heartbeat: a lifetime tick in that
gap saw no pending hand and a stale beat, and exited under the hand
just claimed. GET / now stamps the claim when it consumes a pending
hand, and the idle exit honors the same bounded grace from that stamp,
so the reloading page gets its seconds to beat while an abandoned claim
still ends the daemon at the grace.

The claim-window regression test now also fetches after the claim, past
another lifetime tick, and asserts the daemon survived the gap;
verified it fails on the previous commit.

AI-assisted (Cursor agent) under maintainer instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix review finding: --wait must ride out the claim gap too

The claim deletes the next file --wait's mid-delivery grace watches,
and the reloading page has not beat yet, so --wait in that gap read the
stale beat as PAGE CLOSED while the daemon was alive serving the dealt
round, and the agent abandoned a browser session that had just
recovered. GET / now persists the claim stamp into the per-key state
file, and --wait's suppression honors it under the same bounded grace:
a fresh claim stays WAITING, a claim nobody followed with a beat still
reads as the closed page it is.

Regression test drives --wait through the gap (claim with a stale beat:
WAITING, not exit 4) and past it (backdated claim stamp: exit 4);
verified it fails on the previous commit.

AI-assisted (Cursor agent) under maintainer instruction.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Paul Bakaus <paul.bakaus@gmail.com>
This commit is contained in:
Abdul Wahab
2026-08-16 14:26:02 -07:00
committed by GitHub
co-authored by Cursor Paul Bakaus
parent 3fcfa7eedf
commit 3c6f53406b
4 changed files with 804 additions and 39 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node {{scripts_path}}/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen. <!-- rule:skill-concept-procedure --> <!-- rule:skill-verdict-and-donation -->
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel. <!-- rule:skill-pick-card-one-only -->
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. <!-- rule:skill-canon-standing-exit --> Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. <!-- rule:skill-assigned-plus-reroll --> Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node {{scripts_path}}/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. <!-- rule:skill-visual-decision-page -->
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. <!-- rule:skill-canon-standing-exit --> Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. <!-- rule:skill-assigned-plus-reroll --> Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node {{scripts_path}}/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry. <!-- rule:skill-visual-decision-page -->
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. <!-- rule:skill-decision-comps-full-fidelity --> <!-- rule:skill-salience-parity -->
+238 -36
View File
@@ -95,9 +95,16 @@
* --stop --key K kill a daemonized question.
* --update --key K --payload F deliver the next hand after a re-roll: the
* live page swaps to loading cards when the user re-rolls, and
* reloads into this new payload the moment it lands.
* reloads into this new payload the moment it lands. Always the
* same key the round started with; a second --start serves a new
* URL and strands the open tab on a hand that never arrives.
*
* node serve-question.mjs --payload question.json [--timeout 900] [--no-open] [--port 0]
* --timeout bounds the wait for a page to arrive, never the user's decision:
* once the page heartbeats, the server lives while the page does, and exits
* only after --idle-grace seconds (default 600) pass with no beat, wide
* enough to survive a closed laptop lid mid-decision.
*
* node serve-question.mjs --payload question.json [--timeout 900] [--idle-grace 600] [--no-open] [--port 0]
*/
import http from 'node:http';
import fs from 'node:fs';
@@ -120,11 +127,13 @@ if (process.env.IMPECCABLE_QUESTION_DISABLED) {
}
// Headless self-detection, applied only where a browser is actually wanted.
// --no-open means the caller opens the URL itself, and --wait / --stop /
// --schema never open anything: --wait polls a daemon whose browser question
// was already settled at --start, --stop kills one, --schema prints text. A
// spurious exit 2 from those breaks the documented loop, which polls --wait
// while it exits 3 and reads --schema before building a payload.
const wantsBrowser = !hasFlag('no-open') && !hasFlag('wait') && !hasFlag('stop') && !hasFlag('schema');
// --schema / --update never open anything: --wait polls a daemon whose
// browser question was already settled at --start, --stop kills one,
// --schema prints text, and --update hands the next round to a page that is
// already open. A spurious exit 2 from those breaks the documented loop,
// which polls --wait while it exits 3, reads --schema before building a
// payload, and delivers re-rolled hands with --update.
const wantsBrowser = !hasFlag('no-open') && !hasFlag('wait') && !hasFlag('stop') && !hasFlag('schema') && !hasFlag('update');
if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) {
const headless =
process.env.CI ||
@@ -176,7 +185,20 @@ function printAnswer(raw) {
}
const payloadPath = arg('payload');
const timeoutSec = Number(arg('timeout', '900'));
// --timeout bounds only the wait for a page to open; 0 is the explicit
// wait-forever. A negative or unparseable value takes the default, so a
// typo cannot disarm the no-page exit and leak the daemon.
const timeoutArg = Number(arg('timeout', '900'));
const timeoutSec = Number.isFinite(timeoutArg) && timeoutArg >= 0 ? timeoutArg : 900;
// How long the server (and the page's own delivery deadline) outlive the
// last heartbeat; a zero, negative, or unparseable value takes the default.
const idleGraceArg = Number(arg('idle-grace', '600'));
const idleGraceMs = (Number.isFinite(idleGraceArg) && idleGraceArg > 0 ? idleGraceArg : 600) * 1000;
// How long a delivered next hand may sit unclaimed before it means no page
// is coming back: --wait reads it to keep a stalled page from counting as
// closed mid-delivery, and the daemon reads it to survive until the page's
// watch claims a hand delivered moments before the idle deadline.
const NEXT_CLAIM_GRACE_MS = 10000;
const portArg = Number(arg('port', '0'));
const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions');
const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`);
@@ -243,7 +265,19 @@ if (hasFlag('wait')) {
}
try {
const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8'));
if (state.lastBeat && Date.now() - state.lastBeat > 15000) { sawClose = true; break; }
// A silent page is not a closed one while a freshly delivered next
// hand sits unclaimed: a stalled page stops beating by design and its
// watch reloads, beating again, within seconds of the file landing.
// The suppression is age-bound because a closed tab never claims the
// hand: a file still there after the grace means no page is coming.
const midDelivery = (() => {
try { if (Date.now() - fs.statSync(path.join(QUESTION_DIR, `${key}.next.json`)).mtimeMs < NEXT_CLAIM_GRACE_MS) return true; }
catch { /* nothing delivered */ }
// The claim deletes that file before the reloaded page can beat: the
// claim stamp the server persisted covers the same bounded gap.
return Boolean(state.claimedAt) && Date.now() - state.claimedAt < NEXT_CLAIM_GRACE_MS;
})();
if (!midDelivery && state.lastBeat && Date.now() - state.lastBeat > 15000) { sawClose = true; break; }
} catch { /* state mid-write */ }
await new Promise((r) => setTimeout(r, 1000));
}
@@ -280,10 +314,33 @@ if (hasFlag('stop')) {
if (hasFlag('update')) {
const key = arg('key');
if (!key || !payloadPath) { console.error('serve-question: --update needs --key and --payload'); process.exit(1); }
JSON.parse(fs.readFileSync(payloadPath, 'utf8'));
try { process.kill(JSON.parse(fs.readFileSync(stateFile(key), 'utf8')).pid, 0); }
catch { console.error('serve-question: no live question server for that key'); process.exit(2); }
fs.copyFileSync(payloadPath, path.join(QUESTION_DIR, `${key}.next.json`));
// A hand the server cannot load must fail here, at the sender: delivered
// anyway, the page would see ready:true for a round that never renders.
const nextRound = JSON.parse(fs.readFileSync(payloadPath, 'utf8'));
if (!nextRound || !Array.isArray(nextRound.options) || nextRound.options.length === 0) {
console.error('serve-question: --update payload needs an options array; nothing was delivered. Fix the payload and rerun --update on the same key.');
process.exit(1);
}
// Liveness mirrors --wait: a fresh page heartbeat is the primary proof, the
// kill probe is secondary, and EPERM means a sandbox blocked the signal,
// never a dead server. This is the documented re-roll delivery step, so a
// false "no live server" here strands the page mid-shuffle.
const live = (() => {
try {
const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8'));
if (state.lastBeat && Date.now() - state.lastBeat < 12000) return true;
try { process.kill(state.pid, 0); return true; }
catch (err) { return err.code === 'EPERM'; }
} catch { return false; }
})();
if (!live) { console.error('serve-question: no live question server for that key; the page it served is gone too. Re-present the round with --start and a fresh key, or fall back to the structured question tool.'); process.exit(2); }
const deliveredFile = path.join(QUESTION_DIR, `${key}.next.json`);
fs.copyFileSync(payloadPath, deliveredFile);
// The file's mtime is the delivery clock --wait's grace reads: stamp it
// here, because a copy that preserves the source payload's older mtime
// would start the grace already spent.
const deliveredAt = new Date();
fs.utimesSync(deliveredFile, deliveredAt, deliveredAt);
console.log('next round delivered; the page reloads itself');
process.exit(0);
}
@@ -301,7 +358,8 @@ if (hasFlag('start')) {
const logFd = fs.openSync(logFile, 'a');
const child = spawn(process.execPath, [
fileURLToPath(import.meta.url), '--payload', payloadPath, '--detached-serve', '--key', key,
'--timeout', String(timeoutSec), ...(hasFlag('open') ? [] : ['--no-open']),
'--timeout', String(timeoutSec), ...(arg('idle-grace') ? ['--idle-grace', arg('idle-grace')] : []),
...(hasFlag('open') ? [] : ['--no-open']),
], { detached: true, stdio: ['ignore', logFd, logFd] });
child.unref();
fs.closeSync(logFd);
@@ -338,6 +396,13 @@ let localImages = [];
// even when the round never rendered a toggle.
let buildPathDefault = null;
let liveBuildPath = null;
// True between a collected re-roll or followup answer and the --update that
// replaces the round: the window where GET / must serve the wait, not the
// answered cards. The timestamp anchors the delivery deadline server-side,
// so a native refresh re-enters the wait with the time already spent, never
// with a fresh allowance.
let awaitingNext = false;
let awaitingNextSince = 0;
function loadRound(json) {
const parsed = JSON.parse(json);
@@ -387,6 +452,9 @@ function loadRound(json) {
? { value: parsed.buildPath.value, toggle: parsed.buildPath.toggle === true }
: null;
liveBuildPath = buildPathDefault?.value ?? null;
// Last: a round that failed to load anywhere above must leave the waiting
// window open, never resurrect the answered cards.
awaitingNext = false;
}
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
@@ -394,7 +462,11 @@ const nextFile = () => detachedKey ? path.join(QUESTION_DIR, `${detachedKey}.nex
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
function page() {
function page(waiting = false) {
// The delivery deadline survives refreshes: a waiting page gets whatever
// remains of the original allowance, so reloading cannot renew it. Spent
// means the page renders already stalled and never starts a heartbeat.
const waitBudgetMs = waiting ? Math.max(0, awaitingNextSince + idleGraceMs - Date.now()) : idleGraceMs;
const flipChip = (label) => `<button type="button" class="chip flip" aria-label="Flip the card"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a8 8 0 1 1-8 8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M4 5.5V12h6.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg><span>${label}</span></button>`;
const expandChip = `<button type="button" class="chip expand" aria-label="Expand the image"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 15v5h-5M20 9V4h-5M4 15v5h5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></button>`;
// Structured anatomy: chips and one-line facts render when the payload
@@ -866,6 +938,7 @@ function page() {
not a recommendation. */
#canon { align-self: center; padding: 0 4px; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: inherit; opacity: .45; background: transparent; border: none; border-bottom: 1px dotted currentColor; cursor: pointer; transition: opacity .2s ease; }
#canon:hover { opacity: .85; }
#canon[disabled] { opacity: .18; cursor: default; }
.card.skeleton .media { background: var(--ks-graphite); }
.shimmer { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
.card.skeleton .line { height: 11px; border-radius: 4px; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
@@ -877,6 +950,8 @@ function page() {
@keyframes shimmer { from { background-position: 120% 0; } to { background-position: -80% 0; } }
@media (prefers-reduced-motion: reduce) { .shimmer, .card.skeleton .line { animation: none; } }
.done { display: flex; flex-direction: column; align-items: center; gap: 1rem; padding: 7rem 1rem; font-family: var(--ks-font-display); font-size: 1.4rem; color: var(--ks-champagne); text-align: center; }
.stall { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 1.2rem; padding: 4.5rem 1rem; font-family: var(--ks-font-display); font-size: 1.4rem; color: var(--ks-champagne); text-align: center; }
.stall .choose { align-self: center; margin-top: 0; }
</style>
<div id="ambient" aria-hidden="true"></div>
<div id="scrim" aria-hidden="true"></div>
@@ -945,11 +1020,22 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
beat();
setInterval(beat, 5000);
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
// swallow the click and never print the confirmation, so the user believed
// a choice had landed that no one would ever collect.
async function answer(optionId) {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
if (FOLLOWUP) { await awaitNextRound(); return; }
// Quiet at the click: a re-roll or canon posted while this pick's POST
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
}
if (FOLLOWUP) { await awaitNextRound(true); return; }
document.body.innerHTML = '<div class="done"><svg viewBox="0 0 24 24" width="38" height="38" fill="oklch(84% 0.19 80.46)" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>Choice recorded. The agent is resuming; you can close this tab.</div>';
}
document.querySelectorAll('button.choose').forEach(b => b.addEventListener('click', () => answer(b.dataset.id)));
@@ -1381,15 +1467,62 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
const dealAgain = async (register) => {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await awaitNextRound();
// Quiet at the click, not after the fly-out: the POST round-trip plus
// the 700ms animation was a window where a second click posted another
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
}
await awaitNextRound(true);
};
async function awaitNextRound() {
async function awaitNextRound(animate, budgetMs = ${idleGraceMs}) {
const grid = document.querySelector('.grid');
let poll;
let misses = 0;
const shuffleStart = Date.now();
const stall = (message) => {
clearInterval(poll);
// A stalled page is an abandoned flow: keep heartbeating and the
// daemon never reaches its idle grace, so --wait spins on WAITING
// forever. Go silent and let the server reclaim itself. Reload must
// not undo that silence: an unconditional reload re-serves the same
// unresolved round and its fresh page beats again, so check for a
// delivered hand first and only reload when one exists. The re-roll
// buttons and the canon exit go too: a stalled page served already
// expired never disabled them, a re-roll would renew the deadline the
// stall just enforced, and a canon pick would overwrite a re-roll
// --wait already collected, closing the table under the agent.
clearInterval(beatTimer);
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
// Silence is for heartbeats only: a hand delivered after the deadline
// must still land without a click, so a beat-free watch keeps checking
// and reloads into it. /next-status never beats, so the daemon's idle
// grace still reclaims a flow nobody resumes.
const watch = setInterval(async () => {
try { if ((await (await fetch('/next-status')).json()).ready) { clearInterval(watch); location.reload(); } } catch { /* server gone; the screen already says so */ }
}, 1500);
grid.innerHTML = '<div class="stall"><p>' + message + '</p><button type="button" class="choose">Reload</button></div>';
grid.querySelector('.stall .choose').addEventListener('click', async () => {
try {
if ((await (await fetch('/next-status')).json()).ready) { location.reload(); return; }
grid.querySelector('.stall p').textContent = 'Still nothing to deal. Check the agent session, or answer in the chat instead.';
} catch {
grid.querySelector('.stall p').textContent = 'The question server went away. Ask the agent to restart it, or answer in the chat instead.';
}
});
};
// A refresh that lands after the delivery deadline has nothing left to
// wait for: stall before the heartbeat timer's first tick can fire, so
// the served page stays silent.
if (budgetMs <= 0) { stall('The next hand never arrived. Check the agent session, then reload.'); return; }
const cardsNow = [...grid.querySelectorAll('.card')];
const g = grid.getBoundingClientRect();
const cx = g.left + g.width / 2, cy = g.top + g.height / 2;
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
if (animate && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
const g = grid.getBoundingClientRect();
const cx = g.left + g.width / 2, cy = g.top + g.height / 2;
cardsNow.forEach((card, i) => {
const r = card.getBoundingClientRect();
card.style.transition = 'transform .5s cubic-bezier(.5,0,.75,0) ' + (i * 60) + 'ms, opacity .4s ease ' + (i * 60 + 120) + 'ms, filter .45s ease ' + (i * 60) + 'ms';
@@ -1401,17 +1534,35 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
}
const cardHeight = cardsNow[0] ? cardsNow[0].getBoundingClientRect().height : 0;
grid.innerHTML = cardsNow.map(() => '<article class="card skeleton"' + (cardHeight ? ' style="height:' + cardHeight + 'px"' : '') + '><div class="card-inner"><div class="face front"><div class="media"><div class="shimmer"></div></div><div class="body"><div class="line tier w40"></div><div class="line title w70"></div><div class="line w90"></div><div class="line w80"></div><div class="line w60"></div><div class="line button"></div></div></div></div></article>').join('');
document.querySelectorAll('.reroll-btn').forEach(b => b.setAttribute('disabled', ''));
const poll = setInterval(async () => {
// Canon goes quiet with the re-roll buttons: a pick posted mid-wait can
// never be collected once --wait has the re-roll, only close the table.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
// The wait must be able to end: a dead server rejects every tick and a
// round nobody delivers stays ready:false forever, and both used to spin
// the skeletons indefinitely. Distinguish them, say so, and offer a way
// out. The delivery deadline is the server's own idle grace, so the page
// never gives up on a server that would still accept the hand.
poll = setInterval(async () => {
try {
const status = await (await fetch('/next-status')).json();
misses = 0;
if (status.ready) { clearInterval(poll); location.reload(); }
} catch { /* server briefly busy */ }
else if (Date.now() - shuffleStart > budgetMs) stall('The next hand never arrived. Check the agent session, then reload.');
} catch {
misses += 1;
if (misses >= 8) stall('The question server went away. Ask the agent to restart it, or answer in the chat instead.');
}
}, 1200);
}
document.getElementById('reroll')?.addEventListener('click', () => dealAgain());
document.getElementById('reroll-safer')?.addEventListener('click', () => dealAgain('safer'));
document.getElementById('reroll-bolder')?.addEventListener('click', () => dealAgain('bolder'));
// A native refresh must not resurrect an answered round: while the server
// holds a collected re-roll or followup pick with no replacement delivered,
// it serves the page in waiting mode and the refresh re-enters the same
// bounded wait, with only the time the original deadline has left, instead
// of showing dead cards whose heartbeat props the daemon forever.
${waiting ? `awaitNextRound(false, ${waitBudgetMs});` : ''}
</script>`;
}
@@ -1419,14 +1570,32 @@ const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
try { loadRound(fs.readFileSync(pending, 'utf8')); fs.rmSync(pending); } catch { /* keep current round */ }
// A next file the round cannot load has to leave the disk either way:
// kept, /next-status stays ready:true and the waiting page reloads
// into the same failure without bound.
try { loadRound(fs.readFileSync(pending, 'utf8')); } catch { /* keep current round */ }
try { fs.rmSync(pending); } catch { /* already gone */ }
// The claim consumes the file the idle-exit hold reads, and the
// reloading page cannot beat until it has parsed: stamp the claim so
// the same bounded grace covers the gap between them. Persisted too,
// because --wait watches the same gap from outside this process and
// would otherwise read the stale beat as a closed page.
server.lastClaimAt = Date.now();
if (detachedKey) {
try {
const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8'));
state.claimedAt = server.lastClaimAt;
fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state));
} catch { /* state file recreated on next beat */ }
}
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(page());
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
const now = Date.now();
if (!server.lastBeatWrite || now - server.lastBeatWrite > 4000) {
@@ -1502,6 +1671,11 @@ const server = http.createServer((req, res) => {
...((chosen?.comp ?? chosen?.sketch) ? { comp: chosen.comp ?? chosen.sketch } : {}),
...(liveBuildPath && !isReroll ? { buildPath: liveBuildPath, buildPathFlipped: liveBuildPath !== (buildPathDefault?.value ?? null) } : {}),
});
// The delivery deadline is single-issue: a duplicate answer racing the
// page's disable must not restamp the allowance already inherited.
const wasAwaiting = awaitingNext;
awaitingNext = (isReroll || followupOpen) && Boolean(detachedKey);
if (awaitingNext && !wasAwaiting) awaitingNextSince = Date.now();
if (detachedKey) {
fs.mkdirSync(QUESTION_DIR, { recursive: true });
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
@@ -1531,10 +1705,38 @@ server.listen(portArg, '127.0.0.1', () => {
if (!hasFlag('no-open')) {
openSystemBrowser(url);
}
if (timeoutSec > 0) {
setTimeout(() => {
console.log('serve-question: timed out with no answer');
process.exit(2);
}, timeoutSec * 1000).unref?.();
}
// The timeout bounds the wait for a page, never the user's decision: an
// absolute guillotine counted from start used to kill the server under a
// still-open tab (a slow re-rolled round easily outlived it), leaving the
// page polling skeletons that could never resolve. Once the page beats,
// the server's lifetime tracks the beats, and it exits only after the idle
// grace passes with none, long enough to survive a closed laptop lid.
// --timeout 0 waits for a page forever, but the idle grace still applies
// once one has beat: a page that arrived and went silent is a closed tab,
// and no timeout setting should let that daemon leak.
const startedAt = Date.now();
const lifetime = setInterval(() => {
if (!server.lastBeatSeen) {
if (timeoutSec > 0 && Date.now() - startedAt > timeoutSec * 1000) {
console.log('serve-question: timed out with no answer');
process.exit(2);
}
} else if (Date.now() - server.lastBeatSeen > idleGraceMs) {
// A hand delivered moments before this deadline still gets its claim
// window: the stalled page's watch reloads into it and beats again
// within seconds, while a file unclaimed past the grace means no page
// is coming back (the same verdict --wait reads from its age). The
// claim itself holds the daemon too: GET / deletes the file before the
// reloaded page can beat, so a tick in that gap must not exit under
// the hand just claimed.
const pending = nextFile();
let deliveredAt = 0;
if (pending) { try { deliveredAt = fs.statSync(pending).mtimeMs; } catch { /* nothing delivered */ } }
if (Date.now() - Math.max(deliveredAt, server.lastClaimAt || 0) > NEXT_CLAIM_GRACE_MS) {
console.log('serve-question: the page stopped beating and never came back; exiting');
process.exit(2);
}
}
}, 2000);
lifetime.unref?.();
});
+179
View File
@@ -791,6 +791,185 @@ describe('new-work-e2e: serve-question decision page', () => {
rmSync(cwd, { recursive: true, force: true });
}
});
it('(g) a server that dies mid-shuffle stops the poll and names the failure', async () => {
const cwd = makeWorkspace();
const key = 'gonemid';
const payload = {
title: 'Choose the visual world',
options: [
{ id: 'assigned', label: 'First Hand', kicker: 'THE ROLL' },
{ id: 'challenger-a', label: 'Alt One' },
],
reroll: true, steer: true,
};
const { url } = await startDaemon(cwd, payload, key);
const context = await browser.newContext();
try {
const page = await context.newPage();
await page.goto(url, { waitUntil: 'load' });
await page.click('#reroll');
await page.waitForSelector('.card.skeleton');
// Collect the re-roll answer, then kill the daemon out from under the
// still-open page: the poll must stop and say the server is gone
// instead of spinning skeletons forever.
const first = await waitLoop(cwd, key);
assert.match(first.out, /"optionId":"reroll"/);
await run(['--stop', '--key', key], cwd);
await page.waitForSelector('.stall', { timeout: 30000 });
const text = await page.$eval('.stall', (el) => el.textContent);
assert.match(text, /The question server went away/);
assert.ok(await page.$('.stall .choose'), 'a way out is offered');
} finally {
await context.close();
await stopDaemon(cwd, key);
rmSync(cwd, { recursive: true, force: true });
}
});
it('(h) a round nobody delivers stops the poll at the deadline and says so', async () => {
const cwd = makeWorkspace();
const key = 'nodeal';
const payload = {
title: 'Choose the visual world',
options: [{ id: 'assigned', label: 'First Hand', kicker: 'THE ROLL' }],
reroll: true, steer: true, canon: true,
};
const { url } = await startDaemon(cwd, payload, key);
const context = await browser.newContext();
try {
const page = await context.newPage();
// Fake the page clock so the 10-minute delivery deadline is reachable;
// the server keeps its real clock, so its own idle grace never fires.
await page.clock.install();
let beats = 0;
page.on('request', (r) => { if (r.url().endsWith('/heartbeat')) beats += 1; });
await page.goto(url, { waitUntil: 'load' });
// Playwright actionability waits on rAF, which the fake clock owns, so
// dispatch the click directly.
await page.$eval('#reroll', (el) => el.click());
// The controls must go quiet at the click itself: the POST round-trip
// plus the fly-out used to leave them live, and a second click posted
// another re-roll that renewed the delivery deadline.
assert.ok(await page.$eval('#reroll', (el) => el.disabled), 'the re-roll goes quiet at the click, not after the fly-out');
assert.ok(await page.$eval('#canon', (el) => el.disabled), 'the canon exit goes quiet at the click too');
// Walk the fake clock forward past the fly-out settle and the deadline.
// page.$ runs over CDP, not in-page timers, so it stays safe to poll.
let stalled = null;
for (let i = 0; i < 40 && !stalled; i++) {
await page.clock.fastForward(20000);
await new Promise((r) => setTimeout(r, 100));
stalled = await page.$('.stall');
}
assert.ok(stalled, 'the poll stops at the deadline instead of spinning forever');
const text = await page.$eval('.stall', (el) => el.textContent);
assert.match(text, /The next hand never arrived/);
assert.ok(await page.$('.stall .choose'), 'a way out is offered');
// The canon exit must go quiet with the re-roll buttons: --wait already
// consumed the re-roll, so a canon pick posted now could never be
// collected, only close the table under the agent.
assert.ok(await page.$eval('#canon', (el) => el.disabled), 'the canon exit is disabled on the stall screen');
// The stalled page must also stop heartbeating: the beats are what keep
// the daemon alive, so a stalled tab left open used to hold it past its
// idle grace forever while --wait spun on WAITING.
assert.ok(beats > 0, 'the heartbeat counter observes beats before the stall');
const beatsAtStall = beats;
await page.clock.fastForward(60000);
await new Promise((r) => setTimeout(r, 750));
assert.equal(beats, beatsAtStall, 'no heartbeat fires after the stall, so the idle grace can reclaim the daemon');
// Reload must not revive the abandoned flow: with no hand delivered it
// stays on the silent stall screen and says so, rather than re-serving
// the unresolved round with a fresh heartbeat.
await page.$eval('.stall .choose', (el) => el.click());
// Poll over CDP, not in-page waiters: the fake clock owns rAF.
let msg = '';
for (let i = 0; i < 50 && !/Still nothing to deal/.test(msg); i++) {
await new Promise((r) => setTimeout(r, 100));
msg = await page.$eval('.stall p', (el) => el.textContent);
}
assert.match(msg, /Still nothing to deal/, 'the stall says a reload found nothing');
await page.clock.fastForward(30000);
await new Promise((r) => setTimeout(r, 500));
assert.equal(beats, beatsAtStall, 'a reload attempt with nothing to deal leaves the page silent');
// A browser-native refresh bypasses the gated button entirely, so the
// server serves the page in waiting mode: the refresh re-enters the
// bounded shuffle wait (beating while it waits, like any live wait)
// rather than resurrecting the answered cards with an unbounded
// heartbeat -- and the deadline silences it all over again.
await page.reload({ waitUntil: 'load' });
let waitingAgain = null;
for (let i = 0; i < 50 && !waitingAgain; i++) {
await new Promise((r) => setTimeout(r, 100));
waitingAgain = await page.$('.card.skeleton');
}
assert.ok(waitingAgain, 'a native refresh mid re-roll re-enters the shuffle wait, not the answered round');
assert.ok(await page.$eval('#canon', (el) => el.disabled), 'the refreshed waiting page serves the canon exit disabled too');
let restalled = null;
for (let i = 0; i < 40 && !restalled; i++) {
await page.clock.fastForward(20000);
await new Promise((r) => setTimeout(r, 100));
restalled = await page.$('.stall');
}
assert.ok(restalled, 'the refreshed wait still ends at the deadline');
const beatsAtSecondStall = beats;
await page.clock.fastForward(30000);
await new Promise((r) => setTimeout(r, 500));
assert.equal(beats, beatsAtSecondStall, 'the refreshed page goes silent again at its own deadline');
// Once a hand actually lands, the stalled page's beat-free watch deals
// it on its own, no click owed, and the heartbeat legitimately resumes:
// a live round is not an abandoned flow.
const nextPayloadPath = path.join(cwd, 'next.json');
writeFileSync(nextPayloadPath, JSON.stringify({
title: 'Choose the visual world',
options: [{ id: 'assigned', label: 'Second Hand', kicker: 'RE-ROLLED' }],
reroll: true, steer: true, canon: true,
}));
const updated = await run(['--update', '--key', key, '--payload', nextPayloadPath], cwd);
assert.equal(updated.code, 0, updated.out);
let dealt = null;
for (let i = 0; i < 50 && !dealt; i++) {
await page.clock.fastForward(2000);
await new Promise((r) => setTimeout(r, 100));
dealt = await page.$('button.choose');
}
assert.ok(dealt, 'the stalled page notices the delivered hand on its own and deals it');
const label = await page.$eval('.card', (el) => el.textContent);
assert.match(label, /Second Hand/, 'reload with a delivered hand serves the new round');
assert.ok(beats > beatsAtSecondStall, 'the heartbeat resumes on the re-dealt round');
assert.ok(await page.$eval('#canon', (el) => !el.disabled), 'the dealt round serves the canon exit live again');
} finally {
await context.close();
await stopDaemon(cwd, key);
rmSync(cwd, { recursive: true, force: true });
}
});
it('(i) Build this against a dead server fails loudly instead of confirming', async () => {
const cwd = makeWorkspace();
const key = 'deadpick';
const payload = {
title: 'Choose the visual world',
options: [{ id: 'assigned', label: 'First Hand', kicker: 'THE ROLL' }],
reroll: true, steer: true,
};
const { url } = await startDaemon(cwd, payload, key);
const context = await browser.newContext();
try {
const page = await context.newPage();
await page.goto(url, { waitUntil: 'load' });
await page.waitForSelector('button.choose');
await run(['--stop', '--key', key], cwd);
await page.click('button.choose');
await page.waitForSelector('.done', { timeout: 15000 });
const text = await page.$eval('.done', (el) => el.textContent);
assert.match(text, /went away before this choice could land/);
assert.doesNotMatch(text, /Choice recorded/);
} finally {
await context.close();
await stopDaemon(cwd, key);
rmSync(cwd, { recursive: true, force: true });
}
});
});
// --------------------------------------------------------------------------
+386 -2
View File
@@ -1,7 +1,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { spawn, execSync } from 'node:child_process';
import { writeFileSync, readFileSync, rmSync, utimesSync, mkdtempSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -137,6 +137,10 @@ describe('serve-question', () => {
try {
const waiting = await run(['--wait', '--key', 'hk', '--poll', '1']);
assert.equal(waiting.code, 3, `--wait under CI must report WAITING, got ${waiting.code}: ${waiting.out}`);
// --update delivers a re-rolled hand to a page that is already open; a
// headless gate that eats it strands that page mid-shuffle (issue #469).
const updated = await run(['--update', '--key', 'hk', '--payload', payloadPath]);
assert.equal(updated.code, 0, `--update under CI must deliver, got ${updated.code}: ${updated.out}`);
} finally {
const stopped = await run(['--stop', '--key', 'hk']);
assert.equal(stopped.code, 0, `--stop under CI must kill the daemon, got ${stopped.code}: ${stopped.out}`);
@@ -190,6 +194,386 @@ describe('serve-question', () => {
assert.equal(dead, 2, 'a truly missing process must still read as gone');
});
it('a heartbeating page keeps the daemon alive past --timeout; silence ends it after the idle grace', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const run = (args) => new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.on('exit', (code) => resolve({ code, out }));
});
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'life', '--timeout', '3', '--idle-grace', '3']);
assert.equal(started.code, 0, started.out);
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
assert.ok(url, started.out);
// Beat well past the 3s timeout: the timer must not fire under a live page.
const beatUntil = Date.now() + 5500;
while (Date.now() < beatUntil) {
await fetch(`${url}heartbeat`, { method: 'POST' });
await new Promise((r) => setTimeout(r, 400));
}
const alive = await fetch(url);
assert.equal(alive.status, 200, 'the daemon outlives --timeout while the page heartbeats');
// Then silence: the idle grace (3s here) plus the 2s check interval pass
// with no beat, and the daemon must exit rather than leak. Poll rather
// than sleep a fixed margin so a loaded runner cannot flake this.
const deadline = Date.now() + 12000;
let gone = false;
while (Date.now() < deadline && !gone) {
await new Promise((r) => setTimeout(r, 500));
try { await fetch(url); } catch { gone = true; }
}
assert.ok(gone, 'the daemon exits after the idle grace passes with no heartbeat');
});
it('a page that never opens still ends the daemon at --timeout', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const run = (args) => new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.on('exit', (code) => resolve({ code, out }));
});
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'leak', '--timeout', '1']);
assert.equal(started.code, 0, started.out);
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
const deadline = Date.now() + 8000;
let gone = false;
while (Date.now() < deadline && !gone) {
await new Promise((r) => setTimeout(r, 500));
try { await fetch(url); } catch { gone = true; }
}
assert.ok(gone, 'with no heartbeat ever, the daemon still exits at --timeout');
});
it('an unparseable or negative --timeout takes the default instead of disarming the no-page exit', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const run = (args) => new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.on('exit', (code) => resolve({ code, out }));
});
// NaN used to flow into the lifetime timer, where timeoutSec > 0 is false
// and the no-page exit never fires: a daemon nothing would ever reclaim.
// The clamped value is observable in the detached daemon's own argv.
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'clamp', '--timeout', 'bogus']);
assert.equal(started.code, 0, started.out);
try {
const state = JSON.parse(readFileSync(path.join(dir, '.impeccable', 'questions', 'clamp.state.json'), 'utf8'));
const argv = execSync(`ps -ww -o args= -p ${state.pid}`).toString();
assert.match(argv, /--timeout 900/, 'the daemon runs with the clamped default, not NaN');
} finally {
await run(['--stop', '--key', 'clamp']);
}
});
it('--timeout 0 waits for a page forever, but a page that beat and went silent still ends the daemon', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const run = (args) => new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.on('exit', (code) => resolve({ code, out }));
});
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'zero', '--timeout', '0', '--idle-grace', '3']);
assert.equal(started.code, 0, started.out);
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
assert.ok(url, started.out);
// No page yet: --timeout 0 means wait indefinitely, so the daemon must
// survive well past where any small timeout would have fired.
await new Promise((r) => setTimeout(r, 3000));
const alive = await fetch(url);
assert.equal(alive.status, 200, 'with --timeout 0 and no page yet, the daemon keeps waiting');
// One beat, then silence: the idle grace must still reclaim the daemon.
// Before the fix, the whole lifetime check sat inside timeoutSec > 0 and
// a closed tab leaked this daemon forever.
await fetch(`${url}heartbeat`, { method: 'POST' });
const deadline = Date.now() + 12000;
let gone = false;
while (Date.now() < deadline && !gone) {
await new Promise((r) => setTimeout(r, 500));
try { await fetch(url); } catch { gone = true; }
}
assert.ok(gone, 'the idle grace applies under --timeout 0 once a page has beat');
});
it('a hand delivered just before the idle deadline holds the daemon for its claim window', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const run = (args) => new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.on('exit', (code) => resolve({ code, out }));
});
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'latehand', '--timeout', '30', '--idle-grace', '3']);
assert.equal(started.code, 0, started.out);
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
assert.ok(url, started.out);
try {
await fetch(`${url}heartbeat`, { method: 'POST' });
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
// Go silent like a stalled page until just before the 3s idle
// deadline, then deliver: the daemon used to exit before the page's
// watch could claim the hand, orphaning a delivery --update had
// already confirmed.
await new Promise((r) => setTimeout(r, 2500));
const nextPath = path.join(dir, 'next.json');
writeFileSync(nextPath, JSON.stringify({ ...PAYLOAD, title: 'Late round' }));
const updated = await run(['--update', '--key', 'latehand', '--payload', nextPath]);
assert.equal(updated.code, 0, updated.out);
await new Promise((r) => setTimeout(r, 3000));
const served = await (await fetch(url)).text();
assert.ok(served.includes('Late round'), 'past the idle deadline, the daemon survives its claim window and deals the delivered hand');
// The claim itself must hold the daemon too: that GET deleted the next
// file before any page could beat, so a lifetime tick in the gap used
// to exit under the hand just claimed.
await new Promise((r) => setTimeout(r, 2500));
const alive = await fetch(url);
assert.equal(alive.status, 200, 'the daemon survives the claim-to-first-beat gap');
} finally {
await run(['--stop', '--key', 'latehand']);
}
});
it('a refresh while a re-roll is outstanding re-enters the wait instead of re-serving the answered round', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const run = (args) => new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.on('exit', (code) => resolve({ code, out }));
});
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'refresh', '--timeout', '30']);
assert.equal(started.code, 0, started.out);
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
assert.ok(url, started.out);
try {
const before = await (await fetch(url)).text();
assert.ok(!before.includes('awaitNextRound(false,'), 'a fresh round serves the normal page');
// A native refresh bypasses the page's own gated Reload button, so the
// serving decision has to live here: once a re-roll answer is collected
// and no replacement has landed, GET / re-enters the bounded shuffle
// wait instead of re-serving the answered cards.
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
const waitingPage = await (await fetch(url)).text();
assert.ok(waitingPage.includes('awaitNextRound(false,'), 'a refresh mid re-roll re-enters the shuffle wait');
const nextPath = path.join(dir, 'next.json');
writeFileSync(nextPath, JSON.stringify({ ...PAYLOAD, title: 'Second round' }));
const updated = await run(['--update', '--key', 'refresh', '--payload', nextPath]);
assert.equal(updated.code, 0, updated.out);
const after = await (await fetch(url)).text();
assert.ok(after.includes('Second round'), 'the delivered hand is served');
assert.ok(!after.includes('awaitNextRound(false,'), 'the wait ends once the hand lands');
} finally {
await run(['--stop', '--key', 'refresh']);
}
});
it('a refresh cannot renew the delivery deadline: the waiting page inherits what remains and serves stalled and silent once it is spent', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const run = (args) => new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.on('exit', (code) => resolve({ code, out }));
});
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'deadline', '--timeout', '30', '--idle-grace', '3']);
assert.equal(started.code, 0, started.out);
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
assert.ok(url, started.out);
try {
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
const fresh = await (await fetch(url)).text();
const budget = Number(fresh.match(/awaitNextRound\(false, (\d+)\);/)?.[1]);
assert.ok(budget > 0 && budget <= 3000, `the waiting page carries the remaining allowance, got ${budget}`);
assert.match(fresh, /^\s*beat\(\);\s*$/m, 'a live wait still heartbeats');
// A duplicate answer must not restamp the deadline either: the page's
// click-time disable can race a second click, so the server keeps the
// first stamp instead of renewing the allowance.
await new Promise((r) => setTimeout(r, 1200));
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
const restamped = Number((await (await fetch(url)).text()).match(/awaitNextRound\(false, (\d+)\);/)?.[1]);
assert.ok(restamped > 0 && restamped < 2500, `a duplicate re-roll does not renew the allowance, got ${restamped}`);
await new Promise((r) => setTimeout(r, 3500));
const spent = await (await fetch(url)).text();
assert.ok(spent.includes('awaitNextRound(false, 0);'), 'a refresh after the deadline gets no new allowance');
assert.ok(!/^\s*beat\(\);\s*$/m.test(spent), 'an expired wait never starts the heartbeat');
} finally {
await run(['--stop', '--key', 'deadline']);
}
});
it('an unloadable next hand fails at --update, and one already on disk is discarded instead of reload-looping', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const run = (args) => new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.stderr.on('data', (chunk) => { out += chunk; });
child.on('exit', (code) => resolve({ code, out }));
});
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'badhand', '--timeout', '30']);
assert.equal(started.code, 0, started.out);
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
assert.ok(url, started.out);
try {
const badPath = path.join(dir, 'bad.json');
writeFileSync(badPath, JSON.stringify({ title: 'No options' }));
const rejected = await run(['--update', '--key', 'badhand', '--payload', badPath]);
assert.equal(rejected.code, 1, rejected.out);
assert.match(rejected.out, /options array/, 'the sender hears why the hand was refused');
// A bad file that reaches the disk anyway must not trap the page:
// GET / discards it, so /next-status stops reporting a hand that can
// never render and the bounded wait resumes instead of reload-looping.
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
writeFileSync(path.join(dir, '.impeccable', 'questions', 'badhand.next.json'), JSON.stringify({ title: 'No options' }));
const page = await (await fetch(url)).text();
assert.ok(page.includes('awaitNextRound(false,'), 'the round stays in the wait');
const status = await (await fetch(`${url}next-status`)).json();
assert.equal(status.ready, false, 'the unloadable hand left the disk');
} finally {
await run(['--stop', '--key', 'badhand']);
}
});
it('--wait does not conclude PAGE CLOSED while a delivered next hand sits unclaimed', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const run = (args) => new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.on('exit', (code) => resolve({ code, out }));
});
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'silent', '--timeout', '30']);
assert.equal(started.code, 0, started.out);
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
assert.ok(url, started.out);
try {
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
const collected = await run(['--wait', '--key', 'silent', '--poll', '2']);
assert.equal(collected.code, 0, collected.out);
// The stalled page went silent by design: fake a beat older than the
// 15s page-closed threshold, then deliver the hand late.
const statePath = path.join(dir, '.impeccable', 'questions', 'silent.state.json');
const state = JSON.parse(readFileSync(statePath, 'utf8'));
state.lastBeat = Date.now() - 20000;
writeFileSync(statePath, JSON.stringify(state));
const nextPath = path.join(dir, 'next.json');
writeFileSync(nextPath, JSON.stringify(PAYLOAD));
// The delivery clock must be --update's own stamp, never the source
// payload's: an old file delivered now still opens a full grace.
const staleSource = new Date(Date.now() - 60000);
utimesSync(nextPath, staleSource, staleSource);
const updated = await run(['--update', '--key', 'silent', '--payload', nextPath]);
assert.equal(updated.code, 0, updated.out);
// Mid-delivery, the silence is the stall's, not a closed tab's: the
// page's watch reloads into the hand and beats again. --wait must keep
// waiting instead of routing the agent away from the open browser.
const waiting = await run(['--wait', '--key', 'silent', '--poll', '2']);
assert.equal(waiting.code, 3, `mid-delivery silence stays WAITING, got: ${waiting.out}`);
// The suppression is age-bound: a hand nobody claimed within the grace
// means the page is gone, and the delivered file must not mask that.
const nextOnDisk = path.join(dir, '.impeccable', 'questions', 'silent.next.json');
const aged = new Date(Date.now() - 20000);
utimesSync(nextOnDisk, aged, aged);
const masked = await run(['--wait', '--key', 'silent', '--poll', '2']);
assert.equal(masked.code, 4, `an unclaimed stale delivery reads as a closed page, got: ${masked.out}`);
// With no hand pending at all, the same stale beat also means closed.
rmSync(nextOnDisk);
const closed = await run(['--wait', '--key', 'silent', '--poll', '5']);
assert.equal(closed.code, 4, closed.out);
} finally {
await run(['--stop', '--key', 'silent']);
}
});
it('a claimed hand\'s reload gap must not read as a closed page', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const payloadPath = path.join(dir, 'q.json');
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
const run = (args) => new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] });
let out = '';
child.stdout.on('data', (chunk) => { out += chunk; });
child.on('exit', (code) => resolve({ code, out }));
});
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'claimgap', '--timeout', '30']);
assert.equal(started.code, 0, started.out);
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
assert.ok(url, started.out);
try {
await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: '' }) });
const collected = await run(['--wait', '--key', 'claimgap', '--poll', '2']);
assert.equal(collected.code, 0, collected.out);
const statePath = path.join(dir, '.impeccable', 'questions', 'claimgap.state.json');
const state = JSON.parse(readFileSync(statePath, 'utf8'));
state.lastBeat = Date.now() - 20000;
writeFileSync(statePath, JSON.stringify(state));
const nextPath = path.join(dir, 'next.json');
writeFileSync(nextPath, JSON.stringify({ ...PAYLOAD, title: 'Claimed round' }));
const updated = await run(['--update', '--key', 'claimgap', '--payload', nextPath]);
assert.equal(updated.code, 0, updated.out);
// The claim deletes the next file --wait's mid-delivery grace watches,
// and the reloading page has not beat yet: --wait used to read the
// stale beat as PAGE CLOSED while the daemon served the dealt round.
const served = await (await fetch(url)).text();
assert.ok(served.includes('Claimed round'), 'the GET claims the delivered hand');
const waiting = await run(['--wait', '--key', 'claimgap', '--poll', '2']);
assert.equal(waiting.code, 3, `the claim gap stays WAITING, got: ${waiting.out}`);
// Bounded like the delivery grace: a claim nobody followed with a beat
// still reads as the closed page it is.
const aged = JSON.parse(readFileSync(statePath, 'utf8'));
aged.claimedAt = Date.now() - 20000;
writeFileSync(statePath, JSON.stringify(aged));
const closed = await run(['--wait', '--key', 'claimgap', '--poll', '2']);
assert.equal(closed.code, 4, `a claim nobody resumed reads as closed, got: ${closed.out}`);
} finally {
await run(['--stop', '--key', 'claimgap']);
}
});
it('--update trusts a fresh heartbeat over a failed kill probe, and still detects true death', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const qdir = path.join(dir, '.impeccable', 'questions');
const { mkdirSync } = await import('node:fs');
mkdirSync(qdir, { recursive: true });
const nextPath = path.join(dir, 'next.json');
writeFileSync(nextPath, JSON.stringify(PAYLOAD));
const run = (key) => new Promise((resolve) => {
const child = spawn(process.execPath, [SCRIPT, '--update', '--key', key, '--payload', nextPath], { cwd: dir, stdio: 'ignore' });
child.on('exit', resolve);
});
// Fresh heartbeat + a pid the sandbox cannot signal (pid 1 throws EPERM):
// --update is the documented re-roll delivery step, so a false "no live
// server" here strands the page mid-shuffle. Must deliver, exit 0.
writeFileSync(path.join(qdir, 'upbeat.state.json'), JSON.stringify({ pid: 1, port: 1, url: 'http://127.0.0.1:1/', lastBeat: Date.now() }));
assert.equal(await run('upbeat'), 0, 'fresh heartbeat must read as alive regardless of the kill probe');
assert.ok(existsSync(path.join(qdir, 'upbeat.next.json')), 'the next hand landed');
// Stale heartbeat + a genuinely dead pid: exit 2, nothing delivered.
writeFileSync(path.join(qdir, 'updead.state.json'), JSON.stringify({ pid: 999999999 >>> 8, port: 1, url: 'http://127.0.0.1:1/' }));
assert.equal(await run('updead'), 2, 'a truly missing process must still read as gone');
assert.ok(!existsSync(path.join(qdir, 'updead.next.json')), 'no hand is delivered to a dead server');
});
it('renders anatomy, streams late comps, and returns the chosen comp', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
const compPath = path.join(dir, 'comps', 'assigned.webp');