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([]);