fix: treat lost relay sends as ambiguous instead of failed

A prompt whose response is lost after the request left the client may
already be running server-side. The relay tunnel reported those failures
as plain text errors ("stream aborted by host", "relay keepalive
timeout"), which matched none of the patterns in isAmbiguousSendFailure,
so an accepted prompt was rolled back and the message queue re-sent it —
two independent AI responses for one user message (#2425). Direct
connections never hit the path.

Transports now tag dispatched-but-unconfirmed failures and the classifier
reads the tag before falling back to status/text heuristics. Confirmation
waits for the connection to actually return (bounded) and retries with
backoff instead of two attempts 150ms apart over the just-broken tunnel.
This commit is contained in:
Bohdan Triapitsyn
2026-08-03 23:09:43 +03:00
parent e4fddabb19
commit fe38f7a56b
7 changed files with 161 additions and 9 deletions
+27 -3
View File
@@ -29,11 +29,21 @@ import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/l
import { getImperativeSessionMessageLoader } from "./session-message-loader"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error"
const MESSAGE_REFETCH_LIMIT = 100
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
const SEND_CONFIRMATION_REFETCH_ATTEMPTS = 2
const SEND_CONFIRMATION_REFETCH_RETRY_MS = 150
// A relay-tunnel send fails when the tunnel drops, and the confirming refetch
// then has to travel over that same tunnel to answer "did my message land?".
// Two attempts 150ms apart always answered "no" on a remote connection, so an
// accepted prompt looked like a failed one and got re-sent — two AI responses
// for one user message. Wait for the connection to actually come back (an
// authoritative signal, not a blind sleep), then retry with backoff. A healthy
// connection skips the wait and answers on the first attempt.
const SEND_CONFIRMATION_REFETCH_ATTEMPTS = 3
const SEND_CONFIRMATION_REFETCH_BASE_RETRY_MS = 250
const SEND_CONFIRMATION_RECONNECT_TIMEOUT_MS = 3000
const SEND_CONFIRMATION_RECONNECT_POLL_MS = 100
const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const UNREVERT_REFETCH_ATTEMPTS = 3
const UNREVERT_REFETCH_RETRY_MS = 150
@@ -360,6 +370,13 @@ function getErrorStatus(error: unknown): number | null {
}
function isAmbiguousSendFailure(error: unknown): boolean {
// Authoritative first: the transport that lost the request says whether it
// had already been dispatched. The text matching below only covers direct
// fetch/HTTP failures, whose wording we do not control either — relay tunnel
// aborts ("stream aborted by host", "relay keepalive timeout", …) match none
// of those patterns and used to be misread as definite failures.
if (isAmbiguousTransportFailure(error)) return true
const status = getErrorStatus(error)
if (status === 503 || status === 504 || status === 408) return true
if (error instanceof TypeError) return true
@@ -1255,8 +1272,15 @@ async function fetchRecentSendConfirmationRecords(
messageID: string,
directory?: string | null,
): Promise<Array<{ info: Message; parts?: Part[] }> | null> {
// Bounded: a connection that never returns must still let the send fail
// rather than hang the composer.
const reconnectDeadline = Date.now() + SEND_CONFIRMATION_RECONNECT_TIMEOUT_MS
while (!useConfigStore.getState().isConnected && Date.now() < reconnectDeadline) {
await wait(SEND_CONFIRMATION_RECONNECT_POLL_MS)
}
for (let attempt = 0; attempt < SEND_CONFIRMATION_REFETCH_ATTEMPTS; attempt += 1) {
if (attempt > 0) await wait(SEND_CONFIRMATION_REFETCH_RETRY_MS)
if (attempt > 0) await wait(SEND_CONFIRMATION_REFETCH_BASE_RETRY_MS * 2 ** (attempt - 1))
try {
const result = await sdk().session.messages({
sessionID: sessionId,