From 78f5feea1166866eb612556f56488a8b5764a62a Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 8 Aug 2026 23:28:50 +0400 Subject: [PATCH] fix(vibe): let a session survive a phone losing its connection A locked screen drops the radio, changes network or dozes, and one request fails. Both halves of an advance treated that as the session's fault. The outbox kept the failed event at its head with the comment that a later retry would pick it up, but nothing ever triggered one, so it sat there while the caller was rejected. advanceVibe then cleared the prefetched future and paused, destroying a plan that was still valid. Transient failures now hold the outbox entry unsettled and resend the same event id on a backoff, so no duplicate feedback reaches the director. The retry around the serve sits on the serve alone: serving a version is idempotent, while replaying the whole advance would report a second outcome for a track heard once. Each wait ends early when the browser says the network is back, which is the moment that matters when a screen unlocks. A drop that outlives every retry leaves the future intact to carry on from. Co-Authored-By: Claude Opus 5 --- frontend/src/services/vibeSession.test.ts | 77 +++++++++++++++++++++++ frontend/src/services/vibeSession.ts | 77 ++++++++++++++++++++++- 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/frontend/src/services/vibeSession.test.ts b/frontend/src/services/vibeSession.test.ts index 289b23e..bba4a53 100644 --- a/frontend/src/services/vibeSession.test.ts +++ b/frontend/src/services/vibeSession.test.ts @@ -254,6 +254,83 @@ describe('durable Vibe session client', () => { expect(useVibeStore.getState().currentPlanItem).toMatchObject({ track_id: 'two', ordinal: 1 }); }); + // A locked phone drops its connection for a moment. Neither half of the + // advance may treat that as the session's fault: the plan is still valid, the + // prefetched future is still worth keeping, and the listener's action must + // reach the director exactly once. + const offline = () => new AxiosError('network error', 'ERR_NETWORK'); + + /** Let the pending backoff hear the network come back, rather than waiting it out. */ + const comeBackOnline = async () => { + await Promise.resolve(); + window.dispatchEvent(new Event('online')); + }; + + it('waits out a dropped connection and sends the same event once', async () => { + start.mockResolvedValue(response(1, item('one'), [item('two')])); + next + .mockResolvedValueOnce(response(1, item('one', true), [item('two')])) + .mockResolvedValue(response(2, item('two', true), [item('three')])); + await startVibeSession(track('one')); + + // Fails, retries immediately, fails again, then waits for the network. + event + .mockRejectedValueOnce(offline()) + .mockRejectedValueOnce(offline()) + .mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false }); + + const advancing = advanceVibe('completed'); + await vi.waitFor(() => expect(event).toHaveBeenCalledTimes(2)); + await comeBackOnline(); + await advancing; + + const eventIds = event.mock.calls.map((call) => (call[1] as { eventId: string }).eventId); + expect(new Set(eventIds).size).toBe(1); + expect(usePlaybackStore.getState().currentTrack?.id).toBe('two'); + expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']); + }); + + it('retries only the serve when the connection drops after the event landed', async () => { + start.mockResolvedValue(response(1, item('one'), [item('two')])); + next + .mockResolvedValueOnce(response(1, item('one', true), [item('two')])) + .mockRejectedValueOnce(offline()) + .mockResolvedValue(response(2, item('two', true), [item('three')])); + event.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false }); + await startVibeSession(track('one')); + + const advancing = advanceVibe('completed'); + await vi.waitFor(() => expect(next).toHaveBeenCalledTimes(2)); + await comeBackOnline(); + await advancing; + + // The event is the listener's action and must not be replayed by a retry + // that only the serve needed. + expect(event).toHaveBeenCalledTimes(1); + expect(usePlaybackStore.getState().currentTrack?.id).toBe('two'); + }); + + it('keeps the prefetched future when a dropped connection outlives every retry', async () => { + start.mockResolvedValue(response(1, item('one'), [item('two')])); + next.mockResolvedValue(response(1, item('one', true), [item('two')])); + await startVibeSession(track('one')); + const future = usePlaybackStore.getState().queue.map((entry) => entry.id); + + event.mockRejectedValue(new AxiosError('bad gateway', undefined, undefined, undefined, { + data: {}, status: 502, statusText: 'Bad Gateway', headers: {}, config: {} as never, + })); + + // Cut every backoff short so the retries exhaust without real waiting. + const exhausting = advanceVibe('completed'); + const pump = setInterval(() => window.dispatchEvent(new Event('online')), 0); + await expect(exhausting).rejects.toThrow('bad gateway'); + clearInterval(pump); + + expect(useVibeStore.getState().activeSessionId).toBe('session-a'); + expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(future); + expect(usePlaybackStore.getState().isPlaying).toBe(false); + }); + it('plays the seed first and steps off it without reporting plan feedback', async () => { start.mockResolvedValue(response(1, item('one'), [item('two')])); next.mockResolvedValue(response(1, item('one', true), [item('two')])); diff --git a/frontend/src/services/vibeSession.ts b/frontend/src/services/vibeSession.ts index a8fad2b..edc4763 100644 --- a/frontend/src/services/vibeSession.ts +++ b/frontend/src/services/vibeSession.ts @@ -32,10 +32,18 @@ interface PendingEvent { input: Parameters[1]; retried: boolean; settled: boolean; + /** Consecutive failures that were the network's fault rather than the server's. */ + transientAttempts: number; resolve: (response: DurableVibeSessionResponse) => void; reject: (error: unknown) => void; } +// A phone with a locked screen drops its radio, changes network, or dozes, and +// one request fails. Waiting through it is right: the plan is still valid and +// the event id is stable, so the same event is simply sent again. Roughly two +// minutes of waiting in total before giving up on the listener's behalf. +const TRANSIENT_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000, 30_000, 30_000]; + // The event ledger deduplicates client_event_id. Keep an event in this ordered // outbox until the server acknowledges it so a transient failure never turns a // retry into a second listener action. @@ -184,6 +192,26 @@ async function serveNextPlayable( return resolvePlayableResponse(sessionId, await serveNextCurrent(sessionId, version)); } +/** + * Serving a version is idempotent, so a dropped connection here costs nothing + * but the wait. The feedback event has already been acknowledged by this point, + * which is why the retry sits around the serve alone: replaying the whole + * advance would send a second event for a track the listener heard once. + */ +async function serveNextPlayableRetrying( + sessionId: string, + version: number, +): Promise<{ response: DurableVibeSessionResponse; now: Track; preview: Track[] } | null> { + for (let attempt = 0; ; attempt++) { + try { + return await serveNextPlayable(sessionId, version); + } catch (error) { + if (!isTransientEventError(error) || attempt >= TRANSIENT_BACKOFF_MS.length - 1) throw error; + await waitBeforeRetry(attempt); + } + } +} + async function advanceResponsePastUnplayable( sessionId: string, response: DurableVibeSessionResponse, @@ -280,7 +308,7 @@ async function sendEvent( durationMs, }; return new Promise((resolve, reject) => { - eventOutbox.push({ sessionId, input, retried: false, settled: false, resolve, reject }); + eventOutbox.push({ sessionId, input, retried: false, settled: false, transientAttempts: 0, resolve, reject }); void flushEventOutbox(); }); } @@ -289,6 +317,35 @@ function retryableEventError(error: unknown): boolean { return !isSessionTerminalError(error); } +/** + * A failure the request never survived to reach an opinion about: no response + * at all (offline, timeout, DNS), or a server that is momentarily unable rather + * than refusing. These say nothing about the session, so they must not be + * allowed to discard a valid plan. + */ +function isTransientEventError(error: unknown): boolean { + if (!axios.isAxiosError(error)) return false; + if (!error.response) return true; + return error.response.status === 429 || error.response.status >= 500; +} + +/** Wait out a backoff, but come back early the moment the network returns. */ +function waitBeforeRetry(attempt: number): Promise { + const delay = TRANSIENT_BACKOFF_MS[Math.min(attempt, TRANSIENT_BACKOFF_MS.length - 1)]; + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + window.removeEventListener('online', finish); + resolve(); + }; + const timer = setTimeout(finish, delay); + window.addEventListener('online', finish); + }); +} + async function flushEventOutbox(): Promise { if (flushingOutbox) return; flushingOutbox = true; @@ -308,6 +365,15 @@ async function flushEventOutbox(): Promise { entry.retried = true; continue; } + // The network failed, not the session. Hold the entry unsettled and + // keep trying: rejecting here is what used to end the Vibe whenever a + // locked phone lost its connection for a moment. + if (isTransientEventError(error) && entry.transientAttempts < TRANSIENT_BACKOFF_MS.length) { + const attempt = entry.transientAttempts; + entry.transientAttempts += 1; + await waitBeforeRetry(attempt); + continue; + } // A session that is gone/ended can never acknowledge this event. Do // not let an irrecoverable old-session entry block a later session. if (isSessionTerminalError(error)) eventOutbox.shift(); @@ -376,7 +442,7 @@ export function advanceVibe(reason: VibeAdvanceReason): Promise { usePlaybackStore.getState().pause(); return; } - const served = await serveNextPlayable(vibe.activeSessionId, feedback.planVersion); + const served = await serveNextPlayableRetrying(vibe.activeSessionId, feedback.planVersion); if (!served || served.response.sessionId !== vibe.activeSessionId || !isCurrentVibeOwner(vibe.activeSessionId)) { // Never play an uncommitted or unresolvable plan item. The user can // retry from the page after the director publishes another revision. @@ -390,6 +456,13 @@ export function advanceVibe(reason: VibeAdvanceReason): Promise { replaceUnplayedQueue([served.now, ...served.preview]); usePlaybackStore.getState().advance(); } catch (error) { + // A network failure that outlived every retry leaves the plan valid and + // the prefetched future worth keeping, so the listener can carry on from + // the page once they are back on a connection. + if (isTransientEventError(error)) { + usePlaybackStore.getState().pause(); + throw error; + } // Clearing the future is deliberate: carrying on with stale prefetches // after a rejected feedback/replan would violate the plan boundary. replaceUnplayedQueue([]);