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