Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit 6f2da6b

Browse files
authored
Merge pull request #258 from closedloop-ai/FEA-1461-sync-rate-limit-deadletter
FEA-1461: Dead-letter agent-session sync on persistent rate_limited
2 parents 388389a + c6b07c2 commit 6f2da6b

5 files changed

Lines changed: 805 additions & 118 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "desktop",
3-
"version": "0.15.103",
3+
"version": "0.15.104",
44
"description": "ClosedLoop Desktop",
55
"author": "ClosedLoop AI <support@closedloop.ai>",
66
"private": true,

apps/desktop/src/main/agent-session-sync-service.ts

Lines changed: 149 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ export const SESSION_PAYLOAD_BYTE_CAP = 262_144;
3737
// After this many consecutive ack timeouts on the same session, dead-letter it
3838
// so one oversized or slow session does not permanently block the queue.
3939
export const MAX_CONSECUTIVE_TIMEOUTS = 3;
40+
// FEA-1461: after this many consecutive `rate_limited` rejections on the same
41+
// session, dead-letter it. Higher than the timeout threshold because
42+
// rate-limits are more legitimately transient (the relay may genuinely just
43+
// be throttling a burst), but still bounded so a persistently-rejected
44+
// session cannot infinite-loop the sync queue + log spam.
45+
export const MAX_CONSECUTIVE_RATE_LIMITED = 5;
46+
// FEA-1461: after a `rate_limited` rejection, defer re-attempting the same
47+
// session for this long. Prevents the 5-second sync tick from re-chunking and
48+
// re-sending the same oversized session every cycle (the original symptom).
49+
// Other queued sessions continue to flow through `pickReadyCandidates`.
50+
export const RATE_LIMIT_BACKOFF_MS = 30_000;
4051

4152
export function estimateSessionPayloadBytes(session: SyncedAgentSession): number {
4253
return Buffer.byteLength(JSON.stringify(session));
@@ -146,7 +157,18 @@ export class AgentSessionSyncService {
146157
};
147158
/** Consecutive timeout count per session ID for dead-letter detection. */
148159
private readonly timeoutCountById = new Map<string, number>();
149-
/** Session IDs removed from the queue after exceeding MAX_CONSECUTIVE_TIMEOUTS. */
160+
/**
161+
* FEA-1461: consecutive `rate_limited` count per session ID. Parallel to
162+
* `timeoutCountById` — kept separate so the existing timeout dead-letter
163+
* threshold and the new rate-limit threshold do not contaminate each other.
164+
*/
165+
private readonly rateLimitedCountById = new Map<string, number>();
166+
/**
167+
* FEA-1461: per-session deferred-retry deadline (ms since epoch). While the
168+
* deadline is in the future, `pickReadyCandidates` skips the session.
169+
*/
170+
private readonly nextRetryAfterMs = new Map<string, number>();
171+
/** Session IDs removed from the queue after exceeding MAX_CONSECUTIVE_TIMEOUTS or MAX_CONSECUTIVE_RATE_LIMITED. */
150172
private readonly deadLetteredIds = new Set<string>();
151173
/** Remaining chunks for an oversized session being sent in parts. */
152174
private pendingChunks: {
@@ -270,23 +292,41 @@ export class AgentSessionSyncService {
270292

271293
const nowMs = Date.now();
272294
let candidateIds: string[] = [];
295+
// FEA-1461: try incremental first. `pickReadyCandidates` may filter
296+
// out every queued session (all in rate-limit backoff). When that
297+
// happens, fall through to backfill so it does not get starved
298+
// for the whole backoff window — previously the early-return
299+
// below would skip backfill entirely on every tick.
273300
if (
274301
this.incrementalQueue.length > 0 &&
275302
nowMs - this.lastIncrementalBatchAttemptedAtMs >=
276303
MIN_INCREMENTAL_SYNC_INTERVAL_MS
277304
) {
278-
syncMode = "incremental";
279-
candidateIds = this.incrementalQueue.slice(
280-
0,
305+
candidateIds = this.pickReadyCandidates(
306+
this.incrementalQueue,
281307
INCREMENTAL_SESSION_BATCH_SIZE,
308+
nowMs,
282309
);
283-
this.lastIncrementalBatchAttemptedAtMs = nowMs;
284-
} else if (this.backfillQueue.length > 0) {
285-
syncMode = "backfill";
286-
candidateIds = this.backfillQueue.slice(
287-
0,
310+
if (candidateIds.length > 0) {
311+
syncMode = "incremental";
312+
// Only stamp the throttle timestamp if we actually selected
313+
// at least one ready candidate. Stamping when every candidate
314+
// was filtered by backoff would unnecessarily delay a session
315+
// added to the queue moments later by the full
316+
// MIN_INCREMENTAL_SYNC_INTERVAL_MS window.
317+
this.lastIncrementalBatchAttemptedAtMs = nowMs;
318+
}
319+
}
320+
if (candidateIds.length === 0 && this.backfillQueue.length > 0) {
321+
const backfillCandidates = this.pickReadyCandidates(
322+
this.backfillQueue,
288323
BACKFILL_SESSION_BATCH_SIZE,
324+
nowMs,
289325
);
326+
if (backfillCandidates.length > 0) {
327+
syncMode = "backfill";
328+
candidateIds = backfillCandidates;
329+
}
290330
}
291331

292332
if (!syncMode || candidateIds.length === 0) {
@@ -474,6 +514,31 @@ export class AgentSessionSyncService {
474514
this.observedIdsAtTopUpdatedAt = nextTopIds;
475515
}
476516

517+
/**
518+
* FEA-1461: pick up to `limit` session IDs from `queue`, skipping any whose
519+
* deferred-retry deadline (set by a prior `rate_limited` failure) is still
520+
* in the future. Order is preserved for selected IDs so the queue remains
521+
* stable; only the backed-off entries are skipped, not reordered.
522+
*/
523+
private pickReadyCandidates(
524+
queue: readonly string[],
525+
limit: number,
526+
nowMs: number,
527+
): string[] {
528+
const result: string[] = [];
529+
for (const id of queue) {
530+
const deadline = this.nextRetryAfterMs.get(id);
531+
if (deadline !== undefined && deadline > nowMs) {
532+
continue;
533+
}
534+
result.push(id);
535+
if (result.length >= limit) {
536+
break;
537+
}
538+
}
539+
return result;
540+
}
541+
477542
private handleBatchAck(
478543
syncMode: AgentSessionSyncMode,
479544
ids: string[],
@@ -490,6 +555,11 @@ export class AgentSessionSyncService {
490555
if (!hasMoreChunks) {
491556
for (const id of ids) {
492557
this.timeoutCountById.delete(id);
558+
// FEA-1461: a successful ack resets the rate-limit counter and
559+
// clears any deferred-retry deadline for this session, so a future
560+
// rate-limited rejection starts the count over at 1.
561+
this.rateLimitedCountById.delete(id);
562+
this.nextRetryAfterMs.delete(id);
493563
}
494564
this.dequeue(syncMode, ids);
495565
}
@@ -508,6 +578,15 @@ export class AgentSessionSyncService {
508578

509579
// On any failure, discard remaining chunks for this session — partial
510580
// chunk sequences are not useful without server-side reassembly.
581+
//
582+
// FEA-1461: the next retry will re-fetch + re-chunk the source session
583+
// from SQLite. That re-chunk work is bounded for transient failures
584+
// (rate_limited) by the per-session backoff added below — the same
585+
// session is not re-attempted within RATE_LIMIT_BACKOFF_MS — and by the
586+
// MAX_CONSECUTIVE_RATE_LIMITED dead-letter trip. True resume-from-chunk-N
587+
// would eliminate the re-chunk work entirely but requires server-side
588+
// partial-payload reassembly that does not exist today; tracked as out
589+
// of scope on FEA-1461.
511590
if (this.pendingChunks && ids.includes(this.pendingChunks.sessionId)) {
512591
gatewayLog.warn(
513592
TAG,
@@ -536,6 +615,10 @@ export class AgentSessionSyncService {
536615
if (count >= MAX_CONSECUTIVE_TIMEOUTS) {
537616
deadLettered.push(id);
538617
this.timeoutCountById.delete(id);
618+
// FEA-1461: also clear any orphaned rate-limit state for this
619+
// session so a dead-lettered id leaves no Map entries behind.
620+
this.rateLimitedCountById.delete(id);
621+
this.nextRetryAfterMs.delete(id);
539622
this.deadLetteredIds.add(id);
540623
} else {
541624
this.timeoutCountById.set(id, count);
@@ -558,6 +641,63 @@ export class AgentSessionSyncService {
558641
`(attempt ${attempt}/${MAX_CONSECUTIVE_TIMEOUTS}); batch left queued for retry`,
559642
);
560643
}
644+
} else if (ack.reason === DesktopAgentSessionsAckReason.RateLimited) {
645+
// FEA-1461: previously fell through to the bare `else` below — debug
646+
// log only, no counter, no dead-letter, no dequeue, no backoff. For an
647+
// oversized session that's permanently throttled, that produced an
648+
// infinite retry loop (re-chunking + log spam every 5s).
649+
//
650+
// FEA-1461 review fix (PR #258, Codex P1): `cloud-socket.sendAgentSessions`
651+
// returns `RateLimited` for BOTH server-side payload throttling AND
652+
// local transport unavailability (`!isRelayReady()` or socket
653+
// disconnected after the batch was prepared). Treating a relay flap
654+
// as a session-payload problem would dead-letter perfectly good
655+
// sessions after 5 disconnects. Re-check relay readiness here: if the
656+
// relay is down right now, the ack came from the transport layer —
657+
// defer with backoff but do NOT increment the dead-letter counter.
658+
const relayHealthy = this.options.isRelayReady();
659+
const deadLettered: string[] = [];
660+
const deferred: string[] = [];
661+
const retryDeadline = Date.now() + RATE_LIMIT_BACKOFF_MS;
662+
for (const id of ids) {
663+
const previousCount = this.rateLimitedCountById.get(id) ?? 0;
664+
const count = relayHealthy ? previousCount + 1 : previousCount;
665+
if (relayHealthy && count >= MAX_CONSECUTIVE_RATE_LIMITED) {
666+
deadLettered.push(id);
667+
this.rateLimitedCountById.delete(id);
668+
this.nextRetryAfterMs.delete(id);
669+
// FEA-1461: also clear any orphaned timeout state for this
670+
// session so a dead-lettered id leaves no Map entries behind.
671+
this.timeoutCountById.delete(id);
672+
this.deadLetteredIds.add(id);
673+
} else {
674+
if (relayHealthy) {
675+
this.rateLimitedCountById.set(id, count);
676+
}
677+
this.nextRetryAfterMs.set(id, retryDeadline);
678+
deferred.push(id);
679+
}
680+
}
681+
if (deadLettered.length > 0) {
682+
this.dequeue(syncMode, deadLettered);
683+
gatewayLog.warn(
684+
TAG,
685+
`dead-lettered ${deadLettered.length} agent session(s) after ${MAX_CONSECUTIVE_RATE_LIMITED} consecutive rate_limited rejections ` +
686+
`(payload ~${formatBytes(payloadBytes)}); ids: ${deadLettered.join(", ")}; ` +
687+
`remaining incremental=${this.incrementalQueue.length} backfill=${this.backfillQueue.length} deadLettered=${this.deadLetteredIds.size}`,
688+
);
689+
}
690+
if (deferred.length > 0) {
691+
const sampleId = deferred[0];
692+
const attempt = this.rateLimitedCountById.get(sampleId) ?? 0;
693+
gatewayLog.info(
694+
TAG,
695+
`agent-session batch (${syncMode}, ~${formatBytes(payloadBytes)}) rate_limited ` +
696+
`(${relayHealthy ? "server payload throttle" : "transport unavailable"}); ` +
697+
`deferring ${deferred.length} session(s) for ${Math.round(RATE_LIMIT_BACKOFF_MS / 1000)}s ` +
698+
`(attempt ${attempt}/${MAX_CONSECUTIVE_RATE_LIMITED}); batch left queued for retry`,
699+
);
700+
}
561701
} else {
562702
gatewayLog.debug(
563703
TAG,

0 commit comments

Comments
 (0)