@cartanova/qgrid-cli 2.4.2 → 2.4.4

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 (95) hide show
  1. package/bundle/dist/application/qgrid/qgrid.frame.js +13 -17
  2. package/bundle/dist/application/qgrid/token-subscriber.js +23 -9
  3. package/bundle/dist/application/qgrid/token-trigger-setup.js +4 -4
  4. package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
  5. package/bundle/dist/application/sonamu.generated.js +5 -3
  6. package/bundle/dist/application/sonamu.generated.sso.js +3 -2
  7. package/bundle/dist/application/token/token.model.js +8 -3
  8. package/bundle/dist/application/token/token.types.js +8 -3
  9. package/bundle/dist/i18n/sd.generated.js +2 -1
  10. package/bundle/dist/index.js +3 -6
  11. package/bundle/dist/migrations/20260521134025_alter_request_log_steps_alter1.js +3 -9
  12. package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +1 -1
  13. package/bundle/dist/migrations/20260710090000_alter_tokens_add_weight.js +41 -0
  14. package/bundle/dist/server-bootstrap.js +17 -0
  15. package/bundle/dist/sonamu.config.js +2 -10
  16. package/bundle/dist/startup-migrations.js +21 -0
  17. package/bundle/dist/testing/global.js +93 -5
  18. package/bundle/dist/testing/test-database-name.js +22 -0
  19. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +24 -27
  20. package/bundle/dist/utils/providers/common/model-cost.js +22 -1
  21. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
  22. package/bundle/dist/utils/providers/openai/codex-worker.js +19 -2
  23. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +53 -40
  24. package/bundle/src/application/qgrid/qgrid.frame.test.ts +36 -14
  25. package/bundle/src/application/qgrid/qgrid.frame.ts +22 -25
  26. package/bundle/src/application/qgrid/token-subscriber.test.ts +102 -0
  27. package/bundle/src/application/qgrid/token-subscriber.ts +37 -6
  28. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +9 -0
  29. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -2
  30. package/bundle/src/application/qgrid/token-weight-migration.test.ts +42 -0
  31. package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
  32. package/bundle/src/application/sonamu.generated.http +4 -2
  33. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  34. package/bundle/src/application/sonamu.generated.ts +10 -1
  35. package/bundle/src/application/token/token.entity.json +17 -1
  36. package/bundle/src/application/token/token.model.test.ts +43 -0
  37. package/bundle/src/application/token/token.model.ts +18 -2
  38. package/bundle/src/application/token/token.types.test.ts +11 -0
  39. package/bundle/src/application/token/token.types.ts +3 -0
  40. package/bundle/src/i18n/sd.generated.ts +1 -0
  41. package/bundle/src/index.ts +2 -5
  42. package/bundle/src/migrations/20260521134025_alter_request_log_steps_alter1.ts +4 -8
  43. package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +1 -1
  44. package/bundle/src/migrations/20260710090000_alter_tokens_add_weight.ts +38 -0
  45. package/bundle/src/server-bootstrap.test.ts +39 -0
  46. package/bundle/src/server-bootstrap.ts +21 -0
  47. package/bundle/src/sonamu.config.ts +3 -12
  48. package/bundle/src/startup-migrations.test.ts +25 -0
  49. package/bundle/src/startup-migrations.ts +20 -0
  50. package/bundle/src/testing/global.test.ts +130 -0
  51. package/bundle/src/testing/global.ts +143 -13
  52. package/bundle/src/testing/test-database-name.test.ts +38 -0
  53. package/bundle/src/testing/test-database-name.ts +21 -0
  54. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +75 -45
  55. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +23 -29
  56. package/bundle/src/utils/providers/common/model-cost.test.ts +54 -0
  57. package/bundle/src/utils/providers/common/model-cost.ts +31 -0
  58. package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -1
  59. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.test.ts +73 -0
  60. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +48 -0
  61. package/bundle/src/utils/providers/openai/codex-worker.test.ts +70 -1
  62. package/bundle/src/utils/providers/openai/codex-worker.ts +18 -6
  63. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +109 -6
  64. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +72 -55
  65. package/bundle/web-dist/client/assets/{index-CN7eSKO4.js → index-D1ExP77_.js} +3 -3
  66. package/bundle/web-dist/client/assets/{logs-NubdF_C_.js → logs-vuI-Zgnq.js} +1 -1
  67. package/bundle/web-dist/client/assets/routes-0Fki0JEi.js +5 -0
  68. package/bundle/web-dist/client/assets/{sd.generated-BrUnRt0B.js → sd.generated-WA5SGJYZ.js} +1 -1
  69. package/bundle/web-dist/client/assets/{services.generated-AsEvHbJ7.js → services.generated-DzZLnb7S.js} +1 -1
  70. package/bundle/web-dist/client/assets/{show-BvDjIZCX.js → show-BreAgaFH.js} +1 -1
  71. package/bundle/web-dist/client/assets/tokens-4VgHQtXd.js +1 -0
  72. package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +1 -0
  73. package/bundle/web-dist/client/index.html +2 -2
  74. package/bundle/web-dist/server/assets/{lazyRouteComponent-BswulcOx.js → lazyRouteComponent-BrPAFtBA.js} +1 -1
  75. package/bundle/web-dist/server/assets/{logs-Bk9C0ELo.js → logs-CKM6W3W6.js} +3 -3
  76. package/bundle/web-dist/server/assets/{routes-D9OiKoqa.js → routes-DhIpPgXz.js} +130 -7
  77. package/bundle/web-dist/server/assets/{sd.generated-DRuQSIKo.js → sd.generated-Cwg2f53z.js} +1 -0
  78. package/bundle/web-dist/server/assets/{services.generated-Dp0uKgpV.js → services.generated-CsotZP44.js} +5 -5
  79. package/bundle/web-dist/server/assets/{show-3GokIDEJ.js → show-CV2UFjaH.js} +3 -3
  80. package/bundle/web-dist/server/assets/{tokens-Cjw6cQxZ.js → tokens-DfGj1y2v.js} +5 -5
  81. package/bundle/web-dist/server/assets/use-update-token-mutation-BL96w-Pg.js +7 -0
  82. package/bundle/web-dist/server/entry-server.generated.js +7 -7
  83. package/package.json +1 -1
  84. package/skills/qgrid/SKILL.md +1 -1
  85. package/skills/qgrid/references/anthropic-claude-code-runtime.md +2 -2
  86. package/skills/qgrid/references/cli-env-and-server-boot.md +9 -9
  87. package/skills/qgrid/references/decision-rationale.md +23 -0
  88. package/skills/qgrid/references/openai-codex-runtime.md +4 -3
  89. package/skills/qgrid/references/prompt-cache-and-usage.md +2 -0
  90. package/skills/qgrid/references/provider-runtime-differences.md +2 -1
  91. package/skills/qgrid/references/repo-map.md +2 -0
  92. package/skills/qgrid/references/token-auth-quota-lifecycle.md +8 -3
  93. package/skills/qgrid/references/verification-and-debugging.md +3 -0
  94. package/bundle/web-dist/client/assets/routes-NKxXqHvs.js +0 -5
  95. package/bundle/web-dist/client/assets/tokens-XAhJyLqj.js +0 -1
@@ -0,0 +1,48 @@
1
+ export class SmoothWeightedRoundRobin {
2
+ private readonly weights = new Map<number, number>();
3
+ private readonly currentScores = new Map<number, number>();
4
+
5
+ setToken(tokenId: number, weight: number): void {
6
+ if (!Number.isInteger(weight) || weight < 1 || weight > 100) {
7
+ throw new RangeError("weight must be an integer between 1 and 100");
8
+ }
9
+ if (this.weights.get(tokenId) === weight) return;
10
+ this.weights.set(tokenId, weight);
11
+ this.resetScores();
12
+ }
13
+
14
+ removeToken(tokenId: number): void {
15
+ if (!this.weights.delete(tokenId)) return;
16
+ this.currentScores.delete(tokenId);
17
+ this.resetScores();
18
+ }
19
+
20
+ resetScores(): void {
21
+ this.currentScores.clear();
22
+ }
23
+
24
+ select(eligibleTokenIds: ReadonlySet<number>): number | null {
25
+ const candidates = [...eligibleTokenIds]
26
+ .filter((tokenId) => this.weights.has(tokenId))
27
+ .toSorted((a, b) => a - b);
28
+ if (candidates.length === 0) return null;
29
+
30
+ let selected = candidates[0]!;
31
+ let selectedScore = Number.NEGATIVE_INFINITY;
32
+ let totalWeight = 0;
33
+
34
+ for (const tokenId of candidates) {
35
+ const weight = this.weights.get(tokenId)!;
36
+ totalWeight += weight;
37
+ const score = (this.currentScores.get(tokenId) ?? 0) + weight;
38
+ this.currentScores.set(tokenId, score);
39
+ if (score > selectedScore) {
40
+ selected = tokenId;
41
+ selectedScore = score;
42
+ }
43
+ }
44
+
45
+ this.currentScores.set(selected, selectedScore - totalWeight);
46
+ return selected;
47
+ }
48
+ }
@@ -1,6 +1,10 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
1
5
  import { describe, expect, it, vi } from "vitest";
2
6
 
3
- import { CodexAppServerWorker, type StreamCallbacks } from "./codex-worker";
7
+ import { CODEX_CONFIG_TOML, CodexAppServerWorker, type StreamCallbacks } from "./codex-worker";
4
8
 
5
9
  function createWorkerWithFakeRpc() {
6
10
  const worker = new CodexAppServerWorker({
@@ -349,6 +353,40 @@ describe("CodexAppServerWorker image generation capture", () => {
349
353
  expect(result.imageAttempted).toBe(true);
350
354
  });
351
355
 
356
+ it("removes generated image artifacts after a successful image turn", async () => {
357
+ const { worker } = createWorkerWithNotificationRpc(async (method, _params, handlers) => {
358
+ if (method === "thread/start") return { thread: { id: "t1" }, model: "gpt-test" };
359
+ if (method === "turn/start") {
360
+ handlers.get("item/completed")?.({
361
+ threadId: "t1",
362
+ item: imageItem("img-a", IMAGE_B64, "a red circle"),
363
+ } as never);
364
+ handlers.get("turn/completed")?.({
365
+ threadId: "t1",
366
+ turn: { status: "completed", durationMs: 100 },
367
+ } as never);
368
+ return { turn: { id: "turn-1" } };
369
+ }
370
+ throw new Error(`unexpected request: ${method}`);
371
+ });
372
+ const codexHome = mkdtempSync(join(tmpdir(), "qgrid-codex-worker-test-"));
373
+ const generatedImages = join(codexHome, "generated_images", "session");
374
+ mkdirSync(generatedImages, { recursive: true });
375
+ writeFileSync(join(generatedImages, "img-a.png"), "image");
376
+ (worker as unknown as { codexHome: string }).codexHome = codexHome;
377
+
378
+ try {
379
+ await worker.executeTurn({
380
+ input: [{ type: "text", text: "two images", text_elements: [] }],
381
+ imageGeneration: true,
382
+ });
383
+
384
+ expect(existsSync(join(codexHome, "generated_images"))).toBe(false);
385
+ } finally {
386
+ rmSync(codexHome, { recursive: true, force: true });
387
+ }
388
+ });
389
+
352
390
  it("treats status='generating' completed item with valid base64 as a finished image", async () => {
353
391
  const { worker } = createWorkerWithNotificationRpc(async (method, _params, handlers) => {
354
392
  if (method === "thread/start") return { thread: { id: "t1" }, model: "gpt-test" };
@@ -488,6 +526,37 @@ describe("CodexAppServerWorker image generation capture", () => {
488
526
  });
489
527
  });
490
528
 
529
+ describe("CodexAppServerWorker CODEX_HOME artifacts", () => {
530
+ it("disables bundled system skills in the generated Codex config", () => {
531
+ expect(CODEX_CONFIG_TOML).toContain("[skills.bundled]");
532
+ expect(CODEX_CONFIG_TOML).toContain("enabled = false");
533
+ });
534
+
535
+ it("removes the generated_images directory without touching other worker files", () => {
536
+ const worker = new CodexAppServerWorker({
537
+ tokenId: 1,
538
+ tokenName: "token",
539
+ accessToken: "access",
540
+ accountId: "account",
541
+ });
542
+ const codexHome = mkdtempSync(join(tmpdir(), "qgrid-codex-worker-test-"));
543
+ const generatedImages = join(codexHome, "generated_images", "session");
544
+ mkdirSync(generatedImages, { recursive: true });
545
+ writeFileSync(join(generatedImages, "img-a.png"), "image");
546
+ writeFileSync(join(codexHome, "state_5.sqlite"), "state");
547
+ (worker as unknown as { codexHome: string }).codexHome = codexHome;
548
+
549
+ try {
550
+ (worker as unknown as { cleanupGeneratedImages(): void }).cleanupGeneratedImages();
551
+
552
+ expect(existsSync(join(codexHome, "generated_images"))).toBe(false);
553
+ expect(existsSync(join(codexHome, "state_5.sqlite"))).toBe(true);
554
+ } finally {
555
+ rmSync(codexHome, { recursive: true, force: true });
556
+ }
557
+ });
558
+ });
559
+
491
560
  describe("CodexAppServerWorker image generation thread", () => {
492
561
  type WorkerInternals = {
493
562
  createThread(req: unknown): Promise<{ threadId: string; model: string }>;
@@ -3,6 +3,11 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
 
4
4
  import { getLogger } from "@logtape/logtape";
5
5
 
6
+ import {
7
+ CODEX_IMAGE_GENERATION_MODEL,
8
+ resolveImageGenerationOptions,
9
+ } from "../../../application/qgrid/qgrid-image-generation";
10
+ import { type ImageGenerationOptions } from "../../../application/qgrid/qgrid.types";
6
11
  import { type Model } from "../../../codex-protocol/v2/Model";
7
12
  import { type ModelListResponse } from "../../../codex-protocol/v2/ModelListResponse";
8
13
  import { type ModelProviderCapabilitiesReadResponse } from "../../../codex-protocol/v2/ModelProviderCapabilitiesReadResponse";
@@ -12,11 +17,6 @@ import { type ThreadStartResponse } from "../../../codex-protocol/v2/ThreadStart
12
17
  import { type TokenUsageBreakdown } from "../../../codex-protocol/v2/TokenUsageBreakdown";
13
18
  import { type TurnStartParams } from "../../../codex-protocol/v2/TurnStartParams";
14
19
  import { type TurnStartResponse } from "../../../codex-protocol/v2/TurnStartResponse";
15
- import {
16
- CODEX_IMAGE_GENERATION_MODEL,
17
- resolveImageGenerationOptions,
18
- } from "../../../application/qgrid/qgrid-image-generation";
19
- import { type ImageGenerationOptions } from "../../../application/qgrid/qgrid.types";
20
20
  import { type StreamCallbacks as CommonStreamCallbacks } from "../common/provider-dispatcher";
21
21
  import { createTtftTracker } from "../common/ttft";
22
22
  import { CodexRpcClient } from "./codex-rpc";
@@ -115,7 +115,7 @@ function isCompletedImageResult(result: unknown): result is string {
115
115
  // instruction 블록(permissions/environment_context/skills, ~10KB)을 비활성화
116
116
  // $CODEX_HOME/config.toml 로 써넣으면 codex 부팅 시 scan
117
117
  // 통제불가: update_plan/request_user_input
118
- const CODEX_CONFIG_TOML = `web_search = "disabled"
118
+ export const CODEX_CONFIG_TOML = `web_search = "disabled"
119
119
  include_permissions_instructions = false
120
120
  include_apps_instructions = false
121
121
  include_environment_context = false
@@ -134,6 +134,9 @@ view_image = false
134
134
 
135
135
  [skills]
136
136
  include_instructions = false
137
+
138
+ [skills.bundled]
139
+ enabled = false
137
140
  `;
138
141
 
139
142
  // ── Worker ──────────────────────────────────────────────────────────
@@ -179,6 +182,7 @@ export class CodexAppServerWorker {
179
182
  this.cachedCapabilities = undefined;
180
183
  this.cachedModels = undefined;
181
184
  const cwd = `${this.codexHome}/cwd`;
185
+ rmSync(this.codexHome, { recursive: true, force: true });
182
186
  mkdirSync(cwd, { recursive: true });
183
187
  // codex 내장 tool/web_search/instruction 블록 비활성화
184
188
  writeFileSync(`${this.codexHome}/config.toml`, CODEX_CONFIG_TOML);
@@ -583,6 +587,12 @@ export class CodexAppServerWorker {
583
587
  abort?.(error);
584
588
  }
585
589
 
590
+ private cleanupGeneratedImages(): void {
591
+ try {
592
+ rmSync(`${this.codexHome}/generated_images`, { recursive: true, force: true });
593
+ } catch {}
594
+ }
595
+
586
596
  private consumeTurnNotifications(
587
597
  threadId: string,
588
598
  model: string,
@@ -633,6 +643,7 @@ export class CodexAppServerWorker {
633
643
  if (settled) return;
634
644
  settled = true;
635
645
  cleanup();
646
+ if (result.imageAttempted) this.cleanupGeneratedImages();
636
647
  resolve(result);
637
648
  };
638
649
 
@@ -751,6 +762,7 @@ export class CodexAppServerWorker {
751
762
  if (settled) return;
752
763
  settled = true;
753
764
  cleanup();
765
+ if (result.imageAttempted) this.cleanupGeneratedImages();
754
766
  cb.onComplete(result);
755
767
  resolve();
756
768
  };
@@ -112,6 +112,9 @@ function fakeWorker(
112
112
  get isReady() {
113
113
  return options.ready ?? true;
114
114
  },
115
+ get hasCapacity() {
116
+ return !busy;
117
+ },
115
118
  tryAcquireTurn: vi.fn(() => {
116
119
  if (busy) return false;
117
120
  busy = true;
@@ -195,7 +198,7 @@ describe("OpenAIDispatcher token updates", () => {
195
198
  await dispatcher.onTokenUpdated(1, "token", changedIdentity, null);
196
199
 
197
200
  expect(worker.kill).toHaveBeenCalledTimes(1);
198
- expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity, null);
201
+ expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity, null, 1);
199
202
  });
200
203
 
201
204
  it("uses tokenId keyed rate-limit cache", async () => {
@@ -227,7 +230,7 @@ describe("OpenAIDispatcher token updates", () => {
227
230
  addWorkers(dispatcher, [workerA, workerB]);
228
231
 
229
232
  await dispatcher.replaceTokens([
230
- { id: 1, name: "tok-A", credentials: credentials(), quotaThreshold: 80 },
233
+ { id: 1, name: "tok-A", credentials: credentials(), quotaThreshold: 80, weight: 1 },
231
234
  ]);
232
235
  readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(85));
233
236
 
@@ -238,6 +241,106 @@ describe("OpenAIDispatcher token updates", () => {
238
241
  });
239
242
  });
240
243
 
244
+ describe("OpenAIDispatcher weighted routing", () => {
245
+ it("routes cold OpenAI requests by token weight instead of worker count", async () => {
246
+ const dispatcher = new OpenAIDispatcher();
247
+ const a0 = fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 0 });
248
+ const a1 = fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 1 });
249
+ const b0 = fakeWorker({ tokenId: 2, tokenName: "tok-B", workerIndex: 0 });
250
+ addWorkers(dispatcher, [a0, a1, b0]);
251
+ await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 3);
252
+ await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
253
+
254
+ const names: string[] = [];
255
+ for (let i = 0; i < 4; i++) {
256
+ const worker = await dispatcher.acquireIdleWorker();
257
+ names.push(worker!.tokenName);
258
+ worker!.releaseTurn();
259
+ }
260
+
261
+ expect(names.filter((name) => name === "tok-A")).toHaveLength(3);
262
+ expect(names.filter((name) => name === "tok-B")).toHaveLength(1);
263
+ });
264
+
265
+ it("does not give a token extra cold turns for having more workers", async () => {
266
+ const dispatcher = new OpenAIDispatcher();
267
+ addWorkers(dispatcher, [
268
+ fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 0 }),
269
+ fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 1 }),
270
+ fakeWorker({ tokenId: 2, tokenName: "tok-B", workerIndex: 0 }),
271
+ ]);
272
+ await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
273
+ await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
274
+
275
+ const names: string[] = [];
276
+ for (let i = 0; i < 4; i++) {
277
+ const worker = await dispatcher.acquireIdleWorker();
278
+ names.push(worker!.tokenName);
279
+ worker!.releaseTurn();
280
+ }
281
+
282
+ expect(names).toEqual(["tok-A", "tok-B", "tok-A", "tok-B"]);
283
+ });
284
+
285
+ it("skips a high-weight token when all of its workers are busy", async () => {
286
+ const dispatcher = new OpenAIDispatcher();
287
+ addWorkers(dispatcher, [
288
+ fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true }),
289
+ fakeWorker({ tokenId: 2, tokenName: "tok-B" }),
290
+ ]);
291
+ await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 100);
292
+ await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
293
+
294
+ await expect(dispatcher.acquireIdleWorker()).resolves.toMatchObject({ tokenName: "tok-B" });
295
+ });
296
+
297
+ it("uses weighted selection when draining after a worker release", async () => {
298
+ const dispatcher = new OpenAIDispatcher();
299
+ const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true });
300
+ const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B", busy: true });
301
+ addWorkers(dispatcher, [workerA, workerB]);
302
+ await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
303
+ await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 3);
304
+
305
+ const queued = dispatcher.enqueue(async (worker) => worker.tokenName);
306
+ await waitForQueue(dispatcher);
307
+ workerA.releaseTurn();
308
+ workerB.releaseTurn();
309
+
310
+ await dispatcher.drainQueue();
311
+ await expect(queued).resolves.toBe("tok-B");
312
+ });
313
+
314
+ it("does not advance weighted state for successful thread reuse", async () => {
315
+ const dispatcher = new OpenAIDispatcher();
316
+ const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
317
+ const workerB = fakeWorker({
318
+ tokenId: 2,
319
+ tokenName: "tok-B",
320
+ threads: ["thread-B"],
321
+ });
322
+ addWorkers(dispatcher, [workerA, workerB]);
323
+ await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
324
+ await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
325
+ vi.spyOn(dispatcher, "executeTurn").mockImplementation(
326
+ async (worker, _req, reuseThreadId) =>
327
+ resultFor(worker, reuseThreadId ?? "thread-new"),
328
+ );
329
+
330
+ const cold1 = await dispatcher.generate(baseReq());
331
+ const reused = await dispatcher.generate(
332
+ baseReq({ reuse: { workerId: 20, threadId: "thread-B", epoch: 1 } }),
333
+ );
334
+ const cold2 = await dispatcher.generate(baseReq());
335
+
336
+ expect([cold1.tokenName, cold2.tokenName]).toEqual(["tok-A", "tok-B"]);
337
+ expect(reused).toMatchObject({
338
+ tokenName: "tok-B",
339
+ threadCoord: { threadId: "thread-B" },
340
+ });
341
+ });
342
+ });
343
+
241
344
  describe("OpenAIDispatcher quota threshold gate", () => {
242
345
  it("excludes over-threshold tokenIds before idle selection", async () => {
243
346
  const dispatcher = new OpenAIDispatcher();
@@ -280,7 +383,7 @@ describe("OpenAIDispatcher quota threshold gate", () => {
280
383
  await waitForQueue(dispatcher);
281
384
 
282
385
  worker.releaseTurn();
283
- await dispatcher.drainQueue(worker);
386
+ await dispatcher.drainQueue();
284
387
  await expect(queued).resolves.toBe("tok-A");
285
388
  });
286
389
 
@@ -310,11 +413,10 @@ describe("OpenAIDispatcher quota threshold gate", () => {
310
413
  await waitForQueue(dispatcher);
311
414
  readOpenAIQuotaUsageMock.mockReset();
312
415
  readOpenAIQuotaUsageMock
313
- .mockResolvedValueOnce(quotaOk(90))
314
416
  .mockResolvedValueOnce(quotaOk(90))
315
417
  .mockResolvedValueOnce(quotaOk(10));
316
418
 
317
- await dispatcher.drainQueue(workerA);
419
+ await dispatcher.drainQueue();
318
420
 
319
421
  await expect(queued).resolves.toBe("tok-B");
320
422
  });
@@ -323,11 +425,12 @@ describe("OpenAIDispatcher quota threshold gate", () => {
323
425
  const dispatcher = new OpenAIDispatcher();
324
426
  const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
325
427
  addWorkers(dispatcher, [worker]);
428
+ await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
326
429
  const execute = vi.fn(async (w: CodexAppServerWorker) => w.tokenName);
327
430
  const queued = dispatcher.enqueue(execute);
328
431
  await waitForQueue(dispatcher);
329
432
 
330
- await Promise.all([dispatcher.drainQueue(worker), dispatcher.drainQueue(worker)]);
433
+ await Promise.all([dispatcher.drainQueue(), dispatcher.drainQueue()]);
331
434
 
332
435
  await expect(queued).resolves.toBe("tok-A");
333
436
  expect(execute).toHaveBeenCalledTimes(1);