@cartanova/qgrid-cli 2.5.8 → 2.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/dist/utils/providers/openai/codex-worker.js +16 -3
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +86 -35
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +72 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +15 -2
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +442 -4
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +163 -49
- package/bundle/web-dist/client/assets/{index-oLaOO8wP.js → index-CkBeqjBm.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-D8U3hI1f.js → logs-d9HdibiE.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-NKS90qFj.js → monit-C8qYvOMj.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-BuxVmofi.js → routes-DT0AY2Lq.js} +1 -1
- package/bundle/web-dist/client/assets/{show-C8Qv9_Ww.js → show-DPG1oKpW.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-ChWIXTeL.js → tokens-Dz7hmKFm.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +1 -1
- package/package.json +1 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +2 -2
- package/skills/qgrid/references/openai-codex-runtime.md +13 -0
- package/skills/qgrid/references/verification-and-debugging.md +3 -1
|
@@ -197,6 +197,78 @@ describe("CodexAppServerWorker active turn cleanup", () => {
|
|
|
197
197
|
});
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
describe("CodexAppServerWorker terminal restart failure", () => {
|
|
201
|
+
it("notifies terminal failure exactly once after exhausting the restart budget", () => {
|
|
202
|
+
const worker = new CodexAppServerWorker({
|
|
203
|
+
tokenId: 1,
|
|
204
|
+
tokenName: "token",
|
|
205
|
+
accessToken: "access",
|
|
206
|
+
accountId: "account",
|
|
207
|
+
});
|
|
208
|
+
const onTerminalFailure = vi.fn();
|
|
209
|
+
worker.onTerminalFailure = onTerminalFailure;
|
|
210
|
+
const internals = worker as unknown as {
|
|
211
|
+
restartAttempts: number;
|
|
212
|
+
scheduleRestart(): void;
|
|
213
|
+
};
|
|
214
|
+
internals.restartAttempts = 3;
|
|
215
|
+
|
|
216
|
+
internals.scheduleRestart();
|
|
217
|
+
internals.scheduleRestart();
|
|
218
|
+
|
|
219
|
+
expect(onTerminalFailure).toHaveBeenCalledTimes(1);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("does not report an intentional worker shutdown as terminal failure", () => {
|
|
223
|
+
const worker = new CodexAppServerWorker({
|
|
224
|
+
tokenId: 1,
|
|
225
|
+
tokenName: "token",
|
|
226
|
+
accessToken: "access",
|
|
227
|
+
accountId: "account",
|
|
228
|
+
});
|
|
229
|
+
const onTerminalFailure = vi.fn();
|
|
230
|
+
worker.onTerminalFailure = onTerminalFailure;
|
|
231
|
+
const internals = worker as unknown as {
|
|
232
|
+
destroyed: boolean;
|
|
233
|
+
restartAttempts: number;
|
|
234
|
+
scheduleRestart(): void;
|
|
235
|
+
};
|
|
236
|
+
internals.destroyed = true;
|
|
237
|
+
internals.restartAttempts = 3;
|
|
238
|
+
|
|
239
|
+
internals.scheduleRestart();
|
|
240
|
+
|
|
241
|
+
expect(onTerminalFailure).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("resets terminal notification state after a successful initialization", async () => {
|
|
245
|
+
const worker = new CodexAppServerWorker({
|
|
246
|
+
tokenId: 1,
|
|
247
|
+
tokenName: "token",
|
|
248
|
+
accessToken: "access",
|
|
249
|
+
accountId: "account",
|
|
250
|
+
});
|
|
251
|
+
const request = vi.fn().mockResolvedValue({});
|
|
252
|
+
const internals = worker as unknown as {
|
|
253
|
+
rpc: { request: typeof request };
|
|
254
|
+
restartAttempts: number;
|
|
255
|
+
terminalFailureNotified: boolean;
|
|
256
|
+
spawnAndInit(): Promise<void>;
|
|
257
|
+
waitForLoginCompleted(): Promise<void>;
|
|
258
|
+
};
|
|
259
|
+
internals.restartAttempts = 3;
|
|
260
|
+
internals.terminalFailureNotified = true;
|
|
261
|
+
internals.rpc = { request };
|
|
262
|
+
vi.spyOn(internals, "spawnAndInit").mockResolvedValue(undefined);
|
|
263
|
+
vi.spyOn(internals, "waitForLoginCompleted").mockResolvedValue(undefined);
|
|
264
|
+
|
|
265
|
+
await worker.initialize();
|
|
266
|
+
|
|
267
|
+
expect(internals.restartAttempts).toBe(0);
|
|
268
|
+
expect(internals.terminalFailureNotified).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
200
272
|
describe("CodexAppServerWorker TTFT", () => {
|
|
201
273
|
it("captures fast non-stream deltas registered before turn/start completes", async () => {
|
|
202
274
|
let now = 1_000;
|
|
@@ -148,12 +148,14 @@ export class CodexAppServerWorker {
|
|
|
148
148
|
private rpc: CodexRpcClient | null = null;
|
|
149
149
|
private codexHome: string;
|
|
150
150
|
private restartAttempts = 0;
|
|
151
|
+
private terminalFailureNotified = false;
|
|
151
152
|
private ready = false;
|
|
152
153
|
private destroyed = false;
|
|
153
154
|
private busy = false;
|
|
154
155
|
private activeTurnAbort?: ActiveTurnAbort;
|
|
155
156
|
active = true;
|
|
156
157
|
onReady?: () => void;
|
|
158
|
+
onTerminalFailure?: () => void;
|
|
157
159
|
// restart 시 rpc 객체가 새로 생성되므로, 핸들러를 보관했다가 매 spawn마다 재바인딩한다.
|
|
158
160
|
serverRequestHandler?: (method: string, params: unknown) => Promise<unknown>;
|
|
159
161
|
|
|
@@ -246,6 +248,7 @@ export class CodexAppServerWorker {
|
|
|
246
248
|
await this.waitForLoginCompleted();
|
|
247
249
|
this.ready = true;
|
|
248
250
|
this.restartAttempts = 0;
|
|
251
|
+
this.terminalFailureNotified = false;
|
|
249
252
|
}
|
|
250
253
|
|
|
251
254
|
async startBrowserLogin(): Promise<string> {
|
|
@@ -261,6 +264,7 @@ export class CodexAppServerWorker {
|
|
|
261
264
|
await this.waitForLoginCompleted();
|
|
262
265
|
this.ready = true;
|
|
263
266
|
this.restartAttempts = 0;
|
|
267
|
+
this.terminalFailureNotified = false;
|
|
264
268
|
logger.info(`worker ${this.config.tokenName} browser login completed`);
|
|
265
269
|
}
|
|
266
270
|
|
|
@@ -873,9 +877,18 @@ export class CodexAppServerWorker {
|
|
|
873
877
|
// ── Restart ─────────────────────────────────────────────────────
|
|
874
878
|
|
|
875
879
|
private scheduleRestart(): void {
|
|
876
|
-
if (this.destroyed
|
|
877
|
-
|
|
880
|
+
if (this.destroyed) return;
|
|
881
|
+
if (this.restartAttempts >= MAX_RESTART_ATTEMPTS) {
|
|
882
|
+
if (!this.terminalFailureNotified) {
|
|
883
|
+
this.terminalFailureNotified = true;
|
|
878
884
|
logger.warn(`worker ${this.config.tokenName} max restart attempts reached`);
|
|
885
|
+
try {
|
|
886
|
+
this.onTerminalFailure?.();
|
|
887
|
+
} catch (e) {
|
|
888
|
+
logger.warn(
|
|
889
|
+
`worker ${this.config.tokenName} terminal failure callback failed: ${(e as Error).message}`,
|
|
890
|
+
);
|
|
891
|
+
}
|
|
879
892
|
}
|
|
880
893
|
return;
|
|
881
894
|
}
|
|
@@ -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 {
|
|
7
|
+
import { CodexAppServerWorker } from "./codex-worker";
|
|
7
8
|
import {
|
|
8
9
|
ImageGenerationError,
|
|
9
10
|
makeOpenAIWorkerId,
|
|
@@ -268,6 +269,244 @@ describe("OpenAIDispatcher monit stats", () => {
|
|
|
268
269
|
});
|
|
269
270
|
|
|
270
271
|
describe("OpenAIDispatcher autoscaling", () => {
|
|
272
|
+
it("runs minimum-capacity maintenance in fixed worker mode", async () => {
|
|
273
|
+
vi.useFakeTimers();
|
|
274
|
+
const findMany = vi.spyOn(TokenModel, "findMany").mockResolvedValue({ rows: [] } as never);
|
|
275
|
+
const dispatcher = new OpenAIDispatcher(
|
|
276
|
+
autoscaleConfig({
|
|
277
|
+
autoscale: false,
|
|
278
|
+
minWorkersPerToken: 2,
|
|
279
|
+
maxWorkersPerToken: 2,
|
|
280
|
+
}),
|
|
281
|
+
() => 64,
|
|
282
|
+
);
|
|
283
|
+
const evaluateAutoscaling = vi
|
|
284
|
+
.spyOn(dispatcher, "evaluateAutoscaling")
|
|
285
|
+
.mockResolvedValue(undefined);
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
await dispatcher.start();
|
|
289
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
290
|
+
|
|
291
|
+
expect(evaluateAutoscaling).toHaveBeenCalledTimes(1);
|
|
292
|
+
} finally {
|
|
293
|
+
await dispatcher.stop();
|
|
294
|
+
findMany.mockRestore();
|
|
295
|
+
vi.useRealTimers();
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("repairs an absent token pool after every initial worker spawn failed", async () => {
|
|
300
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
301
|
+
const tokenCredentials = credentials();
|
|
302
|
+
const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker").mockResolvedValueOnce(null);
|
|
303
|
+
|
|
304
|
+
await dispatcher.spawnWorkers(1, "token", tokenCredentials, null, 1);
|
|
305
|
+
expect(dispatcher.workerPool.has(1)).toBe(false);
|
|
306
|
+
|
|
307
|
+
const recoveredWorker = fakeWorker({ tokenId: 1, workerIndex: 0 });
|
|
308
|
+
spawnSingleWorker.mockResolvedValueOnce(recoveredWorker);
|
|
309
|
+
await dispatcher.evaluateAutoscaling();
|
|
310
|
+
|
|
311
|
+
expect(dispatcher.workerPool.get(1)).toEqual([recoveredWorker]);
|
|
312
|
+
expect(recoveredWorker.kill).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("does not race minimum repair against an in-flight token bootstrap", async () => {
|
|
316
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
317
|
+
const pendingSpawn = deferred<CodexAppServerWorker | null>();
|
|
318
|
+
const spawnSingleWorker = vi
|
|
319
|
+
.spyOn(dispatcher, "spawnSingleWorker")
|
|
320
|
+
.mockReturnValue(pendingSpawn.promise);
|
|
321
|
+
|
|
322
|
+
const bootstrap = dispatcher.spawnWorkers(1, "token", credentials(), null, 1);
|
|
323
|
+
await vi.waitFor(() => expect(spawnSingleWorker).toHaveBeenCalledTimes(1));
|
|
324
|
+
const evaluation = dispatcher.evaluateAutoscaling();
|
|
325
|
+
|
|
326
|
+
expect(spawnSingleWorker).toHaveBeenCalledTimes(1);
|
|
327
|
+
const worker = fakeWorker({ tokenId: 1, workerIndex: 0 });
|
|
328
|
+
pendingSpawn.resolve(worker);
|
|
329
|
+
await Promise.all([bootstrap, evaluation]);
|
|
330
|
+
|
|
331
|
+
expect(dispatcher.workerPool.get(1)).toEqual([worker]);
|
|
332
|
+
expect(worker.kill).not.toHaveBeenCalled();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("discards a bootstrapping worker when its token is removed mid-spawn", async () => {
|
|
336
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
337
|
+
const pendingSpawn = deferred<CodexAppServerWorker | null>();
|
|
338
|
+
const spawnSingleWorker = vi
|
|
339
|
+
.spyOn(dispatcher, "spawnSingleWorker")
|
|
340
|
+
.mockReturnValue(pendingSpawn.promise);
|
|
341
|
+
|
|
342
|
+
const bootstrap = dispatcher.spawnWorkers(1, "token", credentials(), null, 1);
|
|
343
|
+
await vi.waitFor(() => expect(spawnSingleWorker).toHaveBeenCalledTimes(1));
|
|
344
|
+
await dispatcher.onTokenRemoved(1);
|
|
345
|
+
const staleWorker = fakeWorker({ tokenId: 1, workerIndex: 0 });
|
|
346
|
+
pendingSpawn.resolve(staleWorker);
|
|
347
|
+
await bootstrap;
|
|
348
|
+
|
|
349
|
+
expect(dispatcher.workerPool.has(1)).toBe(false);
|
|
350
|
+
expect(staleWorker.kill).toHaveBeenCalledTimes(1);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("repairs the configured minimum even when demand autoscaling is disabled", async () => {
|
|
354
|
+
const dispatcher = new OpenAIDispatcher(
|
|
355
|
+
autoscaleConfig({
|
|
356
|
+
autoscale: false,
|
|
357
|
+
minWorkersPerToken: 2,
|
|
358
|
+
maxWorkersPerToken: 2,
|
|
359
|
+
}),
|
|
360
|
+
() => 0,
|
|
361
|
+
);
|
|
362
|
+
const tokenCredentials = credentials();
|
|
363
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
364
|
+
await dispatcher.onTokenAdded(1, "token", tokenCredentials, null, 1);
|
|
365
|
+
const existing = fakeWorker({ tokenId: 1, workerIndex: 0 });
|
|
366
|
+
dispatcher.workerPool.set(1, [existing]);
|
|
367
|
+
const recovered = fakeWorker({ tokenId: 1, workerIndex: 1 });
|
|
368
|
+
const spawnSingleWorker = vi
|
|
369
|
+
.spyOn(dispatcher, "spawnSingleWorker")
|
|
370
|
+
.mockResolvedValue(recovered);
|
|
371
|
+
|
|
372
|
+
await dispatcher.evaluateAutoscaling();
|
|
373
|
+
|
|
374
|
+
expect(spawnSingleWorker).toHaveBeenCalledWith(1, "token", tokenCredentials, 1);
|
|
375
|
+
expect(dispatcher.workerPool.get(1)).toEqual([existing, recovered]);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("does not retry a failed minimum spawn as demand expansion in the same evaluation", async () => {
|
|
379
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
380
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
381
|
+
await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
|
|
382
|
+
dispatcher.queue.push({} as never);
|
|
383
|
+
const spawnSingleWorker = vi
|
|
384
|
+
.spyOn(dispatcher, "spawnSingleWorker")
|
|
385
|
+
.mockResolvedValue(null);
|
|
386
|
+
|
|
387
|
+
await dispatcher.evaluateAutoscaling();
|
|
388
|
+
|
|
389
|
+
expect(spawnSingleWorker).toHaveBeenCalledTimes(1);
|
|
390
|
+
dispatcher.queue = [];
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("keeps scaling healthy tokens when another token minimum repair fails", async () => {
|
|
394
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
395
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
396
|
+
await dispatcher.onTokenAdded(1, "broken", credentials(), null, 1);
|
|
397
|
+
await dispatcher.onTokenAdded(2, "healthy", credentials(), null, 1);
|
|
398
|
+
dispatcher.workerPool.set(2, [
|
|
399
|
+
fakeWorker({ tokenId: 2, tokenName: "healthy", workerIndex: 0, busy: true }),
|
|
400
|
+
]);
|
|
401
|
+
dispatcher.queue.push({} as never);
|
|
402
|
+
const healthyExpansion = fakeWorker({
|
|
403
|
+
tokenId: 2,
|
|
404
|
+
tokenName: "healthy",
|
|
405
|
+
workerIndex: 1,
|
|
406
|
+
});
|
|
407
|
+
const spawnSingleWorker = vi
|
|
408
|
+
.spyOn(dispatcher, "spawnSingleWorker")
|
|
409
|
+
.mockImplementation(async (tokenId) => (tokenId === 1 ? null : healthyExpansion));
|
|
410
|
+
|
|
411
|
+
await dispatcher.evaluateAutoscaling();
|
|
412
|
+
|
|
413
|
+
expect(spawnSingleWorker).toHaveBeenCalledTimes(2);
|
|
414
|
+
expect(dispatcher.workerPool.get(2)).toHaveLength(2);
|
|
415
|
+
dispatcher.queue = [];
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("does not duplicate a minimum slot while its worker is transiently restarting", async () => {
|
|
419
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
420
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
421
|
+
await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
|
|
422
|
+
dispatcher.workerPool.set(1, [
|
|
423
|
+
fakeWorker({ tokenId: 1, workerIndex: 0, ready: false }),
|
|
424
|
+
]);
|
|
425
|
+
const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker");
|
|
426
|
+
|
|
427
|
+
await dispatcher.evaluateAutoscaling();
|
|
428
|
+
|
|
429
|
+
expect(spawnSingleWorker).not.toHaveBeenCalled();
|
|
430
|
+
expect(dispatcher.workerPool.get(1)).toHaveLength(1);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("queues while active token metadata exists but every worker is unavailable", async () => {
|
|
434
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
435
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
436
|
+
await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
|
|
437
|
+
const evaluateAutoscaling = vi
|
|
438
|
+
.spyOn(dispatcher, "evaluateAutoscaling")
|
|
439
|
+
.mockResolvedValue(undefined);
|
|
440
|
+
|
|
441
|
+
const queued = dispatcher.enqueue(async (worker) => worker.tokenName);
|
|
442
|
+
await waitForQueue(dispatcher);
|
|
443
|
+
|
|
444
|
+
expect(evaluateAutoscaling).toHaveBeenCalled();
|
|
445
|
+
const recovered = fakeWorker({ tokenId: 1, workerIndex: 0 });
|
|
446
|
+
dispatcher.workerPool.set(1, [recovered]);
|
|
447
|
+
await dispatcher.drainQueue();
|
|
448
|
+
|
|
449
|
+
await expect(queued).resolves.toBe("token");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("still rejects immediately when no active OpenAI token metadata exists", async () => {
|
|
453
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
454
|
+
|
|
455
|
+
await expect(dispatcher.enqueue(async () => "ok")).rejects.toThrow("NO_OPENAI_WORKERS");
|
|
456
|
+
expect(dispatcher.queueLength).toBe(0);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("keeps a recovery queue when another token is removed but an active token remains", async () => {
|
|
460
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
461
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
462
|
+
await dispatcher.onTokenAdded(1, "removed", credentials(), null, 1);
|
|
463
|
+
await dispatcher.onTokenAdded(2, "recovering", credentials(), null, 1);
|
|
464
|
+
|
|
465
|
+
const queued = dispatcher.enqueue(async () => "ok");
|
|
466
|
+
const rejection = expect(queued).rejects.toThrow("TEST_CLEANUP");
|
|
467
|
+
await waitForQueue(dispatcher);
|
|
468
|
+
await dispatcher.onTokenRemoved(1);
|
|
469
|
+
|
|
470
|
+
expect(dispatcher.queueLength).toBe(1);
|
|
471
|
+
dispatcher.rejectAllQueued("TEST_CLEANUP");
|
|
472
|
+
await rejection;
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("keeps a recovery queue when another token is deactivated but an active token remains", async () => {
|
|
476
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
477
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
478
|
+
await dispatcher.onTokenAdded(1, "deactivated", credentials(), null, 1);
|
|
479
|
+
await dispatcher.onTokenAdded(2, "recovering", credentials(), null, 1);
|
|
480
|
+
|
|
481
|
+
const queued = dispatcher.enqueue(async () => "ok");
|
|
482
|
+
const rejection = expect(queued).rejects.toThrow("TEST_CLEANUP");
|
|
483
|
+
await waitForQueue(dispatcher);
|
|
484
|
+
dispatcher.onTokenDeactivated(1);
|
|
485
|
+
|
|
486
|
+
expect(dispatcher.queueLength).toBe(1);
|
|
487
|
+
dispatcher.rejectAllQueued("TEST_CLEANUP");
|
|
488
|
+
await rejection;
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("drains immediately when capacity appears before a fixed-mode queue wake-up", async () => {
|
|
492
|
+
const dispatcher = new OpenAIDispatcher(
|
|
493
|
+
autoscaleConfig({
|
|
494
|
+
autoscale: false,
|
|
495
|
+
minWorkersPerToken: 1,
|
|
496
|
+
maxWorkersPerToken: 1,
|
|
497
|
+
}),
|
|
498
|
+
() => 64,
|
|
499
|
+
);
|
|
500
|
+
const worker = fakeWorker({ tokenId: 1, workerIndex: 0 });
|
|
501
|
+
addWorkers(dispatcher, [worker]);
|
|
502
|
+
await dispatcher.onTokenUpdated(1, "token", credentials(), null, 1);
|
|
503
|
+
|
|
504
|
+
await expect(dispatcher.enqueue(async (selected) => selected.tokenName)).resolves.toBe(
|
|
505
|
+
"token",
|
|
506
|
+
);
|
|
507
|
+
expect(dispatcher.queueLength).toBe(0);
|
|
508
|
+
});
|
|
509
|
+
|
|
271
510
|
it("scales six active tokens from 5 to 15 workers per token", async () => {
|
|
272
511
|
const dispatcher = new OpenAIDispatcher(
|
|
273
512
|
autoscaleConfig({
|
|
@@ -363,6 +602,40 @@ describe("OpenAIDispatcher autoscaling", () => {
|
|
|
363
602
|
expect(dispatcher.workerPool.get(2)).toHaveLength(2);
|
|
364
603
|
});
|
|
365
604
|
|
|
605
|
+
it("maintains the configured minimum for a quota-blocked token", async () => {
|
|
606
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
607
|
+
const tokenCredentials = credentials({ accountId: "haze" });
|
|
608
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
609
|
+
await dispatcher.onTokenAdded(1, "openai/haze", tokenCredentials, 80, 1);
|
|
610
|
+
dispatcher.workerPool.set(1, [
|
|
611
|
+
fakeWorker({ tokenId: 1, tokenName: "openai/haze", workerIndex: 0 }),
|
|
612
|
+
]);
|
|
613
|
+
readOpenAIQuotaUsageMock.mockResolvedValue(quotaOk(85));
|
|
614
|
+
|
|
615
|
+
await expect(dispatcher.acquireIdleWorker()).rejects.toBeInstanceOf(
|
|
616
|
+
QuotaThresholdExceededError,
|
|
617
|
+
);
|
|
618
|
+
dispatcher.workerPool.delete(1);
|
|
619
|
+
const replacement = fakeWorker({
|
|
620
|
+
tokenId: 1,
|
|
621
|
+
tokenName: "openai/haze",
|
|
622
|
+
workerIndex: 0,
|
|
623
|
+
});
|
|
624
|
+
const spawnSingleWorker = vi
|
|
625
|
+
.spyOn(dispatcher, "spawnSingleWorker")
|
|
626
|
+
.mockResolvedValue(replacement);
|
|
627
|
+
|
|
628
|
+
await dispatcher.evaluateAutoscaling();
|
|
629
|
+
|
|
630
|
+
expect(spawnSingleWorker).toHaveBeenCalledWith(
|
|
631
|
+
1,
|
|
632
|
+
"openai/haze",
|
|
633
|
+
tokenCredentials,
|
|
634
|
+
0,
|
|
635
|
+
);
|
|
636
|
+
expect(dispatcher.workerPool.get(1)).toEqual([replacement]);
|
|
637
|
+
});
|
|
638
|
+
|
|
366
639
|
it("discards a worker spawned while its token becomes quota-blocked", async () => {
|
|
367
640
|
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
368
641
|
const tokenCredentials = credentials();
|
|
@@ -443,6 +716,168 @@ describe("OpenAIDispatcher autoscaling", () => {
|
|
|
443
716
|
expect(busy.kill).not.toHaveBeenCalled();
|
|
444
717
|
expect(dispatcher.workerPool.get(1)).toEqual([busy]);
|
|
445
718
|
});
|
|
719
|
+
|
|
720
|
+
it.each([1, 2, 5])(
|
|
721
|
+
"converges repeated idle evaluations to configured minimum %i and never below it",
|
|
722
|
+
async (minimum) => {
|
|
723
|
+
vi.useFakeTimers();
|
|
724
|
+
try {
|
|
725
|
+
vi.setSystemTime(1_000);
|
|
726
|
+
const initialCount = minimum + 3;
|
|
727
|
+
const dispatcher = new OpenAIDispatcher(
|
|
728
|
+
autoscaleConfig({
|
|
729
|
+
minWorkersPerToken: minimum,
|
|
730
|
+
maxWorkersPerToken: initialCount,
|
|
731
|
+
scaleDownIdleMs: 1_000,
|
|
732
|
+
}),
|
|
733
|
+
() => 64,
|
|
734
|
+
);
|
|
735
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
736
|
+
await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
|
|
737
|
+
const workers = Array.from({ length: initialCount }, (_, workerIndex) =>
|
|
738
|
+
fakeWorker({ tokenId: 1, workerIndex }),
|
|
739
|
+
);
|
|
740
|
+
dispatcher.workerPool.set(1, workers);
|
|
741
|
+
vi.setSystemTime(2_001);
|
|
742
|
+
|
|
743
|
+
await dispatcher.evaluateAutoscaling();
|
|
744
|
+
expect(dispatcher.workerPool.get(1)).toHaveLength(initialCount - 1);
|
|
745
|
+
await dispatcher.evaluateAutoscaling();
|
|
746
|
+
expect(dispatcher.workerPool.get(1)).toHaveLength(initialCount - 2);
|
|
747
|
+
await dispatcher.evaluateAutoscaling();
|
|
748
|
+
expect(dispatcher.workerPool.get(1)).toHaveLength(minimum);
|
|
749
|
+
await dispatcher.evaluateAutoscaling();
|
|
750
|
+
expect(dispatcher.workerPool.get(1)).toHaveLength(minimum);
|
|
751
|
+
|
|
752
|
+
for (let workerIndex = minimum; workerIndex < initialCount; workerIndex++) {
|
|
753
|
+
expect(workers[workerIndex]?.kill).toHaveBeenCalledTimes(1);
|
|
754
|
+
}
|
|
755
|
+
for (let workerIndex = 0; workerIndex < minimum; workerIndex++) {
|
|
756
|
+
expect(workers[workerIndex]?.kill).not.toHaveBeenCalled();
|
|
757
|
+
}
|
|
758
|
+
} finally {
|
|
759
|
+
vi.useRealTimers();
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
it("still scales healthy idle pools down when another token cannot recover its minimum", async () => {
|
|
765
|
+
vi.useFakeTimers();
|
|
766
|
+
try {
|
|
767
|
+
vi.setSystemTime(1_000);
|
|
768
|
+
const dispatcher = new OpenAIDispatcher(
|
|
769
|
+
autoscaleConfig({ scaleDownIdleMs: 1_000 }),
|
|
770
|
+
() => 64,
|
|
771
|
+
);
|
|
772
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
773
|
+
await dispatcher.onTokenAdded(1, "broken", credentials(), null, 1);
|
|
774
|
+
await dispatcher.onTokenAdded(2, "healthy", credentials(), null, 1);
|
|
775
|
+
const healthyWorkers = [
|
|
776
|
+
fakeWorker({ tokenId: 2, tokenName: "healthy", workerIndex: 0 }),
|
|
777
|
+
fakeWorker({ tokenId: 2, tokenName: "healthy", workerIndex: 1 }),
|
|
778
|
+
];
|
|
779
|
+
dispatcher.workerPool.set(2, healthyWorkers);
|
|
780
|
+
vi.spyOn(dispatcher, "spawnSingleWorker").mockResolvedValue(null);
|
|
781
|
+
vi.setSystemTime(2_001);
|
|
782
|
+
|
|
783
|
+
await dispatcher.evaluateAutoscaling();
|
|
784
|
+
|
|
785
|
+
expect(dispatcher.workerPool.get(2)).toEqual([healthyWorkers[0]]);
|
|
786
|
+
expect(healthyWorkers[1]?.kill).toHaveBeenCalledTimes(1);
|
|
787
|
+
} finally {
|
|
788
|
+
vi.useRealTimers();
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("removes a terminal worker by identity and requests minimum repair", async () => {
|
|
793
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
794
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
795
|
+
await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
|
|
796
|
+
const terminal = fakeWorker({ tokenId: 1, workerIndex: 0, ready: false });
|
|
797
|
+
dispatcher.workerPool.set(1, [terminal]);
|
|
798
|
+
const evaluateAutoscaling = vi
|
|
799
|
+
.spyOn(dispatcher, "evaluateAutoscaling")
|
|
800
|
+
.mockResolvedValue(undefined);
|
|
801
|
+
|
|
802
|
+
await dispatcher.handleWorkerTerminalFailure(terminal);
|
|
803
|
+
|
|
804
|
+
expect(dispatcher.workerPool.has(1)).toBe(false);
|
|
805
|
+
expect(terminal.kill).toHaveBeenCalledTimes(1);
|
|
806
|
+
expect(evaluateAutoscaling).toHaveBeenCalledTimes(1);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it("returns to the configured minimum after scale-down and terminal failure overlap", async () => {
|
|
810
|
+
const dispatcher = new OpenAIDispatcher(
|
|
811
|
+
autoscaleConfig({
|
|
812
|
+
minWorkersPerToken: 2,
|
|
813
|
+
maxWorkersPerToken: 3,
|
|
814
|
+
scaleDownIdleMs: 0,
|
|
815
|
+
}),
|
|
816
|
+
() => 64,
|
|
817
|
+
);
|
|
818
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
819
|
+
await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
|
|
820
|
+
const terminal = fakeWorker({ tokenId: 1, workerIndex: 0, ready: false });
|
|
821
|
+
const survivor = fakeWorker({ tokenId: 1, workerIndex: 1 });
|
|
822
|
+
const excess = fakeWorker({ tokenId: 1, workerIndex: 2 });
|
|
823
|
+
dispatcher.workerPool.set(1, [terminal, survivor, excess]);
|
|
824
|
+
|
|
825
|
+
await dispatcher.scaleDownOneStep();
|
|
826
|
+
expect(dispatcher.workerPool.get(1)).toEqual([terminal, survivor]);
|
|
827
|
+
|
|
828
|
+
const replacement = fakeWorker({ tokenId: 1, workerIndex: 0 });
|
|
829
|
+
vi.spyOn(dispatcher, "spawnSingleWorker").mockResolvedValue(replacement);
|
|
830
|
+
await dispatcher.handleWorkerTerminalFailure(terminal);
|
|
831
|
+
await vi.waitFor(() =>
|
|
832
|
+
expect(dispatcher.workerPool.get(1)).toEqual([replacement, survivor]),
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
expect(excess.kill).toHaveBeenCalledTimes(1);
|
|
836
|
+
expect(terminal.kill).toHaveBeenCalledTimes(1);
|
|
837
|
+
expect(dispatcher.workerPool.get(1)).toHaveLength(2);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
it("ignores a stale terminal callback after the token pool was replaced", async () => {
|
|
841
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
842
|
+
vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
843
|
+
await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
|
|
844
|
+
const stale = fakeWorker({ tokenId: 1, workerIndex: 0, ready: false });
|
|
845
|
+
const current = fakeWorker({ tokenId: 1, workerIndex: 0 });
|
|
846
|
+
dispatcher.workerPool.set(1, [current]);
|
|
847
|
+
const evaluateAutoscaling = vi.spyOn(dispatcher, "evaluateAutoscaling");
|
|
848
|
+
|
|
849
|
+
await dispatcher.handleWorkerTerminalFailure(stale);
|
|
850
|
+
|
|
851
|
+
expect(dispatcher.workerPool.get(1)).toEqual([current]);
|
|
852
|
+
expect(current.kill).not.toHaveBeenCalled();
|
|
853
|
+
expect(evaluateAutoscaling).not.toHaveBeenCalled();
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("wires a spawned worker terminal signal to dispatcher cleanup", async () => {
|
|
857
|
+
const initialize = vi
|
|
858
|
+
.spyOn(CodexAppServerWorker.prototype, "initialize")
|
|
859
|
+
.mockResolvedValue(undefined);
|
|
860
|
+
const kill = vi.spyOn(CodexAppServerWorker.prototype, "kill").mockResolvedValue(undefined);
|
|
861
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
862
|
+
|
|
863
|
+
try {
|
|
864
|
+
await dispatcher.spawnWorkers(1, "token", credentials(), null, 1);
|
|
865
|
+
const worker = dispatcher.workerPool.get(1)?.[0];
|
|
866
|
+
expect(worker).toBeDefined();
|
|
867
|
+
const evaluateAutoscaling = vi
|
|
868
|
+
.spyOn(dispatcher, "evaluateAutoscaling")
|
|
869
|
+
.mockResolvedValue(undefined);
|
|
870
|
+
|
|
871
|
+
worker?.onTerminalFailure?.();
|
|
872
|
+
await vi.waitFor(() => expect(evaluateAutoscaling).toHaveBeenCalledTimes(1));
|
|
873
|
+
|
|
874
|
+
expect(kill).toHaveBeenCalledTimes(1);
|
|
875
|
+
expect(dispatcher.workerPool.has(1)).toBe(false);
|
|
876
|
+
} finally {
|
|
877
|
+
initialize.mockRestore();
|
|
878
|
+
kill.mockRestore();
|
|
879
|
+
}
|
|
880
|
+
});
|
|
446
881
|
});
|
|
447
882
|
|
|
448
883
|
describe("OpenAIDispatcher token updates", () => {
|
|
@@ -987,8 +1422,8 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
987
1422
|
|
|
988
1423
|
it("rechecks threshold at drain time before assigning the released worker", async () => {
|
|
989
1424
|
const dispatcher = new OpenAIDispatcher();
|
|
990
|
-
const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
|
|
991
|
-
const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B" });
|
|
1425
|
+
const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true });
|
|
1426
|
+
const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B", busy: true });
|
|
992
1427
|
addWorkers(dispatcher, [workerA, workerB]);
|
|
993
1428
|
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), 80);
|
|
994
1429
|
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), 80);
|
|
@@ -998,6 +1433,8 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
998
1433
|
readOpenAIQuotaUsageMock
|
|
999
1434
|
.mockResolvedValueOnce(quotaOk(90))
|
|
1000
1435
|
.mockResolvedValueOnce(quotaOk(10));
|
|
1436
|
+
workerA.releaseTurn();
|
|
1437
|
+
workerB.releaseTurn();
|
|
1001
1438
|
|
|
1002
1439
|
await dispatcher.drainQueue();
|
|
1003
1440
|
|
|
@@ -1006,12 +1443,13 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
1006
1443
|
|
|
1007
1444
|
it("serializes concurrent drain calls so a queue item is not assigned twice", async () => {
|
|
1008
1445
|
const dispatcher = new OpenAIDispatcher();
|
|
1009
|
-
const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
|
|
1446
|
+
const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true });
|
|
1010
1447
|
addWorkers(dispatcher, [worker]);
|
|
1011
1448
|
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
|
|
1012
1449
|
const execute = vi.fn(async (w: CodexAppServerWorker) => w.tokenName);
|
|
1013
1450
|
const queued = dispatcher.enqueue(execute);
|
|
1014
1451
|
await waitForQueue(dispatcher);
|
|
1452
|
+
worker.releaseTurn();
|
|
1015
1453
|
|
|
1016
1454
|
await Promise.all([dispatcher.drainQueue(), dispatcher.drainQueue()]);
|
|
1017
1455
|
|