@cartanova/qgrid-cli 2.5.9 → 2.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +2 -8
- package/README.md +2 -8
- package/bundle/dist/utils/providers/openai/codex-worker.js +23 -14
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +5 -1
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +100 -2
- package/bundle/src/utils/providers/openai/codex-worker.ts +30 -17
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +41 -0
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +9 -0
- package/bundle/web-dist/client/assets/{index-CkBeqjBm.js → index-SdI2T3b8.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-d9HdibiE.js → logs-O-8hGStp.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-C8qYvOMj.js → monit-DA8MAY-d.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-DT0AY2Lq.js → routes-COPjkgNr.js} +1 -1
- package/bundle/web-dist/client/assets/{show-DPG1oKpW.js → show-ClG4e0BP.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-Dz7hmKFm.js → tokens-Cc5frn5f.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/ai-sdk-provider-contract.md +1 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
- package/skills/qgrid/references/openai-codex-runtime.md +3 -1
|
@@ -431,7 +431,7 @@ export class CodexAppServerWorker {
|
|
|
431
431
|
// thread 의 기본 model 도 반환 (req.model 미지정 시 fallback).
|
|
432
432
|
async createThread(req: TurnRequest): Promise<{ threadId: string; model: string }> {
|
|
433
433
|
if (!this.rpc || !this.ready) throw new Error("worker not ready");
|
|
434
|
-
this.sweepIdleThreads();
|
|
434
|
+
this.sweepIdleThreads(true);
|
|
435
435
|
|
|
436
436
|
const threadConfig: ThreadStartParams["config"] = {
|
|
437
437
|
...THREAD_DEFAULTS.config,
|
|
@@ -528,7 +528,11 @@ export class CodexAppServerWorker {
|
|
|
528
528
|
// 이미지 thread 는 1 회용 cold thread 인데 threadMeta 에 등록되지 않아
|
|
529
529
|
// sweep 이 못 잡는다. MB 급 base64 가 상주하므로 turn 종료 즉시(성공/실패
|
|
530
530
|
// 불문) 구독을 끊어 codex 30분 언로드 대상으로 만든다.
|
|
531
|
-
if (req.imageGeneration)
|
|
531
|
+
if (req.imageGeneration) {
|
|
532
|
+
this.unsubscribeThread(threadId);
|
|
533
|
+
} else {
|
|
534
|
+
this.touchThread(threadId);
|
|
535
|
+
}
|
|
532
536
|
}
|
|
533
537
|
}
|
|
534
538
|
|
|
@@ -548,18 +552,22 @@ export class CodexAppServerWorker {
|
|
|
548
552
|
cb.onThreadId?.(threadId);
|
|
549
553
|
const ttftTracker = createTtftTracker();
|
|
550
554
|
const resultPromise = this.consumeStreamNotifications(threadId, model, cb, ttftTracker);
|
|
551
|
-
let turnId: string;
|
|
552
555
|
try {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
556
|
+
let turnId: string;
|
|
557
|
+
try {
|
|
558
|
+
ttftTracker.markStart();
|
|
559
|
+
({ turnId } = await this.startTurnOnThread(threadId, req));
|
|
560
|
+
} catch (e) {
|
|
561
|
+
this.failActiveTurn(e as Error);
|
|
562
|
+
await resultPromise.catch(() => {});
|
|
563
|
+
throw e;
|
|
564
|
+
}
|
|
565
|
+
cb.onTurnId?.(turnId);
|
|
566
|
+
await resultPromise;
|
|
567
|
+
return { threadId };
|
|
568
|
+
} finally {
|
|
569
|
+
this.touchThread(threadId);
|
|
559
570
|
}
|
|
560
|
-
cb.onTurnId?.(turnId);
|
|
561
|
-
await resultPromise;
|
|
562
|
-
return { threadId };
|
|
563
571
|
}
|
|
564
572
|
|
|
565
573
|
// ── thread 재사용 지원 ───────────────────────────────────────────
|
|
@@ -572,21 +580,21 @@ export class CodexAppServerWorker {
|
|
|
572
580
|
return this.threadMeta.has(threadId);
|
|
573
581
|
}
|
|
574
582
|
|
|
575
|
-
// idle TTL 초과 / 개수 상한 초과 thread 를 정리.
|
|
583
|
+
// idle TTL 초과 / 개수 상한 초과 thread 를 정리.
|
|
576
584
|
// 맵 제거(재사용 라우팅 차단)와 함께 thread/unsubscribe 를 보낸다 — codex 는
|
|
577
585
|
// "구독자 0 + idle" 이 30분(THREAD_UNLOADING_DELAY, 하드코딩) 지속된 thread 를
|
|
578
586
|
// 메모리에서 자동 언로드하는데, thread/start 가 이 커넥션을 자동 구독자로 등록해
|
|
579
587
|
// 두기 때문에 unsubscribe 없이는 그 언로드가 영원히 발동하지 않는다(SON-516 실증).
|
|
580
|
-
sweepIdleThreads(): void {
|
|
588
|
+
sweepIdleThreads(reserveSlotForNewThread = false): void {
|
|
581
589
|
const now = Date.now();
|
|
582
590
|
for (const [id, meta] of this.threadMeta) {
|
|
583
591
|
if (now - meta.lastUsedAt > CodexAppServerWorker.THREAD_IDLE_TTL_MS) {
|
|
584
592
|
this.evictThread(id);
|
|
585
593
|
}
|
|
586
594
|
}
|
|
587
|
-
// createThread
|
|
588
|
-
//
|
|
589
|
-
const limit = CodexAppServerWorker.MAX_THREADS_PER_WORKER - 1;
|
|
595
|
+
// createThread 직전에는 새 thread 1개가 곧 추가되므로 MAX-1을 사용한다.
|
|
596
|
+
// 주기 sweep은 현재 cap인 MAX까지만 줄인다. LRU: lastUsedAt 오름차순으로 제거.
|
|
597
|
+
const limit = CodexAppServerWorker.MAX_THREADS_PER_WORKER - (reserveSlotForNewThread ? 1 : 0);
|
|
590
598
|
if (this.threadMeta.size > limit) {
|
|
591
599
|
const sorted = [...this.threadMeta.entries()].toSorted(
|
|
592
600
|
(a, b) => a[1].lastUsedAt - b[1].lastUsedAt,
|
|
@@ -596,6 +604,11 @@ export class CodexAppServerWorker {
|
|
|
596
604
|
}
|
|
597
605
|
}
|
|
598
606
|
|
|
607
|
+
private touchThread(threadId: string): void {
|
|
608
|
+
const meta = this.threadMeta.get(threadId);
|
|
609
|
+
if (meta) meta.lastUsedAt = Date.now();
|
|
610
|
+
}
|
|
611
|
+
|
|
599
612
|
// thread 재사용 포기: 맵 제거(라우팅 차단)와 구독 해제를 한 단위로 묶는다.
|
|
600
613
|
// unsubscribe 한 thread 에 turn 을 보내면 알림이 이 커넥션으로 오지 않으므로
|
|
601
614
|
// 두 동작은 반드시 함께여야 한다.
|
|
@@ -142,6 +142,7 @@ function fakeWorker(
|
|
|
142
142
|
releaseTurn: vi.fn(() => {
|
|
143
143
|
busy = false;
|
|
144
144
|
}),
|
|
145
|
+
sweepIdleThreads: vi.fn(),
|
|
145
146
|
hasThread: vi.fn((threadId: string) => (options.threads ?? []).includes(threadId)),
|
|
146
147
|
getRateLimits: vi.fn(
|
|
147
148
|
async () => options.rateLimits ?? { rateLimits: {}, rateLimitsByLimitId: null },
|
|
@@ -296,6 +297,46 @@ describe("OpenAIDispatcher autoscaling", () => {
|
|
|
296
297
|
}
|
|
297
298
|
});
|
|
298
299
|
|
|
300
|
+
it("runs idle-thread cleanup from the fixed-mode health timer", async () => {
|
|
301
|
+
vi.useFakeTimers();
|
|
302
|
+
const findMany = vi.spyOn(TokenModel, "findMany").mockResolvedValue({ rows: [] } as never);
|
|
303
|
+
const dispatcher = new OpenAIDispatcher(
|
|
304
|
+
autoscaleConfig({
|
|
305
|
+
autoscale: false,
|
|
306
|
+
minWorkersPerToken: 1,
|
|
307
|
+
maxWorkersPerToken: 1,
|
|
308
|
+
}),
|
|
309
|
+
() => 64,
|
|
310
|
+
);
|
|
311
|
+
const worker = fakeWorker({ tokenId: 1 });
|
|
312
|
+
addWorkers(dispatcher, [worker]);
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
await dispatcher.start();
|
|
316
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
317
|
+
|
|
318
|
+
expect(worker.sweepIdleThreads).toHaveBeenCalledTimes(1);
|
|
319
|
+
} finally {
|
|
320
|
+
await dispatcher.stop();
|
|
321
|
+
findMany.mockRestore();
|
|
322
|
+
vi.useRealTimers();
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("sweeps only ready idle workers so active turns keep their subscription", async () => {
|
|
327
|
+
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
328
|
+
const idle = fakeWorker({ tokenId: 1, workerIndex: 0 });
|
|
329
|
+
const busy = fakeWorker({ tokenId: 1, workerIndex: 1, busy: true });
|
|
330
|
+
const restarting = fakeWorker({ tokenId: 1, workerIndex: 2, ready: false });
|
|
331
|
+
addWorkers(dispatcher, [idle, busy, restarting]);
|
|
332
|
+
|
|
333
|
+
await dispatcher.evaluateAutoscaling();
|
|
334
|
+
|
|
335
|
+
expect(idle.sweepIdleThreads).toHaveBeenCalledTimes(1);
|
|
336
|
+
expect(busy.sweepIdleThreads).not.toHaveBeenCalled();
|
|
337
|
+
expect(restarting.sweepIdleThreads).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
|
|
299
340
|
it("repairs an absent token pool after every initial worker spawn failed", async () => {
|
|
300
341
|
const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
|
|
301
342
|
const tokenCredentials = credentials();
|
|
@@ -780,6 +780,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
780
780
|
|
|
781
781
|
async evaluateAutoscaling(): Promise<void> {
|
|
782
782
|
if (this.stopped) return;
|
|
783
|
+
this.sweepIdleWorkerThreads();
|
|
783
784
|
const minimumDeficitTokenIds = await this.ensureMinimumWorkersOneStep();
|
|
784
785
|
if (!this.poolConfig.autoscale || this.stopped) return;
|
|
785
786
|
if (this.queue.length > 0) {
|
|
@@ -913,6 +914,14 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
913
914
|
return this.poolConfig.maxWorkersPerToken;
|
|
914
915
|
}
|
|
915
916
|
|
|
917
|
+
private sweepIdleWorkerThreads(): void {
|
|
918
|
+
for (const worker of [...this.workerPool.values()].flat()) {
|
|
919
|
+
// turn 중 unsubscribe하면 해당 connection으로 notification이 더 오지 않아
|
|
920
|
+
// 실행 중 요청이 timeout될 수 있다. ready + idle worker만 sweep한다.
|
|
921
|
+
if (worker.isReady && worker.hasCapacity) worker.sweepIdleThreads();
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
916
925
|
private async spawnAndCommitWorker(
|
|
917
926
|
tokenId: number,
|
|
918
927
|
metadata: TokenMetadata,
|