fix(queue): back off failed queued auto-sends

This commit is contained in:
Bohdan Triapitsyn
2026-07-12 01:12:58 +03:00
parent 72aaa166f7
commit 82c039117a
2 changed files with 55 additions and 0 deletions
@@ -29,6 +29,8 @@ mock.module('@/sync/session-ui-store', () => ({
import {
buildQueuedAutoSendPayload,
getQueuedAutoSendRetryDelayMs,
isQueuedAutoSendBackedOff,
sendQueuedAutoSendPayload,
shouldDispatchQueuedAutoSend,
} from './useQueuedMessageAutoSend';
@@ -49,6 +51,25 @@ describe('shouldDispatchQueuedAutoSend', () => {
});
});
describe('queued auto-send retry backoff', () => {
test('delay grows exponentially and is capped', () => {
expect(getQueuedAutoSendRetryDelayMs(1)).toBe(2000);
expect(getQueuedAutoSendRetryDelayMs(2)).toBe(4000);
expect(getQueuedAutoSendRetryDelayMs(3)).toBe(8000);
expect(getQueuedAutoSendRetryDelayMs(10)).toBe(60000);
expect(getQueuedAutoSendRetryDelayMs(100)).toBe(60000);
});
test('backs off only the failed message within its window', () => {
const failure = { messageId: 'queued-1', failures: 1, nextAttemptAt: 10_000 };
expect(isQueuedAutoSendBackedOff(failure, 'queued-1', 9_999)).toBe(true);
expect(isQueuedAutoSendBackedOff(failure, 'queued-1', 10_000)).toBe(false);
expect(isQueuedAutoSendBackedOff(failure, 'queued-2', 9_999)).toBe(false);
expect(isQueuedAutoSendBackedOff(undefined, 'queued-1', 0)).toBe(false);
});
});
describe('buildQueuedAutoSendPayload', () => {
beforeEach(() => {
visibleAgents = [];
@@ -13,6 +13,24 @@ type SessionStatusType = 'idle' | 'busy' | 'retry';
const RECENT_ABORT_WINDOW_MS = 2000;
const AUTO_SEND_RETRY_BASE_DELAY_MS = 2000;
const AUTO_SEND_RETRY_MAX_DELAY_MS = 60000;
export type QueuedAutoSendFailure = {
messageId: string;
failures: number;
nextAttemptAt: number;
};
export const getQueuedAutoSendRetryDelayMs = (failures: number): number =>
Math.min(AUTO_SEND_RETRY_BASE_DELAY_MS * 2 ** Math.max(failures - 1, 0), AUTO_SEND_RETRY_MAX_DELAY_MS);
export const isQueuedAutoSendBackedOff = (
failure: QueuedAutoSendFailure | undefined,
messageId: string,
now: number,
): boolean => failure !== undefined && failure.messageId === messageId && now < failure.nextAttemptAt;
const hasRecentAbort = (sessionId: string): boolean => {
const abortRecord = useSessionUIStore.getState().sessionAbortFlags.get(sessionId);
if (!abortRecord) {
@@ -124,6 +142,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
const sessionStatusRecord = useDirectorySync((state) => state.session_status);
const inFlightSessionsRef = React.useRef<Set<string>>(new Set());
const sendFailuresRef = React.useRef<Map<string, QueuedAutoSendFailure>>(new Map());
const previousStatusRef = React.useRef<Map<string, SessionStatusType>>(new Map());
const autoReviewBlockedSessionsRef = React.useRef<Set<string>>(new Set());
@@ -157,6 +176,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
return;
}
const failure = sendFailuresRef.current.get(sessionId);
if (failure && failure.messageId !== payload.queuedMessageId) {
sendFailuresRef.current.delete(sessionId);
} else if (isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) {
return;
}
// Use send config captured at queue time; fall back to current config
const captured = payload.sendConfig;
const resolved = captured?.providerID && captured?.modelID
@@ -176,8 +202,16 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
variant: resolved.variant,
});
useMessageQueueStore.getState().removeFromQueue(sessionId, payload.queuedMessageId);
sendFailuresRef.current.delete(sessionId);
} catch (error) {
console.warn('[queue] queued auto-send failed:', error);
const priorFailures = failure?.messageId === payload.queuedMessageId ? failure.failures : 0;
const failures = priorFailures + 1;
sendFailuresRef.current.set(sessionId, {
messageId: payload.queuedMessageId,
failures,
nextAttemptAt: Date.now() + getQueuedAutoSendRetryDelayMs(failures),
});
} finally {
inFlightSessionsRef.current.delete(sessionId);
}