@cartanova/qgrid-cli 2.4.3 → 2.4.5

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 (94) hide show
  1. package/bundle/dist/application/qgrid/qgrid.frame.js +17 -19
  2. package/bundle/dist/application/qgrid/qgrid.types.js +3 -2
  3. package/bundle/dist/application/qgrid/token-subscriber.js +23 -9
  4. package/bundle/dist/application/qgrid/token-trigger-setup.js +4 -4
  5. package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
  6. package/bundle/dist/application/sonamu.generated.js +5 -3
  7. package/bundle/dist/application/sonamu.generated.sso.js +3 -2
  8. package/bundle/dist/application/token/token.model.js +8 -3
  9. package/bundle/dist/application/token/token.types.js +8 -3
  10. package/bundle/dist/i18n/sd.generated.js +2 -1
  11. package/bundle/dist/index.js +3 -6
  12. package/bundle/dist/migrations/20260521134025_alter_request_log_steps_alter1.js +3 -9
  13. package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +1 -1
  14. package/bundle/dist/migrations/20260710090000_alter_tokens_add_weight.js +41 -0
  15. package/bundle/dist/server-bootstrap.js +17 -0
  16. package/bundle/dist/sonamu.config.js +1 -10
  17. package/bundle/dist/startup-migrations.js +21 -0
  18. package/bundle/dist/testing/global.js +93 -5
  19. package/bundle/dist/testing/test-database-name.js +22 -0
  20. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +24 -27
  21. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
  22. package/bundle/dist/utils/providers/openai/codex-worker.js +4 -2
  23. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +220 -49
  24. package/bundle/src/application/qgrid/qgrid.frame.test.ts +60 -14
  25. package/bundle/src/application/qgrid/qgrid.frame.ts +24 -25
  26. package/bundle/src/application/qgrid/qgrid.types.ts +1 -0
  27. package/bundle/src/application/qgrid/token-subscriber.test.ts +102 -0
  28. package/bundle/src/application/qgrid/token-subscriber.ts +37 -6
  29. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +9 -0
  30. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -2
  31. package/bundle/src/application/qgrid/token-weight-migration.test.ts +42 -0
  32. package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
  33. package/bundle/src/application/sonamu.generated.http +4 -2
  34. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  35. package/bundle/src/application/sonamu.generated.ts +10 -1
  36. package/bundle/src/application/token/token.entity.json +17 -1
  37. package/bundle/src/application/token/token.model.test.ts +43 -0
  38. package/bundle/src/application/token/token.model.ts +18 -2
  39. package/bundle/src/application/token/token.types.test.ts +11 -0
  40. package/bundle/src/application/token/token.types.ts +3 -0
  41. package/bundle/src/i18n/sd.generated.ts +1 -0
  42. package/bundle/src/index.ts +2 -5
  43. package/bundle/src/migrations/20260521134025_alter_request_log_steps_alter1.ts +4 -8
  44. package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +1 -1
  45. package/bundle/src/migrations/20260710090000_alter_tokens_add_weight.ts +38 -0
  46. package/bundle/src/server-bootstrap.test.ts +39 -0
  47. package/bundle/src/server-bootstrap.ts +21 -0
  48. package/bundle/src/sonamu.config.ts +0 -12
  49. package/bundle/src/startup-migrations.test.ts +25 -0
  50. package/bundle/src/startup-migrations.ts +20 -0
  51. package/bundle/src/testing/global.test.ts +130 -0
  52. package/bundle/src/testing/global.ts +143 -13
  53. package/bundle/src/testing/test-database-name.test.ts +38 -0
  54. package/bundle/src/testing/test-database-name.ts +21 -0
  55. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +75 -45
  56. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +23 -29
  57. package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -1
  58. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.test.ts +73 -0
  59. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +48 -0
  60. package/bundle/src/utils/providers/openai/codex-worker.test.ts +26 -0
  61. package/bundle/src/utils/providers/openai/codex-worker.ts +3 -1
  62. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +304 -12
  63. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +354 -68
  64. package/bundle/web-dist/client/assets/{index-DsGDBrrH.js → index-D0dRwMp0.js} +3 -3
  65. package/bundle/web-dist/client/assets/{logs-C6EMtwDn.js → logs-BMzzMMRA.js} +1 -1
  66. package/bundle/web-dist/client/assets/routes-XqQHRGxI.js +5 -0
  67. package/bundle/web-dist/client/assets/{sd.generated-BrUnRt0B.js → sd.generated-WA5SGJYZ.js} +1 -1
  68. package/bundle/web-dist/client/assets/{services.generated-AsEvHbJ7.js → services.generated-DzZLnb7S.js} +1 -1
  69. package/bundle/web-dist/client/assets/{show-BSW3PE7Z.js → show-63BsotX6.js} +1 -1
  70. package/bundle/web-dist/client/assets/tokens-C416mWQ-.js +1 -0
  71. package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +1 -0
  72. package/bundle/web-dist/client/index.html +2 -2
  73. package/bundle/web-dist/server/assets/{lazyRouteComponent-BswulcOx.js → lazyRouteComponent-BrPAFtBA.js} +1 -1
  74. package/bundle/web-dist/server/assets/{logs-Bk9C0ELo.js → logs-CKM6W3W6.js} +3 -3
  75. package/bundle/web-dist/server/assets/{routes-D9OiKoqa.js → routes-VY2um2KO.js} +140 -9
  76. package/bundle/web-dist/server/assets/{sd.generated-DRuQSIKo.js → sd.generated-Cwg2f53z.js} +1 -0
  77. package/bundle/web-dist/server/assets/{services.generated-Dp0uKgpV.js → services.generated-CsotZP44.js} +5 -5
  78. package/bundle/web-dist/server/assets/{show-3GokIDEJ.js → show-CV2UFjaH.js} +3 -3
  79. package/bundle/web-dist/server/assets/{tokens-Cjw6cQxZ.js → tokens-DfGj1y2v.js} +5 -5
  80. package/bundle/web-dist/server/assets/use-update-token-mutation-BL96w-Pg.js +7 -0
  81. package/bundle/web-dist/server/entry-server.generated.js +7 -7
  82. package/package.json +1 -1
  83. package/skills/qgrid/SKILL.md +1 -1
  84. package/skills/qgrid/references/anthropic-claude-code-runtime.md +2 -2
  85. package/skills/qgrid/references/cli-env-and-server-boot.md +9 -9
  86. package/skills/qgrid/references/decision-rationale.md +23 -0
  87. package/skills/qgrid/references/openai-codex-runtime.md +4 -3
  88. package/skills/qgrid/references/prompt-cache-and-usage.md +2 -0
  89. package/skills/qgrid/references/provider-runtime-differences.md +2 -1
  90. package/skills/qgrid/references/repo-map.md +2 -0
  91. package/skills/qgrid/references/token-auth-quota-lifecycle.md +8 -3
  92. package/skills/qgrid/references/verification-and-debugging.md +3 -0
  93. package/bundle/web-dist/client/assets/routes-De9iuL80.js +0 -5
  94. package/bundle/web-dist/client/assets/tokens-BMsL6vk9.js +0 -1
@@ -4,7 +4,15 @@ import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.ty
4
4
  import { type OpenAICredentials } from "../../../application/token/token.types";
5
5
  import { type GenerateRequest, type GenerateResult } from "../common/provider-dispatcher";
6
6
  import { type CodexAppServerWorker } from "./codex-worker";
7
- import { ImageGenerationError, OpenAIDispatcher } from "./openai-dispatcher";
7
+ import {
8
+ estimateOpenAIWorkerRssGiB,
9
+ ImageGenerationError,
10
+ makeOpenAIWorkerId,
11
+ MAX_OPENAI_WORKERS_PER_TOKEN,
12
+ type OpenAIWorkerPoolConfig,
13
+ OpenAIDispatcher,
14
+ resolveOpenAIWorkerPoolConfig,
15
+ } from "./openai-dispatcher";
8
16
  import { type OpenAIQuotaUsageResult } from "./openai-quota";
9
17
 
10
18
  const { readOpenAIQuotaUsageMock, loggerInfoMock, loggerWarnMock } = vi.hoisted(() => ({
@@ -70,7 +78,7 @@ function resultFor(worker: CodexAppServerWorker, threadId = "thread-new"): Gener
70
78
  durationMs: 10,
71
79
  model: "gpt-5-codex",
72
80
  threadCoord: {
73
- workerId: worker.tokenId * 10 + worker.workerIndex,
81
+ workerId: makeOpenAIWorkerId(worker.tokenId, worker.workerIndex),
74
82
  threadId,
75
83
  epoch: worker.epoch,
76
84
  },
@@ -112,6 +120,9 @@ function fakeWorker(
112
120
  get isReady() {
113
121
  return options.ready ?? true;
114
122
  },
123
+ get hasCapacity() {
124
+ return !busy;
125
+ },
115
126
  tryAcquireTurn: vi.fn(() => {
116
127
  if (busy) return false;
117
128
  busy = true;
@@ -156,6 +167,21 @@ function addWorkers(dispatcher: OpenAIDispatcher, workers: CodexAppServerWorker[
156
167
  for (const [tokenId, group] of byToken) dispatcher.workerPool.set(tokenId, group);
157
168
  }
158
169
 
170
+ function autoscaleConfig(
171
+ overrides: Partial<OpenAIWorkerPoolConfig> = {},
172
+ ): OpenAIWorkerPoolConfig {
173
+ return {
174
+ autoscale: true,
175
+ minWorkersPerToken: 1,
176
+ maxWorkersPerToken: 3,
177
+ scaleIntervalMs: 5_000,
178
+ scaleDownIdleMs: 600_000,
179
+ maxEstimatedRssGiB: 16,
180
+ minHostAvailableGiB: 20,
181
+ ...overrides,
182
+ };
183
+ }
184
+
159
185
  async function waitForQueue(dispatcher: OpenAIDispatcher, length = 1): Promise<void> {
160
186
  for (let i = 0; i < 10; i++) {
161
187
  await new Promise((resolve) => setTimeout(resolve, 0));
@@ -164,6 +190,159 @@ async function waitForQueue(dispatcher: OpenAIDispatcher, length = 1): Promise<v
164
190
  expect(dispatcher.queueLength).toBe(length);
165
191
  }
166
192
 
193
+ describe("OpenAIDispatcher worker capacity", () => {
194
+ it("supports 20 workers per token without worker id collisions", () => {
195
+ expect(MAX_OPENAI_WORKERS_PER_TOKEN).toBe(20);
196
+
197
+ const tokenIds = [1, 2, 41, 47, 55, 56, 58];
198
+ const workerIds = tokenIds.flatMap((tokenId) =>
199
+ Array.from({ length: MAX_OPENAI_WORKERS_PER_TOKEN }, (_, workerIndex) =>
200
+ makeOpenAIWorkerId(tokenId, workerIndex),
201
+ ),
202
+ );
203
+
204
+ expect(new Set(workerIds)).toHaveLength(workerIds.length);
205
+ expect(makeOpenAIWorkerId(1, 10)).not.toBe(makeOpenAIWorkerId(2, 0));
206
+ });
207
+
208
+ it("defaults to autoscaling from 5 to 15 workers per token", () => {
209
+ expect(resolveOpenAIWorkerPoolConfig({})).toMatchObject({
210
+ autoscale: true,
211
+ minWorkersPerToken: 5,
212
+ maxWorkersPerToken: 15,
213
+ });
214
+ });
215
+
216
+ it("keeps an explicit fixed worker mode for emergency rollback", () => {
217
+ expect(
218
+ resolveOpenAIWorkerPoolConfig({
219
+ QGRID_WORKERS_PER_TOKEN: "5",
220
+ QGRID_OPENAI_AUTOSCALE: "false",
221
+ }),
222
+ ).toMatchObject({
223
+ autoscale: false,
224
+ minWorkersPerToken: 5,
225
+ maxWorkersPerToken: 5,
226
+ });
227
+ });
228
+
229
+ it("estimates worker RSS from the dev0 measurement model", () => {
230
+ expect(estimateOpenAIWorkerRssGiB(50)).toBeCloseTo(8.56, 2);
231
+ expect(estimateOpenAIWorkerRssGiB(60)).toBeCloseTo(10.13, 2);
232
+ });
233
+ });
234
+
235
+ describe("OpenAIDispatcher autoscaling", () => {
236
+ it("scales six active tokens from 5 to 15 workers per token", async () => {
237
+ const dispatcher = new OpenAIDispatcher(
238
+ autoscaleConfig({
239
+ minWorkersPerToken: 5,
240
+ maxWorkersPerToken: 15,
241
+ maxEstimatedRssGiB: 16,
242
+ }),
243
+ () => 64,
244
+ );
245
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
246
+
247
+ for (let tokenId = 1; tokenId <= 6; tokenId++) {
248
+ await dispatcher.onTokenAdded(tokenId, `token-${tokenId}`, credentials(), null, 1);
249
+ dispatcher.workerPool.set(
250
+ tokenId,
251
+ Array.from({ length: 5 }, (_, workerIndex) => fakeWorker({ tokenId, workerIndex })),
252
+ );
253
+ }
254
+
255
+ vi.spyOn(dispatcher, "spawnSingleWorker").mockImplementation(
256
+ async (tokenId, tokenName, _credentials, workerIndex) =>
257
+ fakeWorker({ tokenId, tokenName, workerIndex }),
258
+ );
259
+
260
+ for (let step = 0; step < 12; step++) await dispatcher.scaleUpOneStep();
261
+
262
+ expect(dispatcher.workerCount).toBe(90);
263
+ expect([...dispatcher.workerPool.values()].map((workers) => workers.length)).toEqual([
264
+ 15, 15, 15, 15, 15, 15,
265
+ ]);
266
+ expect(estimateOpenAIWorkerRssGiB(dispatcher.workerCount)).toBeLessThan(16);
267
+ });
268
+
269
+ it("requests a scale-up evaluation when a request enters the queue", async () => {
270
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
271
+ addWorkers(dispatcher, [fakeWorker({ tokenId: 1, busy: true })]);
272
+ const scaleUpOneStep = vi.spyOn(dispatcher, "scaleUpOneStep").mockResolvedValue(undefined);
273
+
274
+ const queued = dispatcher.enqueue(async () => "ok");
275
+ const rejection = expect(queued).rejects.toThrow("TEST_CLEANUP");
276
+ await waitForQueue(dispatcher);
277
+ dispatcher.rejectAllQueued("TEST_CLEANUP");
278
+
279
+ await rejection;
280
+ expect(scaleUpOneStep).toHaveBeenCalledTimes(1);
281
+ });
282
+
283
+ it("adds one worker to each active token in a scale-up step", async () => {
284
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
285
+ const tokenCredentials = credentials();
286
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
287
+ await dispatcher.onTokenAdded(1, "token", tokenCredentials, null, 1);
288
+ dispatcher.workerPool.set(1, [fakeWorker({ tokenId: 1, workerIndex: 0 })]);
289
+ const added = fakeWorker({ tokenId: 1, workerIndex: 1 });
290
+ const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker").mockResolvedValue(added);
291
+
292
+ await dispatcher.scaleUpOneStep();
293
+
294
+ expect(spawnSingleWorker).toHaveBeenCalledWith(1, "token", tokenCredentials, 1);
295
+ expect(dispatcher.workerPool.get(1)).toHaveLength(2);
296
+ expect(dispatcher.workerCount).toBe(2);
297
+ });
298
+
299
+ it("does not scale past the estimated RSS guard", async () => {
300
+ const dispatcher = new OpenAIDispatcher(
301
+ autoscaleConfig({ maxEstimatedRssGiB: 0.9 }),
302
+ () => 64,
303
+ );
304
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
305
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
306
+ dispatcher.workerPool.set(1, [fakeWorker({ tokenId: 1, workerIndex: 0 })]);
307
+ const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker");
308
+
309
+ await dispatcher.scaleUpOneStep();
310
+
311
+ expect(spawnSingleWorker).not.toHaveBeenCalled();
312
+ expect(dispatcher.workerCount).toBe(1);
313
+ });
314
+
315
+ it("does not scale when host available memory is below the floor", async () => {
316
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 10);
317
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
318
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
319
+ dispatcher.workerPool.set(1, [fakeWorker({ tokenId: 1, workerIndex: 0 })]);
320
+ const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker");
321
+
322
+ await dispatcher.scaleUpOneStep();
323
+
324
+ expect(spawnSingleWorker).not.toHaveBeenCalled();
325
+ expect(dispatcher.workerCount).toBe(1);
326
+ });
327
+
328
+ it("removes only idle workers above the configured minimum for inactive tokens", async () => {
329
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
330
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
331
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
332
+ const busy = fakeWorker({ tokenId: 1, workerIndex: 0, busy: true });
333
+ const idle = fakeWorker({ tokenId: 1, workerIndex: 1 });
334
+ dispatcher.workerPool.set(1, [busy, idle]);
335
+ dispatcher.onTokenDeactivated(1);
336
+
337
+ await dispatcher.scaleDownOneStep();
338
+ await dispatcher.scaleDownOneStep();
339
+
340
+ expect(idle.kill).toHaveBeenCalledTimes(1);
341
+ expect(busy.kill).not.toHaveBeenCalled();
342
+ expect(dispatcher.workerPool.get(1)).toEqual([busy]);
343
+ });
344
+ });
345
+
167
346
  describe("OpenAIDispatcher token updates", () => {
168
347
  it("keeps existing workers when only OpenAI auth tokens rotate", async () => {
169
348
  const dispatcher = new OpenAIDispatcher();
@@ -195,7 +374,7 @@ describe("OpenAIDispatcher token updates", () => {
195
374
  await dispatcher.onTokenUpdated(1, "token", changedIdentity, null);
196
375
 
197
376
  expect(worker.kill).toHaveBeenCalledTimes(1);
198
- expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity, null);
377
+ expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity, null, 1);
199
378
  });
200
379
 
201
380
  it("uses tokenId keyed rate-limit cache", async () => {
@@ -227,7 +406,7 @@ describe("OpenAIDispatcher token updates", () => {
227
406
  addWorkers(dispatcher, [workerA, workerB]);
228
407
 
229
408
  await dispatcher.replaceTokens([
230
- { id: 1, name: "tok-A", credentials: credentials(), quotaThreshold: 80 },
409
+ { id: 1, name: "tok-A", credentials: credentials(), quotaThreshold: 80, weight: 1 },
231
410
  ]);
232
411
  readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(85));
233
412
 
@@ -238,6 +417,108 @@ describe("OpenAIDispatcher token updates", () => {
238
417
  });
239
418
  });
240
419
 
420
+ describe("OpenAIDispatcher weighted routing", () => {
421
+ it("routes cold OpenAI requests by token weight instead of worker count", async () => {
422
+ const dispatcher = new OpenAIDispatcher();
423
+ const a0 = fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 0 });
424
+ const a1 = fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 1 });
425
+ const b0 = fakeWorker({ tokenId: 2, tokenName: "tok-B", workerIndex: 0 });
426
+ addWorkers(dispatcher, [a0, a1, b0]);
427
+ await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 3);
428
+ await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
429
+
430
+ const names: string[] = [];
431
+ for (let i = 0; i < 4; i++) {
432
+ const worker = await dispatcher.acquireIdleWorker();
433
+ names.push(worker!.tokenName);
434
+ worker!.releaseTurn();
435
+ }
436
+
437
+ expect(names.filter((name) => name === "tok-A")).toHaveLength(3);
438
+ expect(names.filter((name) => name === "tok-B")).toHaveLength(1);
439
+ });
440
+
441
+ it("does not give a token extra cold turns for having more workers", async () => {
442
+ const dispatcher = new OpenAIDispatcher();
443
+ addWorkers(dispatcher, [
444
+ fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 0 }),
445
+ fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 1 }),
446
+ fakeWorker({ tokenId: 2, tokenName: "tok-B", workerIndex: 0 }),
447
+ ]);
448
+ await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
449
+ await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
450
+
451
+ const names: string[] = [];
452
+ for (let i = 0; i < 4; i++) {
453
+ const worker = await dispatcher.acquireIdleWorker();
454
+ names.push(worker!.tokenName);
455
+ worker!.releaseTurn();
456
+ }
457
+
458
+ expect(names).toEqual(["tok-A", "tok-B", "tok-A", "tok-B"]);
459
+ });
460
+
461
+ it("skips a high-weight token when all of its workers are busy", async () => {
462
+ const dispatcher = new OpenAIDispatcher();
463
+ addWorkers(dispatcher, [
464
+ fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true }),
465
+ fakeWorker({ tokenId: 2, tokenName: "tok-B" }),
466
+ ]);
467
+ await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 100);
468
+ await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
469
+
470
+ await expect(dispatcher.acquireIdleWorker()).resolves.toMatchObject({ tokenName: "tok-B" });
471
+ });
472
+
473
+ it("uses weighted selection when draining after a worker release", async () => {
474
+ const dispatcher = new OpenAIDispatcher();
475
+ const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true });
476
+ const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B", busy: true });
477
+ addWorkers(dispatcher, [workerA, workerB]);
478
+ await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
479
+ await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 3);
480
+
481
+ const queued = dispatcher.enqueue(async (worker) => worker.tokenName);
482
+ await waitForQueue(dispatcher);
483
+ workerA.releaseTurn();
484
+ workerB.releaseTurn();
485
+
486
+ await dispatcher.drainQueue();
487
+ await expect(queued).resolves.toBe("tok-B");
488
+ });
489
+
490
+ it("does not advance weighted state for successful thread reuse", async () => {
491
+ const dispatcher = new OpenAIDispatcher();
492
+ const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
493
+ const workerB = fakeWorker({
494
+ tokenId: 2,
495
+ tokenName: "tok-B",
496
+ threads: ["thread-B"],
497
+ });
498
+ addWorkers(dispatcher, [workerA, workerB]);
499
+ await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
500
+ await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
501
+ vi.spyOn(dispatcher, "executeTurn").mockImplementation(
502
+ async (worker, _req, reuseThreadId) =>
503
+ resultFor(worker, reuseThreadId ?? "thread-new"),
504
+ );
505
+
506
+ const cold1 = await dispatcher.generate(baseReq());
507
+ const reused = await dispatcher.generate(
508
+ baseReq({
509
+ reuse: { workerId: makeOpenAIWorkerId(2, 0), threadId: "thread-B", epoch: 1 },
510
+ }),
511
+ );
512
+ const cold2 = await dispatcher.generate(baseReq());
513
+
514
+ expect([cold1.tokenName, cold2.tokenName]).toEqual(["tok-A", "tok-B"]);
515
+ expect(reused).toMatchObject({
516
+ tokenName: "tok-B",
517
+ threadCoord: { threadId: "thread-B" },
518
+ });
519
+ });
520
+ });
521
+
241
522
  describe("OpenAIDispatcher quota threshold gate", () => {
242
523
  it("excludes over-threshold tokenIds before idle selection", async () => {
243
524
  const dispatcher = new OpenAIDispatcher();
@@ -280,7 +561,7 @@ describe("OpenAIDispatcher quota threshold gate", () => {
280
561
  await waitForQueue(dispatcher);
281
562
 
282
563
  worker.releaseTurn();
283
- await dispatcher.drainQueue(worker);
564
+ await dispatcher.drainQueue();
284
565
  await expect(queued).resolves.toBe("tok-A");
285
566
  });
286
567
 
@@ -310,11 +591,10 @@ describe("OpenAIDispatcher quota threshold gate", () => {
310
591
  await waitForQueue(dispatcher);
311
592
  readOpenAIQuotaUsageMock.mockReset();
312
593
  readOpenAIQuotaUsageMock
313
- .mockResolvedValueOnce(quotaOk(90))
314
594
  .mockResolvedValueOnce(quotaOk(90))
315
595
  .mockResolvedValueOnce(quotaOk(10));
316
596
 
317
- await dispatcher.drainQueue(workerA);
597
+ await dispatcher.drainQueue();
318
598
 
319
599
  await expect(queued).resolves.toBe("tok-B");
320
600
  });
@@ -323,11 +603,12 @@ describe("OpenAIDispatcher quota threshold gate", () => {
323
603
  const dispatcher = new OpenAIDispatcher();
324
604
  const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
325
605
  addWorkers(dispatcher, [worker]);
606
+ await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
326
607
  const execute = vi.fn(async (w: CodexAppServerWorker) => w.tokenName);
327
608
  const queued = dispatcher.enqueue(execute);
328
609
  await waitForQueue(dispatcher);
329
610
 
330
- await Promise.all([dispatcher.drainQueue(worker), dispatcher.drainQueue(worker)]);
611
+ await Promise.all([dispatcher.drainQueue(), dispatcher.drainQueue()]);
331
612
 
332
613
  await expect(queued).resolves.toBe("tok-A");
333
614
  expect(execute).toHaveBeenCalledTimes(1);
@@ -367,7 +648,9 @@ describe("OpenAIDispatcher quota threshold gate", () => {
367
648
  );
368
649
 
369
650
  const result = await dispatcher.generate(
370
- baseReq({ reuse: { workerId: 10, threadId: "thread-1", epoch: 1 } }),
651
+ baseReq({
652
+ reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
653
+ }),
371
654
  );
372
655
 
373
656
  expect(result.tokenName).toBe("tok-B");
@@ -389,7 +672,9 @@ describe("OpenAIDispatcher quota threshold gate", () => {
389
672
  );
390
673
 
391
674
  const result = await dispatcher.generate(
392
- baseReq({ reuse: { workerId: 10, threadId: "thread-1", epoch: 1 } }),
675
+ baseReq({
676
+ reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
677
+ }),
393
678
  );
394
679
 
395
680
  expect(result.tokenName).toBe("tok-A");
@@ -440,7 +725,11 @@ describe("OpenAIDispatcher quota threshold gate", () => {
440
725
  readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(85));
441
726
 
442
727
  await expect(
443
- dispatcher.generate(baseReq({ reuse: { workerId: 10, threadId: "thread-1", epoch: 1 } })),
728
+ dispatcher.generate(
729
+ baseReq({
730
+ reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
731
+ }),
732
+ ),
444
733
  ).rejects.toBeInstanceOf(QuotaThresholdExceededError);
445
734
  });
446
735
  });
@@ -518,7 +807,10 @@ describe("OpenAIDispatcher image generation routing", () => {
518
807
  it("forces cold thread for image requests even when a reuse coordinate is present", async () => {
519
808
  const dispatcher = new OpenAIDispatcher();
520
809
  const worker = await dispatcher.acquireReuseWorker(
521
- baseReq({ imageGeneration: true, reuse: { workerId: 10, threadId: "t1", epoch: 1 } }),
810
+ baseReq({
811
+ imageGeneration: true,
812
+ reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "t1", epoch: 1 },
813
+ }),
522
814
  );
523
815
  expect(worker).toBeNull();
524
816
  });