1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
|
/**
* In-memory per-provider concurrency limiter.
*
* Tracks and limits how many concurrent API requests (token-generating
* requests) are in flight per provider. When the limit is reached, additional
* requests queue and are granted slots based on oldest-agent-first priority
* (the agent whose current prompt started the longest ago wins the next slot).
*
* A watchdog reclaims slots held beyond a timeout (deadlock / stuck-agent
* recovery). 429 backoff pauses a provider's queue for a configurable duration
* AND adaptively reduces the effective limit by 1 (one-way, persisted) so the
* resumed queue runs with headroom instead of re-overshooting.
*
* ── Usage gate (anti-overshoot) ──
* When a `fetchUsage` callback is injected, before admitting a QUEUED agent the
* manager polls the provider's upstream `concurrent_sessions` count and grants
* only when it is below the configured limit. This composes with the release
* cooldown: release → cooldown delay → usage-gate poll → grant (only if upstream
* has room). A waiter is re-checked on two triggers (either one): another agent
* releases a slot (immediate re-poll, restarting the 1s countdown) or a 1s
* fallback timer elapses (in case the upstream count drops on its own). Each
* successful poll admits at most ONE queued waiter (each admission pushes the
* upstream count back toward the limit), so additional waiters are admitted on
* subsequent repolls. When `fetchUsage` is absent or returns `undefined`, the
* gate is skipped and the manager falls back to cooldown-only recycling.
*
* This module is the PURE decision logic. It takes an injected clock (`now`),
* injected timers (`setTimeout`/`clearTimeout`/`setInterval`/`clearInterval`),
* and an injected usage-poll effect (`fetchUsage`) so it is fully testable with
* deterministic fake time + a fake fetcher. The extension layer wires real
* timers + the host's provider registry.
*/
import type { ProviderUsage } from "@dispatch/kernel";
// ─── Types ───────────────────────────────────────────────────────────────────
/** Status snapshot for a single provider's concurrency state. */
export interface ProviderConcurrencyStatus {
readonly providerId: string;
/** Configured concurrency limit. Always present (status is only returned for providers with a limit). */
readonly limit: number;
/** Currently in-flight (held) slots. */
readonly inFlight: number;
/** Agents waiting in the queue for a slot. */
readonly queued: number;
/** Whether the queue is paused (429 backoff). */
readonly paused: boolean;
/** When the pause expires (epoch-ms). Present only when paused. */
readonly pausedUntil?: number;
/**
* Per-slot release cooldown (ms) — how long a recycled slot is held before the
* next waiter is admitted. Covers the upstream provider's accounting lag.
* Configurable + persisted per provider.
*/
readonly cooldownMs: number;
/**
* Whether the limit was auto-reduced by a 429 (adaptive headroom). The user
* restores the limit manually (PUT /concurrency/limits/:providerId) which
* clears this flag. The frontend renders a visible notice when `true`.
*/
readonly autoReduced: boolean;
/** The original limit before auto-reduction (present only when autoReduced). */
readonly autoReducedFrom?: number;
/**
* A human-readable notice string for the frontend to render as a banner when
* the limit was auto-reduced. Present only when `autoReduced` is true.
*/
readonly notice?: string;
}
/**
* The limiter surface a consumer (session-orchestrator) needs: acquire a
* slot before a provider stream starts, release it when the stream completes,
* and report rate-limit (429) events so the manager can back off.
*/
export interface ConcurrencyLimiter {
/**
* Acquire a concurrency slot for `providerId`. Resolves immediately when a
* slot is available; otherwise blocks (queued by starred-workspace-first,
* then oldest-agent-first) until one frees up. The returned function MUST be
* called when the response stream completes (in a `finally` block). For
* providers with no configured limit, resolves instantly with a no-op
* release.
*
* **Priority:** agents from **starred workspaces** are always admitted before
* agents from non-starred workspaces (regardless of `promptStartedAt`).
* Within each group (starred vs non-starred), oldest-agent-first ordering is
* preserved. The starred status is looked up via the injected
* `isWorkspaceStarred` callback at sort time, so starring a workspace while
* agents are queued takes effect on the next sort (new acquire or slot
* release).
*
* If `onQueued` is provided and the request cannot be granted immediately
* (at limit or paused), it is called synchronously BEFORE the Promise is
* created. This lets the caller emit a "queued" status signal. If the slot
* is granted immediately, `onQueued` is NOT called.
*
* @param providerId The provider to limit (e.g. "umans", "openai-compat").
* @param conversationId The agent requesting the slot.
* @param workspaceId The workspace the agent belongs to (for starred
* priority scheduling). Defaults to `"default"`.
* @param promptStartedAt When the agent's current prompt (turn) started
* (epoch-ms). Used for oldest-agent-first scheduling
* within each starred group.
* @param onQueued Called synchronously when the request is enqueued
* (not granted immediately). Optional.
*/
acquire(
providerId: string,
conversationId: string,
workspaceId: string,
promptStartedAt: number,
onQueued?: () => void,
): Promise<() => void>;
/**
* Report a 429 from a provider. Pauses the queue for that provider for
* `retryAfterMs` (or a default duration when omitted), AND reduces the
* provider's effective limit by 1 (one-way, down to a minimum of 1) so the
* resumed queue runs with headroom. Queued and in-flight requests are
* otherwise unaffected; new `acquire` calls block until the pause expires.
*/
reportRateLimit(providerId: string, retryAfterMs?: number): void;
}
/**
* The full service surface (limiter + config + status) for HTTP routes.
*/
export interface ConcurrencyService extends ConcurrencyLimiter {
/** Set the concurrency limit for a provider (MANUAL — clears the auto-reduce notice). Creates the state if new. */
setLimit(providerId: string, limit: number): void;
/**
* Restore a persisted limit on startup WITHOUT clearing the auto-reduce
* notice (Bug 3). Unlike {@link setLimit} (a manual user action that signals
* "the user took control"), this seeds state from disk: it applies the limit
* and, when `autoReducedFrom` is provided, re-marks the state as auto-reduced
* so the frontend banner survives a restart. Used by the extension's
* `loadLimits`/`loadAutoReduce` on activate.
*/
restoreLimit(providerId: string, limit: number, autoReducedFrom?: number): void;
/** Get the configured limit, or `undefined` when none. */
getLimit(providerId: string): number | undefined;
/** Remove the limit for a provider (makes it unlimited). */
removeLimit(providerId: string): void;
/** All configured limits as `{ providerId, limit }` entries. */
getLimits(): readonly { providerId: string; limit: number }[];
/**
* Set the release cooldown (ms) for a provider. Applied to subsequently
* recycled slots; in-flight cooldown timers keep their original duration.
* Creates the state if new (with no limit — unlimited but cooldown-gated).
*/
setCooldown(providerId: string, cooldownMs: number): void;
/** Get the configured cooldown (ms), or `undefined` when none was set. */
getCooldown(providerId: string): number | undefined;
/** All configured cooldowns as `{ providerId, cooldownMs }` entries. */
getCooldowns(): readonly { providerId: string; cooldownMs: number }[];
/** Status for one provider, or `undefined` when no limit is configured. */
getStatus(providerId: string): ProviderConcurrencyStatus | undefined;
/** Status for every provider with a configured limit. */
getStatusAll(): readonly ProviderConcurrencyStatus[];
/**
* Notify the limiter that a workspace's starred state changed. Updates the
* in-memory starred cache so subsequent queue sorts re-evaluate priority
* (a newly-starred workspace's already-queued agents jump ahead). Called by
* the transport layer after persisting the starred toggle.
*/
notifyWorkspaceStarred(workspaceId: string, starred: boolean): void;
/** Stop the watchdog + clear all timers. */
destroy(): void;
}
// ─── Internal state ───────────────────────────────────────────────────────────
interface Slot {
readonly conversationId: string;
readonly acquiredAt: number;
/** Idempotent release — safe to call from the holder or the watchdog. */
readonly releaseFn: () => void;
}
interface QueuedWaiter {
readonly conversationId: string;
readonly workspaceId: string;
readonly promptStartedAt: number;
readonly resolve: (release: () => void) => void;
}
interface ProviderState {
limit: number;
inFlight: number;
slots: Map<number, Slot>;
queue: QueuedWaiter[];
paused: boolean;
pausedUntil: number | undefined;
pauseTimer: ReturnType<typeof setTimeout> | undefined;
/** Per-provider release cooldown (ms). Defaults to the manager opt; settable at runtime. */
cooldownMs: number;
// ── Adaptive headroom ──
autoReduced: boolean;
autoReducedFrom: number | undefined;
notice: string | undefined;
// ── Usage-gate state ──
/** A usage poll is in flight for this provider (prevents overlapping polls). */
gatePolling: boolean;
/** Another repoll trigger fired while a poll was in flight → re-poll on completion. */
gateRepollRequested: boolean;
/** The 1s fallback repoll timer (re-checked periodically even without releases). */
gateRepollTimer: ReturnType<typeof setTimeout> | undefined;
}
export interface ConcurrencyManagerOpts {
/** Monotonic-ish clock (epoch-ms). */
readonly now: () => number;
/** Max time a slot may be held before the watchdog reclaims it (ms). */
readonly slotTimeoutMs: number;
/** How often the watchdog sweeps (ms). */
readonly watchdogIntervalMs: number;
/** Default pause duration when a 429 arrives without Retry-After (ms). */
readonly defaultPauseMs: number;
/**
* Default delay after a slot is released before the slot is recycled (ms).
* During this window `inFlight` stays incremented — a new `acquire` sees the
* slot as still held and queues. This covers the upstream provider's
* accounting lag: the provider's `concurrent_sessions` counter may not
* decrement the instant our stream completes, so re-admitting immediately
* risks an N+1 overshoot. 0 = instant re-admission (no cooldown). Default: 0.
* Per-provider overrides via `setCooldown`.
*/
readonly releaseCooldownMs?: number;
/**
* Injected usage-poll effect. When present, before admitting a QUEUED agent
* the manager calls this and grants only when `concurrentSessions` is below
* the configured limit (usage gate). When absent, the manager falls back to
* cooldown-only slot recycling. Injected (like `now`/`setTimeout`) so the
* manager stays unit-testable with a fake fetcher; never hardcodes `fetch`.
*/
readonly fetchUsage?: (providerId: string) => Promise<ProviderUsage | undefined>;
/** Injected timers (default: global). Override in tests for deterministic time. */
readonly setTimeout?: typeof setTimeout;
readonly clearTimeout?: typeof clearTimeout;
readonly setInterval?: typeof setInterval;
readonly clearInterval?: typeof clearInterval;
/** Optional logger for watchdog + pause + auto-reduce events. */
readonly onWatchdogReclaim?: (providerId: string, conversationId: string, heldMs: number) => void;
readonly onPause?: (providerId: string, durationMs: number) => void;
/** Fired when a 429 adaptively reduces a provider's limit (for persistence + logging). */
readonly onLimitReduced?: (providerId: string, newLimit: number, oldLimit: number) => void;
/**
* Fired when the injected `fetchUsage` throws (network/parse failure beyond the
* graceful-undefined path). The manager treats a thrown poll as "no usage info"
* (cooldown-only fallback) — this callback is for WARN-level logging only. The
* poll never becomes an unhandled rejection.
*/
readonly onUsagePollError?: (providerId: string, err: unknown) => void;
/**
* Injected callback: returns whether a workspace is starred (for priority
* scheduling). When provided, agents from starred workspaces jump ahead of
* non-starred agents in the queue. When omitted (or returns `false`), all
* agents are treated as non-starred (backward-compatible). This is an I/O
* effect injected so the manager stays pure + unit-testable with a fake.
*/
readonly isWorkspaceStarred?: (workspaceId: string) => boolean;
}
/** Min interval between usage-gate fallback repolls (ms). The release trigger is immediate. */
const USAGE_REPOLL_INTERVAL_MS = 1000;
/** Minimum the limit may be auto-reduced to (never 0). */
const MIN_LIMIT = 1;
function noopRelease(): void {
// No limit configured → nothing to release.
}
export function createConcurrencyManager(opts: ConcurrencyManagerOpts): ConcurrencyService {
const now = opts.now;
const slotTimeoutMs = opts.slotTimeoutMs;
const defaultPauseMs = opts.defaultPauseMs;
const defaultCooldownMs = opts.releaseCooldownMs ?? 0;
const fetchUsage = opts.fetchUsage;
const setTimeout = opts.setTimeout ?? globalThis.setTimeout.bind(globalThis);
const clearTimeout = opts.clearTimeout ?? globalThis.clearTimeout.bind(globalThis);
const setInterval = opts.setInterval ?? globalThis.setInterval.bind(globalThis);
const clearInterval = opts.clearInterval ?? globalThis.clearInterval.bind(globalThis);
// In-memory cache of starred workspace IDs. Populated by the extension on
// activation (from the conversation store) + updated via
// `notifyWorkspaceStarred`. The `isWorkspaceStarred` callback reads this
// synchronously so the queue sort comparator (sync) can re-evaluate priority
// on every sort — a newly-starred workspace's already-queued agents jump
// ahead on the next sort (new acquire or slot release).
const starredWorkspaces = new Set<string>();
const isWorkspaceStarred =
opts.isWorkspaceStarred ?? ((wsId: string) => starredWorkspaces.has(wsId));
const states = new Map<string, ProviderState>();
const cooldownOverrides = new Map<string, number>();
const cooldownTimers = new Set<ReturnType<typeof setTimeout>>();
let slotIdCounter = 0;
function makeState(limit: number, cooldownMs: number): ProviderState {
return {
limit,
inFlight: 0,
slots: new Map(),
queue: [],
paused: false,
pausedUntil: undefined,
pauseTimer: undefined,
cooldownMs,
autoReduced: false,
autoReducedFrom: undefined,
notice: undefined,
gatePolling: false,
gateRepollRequested: false,
gateRepollTimer: undefined,
};
}
/** Seed the cooldown for new state from any pending override (else the default). */
function seedCooldown(providerId: string): number {
return cooldownOverrides.get(providerId) ?? defaultCooldownMs;
}
// ── Slot granting ──────────────────────────────────────────────────────────
function grantSlot(state: ProviderState, providerId: string, conversationId: string): () => void {
const id = slotIdCounter++;
let released = false;
const releaseFn = () => {
if (released) return;
released = true;
state.slots.delete(id);
// Recycle the slot: free its inFlight count + attempt to grant the next
// waiter. With a release cooldown > 0, defer this by the cooldown duration
// so the upstream provider has time to decrement its concurrent_sessions
// counter — preventing an N+1 overshoot from accounting lag. During the
// cooldown, inFlight stays incremented, so new acquires queue.
const recycle = () => {
if (fetchUsage === undefined || state.queue.length === 0) {
// No usage gate, OR no one waiting (the lag window is irrelevant when
// there is no waiter to admit) → free the slot immediately. With no
// gate, also drain the queue (grant all that fit).
state.inFlight--;
if (fetchUsage === undefined) grantLoop(state, providerId);
return;
}
// Usage gate configured + a waiter exists → hold inFlight inflated
// DURING the poll window (gatePolling is set synchronously inside
// pollAndGrant, the inFlight decrement is deferred until the poll
// resolves). This closes the overshoot gap: a concurrent acquire arriving
// between the cooldown firing and the poll resolving sees the slot as
// still occupied (inFlight >= limit) and queues instead of fast-pathing.
// pollAndGrant(decrementOnPoll=true) decrements inFlight after observing
// the post-release upstream state, then admits one waiter if there is room.
void pollAndGrant(providerId, state, true);
};
if (state.cooldownMs > 0) {
const timer = setTimeout(() => {
cooldownTimers.delete(timer);
recycle();
}, state.cooldownMs);
cooldownTimers.add(timer);
} else {
recycle();
}
};
state.slots.set(id, {
conversationId,
acquiredAt: now(),
releaseFn,
});
state.inFlight++;
return releaseFn;
}
/**
* Priority comparator for queued waiters: starred-workspace agents first,
* then oldest-agent-first (ascending `promptStartedAt`) within each group.
* Called at sort time (both on insert and before granting) so a workspace
* starred AFTER an agent queued is re-evaluated on the next sort.
*/
function compareWaiters(a: QueuedWaiter, b: QueuedWaiter): number {
const aStarred = isWorkspaceStarred(a.workspaceId);
const bStarred = isWorkspaceStarred(b.workspaceId);
if (aStarred !== bStarred) return aStarred ? -1 : 1; // starred first
return a.promptStartedAt - b.promptStartedAt; // oldest first within group
}
/**
* Grant queued waiters WITHOUT the usage gate (the fast path used when no
* `fetchUsage` is configured, or as the cooldown-only fallback when a poll
* returns no usage info). Grants while there is internal room
* (`inFlight < limit`). Synchronous. Re-sorts with {@link compareWaiters}
* (starred-workspace-first, then oldest-agent-first) before granting so a
* workspace starred AFTER an agent queued is re-evaluated.
*/
function grantLoop(state: ProviderState, providerId: string): void {
// Re-sort before granting: a workspace may have been starred/unstarred
// since the waiters were enqueued, so priority may have changed.
state.queue.sort(compareWaiters);
while (state.queue.length > 0 && state.inFlight < state.limit) {
const waiter = state.queue[0];
if (waiter === undefined) break;
state.queue.shift();
const releaseFn = grantSlot(state, providerId, waiter.conversationId);
waiter.resolve(releaseFn);
}
// If the queue drained, no need to keep the usage-gate fallback timer armed.
if (state.queue.length === 0 && state.gateRepollTimer !== undefined) {
clearTimeout(state.gateRepollTimer);
state.gateRepollTimer = undefined;
}
}
/**
* Admit exactly ONE queued waiter (the front of the queue), if there is
* internal room. Used by the usage-gated path so each admission is confirmed
* by a FRESH upstream poll — admitting multiple from a single (possibly stale)
* poll risks an N+1 overshoot when the upstream count lags. Additional waiters
* are admitted on subsequent repolls.
*/
function grantOne(state: ProviderState, providerId: string): void {
if (state.queue.length === 0) return;
if (state.inFlight >= state.limit) return;
// Re-sort before picking the front: starred-workspace agents must be
// admitted first, even on the usage-gated path (a workspace may have been
// starred since the waiters were enqueued).
state.queue.sort(compareWaiters);
const waiter = state.queue[0];
if (waiter === undefined) return;
state.queue.shift();
const releaseFn = grantSlot(state, providerId, waiter.conversationId);
waiter.resolve(releaseFn);
// If the queue drained, disarm the fallback timer.
if (state.queue.length === 0 && state.gateRepollTimer !== undefined) {
clearTimeout(state.gateRepollTimer);
state.gateRepollTimer = undefined;
}
}
/**
* Invoke the injected `fetchUsage`, treating ANY thrown error as "no usage
* info available" (cooldown-only fallback) — so a throwing `getUsage()` never
* becomes an unhandled rejection. The `onUsagePollError` opt is fired for
* WARN-level logging. Returns `undefined` on throw (Bug 2 fix).
*/
async function safeFetchUsage(providerId: string): Promise<ProviderUsage | undefined> {
if (fetchUsage === undefined) return undefined;
try {
return await fetchUsage(providerId);
} catch (err) {
opts.onUsagePollError?.(providerId, err);
return undefined;
}
}
/**
* Drain the queue, gated on the upstream usage poll when `fetchUsage` is
* configured. Called from setLimit, pause-expiry, and the repoll timer (NOT
* from release — that goes through {@link recycleGated}, which holds inFlight
* inflated during the poll). Async because the usage poll is an injected I/O
* effect; callers fire-and-forget the returned promise.
*
* The fast-path immediate grant in `acquire` (when `inFlight < limit`) is
* disabled while `gatePolling` is true — `acquire` queues instead, so a
* concurrent caller cannot sneak through the accounting-lag / poll window
* (anti-overshoot). When no poll is in flight the fast-path is safe: the
* cooldown keeps `inFlight` inflated during the lag window, and a recycle
* sets `gatePolling` synchronously before decrementing.
*
* Each successful poll admits at most ONE queued waiter (each admission pushes
* the upstream count back toward the limit); additional waiters are admitted
* on subsequent repolls (release triggers an immediate re-poll; the 1s
* fallback timer covers an upstream count that drops on its own).
*/
async function tryGrantNext(providerId: string): Promise<void> {
const state = states.get(providerId);
if (state === undefined) return;
if (state.paused) return;
if (state.queue.length === 0) return;
if (state.inFlight >= state.limit) return; // no internal room
// No usage gate → immediate grant loop (original behavior).
if (fetchUsage === undefined) {
grantLoop(state, providerId);
return;
}
// Avoid overlapping polls for this provider. A poll is already in flight;
// mark that another trigger fired so it re-polls on completion.
if (state.gatePolling) {
state.gateRepollRequested = true;
return;
}
await pollAndGrant(providerId, state);
}
/**
* Shared poll-then-admit. `decrementOnPoll` is true for the recycle path
* (the released slot's inFlight decrement is deferred until the poll resolves,
* holding inFlight inflated so concurrent acquires queue — anti-overshoot) and
* false for the drain path (setLimit/pause-expiry/repoll — no slot to account).
* Admits at most ONE waiter on a successful poll.
*/
async function pollAndGrant(
providerId: string,
state: ProviderState,
decrementOnPoll = false,
): Promise<void> {
state.gatePolling = true;
try {
const snapshot = await safeFetchUsage(providerId);
// For the recycle path, the released slot is now truly freed (the poll
// has observed the post-release upstream state).
if (decrementOnPoll) {
state.inFlight--;
}
// Conditions may have changed during the async poll — re-check.
if (state.paused) return;
if (state.queue.length === 0) return;
if (snapshot === undefined) {
// No usage info available → fall back to cooldown-only (grant one).
grantOne(state, providerId);
return;
}
if (snapshot.concurrentSessions < state.limit) {
// Upstream has room — admit exactly ONE queued waiter.
grantOne(state, providerId);
}
// else: upstream at/over limit → keep queued; repoll timer handles retry.
} finally {
state.gatePolling = false;
// (Re)arm the 1s fallback timer while waiters remain queued, so an
// upstream count that drops on its own is still detected.
armGateRepoll(providerId, state);
if (state.gateRepollRequested) {
state.gateRepollRequested = false;
// A release (or other trigger) fired during the poll → re-poll now.
void tryGrantNext(providerId);
}
}
}
function armGateRepoll(providerId: string, state: ProviderState): void {
// Only arm while there are queued waiters (otherwise no work to re-check).
if (state.queue.length === 0) {
if (state.gateRepollTimer !== undefined) {
clearTimeout(state.gateRepollTimer);
state.gateRepollTimer = undefined;
}
return;
}
if (state.gateRepollTimer !== undefined) {
clearTimeout(state.gateRepollTimer);
}
state.gateRepollTimer = setTimeout(() => {
state.gateRepollTimer = undefined;
void tryGrantNext(providerId);
}, USAGE_REPOLL_INTERVAL_MS);
}
// ── Watchdog ──────────────────────────────────────────────────────────────────
function sweep(): void {
const currentNow = now();
for (const [providerId, state] of states) {
for (const [, slot] of state.slots) {
const heldMs = currentNow - slot.acquiredAt;
if (heldMs > slotTimeoutMs) {
opts.onWatchdogReclaim?.(providerId, slot.conversationId, heldMs);
slot.releaseFn();
}
}
}
}
const watchdogTimer = setInterval(sweep, opts.watchdogIntervalMs);
// ── Adaptive headroom ──────────────────────────────────────────────────────
function clearAutoReduce(state: ProviderState): void {
state.autoReduced = false;
state.autoReducedFrom = undefined;
state.notice = undefined;
}
// ── Public API ─────────────────────────────────────────────────────────────
const manager: ConcurrencyService = {
acquire(providerId, conversationId, workspaceId, promptStartedAt, onQueued) {
const state = states.get(providerId);
if (state === undefined) {
// No limit configured → unlimited.
return Promise.resolve(noopRelease);
}
if (!state.paused && state.inFlight < state.limit) {
// Usage-gate anti-overshoot: while a recycle-poll is in flight, the
// inFlight count is momentarily unreliable (a released slot's decrement
// is deferred until the poll resolves — see pollAndGrant). A concurrent
// caller that fast-pathed now could overshoot the upstream limit before
// the poll confirms room. So route it through the queue instead; the
// in-flight poll will re-check (gateRepollRequested) and admit it once
// upstream confirms room. When no poll is in flight the fast-path is
// safe (the cooldown keeps inFlight inflated through the lag window).
if (fetchUsage !== undefined && state.gatePolling) {
// falls through to the queue path below
} else {
return Promise.resolve(grantSlot(state, providerId, conversationId));
}
}
// Cannot grant immediately — the request will be queued.
// Notify the caller BEFORE creating the Promise so they can emit a
// "queued" status signal while we're still synchronous.
onQueued?.();
// Queue (starred-workspace-first, then oldest-agent-first).
return new Promise<() => void>((resolve) => {
state.queue.push({ conversationId, workspaceId, promptStartedAt, resolve });
// Keep sorted by priority (starred first, then oldest-agent-first).
// The queue is typically tiny (<20), so a simple sort is fine.
state.queue.sort(compareWaiters);
// If the usage gate is active, ensure the fallback repoll timer is
// armed (a release may not come for a while; the 1s timer covers an
// upstream count that drops on its own).
if (fetchUsage !== undefined) {
armGateRepoll(providerId, state);
}
});
},
reportRateLimit(providerId, retryAfterMs) {
const state = states.get(providerId);
if (state === undefined) return;
const pauseDuration = retryAfterMs ?? defaultPauseMs;
state.paused = true;
state.pausedUntil = now() + pauseDuration;
if (state.pauseTimer !== undefined) {
clearTimeout(state.pauseTimer);
}
opts.onPause?.(providerId, pauseDuration);
// Adaptive headroom: reduce the effective limit by 1 (one-way, min 1) so
// the resumed queue runs with headroom instead of re-overshooting. The
// reduction is persisted + surfaced (via onLimitReduced + status).
if (state.limit > MIN_LIMIT) {
const oldLimit = state.limit;
state.limit = Math.max(MIN_LIMIT, state.limit - 1);
if (!state.autoReduced) {
state.autoReduced = true;
state.autoReducedFrom = oldLimit;
}
state.notice =
`Concurrency limit auto-reduced to ${state.limit} after a 429 — ` +
"restore manually when ready.";
opts.onLimitReduced?.(providerId, state.limit, oldLimit);
}
state.pauseTimer = setTimeout(() => {
state.paused = false;
state.pausedUntil = undefined;
state.pauseTimer = undefined;
void tryGrantNext(providerId);
}, pauseDuration);
},
setLimit(providerId, limit) {
let state = states.get(providerId);
if (state === undefined) {
state = makeState(limit, seedCooldown(providerId));
states.set(providerId, state);
} else {
state.limit = limit;
// A MANUAL limit set clears the auto-reduce notice (the user took control).
clearAutoReduce(state);
}
// A higher limit may let queued requests through.
void tryGrantNext(providerId);
},
restoreLimit(providerId, limit, autoReducedFrom) {
// Startup restoration (Bug 3): seed state from disk WITHOUT the manual
// "user took control" semantics, so a persisted auto-reduced limit keeps
// its notice/banner across a restart. When autoReducedFrom is provided,
// re-mark the state as auto-reduced (rebuild the notice).
let state = states.get(providerId);
if (state === undefined) {
state = makeState(limit, seedCooldown(providerId));
states.set(providerId, state);
} else {
state.limit = limit;
}
if (autoReducedFrom !== undefined && autoReducedFrom > limit) {
state.autoReduced = true;
state.autoReducedFrom = autoReducedFrom;
state.notice =
`Concurrency limit auto-reduced to ${limit} after a 429 — ` +
"restore manually when ready.";
}
// A higher limit may let queued requests through.
void tryGrantNext(providerId);
},
getLimit(providerId) {
return states.get(providerId)?.limit;
},
removeLimit(providerId) {
const state = states.get(providerId);
if (state === undefined) return;
// Clear pause.
state.paused = false;
state.pausedUntil = undefined;
if (state.pauseTimer !== undefined) {
clearTimeout(state.pauseTimer);
state.pauseTimer = undefined;
}
// Clear usage-gate fallback timer.
if (state.gateRepollTimer !== undefined) {
clearTimeout(state.gateRepollTimer);
state.gateRepollTimer = undefined;
}
clearAutoReduce(state);
// Grant all queued requests (they become unlimited now).
while (state.queue.length > 0) {
const waiter = state.queue[0];
if (waiter === undefined) break;
state.queue.shift();
const releaseFn = grantSlot(state, providerId, waiter.conversationId);
waiter.resolve(releaseFn);
}
// Remove the state. In-flight slots' release functions still work —
// they close over `state` and call `tryGrantNext` which finds no state
// and returns early. The watchdog won't sweep removed states.
states.delete(providerId);
},
getLimits() {
return [...states.entries()].map(([providerId, s]) => ({
providerId,
limit: s.limit,
}));
},
setCooldown(providerId, cooldownMs) {
// A cooldown is only meaningful WITH a limit (it gates slot recycling,
// which only happens under a limit). But we store the override regardless
// so it applies the moment a limit IS set — and so a persisted cooldown
// restored before a limit does NOT impose a limit (setCooldown never
// creates a state). If a state already exists, update it live.
cooldownOverrides.set(providerId, cooldownMs);
const state = states.get(providerId);
if (state !== undefined) {
state.cooldownMs = cooldownMs;
}
},
getCooldown(providerId) {
const state = states.get(providerId);
if (state !== undefined) return state.cooldownMs;
return cooldownOverrides.get(providerId);
},
getCooldowns() {
// Merge: states (cooldown from state.cooldownMs) + pending overrides with no state.
const seen = new Set<string>();
const out: { providerId: string; cooldownMs: number }[] = [];
for (const [providerId, s] of states) {
seen.add(providerId);
out.push({ providerId, cooldownMs: s.cooldownMs });
}
for (const [providerId, cooldownMs] of cooldownOverrides) {
if (!seen.has(providerId)) {
out.push({ providerId, cooldownMs });
}
}
return out;
},
getStatus(providerId) {
const state = states.get(providerId);
if (state === undefined) return undefined;
return {
providerId,
limit: state.limit,
inFlight: state.inFlight,
queued: state.queue.length,
paused: state.paused,
cooldownMs: state.cooldownMs,
autoReduced: state.autoReduced,
...(state.pausedUntil !== undefined ? { pausedUntil: state.pausedUntil } : {}),
...(state.autoReducedFrom !== undefined ? { autoReducedFrom: state.autoReducedFrom } : {}),
...(state.notice !== undefined ? { notice: state.notice } : {}),
};
},
getStatusAll() {
return [...states.keys()]
.map((providerId) => manager.getStatus(providerId))
.filter((s): s is ProviderConcurrencyStatus => s !== undefined);
},
notifyWorkspaceStarred(workspaceId, starred) {
if (starred) {
starredWorkspaces.add(workspaceId);
} else {
starredWorkspaces.delete(workspaceId);
}
// Re-sort all queues so a newly-starred workspace's already-queued
// agents jump ahead immediately (no need to wait for the next acquire).
for (const [providerId, state] of states) {
if (state.queue.length > 0) {
state.queue.sort(compareWaiters);
tryGrantNext(providerId);
}
}
},
destroy() {
clearInterval(watchdogTimer);
for (const timer of cooldownTimers) {
clearTimeout(timer);
}
cooldownTimers.clear();
for (const state of states.values()) {
if (state.pauseTimer !== undefined) {
clearTimeout(state.pauseTimer);
}
if (state.gateRepollTimer !== undefined) {
clearTimeout(state.gateRepollTimer);
}
}
states.clear();
},
};
return manager;
}
|