From 8a6c4ab486f3e2c1631a7f851dee3eb81ce782c2 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 13 Jul 2026 09:54:23 -0700 Subject: [PATCH] Fix early Live choice queue ownership AI-assisted: Codex --- skill/scripts/live-server.mjs | 19 +++++++++++++ tests/live-server.test.mjs | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index 9b3f0cbb0..2e4967bae 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -290,6 +290,22 @@ function acknowledgePendingEvent(id, sourceEventType) { return acknowledged; } +function retirePendingGeneration(id) { + if (!id) return 0; + let retired = 0; + for (let index = state.pendingEvents.length - 1; index >= 0; index -= 1) { + const event = state.pendingEvents[index]?.event; + if (event?.id !== id || event.type !== 'generate') continue; + state.pendingEvents.splice(index, 1); + retired += 1; + } + if (retired > 0) { + scheduleLeaseFlush(); + broadcastAgentPollingIfChanged(); + } + return retired; +} + function findPendingEventById(id, sourceEventType) { if (!id) return null; const entry = state.pendingEvents.find((item) => ( @@ -823,6 +839,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) { return; } } + if (msg.type === 'accept' || msg.type === 'discard') { + retirePendingGeneration(msg.id); + } recordGenerationCheckpoint(msg); if (msg.type === 'exit') { cleanupSvelteComponentSessionsBeforeExit(); diff --git a/tests/live-server.test.mjs b/tests/live-server.test.mjs index 76b7d2262..32c0238f2 100644 --- a/tests/live-server.test.mjs +++ b/tests/live-server.test.mjs @@ -2599,6 +2599,59 @@ colors: {} assert.equal(acked.type, 'timeout', 'acked event should be removed from the poll queue'); }); + it('retires the leased Generate when early Accept or Discard takes ownership', async () => { + await drainPolls(server); + for (const [type, id] of [['accept', 'ea11ac01'], ['discard', 'ea11dc01']]) { + const generated = await fetch(`http://localhost:${server.port}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: server.token, + type: 'generate', + id, + action: 'bolder', + count: 3, + element: { outerHTML: '
early choice
', tagName: 'section' }, + }), + }); + assert.equal(generated.status, 200); + const generation = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=100&leaseMs=40`).then((response) => response.json()); + assert.equal(generation.id, id); + + const chosen = await fetch(`http://localhost:${server.port}/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: server.token, + type, + id, + ...(type === 'accept' ? { variantId: '1' } : {}), + }), + }); + assert.equal(chosen.status, 200); + const choice = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=${type}&timeout=100&leaseMs=40`).then((response) => response.json()); + assert.equal(choice.type, type); + assert.equal(choice.id, id); + const reply = await fetch(`http://localhost:${server.port}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: server.token, + id, + sourceEventType: type, + type: type === 'discard' ? 'discarded' : 'complete', + }), + }); + assert.equal(reply.status, 200); + + await new Promise((resolve) => setTimeout(resolve, 60)); + const stale = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&types=generate&timeout=30&leaseMs=20`).then((response) => response.json()); + assert.equal(stale.type, 'timeout', `${type} must prevent Generate redelivery after its old lease expires`); + const status = await fetch(`http://localhost:${server.port}/status?token=${server.token}`).then((response) => response.json()); + assert.equal(status.pendingEvents.some((event) => event.id === id && event.type === 'generate'), false); + } + }); + it('wakes a parked poll as soon as a missed-ack lease expires', async () => { await drainPolls(server);