From e5f6d27a9c8d7fd0cd45cedd657515b7ec1f2de1 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 27 Jul 2026 16:33:34 -0700 Subject: [PATCH] fix: fifth review round (durable mount failures, {#key} hydration slots) cursor[bot]: - variant_mount_failed now sets the session's pendingEvent (without clobbering a still-pending generate), so a helper restart replays it onto /poll and a repair --reply resolves instead of returning unknown_poll_reply_id. live-resume's next action names the real event id instead of a literal EVENT_ID placeholder. - Contract v2 text hydration strips {#key} DELIMITERS from the zip source (content stays; it always renders), so key blocks can no longer shift expression slots against the live DOM. This work was produced with AI assistance (Claude Code). Co-Authored-By: Claude Code --- skill/scripts/live-browser.js | 30 +++++++++++++++++++++- skill/scripts/live-resume.mjs | 2 +- skill/scripts/live/session-store.mjs | 7 ++++++ tests/live-recovery-commands.test.mjs | 2 +- tests/live-session-store.test.mjs | 36 +++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 887ea882c..c7c7de6e5 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -5501,9 +5501,13 @@ } // Remove balanced {#each}...{/each} and {#if}...{/if} regions (including - // the delimiters) from a markup string. Nesting-aware. + // the delimiters) from a markup string. Nesting-aware. {#key} blocks keep + // their CONTENT (it always renders) but lose their delimiter tokens, which + // would otherwise consume live text slots in the zip and shift every + // following expression. function stripSvelteBlockRegions(markup) { let out = String(markup || ''); + out = stripSvelteKeyDelimiters(out); for (const kind of ['each', 'if']) { const open = '{#' + kind; const close = '{/' + kind + '}'; @@ -5530,6 +5534,30 @@ return out; } + function stripSvelteKeyDelimiters(markup) { + let out = String(markup || ''); + for (;;) { + const start = out.indexOf('{#key'); + if (start === -1) break; + // The opening tag runs to its matching close brace (expressions inside + // may nest braces). + let depth = 0; + let i = start; + let openEnd = -1; + while (i < out.length) { + if (out[i] === '{') depth++; + else if (out[i] === '}') { + depth--; + if (depth === 0) { openEnd = i + 1; break; } + } + i++; + } + if (openEnd === -1) break; + out = out.slice(0, start) + out.slice(openEnd); + } + return out.split('{/key}').join(''); + } + function cloneWithoutElements(rootEl, excludedEls) { if (!excludedEls || excludedEls.length === 0) return rootEl; const excludedSet = new Set(excludedEls); diff --git a/skill/scripts/live-resume.mjs b/skill/scripts/live-resume.mjs index 04306ff0c..b9459a8d4 100644 --- a/skill/scripts/live-resume.mjs +++ b/skill/scripts/live-resume.mjs @@ -69,7 +69,7 @@ export function mountFailureAction(snapshot = {}) { if (!latest) return null; const where = latest.url ? ` from ${latest.url}` : ''; const why = latest.error ? ` (${latest.error})` : ''; - return `The browser failed to mount variant ${latest.variant}${where}${why}; nothing is on screen. Fix the variant files, then reply with live-poll.mjs --reply EVENT_ID done --file for the queued variant_mount_failed event (or republish) so the browser retries.`; + return `The browser failed to mount variant ${latest.variant}${where}${why}; nothing is on screen. Fix the variant files, then reply with live-poll.mjs --reply ${snapshot?.pendingEvent?.id || snapshot?.id || 'SESSION_ID'} done --file for the queued variant_mount_failed event (or republish) so the browser retries.`; } function parseArgs(argv) { diff --git a/skill/scripts/live/session-store.mjs b/skill/scripts/live/session-store.mjs index d77ee1323..0d1715e7c 100644 --- a/skill/scripts/live/session-store.mjs +++ b/skill/scripts/live/session-store.mjs @@ -410,6 +410,13 @@ function applyEvent(snapshot, entry) { }, ].slice(-MOUNT_FAILURE_HISTORY); next.renderState = deriveRenderState(next); + // The failure needs an agent reply, so it must survive a helper + // restart the same way a generate does. Never clobber a still-pending + // generate: a progressive publish can fail an early mount while the + // generate event itself is still leased. + if (!next.pendingEvent) { + next.pendingEvent = toPendingEvent(event); + } break; } case 'checkpoint': diff --git a/tests/live-recovery-commands.test.mjs b/tests/live-recovery-commands.test.mjs index 491b2c556..c442e812a 100644 --- a/tests/live-recovery-commands.test.mjs +++ b/tests/live-recovery-commands.test.mjs @@ -163,7 +163,7 @@ describe('live recovery CLI commands', () => { 'event=live_resume.mount_failure_action actor=agent operation=recover_session risk=agent_thinks_variants_are_on_screen expected=named failing variant and url actual=' + resume.nextAction, ); assert.match(resume.nextAction, /variant_mount_failed/); - assert.match(resume.nextAction, /--reply EVENT_ID done --file/); + assert.match(resume.nextAction, /--reply cli-render-2 done --file/); const status = runJson(STATUS_SCRIPT, [], cwd); assert.match(status.recoveryHint, /failed to mount variant 2/); diff --git a/tests/live-session-store.test.mjs b/tests/live-session-store.test.mjs index b06498c89..fa68a4cd0 100644 --- a/tests/live-session-store.test.mjs +++ b/tests/live-session-store.test.mjs @@ -730,3 +730,39 @@ describe('live-session-store', () => { }); }); }); + +describe('review regressions: durable mount failures', () => { + it('variant_mount_failed survives a helper restart as the pending event', () => { + const tmp = mkdtempSync(join(tmpdir(), 'impeccable-store-mountfail-')); + try { + const store = createLiveSessionStore({ cwd: tmp }); + store.appendEvent({ type: 'generate', id: 'mf123456', count: 3, pageUrl: '/', element: { tagName: 'h1' } }); + store.appendEvent({ type: 'agent_done', id: 'mf123456' }); + store.appendEvent({ type: 'variant_mount_failed', id: 'mf123456', variant: 2, url: 'http://x/v2.svelte', error: 'boom' }); + + // A second store instance = the restarted helper. + const restarted = createLiveSessionStore({ cwd: tmp }); + const snapshot = restarted.getSnapshot('mf123456'); + assert.equal(snapshot.pendingEvent?.type, 'variant_mount_failed'); + assert.equal(snapshot.pendingEvent?.variant, 2); + + // The repair reply retires it. + restarted.appendEvent({ type: 'agent_done', id: 'mf123456', sourceEventType: 'variant_mount_failed' }); + assert.equal(restarted.getSnapshot('mf123456').pendingEvent, null); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('a mount failure does not clobber a still-pending generate', () => { + const tmp = mkdtempSync(join(tmpdir(), 'impeccable-store-mountfail2-')); + try { + const store = createLiveSessionStore({ cwd: tmp }); + store.appendEvent({ type: 'generate', id: 'mf223456', count: 3, pageUrl: '/', element: { tagName: 'h1' } }); + store.appendEvent({ type: 'variant_mount_failed', id: 'mf223456', variant: 1, url: 'http://x/v1.svelte', error: 'early' }); + assert.equal(store.getSnapshot('mf223456').pendingEvent?.type, 'generate'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +});