@@ -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.
3939export 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
4152export 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