mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 00:26:41 +03:00
* 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:
co-authored by
Cursor
Paul Bakaus
parent
3fcfa7eedf
commit
3c6f53406b
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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?.();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user