@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.
@@ -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) this.unsubscribeThread(threadId);
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
- ttftTracker.markStart();
554
- ({ turnId } = await this.startTurnOnThread(threadId, req));
555
- } catch (e) {
556
- this.failActiveTurn(e as Error);
557
- await resultPromise.catch(() => {});
558
- throw e;
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 를 정리. createThread 직전 lazy 호출.
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 직전 호출 → 새 thread 1 개가 곧 추가된다. 추가 후에도 MAX 를 넘지 않도록
588
- // MAX-1 이하로 줄여 둔다(그래야 생성 직후 정확히 MAX). LRU: lastUsedAt 오름차순으로 제거.
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,