@cartanova/qgrid-cli 2.5.7 → 2.5.9

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.
@@ -1,9 +1,10 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.types";
4
+ import { TokenModel } from "../../../application/token/token.model";
4
5
  import { type OpenAICredentials } from "../../../application/token/token.types";
5
6
  import { type GenerateRequest, type GenerateResult } from "../common/provider-dispatcher";
6
- import { type CodexAppServerWorker } from "./codex-worker";
7
+ import { CodexAppServerWorker } from "./codex-worker";
7
8
  import {
8
9
  ImageGenerationError,
9
10
  makeOpenAIWorkerId,
@@ -268,6 +269,244 @@ describe("OpenAIDispatcher monit stats", () => {
268
269
  });
269
270
 
270
271
  describe("OpenAIDispatcher autoscaling", () => {
272
+ it("runs minimum-capacity maintenance in fixed worker mode", async () => {
273
+ vi.useFakeTimers();
274
+ const findMany = vi.spyOn(TokenModel, "findMany").mockResolvedValue({ rows: [] } as never);
275
+ const dispatcher = new OpenAIDispatcher(
276
+ autoscaleConfig({
277
+ autoscale: false,
278
+ minWorkersPerToken: 2,
279
+ maxWorkersPerToken: 2,
280
+ }),
281
+ () => 64,
282
+ );
283
+ const evaluateAutoscaling = vi
284
+ .spyOn(dispatcher, "evaluateAutoscaling")
285
+ .mockResolvedValue(undefined);
286
+
287
+ try {
288
+ await dispatcher.start();
289
+ await vi.advanceTimersByTimeAsync(5_000);
290
+
291
+ expect(evaluateAutoscaling).toHaveBeenCalledTimes(1);
292
+ } finally {
293
+ await dispatcher.stop();
294
+ findMany.mockRestore();
295
+ vi.useRealTimers();
296
+ }
297
+ });
298
+
299
+ it("repairs an absent token pool after every initial worker spawn failed", async () => {
300
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
301
+ const tokenCredentials = credentials();
302
+ const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker").mockResolvedValueOnce(null);
303
+
304
+ await dispatcher.spawnWorkers(1, "token", tokenCredentials, null, 1);
305
+ expect(dispatcher.workerPool.has(1)).toBe(false);
306
+
307
+ const recoveredWorker = fakeWorker({ tokenId: 1, workerIndex: 0 });
308
+ spawnSingleWorker.mockResolvedValueOnce(recoveredWorker);
309
+ await dispatcher.evaluateAutoscaling();
310
+
311
+ expect(dispatcher.workerPool.get(1)).toEqual([recoveredWorker]);
312
+ expect(recoveredWorker.kill).not.toHaveBeenCalled();
313
+ });
314
+
315
+ it("does not race minimum repair against an in-flight token bootstrap", async () => {
316
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
317
+ const pendingSpawn = deferred<CodexAppServerWorker | null>();
318
+ const spawnSingleWorker = vi
319
+ .spyOn(dispatcher, "spawnSingleWorker")
320
+ .mockReturnValue(pendingSpawn.promise);
321
+
322
+ const bootstrap = dispatcher.spawnWorkers(1, "token", credentials(), null, 1);
323
+ await vi.waitFor(() => expect(spawnSingleWorker).toHaveBeenCalledTimes(1));
324
+ const evaluation = dispatcher.evaluateAutoscaling();
325
+
326
+ expect(spawnSingleWorker).toHaveBeenCalledTimes(1);
327
+ const worker = fakeWorker({ tokenId: 1, workerIndex: 0 });
328
+ pendingSpawn.resolve(worker);
329
+ await Promise.all([bootstrap, evaluation]);
330
+
331
+ expect(dispatcher.workerPool.get(1)).toEqual([worker]);
332
+ expect(worker.kill).not.toHaveBeenCalled();
333
+ });
334
+
335
+ it("discards a bootstrapping worker when its token is removed mid-spawn", async () => {
336
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
337
+ const pendingSpawn = deferred<CodexAppServerWorker | null>();
338
+ const spawnSingleWorker = vi
339
+ .spyOn(dispatcher, "spawnSingleWorker")
340
+ .mockReturnValue(pendingSpawn.promise);
341
+
342
+ const bootstrap = dispatcher.spawnWorkers(1, "token", credentials(), null, 1);
343
+ await vi.waitFor(() => expect(spawnSingleWorker).toHaveBeenCalledTimes(1));
344
+ await dispatcher.onTokenRemoved(1);
345
+ const staleWorker = fakeWorker({ tokenId: 1, workerIndex: 0 });
346
+ pendingSpawn.resolve(staleWorker);
347
+ await bootstrap;
348
+
349
+ expect(dispatcher.workerPool.has(1)).toBe(false);
350
+ expect(staleWorker.kill).toHaveBeenCalledTimes(1);
351
+ });
352
+
353
+ it("repairs the configured minimum even when demand autoscaling is disabled", async () => {
354
+ const dispatcher = new OpenAIDispatcher(
355
+ autoscaleConfig({
356
+ autoscale: false,
357
+ minWorkersPerToken: 2,
358
+ maxWorkersPerToken: 2,
359
+ }),
360
+ () => 0,
361
+ );
362
+ const tokenCredentials = credentials();
363
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
364
+ await dispatcher.onTokenAdded(1, "token", tokenCredentials, null, 1);
365
+ const existing = fakeWorker({ tokenId: 1, workerIndex: 0 });
366
+ dispatcher.workerPool.set(1, [existing]);
367
+ const recovered = fakeWorker({ tokenId: 1, workerIndex: 1 });
368
+ const spawnSingleWorker = vi
369
+ .spyOn(dispatcher, "spawnSingleWorker")
370
+ .mockResolvedValue(recovered);
371
+
372
+ await dispatcher.evaluateAutoscaling();
373
+
374
+ expect(spawnSingleWorker).toHaveBeenCalledWith(1, "token", tokenCredentials, 1);
375
+ expect(dispatcher.workerPool.get(1)).toEqual([existing, recovered]);
376
+ });
377
+
378
+ it("does not retry a failed minimum spawn as demand expansion in the same evaluation", async () => {
379
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
380
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
381
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
382
+ dispatcher.queue.push({} as never);
383
+ const spawnSingleWorker = vi
384
+ .spyOn(dispatcher, "spawnSingleWorker")
385
+ .mockResolvedValue(null);
386
+
387
+ await dispatcher.evaluateAutoscaling();
388
+
389
+ expect(spawnSingleWorker).toHaveBeenCalledTimes(1);
390
+ dispatcher.queue = [];
391
+ });
392
+
393
+ it("keeps scaling healthy tokens when another token minimum repair fails", async () => {
394
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
395
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
396
+ await dispatcher.onTokenAdded(1, "broken", credentials(), null, 1);
397
+ await dispatcher.onTokenAdded(2, "healthy", credentials(), null, 1);
398
+ dispatcher.workerPool.set(2, [
399
+ fakeWorker({ tokenId: 2, tokenName: "healthy", workerIndex: 0, busy: true }),
400
+ ]);
401
+ dispatcher.queue.push({} as never);
402
+ const healthyExpansion = fakeWorker({
403
+ tokenId: 2,
404
+ tokenName: "healthy",
405
+ workerIndex: 1,
406
+ });
407
+ const spawnSingleWorker = vi
408
+ .spyOn(dispatcher, "spawnSingleWorker")
409
+ .mockImplementation(async (tokenId) => (tokenId === 1 ? null : healthyExpansion));
410
+
411
+ await dispatcher.evaluateAutoscaling();
412
+
413
+ expect(spawnSingleWorker).toHaveBeenCalledTimes(2);
414
+ expect(dispatcher.workerPool.get(2)).toHaveLength(2);
415
+ dispatcher.queue = [];
416
+ });
417
+
418
+ it("does not duplicate a minimum slot while its worker is transiently restarting", async () => {
419
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
420
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
421
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
422
+ dispatcher.workerPool.set(1, [
423
+ fakeWorker({ tokenId: 1, workerIndex: 0, ready: false }),
424
+ ]);
425
+ const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker");
426
+
427
+ await dispatcher.evaluateAutoscaling();
428
+
429
+ expect(spawnSingleWorker).not.toHaveBeenCalled();
430
+ expect(dispatcher.workerPool.get(1)).toHaveLength(1);
431
+ });
432
+
433
+ it("queues while active token metadata exists but every worker is unavailable", async () => {
434
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
435
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
436
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
437
+ const evaluateAutoscaling = vi
438
+ .spyOn(dispatcher, "evaluateAutoscaling")
439
+ .mockResolvedValue(undefined);
440
+
441
+ const queued = dispatcher.enqueue(async (worker) => worker.tokenName);
442
+ await waitForQueue(dispatcher);
443
+
444
+ expect(evaluateAutoscaling).toHaveBeenCalled();
445
+ const recovered = fakeWorker({ tokenId: 1, workerIndex: 0 });
446
+ dispatcher.workerPool.set(1, [recovered]);
447
+ await dispatcher.drainQueue();
448
+
449
+ await expect(queued).resolves.toBe("token");
450
+ });
451
+
452
+ it("still rejects immediately when no active OpenAI token metadata exists", async () => {
453
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
454
+
455
+ await expect(dispatcher.enqueue(async () => "ok")).rejects.toThrow("NO_OPENAI_WORKERS");
456
+ expect(dispatcher.queueLength).toBe(0);
457
+ });
458
+
459
+ it("keeps a recovery queue when another token is removed but an active token remains", async () => {
460
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
461
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
462
+ await dispatcher.onTokenAdded(1, "removed", credentials(), null, 1);
463
+ await dispatcher.onTokenAdded(2, "recovering", credentials(), null, 1);
464
+
465
+ const queued = dispatcher.enqueue(async () => "ok");
466
+ const rejection = expect(queued).rejects.toThrow("TEST_CLEANUP");
467
+ await waitForQueue(dispatcher);
468
+ await dispatcher.onTokenRemoved(1);
469
+
470
+ expect(dispatcher.queueLength).toBe(1);
471
+ dispatcher.rejectAllQueued("TEST_CLEANUP");
472
+ await rejection;
473
+ });
474
+
475
+ it("keeps a recovery queue when another token is deactivated but an active token remains", async () => {
476
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
477
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
478
+ await dispatcher.onTokenAdded(1, "deactivated", credentials(), null, 1);
479
+ await dispatcher.onTokenAdded(2, "recovering", credentials(), null, 1);
480
+
481
+ const queued = dispatcher.enqueue(async () => "ok");
482
+ const rejection = expect(queued).rejects.toThrow("TEST_CLEANUP");
483
+ await waitForQueue(dispatcher);
484
+ dispatcher.onTokenDeactivated(1);
485
+
486
+ expect(dispatcher.queueLength).toBe(1);
487
+ dispatcher.rejectAllQueued("TEST_CLEANUP");
488
+ await rejection;
489
+ });
490
+
491
+ it("drains immediately when capacity appears before a fixed-mode queue wake-up", async () => {
492
+ const dispatcher = new OpenAIDispatcher(
493
+ autoscaleConfig({
494
+ autoscale: false,
495
+ minWorkersPerToken: 1,
496
+ maxWorkersPerToken: 1,
497
+ }),
498
+ () => 64,
499
+ );
500
+ const worker = fakeWorker({ tokenId: 1, workerIndex: 0 });
501
+ addWorkers(dispatcher, [worker]);
502
+ await dispatcher.onTokenUpdated(1, "token", credentials(), null, 1);
503
+
504
+ await expect(dispatcher.enqueue(async (selected) => selected.tokenName)).resolves.toBe(
505
+ "token",
506
+ );
507
+ expect(dispatcher.queueLength).toBe(0);
508
+ });
509
+
271
510
  it("scales six active tokens from 5 to 15 workers per token", async () => {
272
511
  const dispatcher = new OpenAIDispatcher(
273
512
  autoscaleConfig({
@@ -363,6 +602,40 @@ describe("OpenAIDispatcher autoscaling", () => {
363
602
  expect(dispatcher.workerPool.get(2)).toHaveLength(2);
364
603
  });
365
604
 
605
+ it("maintains the configured minimum for a quota-blocked token", async () => {
606
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
607
+ const tokenCredentials = credentials({ accountId: "haze" });
608
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
609
+ await dispatcher.onTokenAdded(1, "openai/haze", tokenCredentials, 80, 1);
610
+ dispatcher.workerPool.set(1, [
611
+ fakeWorker({ tokenId: 1, tokenName: "openai/haze", workerIndex: 0 }),
612
+ ]);
613
+ readOpenAIQuotaUsageMock.mockResolvedValue(quotaOk(85));
614
+
615
+ await expect(dispatcher.acquireIdleWorker()).rejects.toBeInstanceOf(
616
+ QuotaThresholdExceededError,
617
+ );
618
+ dispatcher.workerPool.delete(1);
619
+ const replacement = fakeWorker({
620
+ tokenId: 1,
621
+ tokenName: "openai/haze",
622
+ workerIndex: 0,
623
+ });
624
+ const spawnSingleWorker = vi
625
+ .spyOn(dispatcher, "spawnSingleWorker")
626
+ .mockResolvedValue(replacement);
627
+
628
+ await dispatcher.evaluateAutoscaling();
629
+
630
+ expect(spawnSingleWorker).toHaveBeenCalledWith(
631
+ 1,
632
+ "openai/haze",
633
+ tokenCredentials,
634
+ 0,
635
+ );
636
+ expect(dispatcher.workerPool.get(1)).toEqual([replacement]);
637
+ });
638
+
366
639
  it("discards a worker spawned while its token becomes quota-blocked", async () => {
367
640
  const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
368
641
  const tokenCredentials = credentials();
@@ -443,6 +716,168 @@ describe("OpenAIDispatcher autoscaling", () => {
443
716
  expect(busy.kill).not.toHaveBeenCalled();
444
717
  expect(dispatcher.workerPool.get(1)).toEqual([busy]);
445
718
  });
719
+
720
+ it.each([1, 2, 5])(
721
+ "converges repeated idle evaluations to configured minimum %i and never below it",
722
+ async (minimum) => {
723
+ vi.useFakeTimers();
724
+ try {
725
+ vi.setSystemTime(1_000);
726
+ const initialCount = minimum + 3;
727
+ const dispatcher = new OpenAIDispatcher(
728
+ autoscaleConfig({
729
+ minWorkersPerToken: minimum,
730
+ maxWorkersPerToken: initialCount,
731
+ scaleDownIdleMs: 1_000,
732
+ }),
733
+ () => 64,
734
+ );
735
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
736
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
737
+ const workers = Array.from({ length: initialCount }, (_, workerIndex) =>
738
+ fakeWorker({ tokenId: 1, workerIndex }),
739
+ );
740
+ dispatcher.workerPool.set(1, workers);
741
+ vi.setSystemTime(2_001);
742
+
743
+ await dispatcher.evaluateAutoscaling();
744
+ expect(dispatcher.workerPool.get(1)).toHaveLength(initialCount - 1);
745
+ await dispatcher.evaluateAutoscaling();
746
+ expect(dispatcher.workerPool.get(1)).toHaveLength(initialCount - 2);
747
+ await dispatcher.evaluateAutoscaling();
748
+ expect(dispatcher.workerPool.get(1)).toHaveLength(minimum);
749
+ await dispatcher.evaluateAutoscaling();
750
+ expect(dispatcher.workerPool.get(1)).toHaveLength(minimum);
751
+
752
+ for (let workerIndex = minimum; workerIndex < initialCount; workerIndex++) {
753
+ expect(workers[workerIndex]?.kill).toHaveBeenCalledTimes(1);
754
+ }
755
+ for (let workerIndex = 0; workerIndex < minimum; workerIndex++) {
756
+ expect(workers[workerIndex]?.kill).not.toHaveBeenCalled();
757
+ }
758
+ } finally {
759
+ vi.useRealTimers();
760
+ }
761
+ },
762
+ );
763
+
764
+ it("still scales healthy idle pools down when another token cannot recover its minimum", async () => {
765
+ vi.useFakeTimers();
766
+ try {
767
+ vi.setSystemTime(1_000);
768
+ const dispatcher = new OpenAIDispatcher(
769
+ autoscaleConfig({ scaleDownIdleMs: 1_000 }),
770
+ () => 64,
771
+ );
772
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
773
+ await dispatcher.onTokenAdded(1, "broken", credentials(), null, 1);
774
+ await dispatcher.onTokenAdded(2, "healthy", credentials(), null, 1);
775
+ const healthyWorkers = [
776
+ fakeWorker({ tokenId: 2, tokenName: "healthy", workerIndex: 0 }),
777
+ fakeWorker({ tokenId: 2, tokenName: "healthy", workerIndex: 1 }),
778
+ ];
779
+ dispatcher.workerPool.set(2, healthyWorkers);
780
+ vi.spyOn(dispatcher, "spawnSingleWorker").mockResolvedValue(null);
781
+ vi.setSystemTime(2_001);
782
+
783
+ await dispatcher.evaluateAutoscaling();
784
+
785
+ expect(dispatcher.workerPool.get(2)).toEqual([healthyWorkers[0]]);
786
+ expect(healthyWorkers[1]?.kill).toHaveBeenCalledTimes(1);
787
+ } finally {
788
+ vi.useRealTimers();
789
+ }
790
+ });
791
+
792
+ it("removes a terminal worker by identity and requests minimum repair", async () => {
793
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
794
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
795
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
796
+ const terminal = fakeWorker({ tokenId: 1, workerIndex: 0, ready: false });
797
+ dispatcher.workerPool.set(1, [terminal]);
798
+ const evaluateAutoscaling = vi
799
+ .spyOn(dispatcher, "evaluateAutoscaling")
800
+ .mockResolvedValue(undefined);
801
+
802
+ await dispatcher.handleWorkerTerminalFailure(terminal);
803
+
804
+ expect(dispatcher.workerPool.has(1)).toBe(false);
805
+ expect(terminal.kill).toHaveBeenCalledTimes(1);
806
+ expect(evaluateAutoscaling).toHaveBeenCalledTimes(1);
807
+ });
808
+
809
+ it("returns to the configured minimum after scale-down and terminal failure overlap", async () => {
810
+ const dispatcher = new OpenAIDispatcher(
811
+ autoscaleConfig({
812
+ minWorkersPerToken: 2,
813
+ maxWorkersPerToken: 3,
814
+ scaleDownIdleMs: 0,
815
+ }),
816
+ () => 64,
817
+ );
818
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
819
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
820
+ const terminal = fakeWorker({ tokenId: 1, workerIndex: 0, ready: false });
821
+ const survivor = fakeWorker({ tokenId: 1, workerIndex: 1 });
822
+ const excess = fakeWorker({ tokenId: 1, workerIndex: 2 });
823
+ dispatcher.workerPool.set(1, [terminal, survivor, excess]);
824
+
825
+ await dispatcher.scaleDownOneStep();
826
+ expect(dispatcher.workerPool.get(1)).toEqual([terminal, survivor]);
827
+
828
+ const replacement = fakeWorker({ tokenId: 1, workerIndex: 0 });
829
+ vi.spyOn(dispatcher, "spawnSingleWorker").mockResolvedValue(replacement);
830
+ await dispatcher.handleWorkerTerminalFailure(terminal);
831
+ await vi.waitFor(() =>
832
+ expect(dispatcher.workerPool.get(1)).toEqual([replacement, survivor]),
833
+ );
834
+
835
+ expect(excess.kill).toHaveBeenCalledTimes(1);
836
+ expect(terminal.kill).toHaveBeenCalledTimes(1);
837
+ expect(dispatcher.workerPool.get(1)).toHaveLength(2);
838
+ });
839
+
840
+ it("ignores a stale terminal callback after the token pool was replaced", async () => {
841
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
842
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
843
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
844
+ const stale = fakeWorker({ tokenId: 1, workerIndex: 0, ready: false });
845
+ const current = fakeWorker({ tokenId: 1, workerIndex: 0 });
846
+ dispatcher.workerPool.set(1, [current]);
847
+ const evaluateAutoscaling = vi.spyOn(dispatcher, "evaluateAutoscaling");
848
+
849
+ await dispatcher.handleWorkerTerminalFailure(stale);
850
+
851
+ expect(dispatcher.workerPool.get(1)).toEqual([current]);
852
+ expect(current.kill).not.toHaveBeenCalled();
853
+ expect(evaluateAutoscaling).not.toHaveBeenCalled();
854
+ });
855
+
856
+ it("wires a spawned worker terminal signal to dispatcher cleanup", async () => {
857
+ const initialize = vi
858
+ .spyOn(CodexAppServerWorker.prototype, "initialize")
859
+ .mockResolvedValue(undefined);
860
+ const kill = vi.spyOn(CodexAppServerWorker.prototype, "kill").mockResolvedValue(undefined);
861
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
862
+
863
+ try {
864
+ await dispatcher.spawnWorkers(1, "token", credentials(), null, 1);
865
+ const worker = dispatcher.workerPool.get(1)?.[0];
866
+ expect(worker).toBeDefined();
867
+ const evaluateAutoscaling = vi
868
+ .spyOn(dispatcher, "evaluateAutoscaling")
869
+ .mockResolvedValue(undefined);
870
+
871
+ worker?.onTerminalFailure?.();
872
+ await vi.waitFor(() => expect(evaluateAutoscaling).toHaveBeenCalledTimes(1));
873
+
874
+ expect(kill).toHaveBeenCalledTimes(1);
875
+ expect(dispatcher.workerPool.has(1)).toBe(false);
876
+ } finally {
877
+ initialize.mockRestore();
878
+ kill.mockRestore();
879
+ }
880
+ });
446
881
  });
447
882
 
448
883
  describe("OpenAIDispatcher token updates", () => {
@@ -987,8 +1422,8 @@ describe("OpenAIDispatcher quota threshold gate", () => {
987
1422
 
988
1423
  it("rechecks threshold at drain time before assigning the released worker", async () => {
989
1424
  const dispatcher = new OpenAIDispatcher();
990
- const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
991
- const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B" });
1425
+ const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true });
1426
+ const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B", busy: true });
992
1427
  addWorkers(dispatcher, [workerA, workerB]);
993
1428
  await dispatcher.onTokenUpdated(1, "tok-A", credentials(), 80);
994
1429
  await dispatcher.onTokenUpdated(2, "tok-B", credentials(), 80);
@@ -998,6 +1433,8 @@ describe("OpenAIDispatcher quota threshold gate", () => {
998
1433
  readOpenAIQuotaUsageMock
999
1434
  .mockResolvedValueOnce(quotaOk(90))
1000
1435
  .mockResolvedValueOnce(quotaOk(10));
1436
+ workerA.releaseTurn();
1437
+ workerB.releaseTurn();
1001
1438
 
1002
1439
  await dispatcher.drainQueue();
1003
1440
 
@@ -1006,12 +1443,13 @@ describe("OpenAIDispatcher quota threshold gate", () => {
1006
1443
 
1007
1444
  it("serializes concurrent drain calls so a queue item is not assigned twice", async () => {
1008
1445
  const dispatcher = new OpenAIDispatcher();
1009
- const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
1446
+ const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true });
1010
1447
  addWorkers(dispatcher, [worker]);
1011
1448
  await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
1012
1449
  const execute = vi.fn(async (w: CodexAppServerWorker) => w.tokenName);
1013
1450
  const queued = dispatcher.enqueue(execute);
1014
1451
  await waitForQueue(dispatcher);
1452
+ worker.releaseTurn();
1015
1453
 
1016
1454
  await Promise.all([dispatcher.drainQueue(), dispatcher.drainQueue()]);
1017
1455