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 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-27 16:33:34 -07:00
co-authored by Claude Code
parent 39df25ee5a
commit e5f6d27a9c
5 changed files with 74 additions and 3 deletions
+29 -1
View File
@@ -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);
+1 -1
View File
@@ -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 <manifest or source path> 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 <manifest or source path> for the queued variant_mount_failed event (or republish) so the browser retries.`;
}
function parseArgs(argv) {
+7
View File
@@ -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':
+1 -1
View File
@@ -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/);
+36
View File
@@ -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 });
}
});
});