qwenproxy-cli 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +14 -14
  2. package/README.md +906 -906
  3. package/bin/qwenproxy.js +5 -1
  4. package/package.json +77 -78
  5. package/src/api/error-classifier.ts +159 -159
  6. package/src/api/error-helpers.ts +118 -118
  7. package/src/api/models.ts +261 -261
  8. package/src/api/server.ts +860 -859
  9. package/src/cache/memory-cache.ts +385 -385
  10. package/src/clean-cache.ts +204 -204
  11. package/src/core/account-concurrency.ts +671 -671
  12. package/src/core/account-manager.ts +301 -297
  13. package/src/core/account-priority.ts +163 -163
  14. package/src/core/accounts.ts +186 -186
  15. package/src/core/config.ts +383 -383
  16. package/src/core/crypto-utils.ts +79 -79
  17. package/src/core/database.ts +276 -276
  18. package/src/core/errors.ts +118 -118
  19. package/src/core/logger.ts +269 -269
  20. package/src/core/memory-usage.ts +84 -84
  21. package/src/core/metrics.ts +291 -291
  22. package/src/core/model-alias.ts +77 -77
  23. package/src/core/model-registry.ts +544 -544
  24. package/src/core/mutex.ts +119 -119
  25. package/src/core/paths.ts +199 -199
  26. package/src/core/prompt-limits.ts +214 -214
  27. package/src/core/reasoning-effort.ts +102 -102
  28. package/src/core/stream-registry.ts +96 -96
  29. package/src/core/waf-isolation.ts +117 -117
  30. package/src/core/watchdog.ts +195 -195
  31. package/src/delete-chats.ts +23 -23
  32. package/src/index.ts +65 -64
  33. package/src/login.ts +147 -147
  34. package/src/reset-cooldowns.ts +11 -11
  35. package/src/routes/anthropic/index.ts +355 -355
  36. package/src/routes/anthropic/translate.ts +522 -522
  37. package/src/routes/anthropic/types.ts +154 -154
  38. package/src/routes/anthropic/validation.ts +144 -144
  39. package/src/routes/chat/account.ts +1817 -1817
  40. package/src/routes/chat/context.ts +241 -241
  41. package/src/routes/chat/errors.ts +85 -85
  42. package/src/routes/chat/helpers.ts +268 -268
  43. package/src/routes/chat/index.ts +618 -618
  44. package/src/routes/chat/media.ts +285 -285
  45. package/src/routes/chat/retry-policy.ts +754 -754
  46. package/src/routes/chat/stop.ts +98 -98
  47. package/src/routes/chat/streaming.ts +2710 -2710
  48. package/src/routes/chat/validation.ts +526 -526
  49. package/src/routes/chat.ts +2 -2
  50. package/src/routes/completions.ts +290 -290
  51. package/src/routes/images.ts +139 -139
  52. package/src/routes/responses/adapter.ts +503 -503
  53. package/src/routes/responses/index.ts +405 -405
  54. package/src/routes/responses/state.ts +230 -230
  55. package/src/routes/responses/streaming.ts +528 -528
  56. package/src/routes/responses/types.ts +285 -285
  57. package/src/routes/responses/validation.ts +202 -202
  58. package/src/routes/upload.ts +731 -731
  59. package/src/routes/videos.ts +214 -214
  60. package/src/services/auth-playwright.ts +173 -173
  61. package/src/services/captcha-coordinator.ts +161 -161
  62. package/src/services/captcha-solver.ts +553 -553
  63. package/src/services/chat-cleanup.ts +80 -80
  64. package/src/services/context-meter.ts +317 -317
  65. package/src/services/fingerprint.ts +242 -242
  66. package/src/services/human-behavior.ts +173 -173
  67. package/src/services/media-generation.ts +1748 -1748
  68. package/src/services/playwright.ts +2878 -2800
  69. package/src/services/qwen-chat-pool.ts +345 -345
  70. package/src/services/qwen-errors.ts +133 -133
  71. package/src/services/qwen-headers.ts +79 -79
  72. package/src/services/qwen-thread-state.ts +393 -393
  73. package/src/services/qwen-url.ts +19 -19
  74. package/src/services/qwen.ts +3126 -3126
  75. package/src/services/session-keeper.ts +88 -88
  76. package/src/services/token-estimation-metrics.ts +118 -118
  77. package/src/sync/claude-code.ts +75 -75
  78. package/src/sync/codex.ts +123 -123
  79. package/src/sync/index.ts +362 -362
  80. package/src/sync/omp.ts +105 -105
  81. package/src/sync/opencode.ts +214 -214
  82. package/src/sync/types.ts +53 -53
  83. package/src/sync/utils.ts +27 -27
  84. package/src/sync-clients.ts +189 -189
  85. package/src/tools/instructions.ts +137 -137
  86. package/src/tools/manifest.ts +81 -81
  87. package/src/tools/parser.ts +2989 -2989
  88. package/src/tools/toolcall-tags.ts +142 -142
  89. package/src/tui/app.ts +259 -264
  90. package/src/tui/index.ts +61 -61
  91. package/src/tui/markdown.ts +258 -258
  92. package/src/tui/proxy-client.ts +331 -326
  93. package/src/tui/screen.ts +294 -278
  94. package/src/tui/server-manager.ts +270 -270
  95. package/src/tui/theme.ts +432 -432
  96. package/src/tui/types.ts +33 -33
  97. package/src/tui/views/accounts-view.ts +656 -656
  98. package/src/tui/views/chat-view.ts +1018 -823
  99. package/src/tui/views/logs-view.ts +479 -413
  100. package/src/tui/views/status-view.ts +204 -204
  101. package/src/tui/views/storage-view.ts +304 -291
  102. package/src/tui/views/sync-view.ts +409 -409
  103. package/src/types/ali-oss.d.ts +32 -32
  104. package/src/update-cli.ts +121 -0
  105. package/src/utils/context-truncation.ts +84 -84
  106. package/src/utils/json.ts +380 -380
  107. package/src/utils/session-id.ts +37 -37
  108. package/src/utils/tool-call-guard.ts +84 -84
  109. package/src/utils/types.ts +109 -109
@@ -1,671 +1,671 @@
1
- /**
2
- * Per-account concurrency guard. Limits the number of simultaneous upstream
3
- * streams per account and queues excess requests with FIFO ordering, timeout,
4
- * and abort support.
5
- *
6
- * Includes stale-lease detection: if a lease is held longer than
7
- * `concurrency.leaseMaxDurationMs` it is considered leaked and force-released.
8
- */
9
-
10
- import { config } from "./config.ts";
11
- import { logger } from "./logger.ts";
12
- import { getStream } from "./stream-registry.ts";
13
-
14
- export interface AccountLease {
15
- accountId: string;
16
- /** Unique id for correlating lease acquire/release in logs. */
17
- leaseId: string;
18
- release(): void;
19
- }
20
-
21
- export interface AcquireAccountLeaseOptions {
22
- signal?: AbortSignal;
23
- /**
24
- * Deadline for hanging on a free slot.
25
- * - `number`: reject with `account_busy` after this many ms.
26
- * - `null`: wait without a deadline until a slot frees or `signal` aborts.
27
- * - `undefined`: fall back to `concurrency.busyWaitMs`.
28
- */
29
- timeoutMs?: number | null;
30
- /** Human-readable label for diagnostics (e.g. reqId or chat session). */
31
- label?: string;
32
- /** AbortController registered with the lease; aborted on session-level replacement. */
33
- leaseAbortController?: AbortController;
34
- }
35
-
36
- interface ActiveLeaseInfo {
37
- leaseId: string;
38
- acquiredAt: number;
39
- label: string;
40
- /** Aborted when a same-session retry supersedes this lease. */
41
- abortController?: AbortController;
42
- /** Set once the stream is acquired; used by the emit-aware supersede. */
43
- completionId?: string;
44
- /** Parallel-escape lease (own chat): excluded from the unemitted check. */
45
- parallelEscape?: boolean;
46
- }
47
-
48
- interface QueueEntry {
49
- resolve: (lease: AccountLease) => void;
50
- reject: (err: Error) => void;
51
- timer: ReturnType<typeof setTimeout> | null;
52
- onAbort: (() => void) | null;
53
- signal: AbortSignal | null;
54
- enqueuedAt: number;
55
- /** Preserved so the lease granted from the queue keeps the session label. */
56
- label: string;
57
- /** Preserved so latest-wins can abort queued-then-granted leases. */
58
- leaseAbortController?: AbortController;
59
- }
60
-
61
- interface AccountSlot {
62
- activeLeases: ActiveLeaseInfo[];
63
- queue: QueueEntry[];
64
- }
65
-
66
- const slots = new Map<string, AccountSlot>();
67
- let leaseCounter = 0;
68
-
69
- function generateLeaseId(): string {
70
- return `lease_${(++leaseCounter).toString(36)}_${Date.now().toString(36)}`;
71
- }
72
-
73
- function getSlot(accountId: string): AccountSlot {
74
- let slot = slots.get(accountId);
75
- if (!slot) {
76
- slot = { activeLeases: [], queue: [] };
77
- slots.set(accountId, slot);
78
- }
79
- return slot;
80
- }
81
-
82
- /** Format active lease holders for log output. */
83
- function formatHolders(slot: AccountSlot): string {
84
- if (slot.activeLeases.length === 0) return "none";
85
- const now = Date.now();
86
- return slot.activeLeases
87
- .map((l) => `${l.label || "unknown"}(held ${Math.round((now - l.acquiredAt) / 1000)}s)`)
88
- .join(", ");
89
- }
90
-
91
- /**
92
- * True when the account slot is at capacity AND every active lease belongs to
93
- * a session other than `label`.
94
- *
95
- * Used to decide how long a request may wait for the slot: when ANOTHER
96
- * session is generating on this account (not our own thread), waiting long is
97
- * wasted latency — the other session may hold the slot for minutes. The
98
- * request should fail fast with `account_busy` (short wait) and rotate to a
99
- * different account instead of queueing for the full `queueWaitForeverCapMs`.
100
- */
101
- export function isAccountSlotHeldByOtherSession(
102
- accountId: string,
103
- label: string | null | undefined,
104
- ): boolean {
105
- if (!label) return false;
106
- const slot = slots.get(accountId);
107
- if (!slot) return false;
108
- if (slot.activeLeases.length === 0) return false;
109
- if (slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
110
- return false;
111
- }
112
- return slot.activeLeases.every((l) => l.label !== label);
113
- }
114
-
115
- function createLease(
116
- accountId: string,
117
- label: string,
118
- abortController?: AbortController,
119
- parallelEscape?: boolean,
120
- ): AccountLease {
121
- const leaseId = generateLeaseId();
122
- const slot = getSlot(accountId);
123
- const info: ActiveLeaseInfo = {
124
- leaseId,
125
- acquiredAt: Date.now(),
126
- label,
127
- abortController,
128
- parallelEscape,
129
- };
130
- slot.activeLeases.push(info);
131
- logger.debug("[concurrency] lease acquired", {
132
- accountId,
133
- leaseId,
134
- label,
135
- parallelEscape: !!parallelEscape,
136
- activeLeases: slot.activeLeases.length,
137
- });
138
-
139
- let released = false;
140
- return {
141
- accountId,
142
- leaseId,
143
- release() {
144
- if (released) return;
145
- released = true;
146
- const idx = slot.activeLeases.findIndex((l) => l.leaseId === leaseId);
147
- if (idx !== -1) slot.activeLeases.splice(idx, 1);
148
- const heldMs = Date.now() - info.acquiredAt;
149
- logger.debug("[concurrency] lease released", {
150
- accountId,
151
- leaseId,
152
- label,
153
- heldMs,
154
- });
155
- if (logger.isLevelEnabled("info")) {
156
- console.log(
157
- `🚦 [Server] Stream slot released | account=${accountId} | held ${heldMs}ms | label=${label} | leaseId=${leaseId.substring(0, 8)}`,
158
- );
159
- }
160
- releaseSlot(accountId);
161
- },
162
- };
163
- }
164
-
165
- function releaseSlot(accountId: string): void {
166
- const slot = slots.get(accountId);
167
- if (!slot) return;
168
-
169
- // Deliver to the next waiter in FIFO order
170
- while (slot.queue.length > 0 && slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
171
- const entry = slot.queue.shift();
172
- if (!entry) break;
173
- cleanupEntry(entry);
174
- if (logger.isLevelEnabled("info")) {
175
- console.log(
176
- `🚦 [Server] Stream slot granted | account=${accountId} | waited ${Date.now() - entry.enqueuedAt}ms | ${slot.queue.length} still queued`,
177
- );
178
- }
179
- entry.resolve(createLease(accountId, entry.label, entry.leaseAbortController));
180
- return; // one at a time to preserve ordering
181
- }
182
-
183
- // Clean up empty slots to avoid unbounded map growth
184
- if (slot.activeLeases.length === 0 && slot.queue.length === 0) {
185
- slots.delete(accountId);
186
- }
187
- }
188
-
189
- function cleanupEntry(entry: QueueEntry): void {
190
- if (entry.timer) {
191
- clearTimeout(entry.timer);
192
- entry.timer = null;
193
- }
194
- if (entry.onAbort && entry.signal) {
195
- entry.signal.removeEventListener("abort", entry.onAbort);
196
- entry.onAbort = null;
197
- }
198
- }
199
-
200
- /**
201
- * Check for and force-release stale leases that exceeded the max duration.
202
- * Returns the number of leases force-released.
203
- */
204
- function sweepStaleLeases(accountId: string): number {
205
- const slot = slots.get(accountId);
206
- if (!slot) return 0;
207
-
208
- const maxDuration = config.concurrency.leaseMaxDurationMs;
209
- if (maxDuration <= 0) return 0;
210
-
211
- const now = Date.now();
212
- let swept = 0;
213
-
214
- for (let i = slot.activeLeases.length - 1; i >= 0; i--) {
215
- const lease = slot.activeLeases[i];
216
- const heldMs = now - lease.acquiredAt;
217
- if (heldMs > maxDuration) {
218
- slot.activeLeases.splice(i, 1);
219
- swept++;
220
- console.warn(
221
- `⚠️ [Server] Stale lease force-released | account=${accountId} | label=${lease.label} | held ${Math.round(heldMs / 1000)}s (limit: ${Math.round(maxDuration / 1000)}s) | leaseId=${lease.leaseId}`,
222
- );
223
- logger.warn("[concurrency] stale lease force-released (possible leak)", {
224
- accountId,
225
- leaseId: lease.leaseId,
226
- label: lease.label,
227
- heldMs,
228
- maxDurationMs: maxDuration,
229
- });
230
- }
231
- }
232
-
233
- // If we swept any, try to deliver to waiters
234
- if (swept > 0) {
235
- releaseSlot(accountId);
236
- }
237
-
238
- return swept;
239
- }
240
-
241
- /**
242
- * Try to acquire a lease without waiting. Returns null if the account is at
243
- * capacity.
244
- */
245
- export function tryAcquireAccountLease(
246
- accountId: string,
247
- label?: string,
248
- leaseAbortController?: AbortController,
249
- parallelEscape?: boolean,
250
- ): AccountLease | null {
251
- const slot = getSlot(accountId);
252
- sweepStaleLeases(accountId);
253
- if (slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
254
- return createLease(
255
- accountId,
256
- label ?? "try-acquire",
257
- leaseAbortController,
258
- parallelEscape,
259
- );
260
- }
261
- return null;
262
- }
263
-
264
- /**
265
- * Acquire a lease, waiting in FIFO order if the account is at capacity.
266
- * Rejects on timeout or abort.
267
- */
268
- export function acquireAccountLease(
269
- accountId: string,
270
- options?: AcquireAccountLeaseOptions,
271
- ): Promise<AccountLease> {
272
- const signal = options?.signal ?? null;
273
- const label = options?.label ?? "unlabeled";
274
-
275
- // Already aborted — reject before touching any slot
276
- if (signal?.aborted) {
277
- return Promise.reject(new Error("Aborted before acquiring account lease"));
278
- }
279
-
280
- const slot = getSlot(accountId);
281
-
282
- // Sweep stale leases before checking capacity — a leaked lease should not
283
- // block new requests indefinitely.
284
- sweepStaleLeases(accountId);
285
-
286
- // A NORMAL request never queues behind an auxiliary (parallel-escape) lease:
287
- // the disposable title must yield its slot immediately. Belt-and-suspenders
288
- // on top of the emit-aware supersede (which kills parallel leases even before
289
- // they emit) — covers the case where the supersede could not find the lease.
290
- if (
291
- slot.activeLeases.length > 0 &&
292
- slot.activeLeases.every((l) => l.parallelEscape)
293
- ) {
294
- for (const lease of [...slot.activeLeases]) {
295
- logger.debug("[concurrency] preempting parallel-escape lease", {
296
- accountId,
297
- leaseId: lease.leaseId,
298
- label: lease.label,
299
- });
300
- lease.abortController?.abort();
301
- const i = slot.activeLeases.indexOf(lease);
302
- if (i !== -1) slot.activeLeases.splice(i, 1);
303
- }
304
- releaseSlot(accountId); // deliver to the earliest waiter, if any
305
- }
306
-
307
- // Fast path: capacity available
308
- if (slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
309
- return Promise.resolve(createLease(accountId, label, options?.leaseAbortController));
310
- }
311
-
312
- const hasExplicitTimeout =
313
- options != null &&
314
- Object.prototype.hasOwnProperty.call(options, "timeoutMs");
315
- const timeoutMs = hasExplicitTimeout
316
- ? options?.timeoutMs
317
- : config.concurrency.busyWaitMs;
318
-
319
- return new Promise<AccountLease>((resolve, reject) => {
320
- const entry: QueueEntry = {
321
- resolve,
322
- reject,
323
- timer: null,
324
- onAbort: null,
325
- signal,
326
- enqueuedAt: Date.now(),
327
- label,
328
- leaseAbortController: options?.leaseAbortController,
329
- };
330
-
331
- const removeSelf = () => {
332
- const idx = slot.queue.indexOf(entry);
333
- if (idx !== -1) slot.queue.splice(idx, 1);
334
- cleanupEntry(entry);
335
- if (slot.activeLeases.length === 0 && slot.queue.length === 0) {
336
- slots.delete(accountId);
337
- }
338
- };
339
-
340
- // Timeout — only when the caller supplied a finite deadline. `null` means
341
- // "wait as long as it takes" (bounded by the abort signal), which is what
342
- // the last-usable account or the thread owner must do to stay lossless.
343
- if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0) {
344
- entry.timer = setTimeout(() => {
345
- removeSelf();
346
- console.log(
347
- `🚦 [Server] Stream wait timeout | account=${accountId} | waited ${timeoutMs}ms | ${slot.queue.length} still queued`,
348
- );
349
- const busyError = new Error(
350
- `Account ${accountId} busy: timed out after ${timeoutMs}ms waiting for a free slot`,
351
- ) as Error & { code?: string };
352
- busyError.code = "account_busy";
353
- reject(busyError);
354
- }, timeoutMs);
355
- }
356
-
357
- // Abort signal
358
- if (signal) {
359
- entry.onAbort = () => {
360
- removeSelf();
361
- if (logger.isLevelEnabled("info")) {
362
- console.log(
363
- `🚦 [Server] Stream waiter aborted | account=${accountId} | waited ${Date.now() - entry.enqueuedAt}ms | ${slot.queue.length} still queued`,
364
- );
365
- }
366
- reject(new Error("Aborted while waiting for account lease"));
367
- };
368
- signal.addEventListener("abort", entry.onAbort, { once: true });
369
- }
370
-
371
- slot.queue.push(entry);
372
-
373
- const holders = formatHolders(slot);
374
- if (logger.isLevelEnabled("info")) {
375
- console.log(
376
- `🚦 [Server] Stream slot busy | account=${accountId} | held by: ${holders} | queued at position ${slot.queue.length} | timeout=${typeof timeoutMs === "number" && Number.isFinite(timeoutMs) ? `${timeoutMs}ms` : "unbounded"}`,
377
- );
378
- }
379
-
380
- logger.debug("[concurrency] queued for account lease", {
381
- accountId,
382
- activeLeases: slot.activeLeases.length,
383
- holders: slot.activeLeases.map((l) => ({ label: l.label, heldMs: Date.now() - l.acquiredAt })),
384
- queueLength: slot.queue.length,
385
- timeoutMs,
386
- });
387
- });
388
- }
389
-
390
- /**
391
- * Abort and remove the active lease matching the given label (session id).
392
- * Used when a client retries the same session: the old generation is no longer
393
- * needed, so we abort it and free the slot for the new request.
394
- *
395
- * With `onlyIfEmitted`, a lease whose stream has NOT yet emitted a chunk to the
396
- * client is PROTECTED: killing it would waste a generation the client has not
397
- * consumed (a parallel title request racing the main stream, or a stream still
398
- * mid-creation). Leases without a completionId (stream not acquired yet) are
399
- * protected too. Once the stream emits, latest-wins applies (tool loop).
400
- * Returns true if a lease was aborted, false otherwise.
401
- */
402
- export function abortLeaseByLabel(
403
- accountId: string,
404
- label: string,
405
- opts?: { onlyIfEmitted?: boolean },
406
- ): boolean {
407
- const slot = slots.get(accountId);
408
- if (!slot) return false;
409
-
410
- const idx = slot.activeLeases.findIndex((l) => l.label === label);
411
- if (idx === -1) return false;
412
-
413
- const lease = slot.activeLeases[idx];
414
- if (opts?.onlyIfEmitted) {
415
- // A parallel-escape lease (auxiliary title on its own chat) is DISPOSABLE:
416
- // any normal request of the same session may preempt it, even before it
417
- // emits — the main conversation must never block on the title's slot.
418
- if (!lease.parallelEscape) {
419
- const stream = lease.completionId
420
- ? getStream(lease.completionId)
421
- : undefined;
422
- if (!stream?.emittedChunk) {
423
- // Unemitted (still thinking / mid-creation): protect the generation.
424
- if (logger.isLevelEnabled("info")) {
425
- console.log(
426
- `🛡️ [Server] Supersede skipped (protected unemitted) | account=${accountId} | label=${label} | leaseId=${lease.leaseId} | completionId=${lease.completionId ?? "none"} | emitted=${stream?.emittedChunk ?? false}`,
427
- );
428
- }
429
- return false;
430
- }
431
- }
432
- }
433
-
434
- const heldMs = Date.now() - lease.acquiredAt;
435
- slot.activeLeases.splice(idx, 1);
436
-
437
- if (logger.isLevelEnabled("info")) {
438
- console.log(
439
- `🔄 [Server] Session retry supersedes active lease | account=${accountId} | label=${label} | held ${Math.round(heldMs / 1000)}s | leaseId=${lease.leaseId}`,
440
- );
441
- }
442
- logger.info("[concurrency] session retry superseded active lease", {
443
- accountId,
444
- label,
445
- heldMs,
446
- leaseId: lease.leaseId,
447
- });
448
-
449
- // Abort the stream associated with the old lease
450
- if (lease.abortController) {
451
- lease.abortController.abort();
452
- }
453
-
454
- // Free the slot for the next waiter
455
- releaseSlot(accountId);
456
- return true;
457
- }
458
-
459
- /**
460
- * Abort any active lease matching the given session label ACROSS ALL accounts.
461
- * Used BEFORE the per-chat lock: the client can fire the next turn while the
462
- * previous stream is still open (streaming tool calls). Killing the stale
463
- * generation first lets the new request take the chat lock immediately instead
464
- * of waiting for the stale-lease sweep (600s) when the old upstream stalled.
465
- * Returns true if at least one lease was aborted.
466
- */
467
- export function abortLeaseBySessionLabel(
468
- label: string,
469
- opts?: { onlyIfEmitted?: boolean },
470
- ): boolean {
471
- let aborted = false;
472
- for (const accountId of slots.keys()) {
473
- if (abortLeaseByLabel(accountId, label, opts)) aborted = true;
474
- }
475
- return aborted;
476
- }
477
-
478
- /**
479
- * True when the session has an ACTIVE lease whose stream has not emitted a
480
- * chunk yet (still thinking / mid-creation). A parallel request (title) must
481
- * not kill it, and must run on its own chat instead of waiting.
482
- */
483
- export function hasUnemittedSessionStream(label: string): boolean {
484
- for (const slot of slots.values()) {
485
- for (const lease of slot.activeLeases) {
486
- if (lease.label !== label) continue;
487
- // A parallel-escape lease runs on its OWN chat — it must not poison the
488
- // check and cascade every following turn into a new chat too.
489
- if (lease.parallelEscape) continue;
490
- const stream = lease.completionId
491
- ? getStream(lease.completionId)
492
- : undefined;
493
- if (!stream?.emittedChunk) {
494
- logger.debug("[concurrency] unemitted session stream found", {
495
- label,
496
- leaseId: lease.leaseId,
497
- completionId: lease.completionId ?? null,
498
- });
499
- return true;
500
- }
501
- }
502
- }
503
- return false;
504
- }
505
-
506
- /**
507
- * Attach the acquired stream's completion id to a lease so the emit-aware
508
- * supersede can check whether the stream reached the client. Called by the
509
- * stream owner right after createQwenStream succeeds.
510
- */
511
- export function markLeaseCompletion(
512
- accountId: string,
513
- leaseId: string,
514
- completionId: string,
515
- ): void {
516
- const slot = slots.get(accountId);
517
- const lease = slot?.activeLeases.find((l) => l.leaseId === leaseId);
518
- if (lease) {
519
- lease.completionId = completionId;
520
- }
521
- }
522
-
523
- /**
524
- * Check whether an account currently has no free slots.
525
- */
526
- export function isAccountBusy(accountId: string): boolean {
527
- const slot = slots.get(accountId);
528
- if (!slot) return false;
529
- return slot.activeLeases.length >= config.concurrency.maxStreamsPerAccount;
530
- }
531
-
532
- /**
533
- * Check whether an account is currently serving at least one stream.
534
- * Different question from isAccountBusy: that one answers "are all slots
535
- * taken", while callers that must not disturb a generation in flight care
536
- * about a single active lease even when spare slots remain.
537
- */
538
- export function hasActiveAccountLease(accountId: string): boolean {
539
- const slot = slots.get(accountId);
540
- if (!slot) return false;
541
- return slot.activeLeases.length > 0;
542
- }
543
-
544
- /**
545
- * Mark an account as temporarily busy (e.g. after chat_in_progress).
546
- * This is a lightweight hint that prevents immediate re-selection without
547
- * consuming a lease slot.
548
- */
549
- const temporaryBusyUntil = new Map<string, number>();
550
-
551
- export function markAccountTemporarilyBusy(
552
- accountId: string,
553
- durationMs: number,
554
- ): number {
555
- const requestedUntil = Date.now() + Math.max(0, durationMs);
556
- const currentUntil = temporaryBusyUntil.get(accountId) ?? 0;
557
- const effectiveUntil = Math.max(currentUntil, requestedUntil);
558
- temporaryBusyUntil.set(accountId, effectiveUntil);
559
- return effectiveUntil;
560
- }
561
-
562
- export function isAccountTemporarilyBusy(accountId: string): boolean {
563
- const until = temporaryBusyUntil.get(accountId);
564
- if (!until) return false;
565
- if (Date.now() >= until) {
566
- temporaryBusyUntil.delete(accountId);
567
- return false;
568
- }
569
- return true;
570
- }
571
-
572
- export function clearTemporaryBusy(
573
- accountId: string,
574
- expectedUntil?: number,
575
- ): void {
576
- if (
577
- expectedUntil !== undefined &&
578
- temporaryBusyUntil.get(accountId) !== expectedUntil
579
- ) {
580
- return;
581
- }
582
- temporaryBusyUntil.delete(accountId);
583
- }
584
-
585
- /**
586
- * Snapshot for logging/metrics.
587
- */
588
- export function getAccountConcurrencySnapshot(): Array<{
589
- accountId: string;
590
- active: number;
591
- waiting: number;
592
- limit: number;
593
- holders: Array<{ label: string; heldMs: number }>;
594
- }> {
595
- const result: Array<{
596
- accountId: string;
597
- active: number;
598
- waiting: number;
599
- limit: number;
600
- holders: Array<{ label: string; heldMs: number }>;
601
- }> = [];
602
- const now = Date.now();
603
- for (const [accountId, slot] of slots) {
604
- result.push({
605
- accountId,
606
- active: slot.activeLeases.length,
607
- waiting: slot.queue.length,
608
- limit: config.concurrency.maxStreamsPerAccount,
609
- holders: slot.activeLeases.map((l) => ({
610
- label: l.label,
611
- heldMs: now - l.acquiredAt,
612
- })),
613
- });
614
- }
615
- return result;
616
- }
617
-
618
- /**
619
- * Sweep all accounts for stale leases. Call periodically (e.g. from a timer
620
- * or before each request batch) to catch leaked leases proactively.
621
- */
622
- export function sweepAllStaleLeases(): number {
623
- let total = 0;
624
- for (const accountId of slots.keys()) {
625
- total += sweepStaleLeases(accountId);
626
- }
627
- return total;
628
- }
629
-
630
- // Periodic stale-lease sweep: runs every 30s to catch leaked leases even when
631
- // no new requests arrive. This is the safety net that prevents "slot busy
632
- // forever" from a single missed release() call.
633
- const SWEEP_INTERVAL_MS = 30_000;
634
- let sweepTimer: ReturnType<typeof setInterval> | null = null;
635
-
636
- export function startLeaseSweepTimer(): void {
637
- if (sweepTimer) return;
638
- sweepTimer = setInterval(() => {
639
- const swept = sweepAllStaleLeases();
640
- if (swept > 0) {
641
- logger.info("[concurrency] periodic stale lease sweep", { swept });
642
- }
643
- }, SWEEP_INTERVAL_MS);
644
- // Allow the process to exit even if the timer is running
645
- if (sweepTimer && typeof sweepTimer === "object" && "unref" in sweepTimer) {
646
- (sweepTimer as NodeJS.Timeout).unref();
647
- }
648
- }
649
-
650
- export function stopLeaseSweepTimer(): void {
651
- if (sweepTimer) {
652
- clearInterval(sweepTimer);
653
- sweepTimer = null;
654
- }
655
- }
656
-
657
- /**
658
- * Reset all state. For tests only.
659
- */
660
- export function resetAccountConcurrencyForTests(): void {
661
- stopLeaseSweepTimer();
662
- for (const [, slot] of slots) {
663
- for (const entry of slot.queue) {
664
- cleanupEntry(entry);
665
- entry.reject(new Error("Reset for tests"));
666
- }
667
- }
668
- slots.clear();
669
- temporaryBusyUntil.clear();
670
- leaseCounter = 0;
671
- }
1
+ /**
2
+ * Per-account concurrency guard. Limits the number of simultaneous upstream
3
+ * streams per account and queues excess requests with FIFO ordering, timeout,
4
+ * and abort support.
5
+ *
6
+ * Includes stale-lease detection: if a lease is held longer than
7
+ * `concurrency.leaseMaxDurationMs` it is considered leaked and force-released.
8
+ */
9
+
10
+ import { config } from "./config.ts";
11
+ import { logger } from "./logger.ts";
12
+ import { getStream } from "./stream-registry.ts";
13
+
14
+ export interface AccountLease {
15
+ accountId: string;
16
+ /** Unique id for correlating lease acquire/release in logs. */
17
+ leaseId: string;
18
+ release(): void;
19
+ }
20
+
21
+ export interface AcquireAccountLeaseOptions {
22
+ signal?: AbortSignal;
23
+ /**
24
+ * Deadline for hanging on a free slot.
25
+ * - `number`: reject with `account_busy` after this many ms.
26
+ * - `null`: wait without a deadline until a slot frees or `signal` aborts.
27
+ * - `undefined`: fall back to `concurrency.busyWaitMs`.
28
+ */
29
+ timeoutMs?: number | null;
30
+ /** Human-readable label for diagnostics (e.g. reqId or chat session). */
31
+ label?: string;
32
+ /** AbortController registered with the lease; aborted on session-level replacement. */
33
+ leaseAbortController?: AbortController;
34
+ }
35
+
36
+ interface ActiveLeaseInfo {
37
+ leaseId: string;
38
+ acquiredAt: number;
39
+ label: string;
40
+ /** Aborted when a same-session retry supersedes this lease. */
41
+ abortController?: AbortController;
42
+ /** Set once the stream is acquired; used by the emit-aware supersede. */
43
+ completionId?: string;
44
+ /** Parallel-escape lease (own chat): excluded from the unemitted check. */
45
+ parallelEscape?: boolean;
46
+ }
47
+
48
+ interface QueueEntry {
49
+ resolve: (lease: AccountLease) => void;
50
+ reject: (err: Error) => void;
51
+ timer: ReturnType<typeof setTimeout> | null;
52
+ onAbort: (() => void) | null;
53
+ signal: AbortSignal | null;
54
+ enqueuedAt: number;
55
+ /** Preserved so the lease granted from the queue keeps the session label. */
56
+ label: string;
57
+ /** Preserved so latest-wins can abort queued-then-granted leases. */
58
+ leaseAbortController?: AbortController;
59
+ }
60
+
61
+ interface AccountSlot {
62
+ activeLeases: ActiveLeaseInfo[];
63
+ queue: QueueEntry[];
64
+ }
65
+
66
+ const slots = new Map<string, AccountSlot>();
67
+ let leaseCounter = 0;
68
+
69
+ function generateLeaseId(): string {
70
+ return `lease_${(++leaseCounter).toString(36)}_${Date.now().toString(36)}`;
71
+ }
72
+
73
+ function getSlot(accountId: string): AccountSlot {
74
+ let slot = slots.get(accountId);
75
+ if (!slot) {
76
+ slot = { activeLeases: [], queue: [] };
77
+ slots.set(accountId, slot);
78
+ }
79
+ return slot;
80
+ }
81
+
82
+ /** Format active lease holders for log output. */
83
+ function formatHolders(slot: AccountSlot): string {
84
+ if (slot.activeLeases.length === 0) return "none";
85
+ const now = Date.now();
86
+ return slot.activeLeases
87
+ .map((l) => `${l.label || "unknown"}(held ${Math.round((now - l.acquiredAt) / 1000)}s)`)
88
+ .join(", ");
89
+ }
90
+
91
+ /**
92
+ * True when the account slot is at capacity AND every active lease belongs to
93
+ * a session other than `label`.
94
+ *
95
+ * Used to decide how long a request may wait for the slot: when ANOTHER
96
+ * session is generating on this account (not our own thread), waiting long is
97
+ * wasted latency — the other session may hold the slot for minutes. The
98
+ * request should fail fast with `account_busy` (short wait) and rotate to a
99
+ * different account instead of queueing for the full `queueWaitForeverCapMs`.
100
+ */
101
+ export function isAccountSlotHeldByOtherSession(
102
+ accountId: string,
103
+ label: string | null | undefined,
104
+ ): boolean {
105
+ if (!label) return false;
106
+ const slot = slots.get(accountId);
107
+ if (!slot) return false;
108
+ if (slot.activeLeases.length === 0) return false;
109
+ if (slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
110
+ return false;
111
+ }
112
+ return slot.activeLeases.every((l) => l.label !== label);
113
+ }
114
+
115
+ function createLease(
116
+ accountId: string,
117
+ label: string,
118
+ abortController?: AbortController,
119
+ parallelEscape?: boolean,
120
+ ): AccountLease {
121
+ const leaseId = generateLeaseId();
122
+ const slot = getSlot(accountId);
123
+ const info: ActiveLeaseInfo = {
124
+ leaseId,
125
+ acquiredAt: Date.now(),
126
+ label,
127
+ abortController,
128
+ parallelEscape,
129
+ };
130
+ slot.activeLeases.push(info);
131
+ logger.debug("[concurrency] lease acquired", {
132
+ accountId,
133
+ leaseId,
134
+ label,
135
+ parallelEscape: !!parallelEscape,
136
+ activeLeases: slot.activeLeases.length,
137
+ });
138
+
139
+ let released = false;
140
+ return {
141
+ accountId,
142
+ leaseId,
143
+ release() {
144
+ if (released) return;
145
+ released = true;
146
+ const idx = slot.activeLeases.findIndex((l) => l.leaseId === leaseId);
147
+ if (idx !== -1) slot.activeLeases.splice(idx, 1);
148
+ const heldMs = Date.now() - info.acquiredAt;
149
+ logger.debug("[concurrency] lease released", {
150
+ accountId,
151
+ leaseId,
152
+ label,
153
+ heldMs,
154
+ });
155
+ if (logger.isLevelEnabled("info")) {
156
+ console.log(
157
+ `🚦 [Server] Stream slot released | account=${accountId} | held ${heldMs}ms | label=${label} | leaseId=${leaseId.substring(0, 8)}`,
158
+ );
159
+ }
160
+ releaseSlot(accountId);
161
+ },
162
+ };
163
+ }
164
+
165
+ function releaseSlot(accountId: string): void {
166
+ const slot = slots.get(accountId);
167
+ if (!slot) return;
168
+
169
+ // Deliver to the next waiter in FIFO order
170
+ while (slot.queue.length > 0 && slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
171
+ const entry = slot.queue.shift();
172
+ if (!entry) break;
173
+ cleanupEntry(entry);
174
+ if (logger.isLevelEnabled("info")) {
175
+ console.log(
176
+ `🚦 [Server] Stream slot granted | account=${accountId} | waited ${Date.now() - entry.enqueuedAt}ms | ${slot.queue.length} still queued`,
177
+ );
178
+ }
179
+ entry.resolve(createLease(accountId, entry.label, entry.leaseAbortController));
180
+ return; // one at a time to preserve ordering
181
+ }
182
+
183
+ // Clean up empty slots to avoid unbounded map growth
184
+ if (slot.activeLeases.length === 0 && slot.queue.length === 0) {
185
+ slots.delete(accountId);
186
+ }
187
+ }
188
+
189
+ function cleanupEntry(entry: QueueEntry): void {
190
+ if (entry.timer) {
191
+ clearTimeout(entry.timer);
192
+ entry.timer = null;
193
+ }
194
+ if (entry.onAbort && entry.signal) {
195
+ entry.signal.removeEventListener("abort", entry.onAbort);
196
+ entry.onAbort = null;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Check for and force-release stale leases that exceeded the max duration.
202
+ * Returns the number of leases force-released.
203
+ */
204
+ function sweepStaleLeases(accountId: string): number {
205
+ const slot = slots.get(accountId);
206
+ if (!slot) return 0;
207
+
208
+ const maxDuration = config.concurrency.leaseMaxDurationMs;
209
+ if (maxDuration <= 0) return 0;
210
+
211
+ const now = Date.now();
212
+ let swept = 0;
213
+
214
+ for (let i = slot.activeLeases.length - 1; i >= 0; i--) {
215
+ const lease = slot.activeLeases[i];
216
+ const heldMs = now - lease.acquiredAt;
217
+ if (heldMs > maxDuration) {
218
+ slot.activeLeases.splice(i, 1);
219
+ swept++;
220
+ console.warn(
221
+ `⚠️ [Server] Stale lease force-released | account=${accountId} | label=${lease.label} | held ${Math.round(heldMs / 1000)}s (limit: ${Math.round(maxDuration / 1000)}s) | leaseId=${lease.leaseId}`,
222
+ );
223
+ logger.warn("[concurrency] stale lease force-released (possible leak)", {
224
+ accountId,
225
+ leaseId: lease.leaseId,
226
+ label: lease.label,
227
+ heldMs,
228
+ maxDurationMs: maxDuration,
229
+ });
230
+ }
231
+ }
232
+
233
+ // If we swept any, try to deliver to waiters
234
+ if (swept > 0) {
235
+ releaseSlot(accountId);
236
+ }
237
+
238
+ return swept;
239
+ }
240
+
241
+ /**
242
+ * Try to acquire a lease without waiting. Returns null if the account is at
243
+ * capacity.
244
+ */
245
+ export function tryAcquireAccountLease(
246
+ accountId: string,
247
+ label?: string,
248
+ leaseAbortController?: AbortController,
249
+ parallelEscape?: boolean,
250
+ ): AccountLease | null {
251
+ const slot = getSlot(accountId);
252
+ sweepStaleLeases(accountId);
253
+ if (slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
254
+ return createLease(
255
+ accountId,
256
+ label ?? "try-acquire",
257
+ leaseAbortController,
258
+ parallelEscape,
259
+ );
260
+ }
261
+ return null;
262
+ }
263
+
264
+ /**
265
+ * Acquire a lease, waiting in FIFO order if the account is at capacity.
266
+ * Rejects on timeout or abort.
267
+ */
268
+ export function acquireAccountLease(
269
+ accountId: string,
270
+ options?: AcquireAccountLeaseOptions,
271
+ ): Promise<AccountLease> {
272
+ const signal = options?.signal ?? null;
273
+ const label = options?.label ?? "unlabeled";
274
+
275
+ // Already aborted — reject before touching any slot
276
+ if (signal?.aborted) {
277
+ return Promise.reject(new Error("Aborted before acquiring account lease"));
278
+ }
279
+
280
+ const slot = getSlot(accountId);
281
+
282
+ // Sweep stale leases before checking capacity — a leaked lease should not
283
+ // block new requests indefinitely.
284
+ sweepStaleLeases(accountId);
285
+
286
+ // A NORMAL request never queues behind an auxiliary (parallel-escape) lease:
287
+ // the disposable title must yield its slot immediately. Belt-and-suspenders
288
+ // on top of the emit-aware supersede (which kills parallel leases even before
289
+ // they emit) — covers the case where the supersede could not find the lease.
290
+ if (
291
+ slot.activeLeases.length > 0 &&
292
+ slot.activeLeases.every((l) => l.parallelEscape)
293
+ ) {
294
+ for (const lease of [...slot.activeLeases]) {
295
+ logger.debug("[concurrency] preempting parallel-escape lease", {
296
+ accountId,
297
+ leaseId: lease.leaseId,
298
+ label: lease.label,
299
+ });
300
+ lease.abortController?.abort();
301
+ const i = slot.activeLeases.indexOf(lease);
302
+ if (i !== -1) slot.activeLeases.splice(i, 1);
303
+ }
304
+ releaseSlot(accountId); // deliver to the earliest waiter, if any
305
+ }
306
+
307
+ // Fast path: capacity available
308
+ if (slot.activeLeases.length < config.concurrency.maxStreamsPerAccount) {
309
+ return Promise.resolve(createLease(accountId, label, options?.leaseAbortController));
310
+ }
311
+
312
+ const hasExplicitTimeout =
313
+ options != null &&
314
+ Object.prototype.hasOwnProperty.call(options, "timeoutMs");
315
+ const timeoutMs = hasExplicitTimeout
316
+ ? options?.timeoutMs
317
+ : config.concurrency.busyWaitMs;
318
+
319
+ return new Promise<AccountLease>((resolve, reject) => {
320
+ const entry: QueueEntry = {
321
+ resolve,
322
+ reject,
323
+ timer: null,
324
+ onAbort: null,
325
+ signal,
326
+ enqueuedAt: Date.now(),
327
+ label,
328
+ leaseAbortController: options?.leaseAbortController,
329
+ };
330
+
331
+ const removeSelf = () => {
332
+ const idx = slot.queue.indexOf(entry);
333
+ if (idx !== -1) slot.queue.splice(idx, 1);
334
+ cleanupEntry(entry);
335
+ if (slot.activeLeases.length === 0 && slot.queue.length === 0) {
336
+ slots.delete(accountId);
337
+ }
338
+ };
339
+
340
+ // Timeout — only when the caller supplied a finite deadline. `null` means
341
+ // "wait as long as it takes" (bounded by the abort signal), which is what
342
+ // the last-usable account or the thread owner must do to stay lossless.
343
+ if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0) {
344
+ entry.timer = setTimeout(() => {
345
+ removeSelf();
346
+ console.log(
347
+ `🚦 [Server] Stream wait timeout | account=${accountId} | waited ${timeoutMs}ms | ${slot.queue.length} still queued`,
348
+ );
349
+ const busyError = new Error(
350
+ `Account ${accountId} busy: timed out after ${timeoutMs}ms waiting for a free slot`,
351
+ ) as Error & { code?: string };
352
+ busyError.code = "account_busy";
353
+ reject(busyError);
354
+ }, timeoutMs);
355
+ }
356
+
357
+ // Abort signal
358
+ if (signal) {
359
+ entry.onAbort = () => {
360
+ removeSelf();
361
+ if (logger.isLevelEnabled("info")) {
362
+ console.log(
363
+ `🚦 [Server] Stream waiter aborted | account=${accountId} | waited ${Date.now() - entry.enqueuedAt}ms | ${slot.queue.length} still queued`,
364
+ );
365
+ }
366
+ reject(new Error("Aborted while waiting for account lease"));
367
+ };
368
+ signal.addEventListener("abort", entry.onAbort, { once: true });
369
+ }
370
+
371
+ slot.queue.push(entry);
372
+
373
+ const holders = formatHolders(slot);
374
+ if (logger.isLevelEnabled("info")) {
375
+ console.log(
376
+ `🚦 [Server] Stream slot busy | account=${accountId} | held by: ${holders} | queued at position ${slot.queue.length} | timeout=${typeof timeoutMs === "number" && Number.isFinite(timeoutMs) ? `${timeoutMs}ms` : "unbounded"}`,
377
+ );
378
+ }
379
+
380
+ logger.debug("[concurrency] queued for account lease", {
381
+ accountId,
382
+ activeLeases: slot.activeLeases.length,
383
+ holders: slot.activeLeases.map((l) => ({ label: l.label, heldMs: Date.now() - l.acquiredAt })),
384
+ queueLength: slot.queue.length,
385
+ timeoutMs,
386
+ });
387
+ });
388
+ }
389
+
390
+ /**
391
+ * Abort and remove the active lease matching the given label (session id).
392
+ * Used when a client retries the same session: the old generation is no longer
393
+ * needed, so we abort it and free the slot for the new request.
394
+ *
395
+ * With `onlyIfEmitted`, a lease whose stream has NOT yet emitted a chunk to the
396
+ * client is PROTECTED: killing it would waste a generation the client has not
397
+ * consumed (a parallel title request racing the main stream, or a stream still
398
+ * mid-creation). Leases without a completionId (stream not acquired yet) are
399
+ * protected too. Once the stream emits, latest-wins applies (tool loop).
400
+ * Returns true if a lease was aborted, false otherwise.
401
+ */
402
+ export function abortLeaseByLabel(
403
+ accountId: string,
404
+ label: string,
405
+ opts?: { onlyIfEmitted?: boolean },
406
+ ): boolean {
407
+ const slot = slots.get(accountId);
408
+ if (!slot) return false;
409
+
410
+ const idx = slot.activeLeases.findIndex((l) => l.label === label);
411
+ if (idx === -1) return false;
412
+
413
+ const lease = slot.activeLeases[idx];
414
+ if (opts?.onlyIfEmitted) {
415
+ // A parallel-escape lease (auxiliary title on its own chat) is DISPOSABLE:
416
+ // any normal request of the same session may preempt it, even before it
417
+ // emits — the main conversation must never block on the title's slot.
418
+ if (!lease.parallelEscape) {
419
+ const stream = lease.completionId
420
+ ? getStream(lease.completionId)
421
+ : undefined;
422
+ if (!stream?.emittedChunk) {
423
+ // Unemitted (still thinking / mid-creation): protect the generation.
424
+ if (logger.isLevelEnabled("info")) {
425
+ console.log(
426
+ `🛡️ [Server] Supersede skipped (protected unemitted) | account=${accountId} | label=${label} | leaseId=${lease.leaseId} | completionId=${lease.completionId ?? "none"} | emitted=${stream?.emittedChunk ?? false}`,
427
+ );
428
+ }
429
+ return false;
430
+ }
431
+ }
432
+ }
433
+
434
+ const heldMs = Date.now() - lease.acquiredAt;
435
+ slot.activeLeases.splice(idx, 1);
436
+
437
+ if (logger.isLevelEnabled("info")) {
438
+ console.log(
439
+ `🔄 [Server] Session retry supersedes active lease | account=${accountId} | label=${label} | held ${Math.round(heldMs / 1000)}s | leaseId=${lease.leaseId}`,
440
+ );
441
+ }
442
+ logger.info("[concurrency] session retry superseded active lease", {
443
+ accountId,
444
+ label,
445
+ heldMs,
446
+ leaseId: lease.leaseId,
447
+ });
448
+
449
+ // Abort the stream associated with the old lease
450
+ if (lease.abortController) {
451
+ lease.abortController.abort();
452
+ }
453
+
454
+ // Free the slot for the next waiter
455
+ releaseSlot(accountId);
456
+ return true;
457
+ }
458
+
459
+ /**
460
+ * Abort any active lease matching the given session label ACROSS ALL accounts.
461
+ * Used BEFORE the per-chat lock: the client can fire the next turn while the
462
+ * previous stream is still open (streaming tool calls). Killing the stale
463
+ * generation first lets the new request take the chat lock immediately instead
464
+ * of waiting for the stale-lease sweep (600s) when the old upstream stalled.
465
+ * Returns true if at least one lease was aborted.
466
+ */
467
+ export function abortLeaseBySessionLabel(
468
+ label: string,
469
+ opts?: { onlyIfEmitted?: boolean },
470
+ ): boolean {
471
+ let aborted = false;
472
+ for (const accountId of slots.keys()) {
473
+ if (abortLeaseByLabel(accountId, label, opts)) aborted = true;
474
+ }
475
+ return aborted;
476
+ }
477
+
478
+ /**
479
+ * True when the session has an ACTIVE lease whose stream has not emitted a
480
+ * chunk yet (still thinking / mid-creation). A parallel request (title) must
481
+ * not kill it, and must run on its own chat instead of waiting.
482
+ */
483
+ export function hasUnemittedSessionStream(label: string): boolean {
484
+ for (const slot of slots.values()) {
485
+ for (const lease of slot.activeLeases) {
486
+ if (lease.label !== label) continue;
487
+ // A parallel-escape lease runs on its OWN chat — it must not poison the
488
+ // check and cascade every following turn into a new chat too.
489
+ if (lease.parallelEscape) continue;
490
+ const stream = lease.completionId
491
+ ? getStream(lease.completionId)
492
+ : undefined;
493
+ if (!stream?.emittedChunk) {
494
+ logger.debug("[concurrency] unemitted session stream found", {
495
+ label,
496
+ leaseId: lease.leaseId,
497
+ completionId: lease.completionId ?? null,
498
+ });
499
+ return true;
500
+ }
501
+ }
502
+ }
503
+ return false;
504
+ }
505
+
506
+ /**
507
+ * Attach the acquired stream's completion id to a lease so the emit-aware
508
+ * supersede can check whether the stream reached the client. Called by the
509
+ * stream owner right after createQwenStream succeeds.
510
+ */
511
+ export function markLeaseCompletion(
512
+ accountId: string,
513
+ leaseId: string,
514
+ completionId: string,
515
+ ): void {
516
+ const slot = slots.get(accountId);
517
+ const lease = slot?.activeLeases.find((l) => l.leaseId === leaseId);
518
+ if (lease) {
519
+ lease.completionId = completionId;
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Check whether an account currently has no free slots.
525
+ */
526
+ export function isAccountBusy(accountId: string): boolean {
527
+ const slot = slots.get(accountId);
528
+ if (!slot) return false;
529
+ return slot.activeLeases.length >= config.concurrency.maxStreamsPerAccount;
530
+ }
531
+
532
+ /**
533
+ * Check whether an account is currently serving at least one stream.
534
+ * Different question from isAccountBusy: that one answers "are all slots
535
+ * taken", while callers that must not disturb a generation in flight care
536
+ * about a single active lease even when spare slots remain.
537
+ */
538
+ export function hasActiveAccountLease(accountId: string): boolean {
539
+ const slot = slots.get(accountId);
540
+ if (!slot) return false;
541
+ return slot.activeLeases.length > 0;
542
+ }
543
+
544
+ /**
545
+ * Mark an account as temporarily busy (e.g. after chat_in_progress).
546
+ * This is a lightweight hint that prevents immediate re-selection without
547
+ * consuming a lease slot.
548
+ */
549
+ const temporaryBusyUntil = new Map<string, number>();
550
+
551
+ export function markAccountTemporarilyBusy(
552
+ accountId: string,
553
+ durationMs: number,
554
+ ): number {
555
+ const requestedUntil = Date.now() + Math.max(0, durationMs);
556
+ const currentUntil = temporaryBusyUntil.get(accountId) ?? 0;
557
+ const effectiveUntil = Math.max(currentUntil, requestedUntil);
558
+ temporaryBusyUntil.set(accountId, effectiveUntil);
559
+ return effectiveUntil;
560
+ }
561
+
562
+ export function isAccountTemporarilyBusy(accountId: string): boolean {
563
+ const until = temporaryBusyUntil.get(accountId);
564
+ if (!until) return false;
565
+ if (Date.now() >= until) {
566
+ temporaryBusyUntil.delete(accountId);
567
+ return false;
568
+ }
569
+ return true;
570
+ }
571
+
572
+ export function clearTemporaryBusy(
573
+ accountId: string,
574
+ expectedUntil?: number,
575
+ ): void {
576
+ if (
577
+ expectedUntil !== undefined &&
578
+ temporaryBusyUntil.get(accountId) !== expectedUntil
579
+ ) {
580
+ return;
581
+ }
582
+ temporaryBusyUntil.delete(accountId);
583
+ }
584
+
585
+ /**
586
+ * Snapshot for logging/metrics.
587
+ */
588
+ export function getAccountConcurrencySnapshot(): Array<{
589
+ accountId: string;
590
+ active: number;
591
+ waiting: number;
592
+ limit: number;
593
+ holders: Array<{ label: string; heldMs: number }>;
594
+ }> {
595
+ const result: Array<{
596
+ accountId: string;
597
+ active: number;
598
+ waiting: number;
599
+ limit: number;
600
+ holders: Array<{ label: string; heldMs: number }>;
601
+ }> = [];
602
+ const now = Date.now();
603
+ for (const [accountId, slot] of slots) {
604
+ result.push({
605
+ accountId,
606
+ active: slot.activeLeases.length,
607
+ waiting: slot.queue.length,
608
+ limit: config.concurrency.maxStreamsPerAccount,
609
+ holders: slot.activeLeases.map((l) => ({
610
+ label: l.label,
611
+ heldMs: now - l.acquiredAt,
612
+ })),
613
+ });
614
+ }
615
+ return result;
616
+ }
617
+
618
+ /**
619
+ * Sweep all accounts for stale leases. Call periodically (e.g. from a timer
620
+ * or before each request batch) to catch leaked leases proactively.
621
+ */
622
+ export function sweepAllStaleLeases(): number {
623
+ let total = 0;
624
+ for (const accountId of slots.keys()) {
625
+ total += sweepStaleLeases(accountId);
626
+ }
627
+ return total;
628
+ }
629
+
630
+ // Periodic stale-lease sweep: runs every 30s to catch leaked leases even when
631
+ // no new requests arrive. This is the safety net that prevents "slot busy
632
+ // forever" from a single missed release() call.
633
+ const SWEEP_INTERVAL_MS = 30_000;
634
+ let sweepTimer: ReturnType<typeof setInterval> | null = null;
635
+
636
+ export function startLeaseSweepTimer(): void {
637
+ if (sweepTimer) return;
638
+ sweepTimer = setInterval(() => {
639
+ const swept = sweepAllStaleLeases();
640
+ if (swept > 0) {
641
+ logger.info("[concurrency] periodic stale lease sweep", { swept });
642
+ }
643
+ }, SWEEP_INTERVAL_MS);
644
+ // Allow the process to exit even if the timer is running
645
+ if (sweepTimer && typeof sweepTimer === "object" && "unref" in sweepTimer) {
646
+ (sweepTimer as NodeJS.Timeout).unref();
647
+ }
648
+ }
649
+
650
+ export function stopLeaseSweepTimer(): void {
651
+ if (sweepTimer) {
652
+ clearInterval(sweepTimer);
653
+ sweepTimer = null;
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Reset all state. For tests only.
659
+ */
660
+ export function resetAccountConcurrencyForTests(): void {
661
+ stopLeaseSweepTimer();
662
+ for (const [, slot] of slots) {
663
+ for (const entry of slot.queue) {
664
+ cleanupEntry(entry);
665
+ entry.reject(new Error("Reset for tests"));
666
+ }
667
+ }
668
+ slots.clear();
669
+ temporaryBusyUntil.clear();
670
+ leaseCounter = 0;
671
+ }