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 <noreply@anthropic.com>
This commit is contained in:
kami
2026-08-08 23:28:50 +04:00
parent 93c737ee49
commit 78f5feea11
2 changed files with 152 additions and 2 deletions
+77
View File
@@ -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')]));
+75 -2
View File
@@ -32,10 +32,18 @@ interface PendingEvent {
input: Parameters<typeof vibeService.event>[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<DurableVibeSessionResponse>((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<void> {
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<void> {
if (flushingOutbox) return;
flushingOutbox = true;
@@ -308,6 +365,15 @@ async function flushEventOutbox(): Promise<void> {
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<void> {
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<void> {
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([]);