@cartanova/qgrid-cli 2.5.8 → 2.5.10

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,
@@ -141,6 +142,7 @@ function fakeWorker(
141
142
  releaseTurn: vi.fn(() => {
142
143
  busy = false;
143
144
  }),
145
+ sweepIdleThreads: vi.fn(),
144
146
  hasThread: vi.fn((threadId: string) => (options.threads ?? []).includes(threadId)),
145
147
  getRateLimits: vi.fn(
146
148
  async () => options.rateLimits ?? { rateLimits: {}, rateLimitsByLimitId: null },
@@ -268,6 +270,284 @@ describe("OpenAIDispatcher monit stats", () => {
268
270
  });
269
271
 
270
272
  describe("OpenAIDispatcher autoscaling", () => {
273
+ it("runs minimum-capacity maintenance in fixed worker mode", async () => {
274
+ vi.useFakeTimers();
275
+ const findMany = vi.spyOn(TokenModel, "findMany").mockResolvedValue({ rows: [] } as never);
276
+ const dispatcher = new OpenAIDispatcher(
277
+ autoscaleConfig({
278
+ autoscale: false,
279
+ minWorkersPerToken: 2,
280
+ maxWorkersPerToken: 2,
281
+ }),
282
+ () => 64,
283
+ );
284
+ const evaluateAutoscaling = vi
285
+ .spyOn(dispatcher, "evaluateAutoscaling")
286
+ .mockResolvedValue(undefined);
287
+
288
+ try {
289
+ await dispatcher.start();
290
+ await vi.advanceTimersByTimeAsync(5_000);
291
+
292
+ expect(evaluateAutoscaling).toHaveBeenCalledTimes(1);
293
+ } finally {
294
+ await dispatcher.stop();
295
+ findMany.mockRestore();
296
+ vi.useRealTimers();
297
+ }
298
+ });
299
+
300
+ it("runs idle-thread cleanup from the fixed-mode health timer", async () => {
301
+ vi.useFakeTimers();
302
+ const findMany = vi.spyOn(TokenModel, "findMany").mockResolvedValue({ rows: [] } as never);
303
+ const dispatcher = new OpenAIDispatcher(
304
+ autoscaleConfig({
305
+ autoscale: false,
306
+ minWorkersPerToken: 1,
307
+ maxWorkersPerToken: 1,
308
+ }),
309
+ () => 64,
310
+ );
311
+ const worker = fakeWorker({ tokenId: 1 });
312
+ addWorkers(dispatcher, [worker]);
313
+
314
+ try {
315
+ await dispatcher.start();
316
+ await vi.advanceTimersByTimeAsync(5_000);
317
+
318
+ expect(worker.sweepIdleThreads).toHaveBeenCalledTimes(1);
319
+ } finally {
320
+ await dispatcher.stop();
321
+ findMany.mockRestore();
322
+ vi.useRealTimers();
323
+ }
324
+ });
325
+
326
+ it("sweeps only ready idle workers so active turns keep their subscription", async () => {
327
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
328
+ const idle = fakeWorker({ tokenId: 1, workerIndex: 0 });
329
+ const busy = fakeWorker({ tokenId: 1, workerIndex: 1, busy: true });
330
+ const restarting = fakeWorker({ tokenId: 1, workerIndex: 2, ready: false });
331
+ addWorkers(dispatcher, [idle, busy, restarting]);
332
+
333
+ await dispatcher.evaluateAutoscaling();
334
+
335
+ expect(idle.sweepIdleThreads).toHaveBeenCalledTimes(1);
336
+ expect(busy.sweepIdleThreads).not.toHaveBeenCalled();
337
+ expect(restarting.sweepIdleThreads).not.toHaveBeenCalled();
338
+ });
339
+
340
+ it("repairs an absent token pool after every initial worker spawn failed", async () => {
341
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
342
+ const tokenCredentials = credentials();
343
+ const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker").mockResolvedValueOnce(null);
344
+
345
+ await dispatcher.spawnWorkers(1, "token", tokenCredentials, null, 1);
346
+ expect(dispatcher.workerPool.has(1)).toBe(false);
347
+
348
+ const recoveredWorker = fakeWorker({ tokenId: 1, workerIndex: 0 });
349
+ spawnSingleWorker.mockResolvedValueOnce(recoveredWorker);
350
+ await dispatcher.evaluateAutoscaling();
351
+
352
+ expect(dispatcher.workerPool.get(1)).toEqual([recoveredWorker]);
353
+ expect(recoveredWorker.kill).not.toHaveBeenCalled();
354
+ });
355
+
356
+ it("does not race minimum repair against an in-flight token bootstrap", async () => {
357
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
358
+ const pendingSpawn = deferred<CodexAppServerWorker | null>();
359
+ const spawnSingleWorker = vi
360
+ .spyOn(dispatcher, "spawnSingleWorker")
361
+ .mockReturnValue(pendingSpawn.promise);
362
+
363
+ const bootstrap = dispatcher.spawnWorkers(1, "token", credentials(), null, 1);
364
+ await vi.waitFor(() => expect(spawnSingleWorker).toHaveBeenCalledTimes(1));
365
+ const evaluation = dispatcher.evaluateAutoscaling();
366
+
367
+ expect(spawnSingleWorker).toHaveBeenCalledTimes(1);
368
+ const worker = fakeWorker({ tokenId: 1, workerIndex: 0 });
369
+ pendingSpawn.resolve(worker);
370
+ await Promise.all([bootstrap, evaluation]);
371
+
372
+ expect(dispatcher.workerPool.get(1)).toEqual([worker]);
373
+ expect(worker.kill).not.toHaveBeenCalled();
374
+ });
375
+
376
+ it("discards a bootstrapping worker when its token is removed mid-spawn", async () => {
377
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
378
+ const pendingSpawn = deferred<CodexAppServerWorker | null>();
379
+ const spawnSingleWorker = vi
380
+ .spyOn(dispatcher, "spawnSingleWorker")
381
+ .mockReturnValue(pendingSpawn.promise);
382
+
383
+ const bootstrap = dispatcher.spawnWorkers(1, "token", credentials(), null, 1);
384
+ await vi.waitFor(() => expect(spawnSingleWorker).toHaveBeenCalledTimes(1));
385
+ await dispatcher.onTokenRemoved(1);
386
+ const staleWorker = fakeWorker({ tokenId: 1, workerIndex: 0 });
387
+ pendingSpawn.resolve(staleWorker);
388
+ await bootstrap;
389
+
390
+ expect(dispatcher.workerPool.has(1)).toBe(false);
391
+ expect(staleWorker.kill).toHaveBeenCalledTimes(1);
392
+ });
393
+
394
+ it("repairs the configured minimum even when demand autoscaling is disabled", async () => {
395
+ const dispatcher = new OpenAIDispatcher(
396
+ autoscaleConfig({
397
+ autoscale: false,
398
+ minWorkersPerToken: 2,
399
+ maxWorkersPerToken: 2,
400
+ }),
401
+ () => 0,
402
+ );
403
+ const tokenCredentials = credentials();
404
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
405
+ await dispatcher.onTokenAdded(1, "token", tokenCredentials, null, 1);
406
+ const existing = fakeWorker({ tokenId: 1, workerIndex: 0 });
407
+ dispatcher.workerPool.set(1, [existing]);
408
+ const recovered = fakeWorker({ tokenId: 1, workerIndex: 1 });
409
+ const spawnSingleWorker = vi
410
+ .spyOn(dispatcher, "spawnSingleWorker")
411
+ .mockResolvedValue(recovered);
412
+
413
+ await dispatcher.evaluateAutoscaling();
414
+
415
+ expect(spawnSingleWorker).toHaveBeenCalledWith(1, "token", tokenCredentials, 1);
416
+ expect(dispatcher.workerPool.get(1)).toEqual([existing, recovered]);
417
+ });
418
+
419
+ it("does not retry a failed minimum spawn as demand expansion in the same evaluation", async () => {
420
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
421
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
422
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
423
+ dispatcher.queue.push({} as never);
424
+ const spawnSingleWorker = vi
425
+ .spyOn(dispatcher, "spawnSingleWorker")
426
+ .mockResolvedValue(null);
427
+
428
+ await dispatcher.evaluateAutoscaling();
429
+
430
+ expect(spawnSingleWorker).toHaveBeenCalledTimes(1);
431
+ dispatcher.queue = [];
432
+ });
433
+
434
+ it("keeps scaling healthy tokens when another token minimum repair fails", async () => {
435
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
436
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
437
+ await dispatcher.onTokenAdded(1, "broken", credentials(), null, 1);
438
+ await dispatcher.onTokenAdded(2, "healthy", credentials(), null, 1);
439
+ dispatcher.workerPool.set(2, [
440
+ fakeWorker({ tokenId: 2, tokenName: "healthy", workerIndex: 0, busy: true }),
441
+ ]);
442
+ dispatcher.queue.push({} as never);
443
+ const healthyExpansion = fakeWorker({
444
+ tokenId: 2,
445
+ tokenName: "healthy",
446
+ workerIndex: 1,
447
+ });
448
+ const spawnSingleWorker = vi
449
+ .spyOn(dispatcher, "spawnSingleWorker")
450
+ .mockImplementation(async (tokenId) => (tokenId === 1 ? null : healthyExpansion));
451
+
452
+ await dispatcher.evaluateAutoscaling();
453
+
454
+ expect(spawnSingleWorker).toHaveBeenCalledTimes(2);
455
+ expect(dispatcher.workerPool.get(2)).toHaveLength(2);
456
+ dispatcher.queue = [];
457
+ });
458
+
459
+ it("does not duplicate a minimum slot while its worker is transiently restarting", async () => {
460
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
461
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
462
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
463
+ dispatcher.workerPool.set(1, [
464
+ fakeWorker({ tokenId: 1, workerIndex: 0, ready: false }),
465
+ ]);
466
+ const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker");
467
+
468
+ await dispatcher.evaluateAutoscaling();
469
+
470
+ expect(spawnSingleWorker).not.toHaveBeenCalled();
471
+ expect(dispatcher.workerPool.get(1)).toHaveLength(1);
472
+ });
473
+
474
+ it("queues while active token metadata exists but every worker is unavailable", async () => {
475
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
476
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
477
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
478
+ const evaluateAutoscaling = vi
479
+ .spyOn(dispatcher, "evaluateAutoscaling")
480
+ .mockResolvedValue(undefined);
481
+
482
+ const queued = dispatcher.enqueue(async (worker) => worker.tokenName);
483
+ await waitForQueue(dispatcher);
484
+
485
+ expect(evaluateAutoscaling).toHaveBeenCalled();
486
+ const recovered = fakeWorker({ tokenId: 1, workerIndex: 0 });
487
+ dispatcher.workerPool.set(1, [recovered]);
488
+ await dispatcher.drainQueue();
489
+
490
+ await expect(queued).resolves.toBe("token");
491
+ });
492
+
493
+ it("still rejects immediately when no active OpenAI token metadata exists", async () => {
494
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
495
+
496
+ await expect(dispatcher.enqueue(async () => "ok")).rejects.toThrow("NO_OPENAI_WORKERS");
497
+ expect(dispatcher.queueLength).toBe(0);
498
+ });
499
+
500
+ it("keeps a recovery queue when another token is removed but an active token remains", async () => {
501
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
502
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
503
+ await dispatcher.onTokenAdded(1, "removed", credentials(), null, 1);
504
+ await dispatcher.onTokenAdded(2, "recovering", credentials(), null, 1);
505
+
506
+ const queued = dispatcher.enqueue(async () => "ok");
507
+ const rejection = expect(queued).rejects.toThrow("TEST_CLEANUP");
508
+ await waitForQueue(dispatcher);
509
+ await dispatcher.onTokenRemoved(1);
510
+
511
+ expect(dispatcher.queueLength).toBe(1);
512
+ dispatcher.rejectAllQueued("TEST_CLEANUP");
513
+ await rejection;
514
+ });
515
+
516
+ it("keeps a recovery queue when another token is deactivated but an active token remains", async () => {
517
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
518
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
519
+ await dispatcher.onTokenAdded(1, "deactivated", credentials(), null, 1);
520
+ await dispatcher.onTokenAdded(2, "recovering", credentials(), null, 1);
521
+
522
+ const queued = dispatcher.enqueue(async () => "ok");
523
+ const rejection = expect(queued).rejects.toThrow("TEST_CLEANUP");
524
+ await waitForQueue(dispatcher);
525
+ dispatcher.onTokenDeactivated(1);
526
+
527
+ expect(dispatcher.queueLength).toBe(1);
528
+ dispatcher.rejectAllQueued("TEST_CLEANUP");
529
+ await rejection;
530
+ });
531
+
532
+ it("drains immediately when capacity appears before a fixed-mode queue wake-up", async () => {
533
+ const dispatcher = new OpenAIDispatcher(
534
+ autoscaleConfig({
535
+ autoscale: false,
536
+ minWorkersPerToken: 1,
537
+ maxWorkersPerToken: 1,
538
+ }),
539
+ () => 64,
540
+ );
541
+ const worker = fakeWorker({ tokenId: 1, workerIndex: 0 });
542
+ addWorkers(dispatcher, [worker]);
543
+ await dispatcher.onTokenUpdated(1, "token", credentials(), null, 1);
544
+
545
+ await expect(dispatcher.enqueue(async (selected) => selected.tokenName)).resolves.toBe(
546
+ "token",
547
+ );
548
+ expect(dispatcher.queueLength).toBe(0);
549
+ });
550
+
271
551
  it("scales six active tokens from 5 to 15 workers per token", async () => {
272
552
  const dispatcher = new OpenAIDispatcher(
273
553
  autoscaleConfig({
@@ -363,6 +643,40 @@ describe("OpenAIDispatcher autoscaling", () => {
363
643
  expect(dispatcher.workerPool.get(2)).toHaveLength(2);
364
644
  });
365
645
 
646
+ it("maintains the configured minimum for a quota-blocked token", async () => {
647
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
648
+ const tokenCredentials = credentials({ accountId: "haze" });
649
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
650
+ await dispatcher.onTokenAdded(1, "openai/haze", tokenCredentials, 80, 1);
651
+ dispatcher.workerPool.set(1, [
652
+ fakeWorker({ tokenId: 1, tokenName: "openai/haze", workerIndex: 0 }),
653
+ ]);
654
+ readOpenAIQuotaUsageMock.mockResolvedValue(quotaOk(85));
655
+
656
+ await expect(dispatcher.acquireIdleWorker()).rejects.toBeInstanceOf(
657
+ QuotaThresholdExceededError,
658
+ );
659
+ dispatcher.workerPool.delete(1);
660
+ const replacement = fakeWorker({
661
+ tokenId: 1,
662
+ tokenName: "openai/haze",
663
+ workerIndex: 0,
664
+ });
665
+ const spawnSingleWorker = vi
666
+ .spyOn(dispatcher, "spawnSingleWorker")
667
+ .mockResolvedValue(replacement);
668
+
669
+ await dispatcher.evaluateAutoscaling();
670
+
671
+ expect(spawnSingleWorker).toHaveBeenCalledWith(
672
+ 1,
673
+ "openai/haze",
674
+ tokenCredentials,
675
+ 0,
676
+ );
677
+ expect(dispatcher.workerPool.get(1)).toEqual([replacement]);
678
+ });
679
+
366
680
  it("discards a worker spawned while its token becomes quota-blocked", async () => {
367
681
  const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
368
682
  const tokenCredentials = credentials();
@@ -443,6 +757,168 @@ describe("OpenAIDispatcher autoscaling", () => {
443
757
  expect(busy.kill).not.toHaveBeenCalled();
444
758
  expect(dispatcher.workerPool.get(1)).toEqual([busy]);
445
759
  });
760
+
761
+ it.each([1, 2, 5])(
762
+ "converges repeated idle evaluations to configured minimum %i and never below it",
763
+ async (minimum) => {
764
+ vi.useFakeTimers();
765
+ try {
766
+ vi.setSystemTime(1_000);
767
+ const initialCount = minimum + 3;
768
+ const dispatcher = new OpenAIDispatcher(
769
+ autoscaleConfig({
770
+ minWorkersPerToken: minimum,
771
+ maxWorkersPerToken: initialCount,
772
+ scaleDownIdleMs: 1_000,
773
+ }),
774
+ () => 64,
775
+ );
776
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
777
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
778
+ const workers = Array.from({ length: initialCount }, (_, workerIndex) =>
779
+ fakeWorker({ tokenId: 1, workerIndex }),
780
+ );
781
+ dispatcher.workerPool.set(1, workers);
782
+ vi.setSystemTime(2_001);
783
+
784
+ await dispatcher.evaluateAutoscaling();
785
+ expect(dispatcher.workerPool.get(1)).toHaveLength(initialCount - 1);
786
+ await dispatcher.evaluateAutoscaling();
787
+ expect(dispatcher.workerPool.get(1)).toHaveLength(initialCount - 2);
788
+ await dispatcher.evaluateAutoscaling();
789
+ expect(dispatcher.workerPool.get(1)).toHaveLength(minimum);
790
+ await dispatcher.evaluateAutoscaling();
791
+ expect(dispatcher.workerPool.get(1)).toHaveLength(minimum);
792
+
793
+ for (let workerIndex = minimum; workerIndex < initialCount; workerIndex++) {
794
+ expect(workers[workerIndex]?.kill).toHaveBeenCalledTimes(1);
795
+ }
796
+ for (let workerIndex = 0; workerIndex < minimum; workerIndex++) {
797
+ expect(workers[workerIndex]?.kill).not.toHaveBeenCalled();
798
+ }
799
+ } finally {
800
+ vi.useRealTimers();
801
+ }
802
+ },
803
+ );
804
+
805
+ it("still scales healthy idle pools down when another token cannot recover its minimum", async () => {
806
+ vi.useFakeTimers();
807
+ try {
808
+ vi.setSystemTime(1_000);
809
+ const dispatcher = new OpenAIDispatcher(
810
+ autoscaleConfig({ scaleDownIdleMs: 1_000 }),
811
+ () => 64,
812
+ );
813
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
814
+ await dispatcher.onTokenAdded(1, "broken", credentials(), null, 1);
815
+ await dispatcher.onTokenAdded(2, "healthy", credentials(), null, 1);
816
+ const healthyWorkers = [
817
+ fakeWorker({ tokenId: 2, tokenName: "healthy", workerIndex: 0 }),
818
+ fakeWorker({ tokenId: 2, tokenName: "healthy", workerIndex: 1 }),
819
+ ];
820
+ dispatcher.workerPool.set(2, healthyWorkers);
821
+ vi.spyOn(dispatcher, "spawnSingleWorker").mockResolvedValue(null);
822
+ vi.setSystemTime(2_001);
823
+
824
+ await dispatcher.evaluateAutoscaling();
825
+
826
+ expect(dispatcher.workerPool.get(2)).toEqual([healthyWorkers[0]]);
827
+ expect(healthyWorkers[1]?.kill).toHaveBeenCalledTimes(1);
828
+ } finally {
829
+ vi.useRealTimers();
830
+ }
831
+ });
832
+
833
+ it("removes a terminal worker by identity and requests minimum repair", async () => {
834
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
835
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
836
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
837
+ const terminal = fakeWorker({ tokenId: 1, workerIndex: 0, ready: false });
838
+ dispatcher.workerPool.set(1, [terminal]);
839
+ const evaluateAutoscaling = vi
840
+ .spyOn(dispatcher, "evaluateAutoscaling")
841
+ .mockResolvedValue(undefined);
842
+
843
+ await dispatcher.handleWorkerTerminalFailure(terminal);
844
+
845
+ expect(dispatcher.workerPool.has(1)).toBe(false);
846
+ expect(terminal.kill).toHaveBeenCalledTimes(1);
847
+ expect(evaluateAutoscaling).toHaveBeenCalledTimes(1);
848
+ });
849
+
850
+ it("returns to the configured minimum after scale-down and terminal failure overlap", async () => {
851
+ const dispatcher = new OpenAIDispatcher(
852
+ autoscaleConfig({
853
+ minWorkersPerToken: 2,
854
+ maxWorkersPerToken: 3,
855
+ scaleDownIdleMs: 0,
856
+ }),
857
+ () => 64,
858
+ );
859
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
860
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
861
+ const terminal = fakeWorker({ tokenId: 1, workerIndex: 0, ready: false });
862
+ const survivor = fakeWorker({ tokenId: 1, workerIndex: 1 });
863
+ const excess = fakeWorker({ tokenId: 1, workerIndex: 2 });
864
+ dispatcher.workerPool.set(1, [terminal, survivor, excess]);
865
+
866
+ await dispatcher.scaleDownOneStep();
867
+ expect(dispatcher.workerPool.get(1)).toEqual([terminal, survivor]);
868
+
869
+ const replacement = fakeWorker({ tokenId: 1, workerIndex: 0 });
870
+ vi.spyOn(dispatcher, "spawnSingleWorker").mockResolvedValue(replacement);
871
+ await dispatcher.handleWorkerTerminalFailure(terminal);
872
+ await vi.waitFor(() =>
873
+ expect(dispatcher.workerPool.get(1)).toEqual([replacement, survivor]),
874
+ );
875
+
876
+ expect(excess.kill).toHaveBeenCalledTimes(1);
877
+ expect(terminal.kill).toHaveBeenCalledTimes(1);
878
+ expect(dispatcher.workerPool.get(1)).toHaveLength(2);
879
+ });
880
+
881
+ it("ignores a stale terminal callback after the token pool was replaced", async () => {
882
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
883
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
884
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
885
+ const stale = fakeWorker({ tokenId: 1, workerIndex: 0, ready: false });
886
+ const current = fakeWorker({ tokenId: 1, workerIndex: 0 });
887
+ dispatcher.workerPool.set(1, [current]);
888
+ const evaluateAutoscaling = vi.spyOn(dispatcher, "evaluateAutoscaling");
889
+
890
+ await dispatcher.handleWorkerTerminalFailure(stale);
891
+
892
+ expect(dispatcher.workerPool.get(1)).toEqual([current]);
893
+ expect(current.kill).not.toHaveBeenCalled();
894
+ expect(evaluateAutoscaling).not.toHaveBeenCalled();
895
+ });
896
+
897
+ it("wires a spawned worker terminal signal to dispatcher cleanup", async () => {
898
+ const initialize = vi
899
+ .spyOn(CodexAppServerWorker.prototype, "initialize")
900
+ .mockResolvedValue(undefined);
901
+ const kill = vi.spyOn(CodexAppServerWorker.prototype, "kill").mockResolvedValue(undefined);
902
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
903
+
904
+ try {
905
+ await dispatcher.spawnWorkers(1, "token", credentials(), null, 1);
906
+ const worker = dispatcher.workerPool.get(1)?.[0];
907
+ expect(worker).toBeDefined();
908
+ const evaluateAutoscaling = vi
909
+ .spyOn(dispatcher, "evaluateAutoscaling")
910
+ .mockResolvedValue(undefined);
911
+
912
+ worker?.onTerminalFailure?.();
913
+ await vi.waitFor(() => expect(evaluateAutoscaling).toHaveBeenCalledTimes(1));
914
+
915
+ expect(kill).toHaveBeenCalledTimes(1);
916
+ expect(dispatcher.workerPool.has(1)).toBe(false);
917
+ } finally {
918
+ initialize.mockRestore();
919
+ kill.mockRestore();
920
+ }
921
+ });
446
922
  });
447
923
 
448
924
  describe("OpenAIDispatcher token updates", () => {
@@ -987,8 +1463,8 @@ describe("OpenAIDispatcher quota threshold gate", () => {
987
1463
 
988
1464
  it("rechecks threshold at drain time before assigning the released worker", async () => {
989
1465
  const dispatcher = new OpenAIDispatcher();
990
- const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
991
- const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B" });
1466
+ const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true });
1467
+ const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B", busy: true });
992
1468
  addWorkers(dispatcher, [workerA, workerB]);
993
1469
  await dispatcher.onTokenUpdated(1, "tok-A", credentials(), 80);
994
1470
  await dispatcher.onTokenUpdated(2, "tok-B", credentials(), 80);
@@ -998,6 +1474,8 @@ describe("OpenAIDispatcher quota threshold gate", () => {
998
1474
  readOpenAIQuotaUsageMock
999
1475
  .mockResolvedValueOnce(quotaOk(90))
1000
1476
  .mockResolvedValueOnce(quotaOk(10));
1477
+ workerA.releaseTurn();
1478
+ workerB.releaseTurn();
1001
1479
 
1002
1480
  await dispatcher.drainQueue();
1003
1481
 
@@ -1006,12 +1484,13 @@ describe("OpenAIDispatcher quota threshold gate", () => {
1006
1484
 
1007
1485
  it("serializes concurrent drain calls so a queue item is not assigned twice", async () => {
1008
1486
  const dispatcher = new OpenAIDispatcher();
1009
- const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
1487
+ const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true });
1010
1488
  addWorkers(dispatcher, [worker]);
1011
1489
  await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
1012
1490
  const execute = vi.fn(async (w: CodexAppServerWorker) => w.tokenName);
1013
1491
  const queued = dispatcher.enqueue(execute);
1014
1492
  await waitForQueue(dispatcher);
1493
+ worker.releaseTurn();
1015
1494
 
1016
1495
  await Promise.all([dispatcher.drainQueue(), dispatcher.drainQueue()]);
1017
1496