fix: self-discard orphaned variant sessions instead of freezing the picker

Fixes #439. When a cycling session is abandoned and the wrapped region is
then edited or regenerated out of the source file, the resumed page used
to sit in GENERATING forever with the picker disarmed; the only recovery
was a manual live-complete --discarded. Now a resumed CYCLING session
whose wrapper cannot be found in source retries the read a few times
(HMR or an agent write may be mid-flight), then discards itself, clears
local state, and re-arms the picker with a toast. GENERATING restores
are exempt: deferred-wrapper flows legitimately have no wrapper in
source until the agent's write lands.

The browser tags the discard event orphaned:true; the server terminalizes
that session directly (phase discarded) and keeps the event out of the
agent poll queue, since there is no source cleanup left to perform and
the normal discard flow would just fail against the missing scaffolding.

New e2e scenario on vite8-react-plain drives the full repro: cycle,
revert source externally, reload, assert self-discard, terminal durable
phase, and a working picker afterward.

AI-assisted (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-28 15:57:33 -07:00
co-authored by Claude Code
parent b9c1d86d68
commit 69456364b2
4 changed files with 160 additions and 9 deletions
+13 -1
View File
@@ -1038,13 +1038,25 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
// An ORPHANED discard is the browser reporting that the session's
// wrapper no longer exists in source (edited or regenerated away).
// There is no cleanup for an agent to perform, and asking one to run
// the normal discard flow would just fail against the missing
// scaffolding, so the server terminalizes the session itself and the
// event stays out of the poll queue.
const orphanedDiscard = msg.type === 'discard' && msg.orphaned === true;
if (orphanedDiscard && state.sessionStore && msg.id) {
try {
state.sessionStore.appendEvent({ type: 'discarded', id: msg.id, orphaned: true });
} catch { /* the discard_requested phase already left the resumable set */ }
}
// `variant_mounted` is the happy path: it is journaled above so the
// snapshot carries render truth, but there is nothing for the agent to
// do about it, so it stays out of the poll queue and off the SSE bus.
// `variant_mount_failed` is the opposite: the agent published something
// the browser could not render, and only the agent can fix it, so it
// goes to the queue as a first-class event.
if (msg.type !== 'checkpoint' && msg.type !== 'variant_mounted') {
if (msg.type !== 'checkpoint' && msg.type !== 'variant_mounted' && !orphanedDiscard) {
enqueueEvent(msg);
}
res.writeHead(200, { 'Content-Type': 'application/json' });