@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.
- package/bundle/dist/application/qgrid/qgrid.frame.js +13 -17
- package/bundle/dist/application/qgrid/token-subscriber.js +23 -9
- package/bundle/dist/application/qgrid/token-trigger-setup.js +4 -4
- package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
- package/bundle/dist/application/sonamu.generated.js +5 -3
- package/bundle/dist/application/sonamu.generated.sso.js +3 -2
- package/bundle/dist/application/token/token.model.js +8 -3
- package/bundle/dist/application/token/token.types.js +8 -3
- package/bundle/dist/i18n/sd.generated.js +2 -1
- package/bundle/dist/index.js +3 -6
- package/bundle/dist/migrations/20260521134025_alter_request_log_steps_alter1.js +3 -9
- package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +1 -1
- package/bundle/dist/migrations/20260710090000_alter_tokens_add_weight.js +41 -0
- package/bundle/dist/server-bootstrap.js +17 -0
- package/bundle/dist/sonamu.config.js +2 -10
- package/bundle/dist/startup-migrations.js +21 -0
- package/bundle/dist/testing/global.js +93 -5
- package/bundle/dist/testing/test-database-name.js +22 -0
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +24 -27
- package/bundle/dist/utils/providers/common/model-cost.js +22 -1
- package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
- package/bundle/dist/utils/providers/openai/codex-worker.js +19 -2
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +53 -40
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +36 -14
- package/bundle/src/application/qgrid/qgrid.frame.ts +22 -25
- package/bundle/src/application/qgrid/token-subscriber.test.ts +102 -0
- package/bundle/src/application/qgrid/token-subscriber.ts +37 -6
- package/bundle/src/application/qgrid/token-trigger-setup.test.ts +9 -0
- package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -2
- package/bundle/src/application/qgrid/token-weight-migration.test.ts +42 -0
- package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
- package/bundle/src/application/sonamu.generated.http +4 -2
- package/bundle/src/application/sonamu.generated.sso.ts +1 -0
- package/bundle/src/application/sonamu.generated.ts +10 -1
- package/bundle/src/application/token/token.entity.json +17 -1
- package/bundle/src/application/token/token.model.test.ts +43 -0
- package/bundle/src/application/token/token.model.ts +18 -2
- package/bundle/src/application/token/token.types.test.ts +11 -0
- package/bundle/src/application/token/token.types.ts +3 -0
- package/bundle/src/i18n/sd.generated.ts +1 -0
- package/bundle/src/index.ts +2 -5
- package/bundle/src/migrations/20260521134025_alter_request_log_steps_alter1.ts +4 -8
- package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +1 -1
- package/bundle/src/migrations/20260710090000_alter_tokens_add_weight.ts +38 -0
- package/bundle/src/server-bootstrap.test.ts +39 -0
- package/bundle/src/server-bootstrap.ts +21 -0
- package/bundle/src/sonamu.config.ts +3 -12
- package/bundle/src/startup-migrations.test.ts +25 -0
- package/bundle/src/startup-migrations.ts +20 -0
- package/bundle/src/testing/global.test.ts +130 -0
- package/bundle/src/testing/global.ts +143 -13
- package/bundle/src/testing/test-database-name.test.ts +38 -0
- package/bundle/src/testing/test-database-name.ts +21 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +75 -45
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +23 -29
- package/bundle/src/utils/providers/common/model-cost.test.ts +54 -0
- package/bundle/src/utils/providers/common/model-cost.ts +31 -0
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -1
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.test.ts +73 -0
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +48 -0
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +70 -1
- package/bundle/src/utils/providers/openai/codex-worker.ts +18 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +109 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +72 -55
- package/bundle/web-dist/client/assets/{index-CN7eSKO4.js → index-D1ExP77_.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-NubdF_C_.js → logs-vuI-Zgnq.js} +1 -1
- package/bundle/web-dist/client/assets/routes-0Fki0JEi.js +5 -0
- package/bundle/web-dist/client/assets/{sd.generated-BrUnRt0B.js → sd.generated-WA5SGJYZ.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-AsEvHbJ7.js → services.generated-DzZLnb7S.js} +1 -1
- package/bundle/web-dist/client/assets/{show-BvDjIZCX.js → show-BreAgaFH.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-4VgHQtXd.js +1 -0
- package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +1 -0
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BswulcOx.js → lazyRouteComponent-BrPAFtBA.js} +1 -1
- package/bundle/web-dist/server/assets/{logs-Bk9C0ELo.js → logs-CKM6W3W6.js} +3 -3
- package/bundle/web-dist/server/assets/{routes-D9OiKoqa.js → routes-DhIpPgXz.js} +130 -7
- package/bundle/web-dist/server/assets/{sd.generated-DRuQSIKo.js → sd.generated-Cwg2f53z.js} +1 -0
- package/bundle/web-dist/server/assets/{services.generated-Dp0uKgpV.js → services.generated-CsotZP44.js} +5 -5
- package/bundle/web-dist/server/assets/{show-3GokIDEJ.js → show-CV2UFjaH.js} +3 -3
- package/bundle/web-dist/server/assets/{tokens-Cjw6cQxZ.js → tokens-DfGj1y2v.js} +5 -5
- package/bundle/web-dist/server/assets/use-update-token-mutation-BL96w-Pg.js +7 -0
- package/bundle/web-dist/server/entry-server.generated.js +7 -7
- package/package.json +1 -1
- package/skills/qgrid/SKILL.md +1 -1
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +2 -2
- package/skills/qgrid/references/cli-env-and-server-boot.md +9 -9
- package/skills/qgrid/references/decision-rationale.md +23 -0
- package/skills/qgrid/references/openai-codex-runtime.md +4 -3
- package/skills/qgrid/references/prompt-cache-and-usage.md +2 -0
- package/skills/qgrid/references/provider-runtime-differences.md +2 -1
- package/skills/qgrid/references/repo-map.md +2 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +8 -3
- package/skills/qgrid/references/verification-and-debugging.md +3 -0
- package/bundle/web-dist/client/assets/routes-NKxXqHvs.js +0 -5
- 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(
|
|
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(
|
|
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(
|
|
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);
|