@cartanova/qgrid-cli 2.5.8 → 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.
@@ -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;
@@ -310,6 +382,92 @@ describe("CodexAppServerWorker TTFT", () => {
310
382
  });
311
383
  });
312
384
 
385
+ describe("CodexAppServerWorker thread activity timestamps", () => {
386
+ type ThreadMetaInternals = {
387
+ threadMeta: Map<string, { lastUsedAt: number }>;
388
+ };
389
+
390
+ it("records non-stream thread activity at turn completion", async () => {
391
+ let now = 1_000;
392
+ const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
393
+ const { worker, request, handlers } = createWorkerWithNotificationRpc(
394
+ async (method, _params) => {
395
+ if (method === "thread/start") {
396
+ return { thread: { id: "thread-1" }, model: "gpt-test" };
397
+ }
398
+ if (method === "turn/start") return { turn: { id: "turn-1" } };
399
+ throw new Error(`unexpected request: ${method}`);
400
+ },
401
+ );
402
+
403
+ try {
404
+ const turn = worker.executeTurn({
405
+ input: [{ type: "text", text: "hi", text_elements: [] }],
406
+ });
407
+ await vi.waitFor(() =>
408
+ expect(request).toHaveBeenCalledWith("turn/start", expect.anything()),
409
+ );
410
+ now = 700_000;
411
+ handlers.get("turn/completed")?.({
412
+ threadId: "thread-1",
413
+ turn: { status: "completed", durationMs: 699_000 },
414
+ } as never);
415
+
416
+ await turn;
417
+
418
+ expect(
419
+ (worker as unknown as ThreadMetaInternals).threadMeta.get("thread-1")?.lastUsedAt,
420
+ ).toBe(700_000);
421
+ } finally {
422
+ nowSpy.mockRestore();
423
+ }
424
+ });
425
+
426
+ it("records streaming thread activity at turn completion", async () => {
427
+ let now = 2_000;
428
+ const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
429
+ const { worker, request, handlers } = createWorkerWithNotificationRpc(
430
+ async (method, _params) => {
431
+ if (method === "thread/start") {
432
+ return { thread: { id: "thread-1" }, model: "gpt-test" };
433
+ }
434
+ if (method === "turn/start") return { turn: { id: "turn-1" } };
435
+ throw new Error(`unexpected request: ${method}`);
436
+ },
437
+ );
438
+ const callbacks: StreamCallbacks = {
439
+ onDelta: vi.fn(),
440
+ onComplete: vi.fn(),
441
+ onError: vi.fn(),
442
+ };
443
+
444
+ try {
445
+ const turn = worker.executeTurnStream(
446
+ {
447
+ input: [{ type: "text", text: "hi", text_elements: [] }],
448
+ },
449
+ callbacks,
450
+ );
451
+ await vi.waitFor(() =>
452
+ expect(request).toHaveBeenCalledWith("turn/start", expect.anything()),
453
+ );
454
+ now = 800_000;
455
+ handlers.get("turn/completed")?.({
456
+ threadId: "thread-1",
457
+ turn: { status: "completed", durationMs: 798_000 },
458
+ } as never);
459
+
460
+ await turn;
461
+
462
+ expect(
463
+ (worker as unknown as ThreadMetaInternals).threadMeta.get("thread-1")?.lastUsedAt,
464
+ ).toBe(800_000);
465
+ } finally {
466
+ nowSpy.mockRestore();
467
+ }
468
+ });
469
+ });
470
+
313
471
  describe("CodexAppServerWorker prompt prefix", () => {
314
472
  it("uses the same base instructions with and without an output schema", async () => {
315
473
  type WorkerInternals = {
@@ -728,7 +886,7 @@ describe("CodexAppServerWorker thread unsubscribe on eviction", () => {
728
886
  expect(unsubscribedIds(request).toSorted()).toEqual(["stale-1", "stale-2"]);
729
887
  });
730
888
 
731
- it("sends thread/unsubscribe for LRU-evicted threads over the cap", () => {
889
+ it("caps periodic LRU cleanup at the configured maximum", () => {
732
890
  const { worker, request, meta } = createWorkerWithUnsubscribeSpy();
733
891
  const now = Date.now();
734
892
  for (let i = 0; i < 17; i++) {
@@ -737,7 +895,19 @@ describe("CodexAppServerWorker thread unsubscribe on eviction", () => {
737
895
 
738
896
  worker.sweepIdleThreads();
739
897
 
740
- // 상한(MAX-1=15)으로 줄이며 가장 오래된 2개가 축출·구독 해제된다.
898
+ expect(meta.size).toBe(16);
899
+ expect(unsubscribedIds(request)).toEqual(["t-0"]);
900
+ });
901
+
902
+ it("reserves one LRU slot before creating a new thread", () => {
903
+ const { worker, request, meta } = createWorkerWithUnsubscribeSpy();
904
+ const now = Date.now();
905
+ for (let i = 0; i < 17; i++) {
906
+ meta.set(`t-${i}`, { lastUsedAt: now - (17 - i) * 1_000 });
907
+ }
908
+
909
+ worker.sweepIdleThreads(true);
910
+
741
911
  expect(meta.size).toBe(15);
742
912
  expect(unsubscribedIds(request).toSorted()).toEqual(["t-0", "t-1"]);
743
913
  });
@@ -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
 
@@ -427,7 +431,7 @@ export class CodexAppServerWorker {
427
431
  // thread 의 기본 model 도 반환 (req.model 미지정 시 fallback).
428
432
  async createThread(req: TurnRequest): Promise<{ threadId: string; model: string }> {
429
433
  if (!this.rpc || !this.ready) throw new Error("worker not ready");
430
- this.sweepIdleThreads();
434
+ this.sweepIdleThreads(true);
431
435
 
432
436
  const threadConfig: ThreadStartParams["config"] = {
433
437
  ...THREAD_DEFAULTS.config,
@@ -524,7 +528,11 @@ export class CodexAppServerWorker {
524
528
  // 이미지 thread 는 1 회용 cold thread 인데 threadMeta 에 등록되지 않아
525
529
  // sweep 이 못 잡는다. MB 급 base64 가 상주하므로 turn 종료 즉시(성공/실패
526
530
  // 불문) 구독을 끊어 codex 30분 언로드 대상으로 만든다.
527
- if (req.imageGeneration) this.unsubscribeThread(threadId);
531
+ if (req.imageGeneration) {
532
+ this.unsubscribeThread(threadId);
533
+ } else {
534
+ this.touchThread(threadId);
535
+ }
528
536
  }
529
537
  }
530
538
 
@@ -544,18 +552,22 @@ export class CodexAppServerWorker {
544
552
  cb.onThreadId?.(threadId);
545
553
  const ttftTracker = createTtftTracker();
546
554
  const resultPromise = this.consumeStreamNotifications(threadId, model, cb, ttftTracker);
547
- let turnId: string;
548
555
  try {
549
- ttftTracker.markStart();
550
- ({ turnId } = await this.startTurnOnThread(threadId, req));
551
- } catch (e) {
552
- this.failActiveTurn(e as Error);
553
- await resultPromise.catch(() => {});
554
- 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);
555
570
  }
556
- cb.onTurnId?.(turnId);
557
- await resultPromise;
558
- return { threadId };
559
571
  }
560
572
 
561
573
  // ── thread 재사용 지원 ───────────────────────────────────────────
@@ -568,21 +580,21 @@ export class CodexAppServerWorker {
568
580
  return this.threadMeta.has(threadId);
569
581
  }
570
582
 
571
- // idle TTL 초과 / 개수 상한 초과 thread 를 정리. createThread 직전 lazy 호출.
583
+ // idle TTL 초과 / 개수 상한 초과 thread 를 정리.
572
584
  // 맵 제거(재사용 라우팅 차단)와 함께 thread/unsubscribe 를 보낸다 — codex 는
573
585
  // "구독자 0 + idle" 이 30분(THREAD_UNLOADING_DELAY, 하드코딩) 지속된 thread 를
574
586
  // 메모리에서 자동 언로드하는데, thread/start 가 이 커넥션을 자동 구독자로 등록해
575
587
  // 두기 때문에 unsubscribe 없이는 그 언로드가 영원히 발동하지 않는다(SON-516 실증).
576
- sweepIdleThreads(): void {
588
+ sweepIdleThreads(reserveSlotForNewThread = false): void {
577
589
  const now = Date.now();
578
590
  for (const [id, meta] of this.threadMeta) {
579
591
  if (now - meta.lastUsedAt > CodexAppServerWorker.THREAD_IDLE_TTL_MS) {
580
592
  this.evictThread(id);
581
593
  }
582
594
  }
583
- // createThread 직전 호출 → 새 thread 1 개가 곧 추가된다. 추가 후에도 MAX 를 넘지 않도록
584
- // MAX-1 이하로 줄여 둔다(그래야 생성 직후 정확히 MAX). LRU: lastUsedAt 오름차순으로 제거.
585
- 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);
586
598
  if (this.threadMeta.size > limit) {
587
599
  const sorted = [...this.threadMeta.entries()].toSorted(
588
600
  (a, b) => a[1].lastUsedAt - b[1].lastUsedAt,
@@ -592,6 +604,11 @@ export class CodexAppServerWorker {
592
604
  }
593
605
  }
594
606
 
607
+ private touchThread(threadId: string): void {
608
+ const meta = this.threadMeta.get(threadId);
609
+ if (meta) meta.lastUsedAt = Date.now();
610
+ }
611
+
595
612
  // thread 재사용 포기: 맵 제거(라우팅 차단)와 구독 해제를 한 단위로 묶는다.
596
613
  // unsubscribe 한 thread 에 turn 을 보내면 알림이 이 커넥션으로 오지 않으므로
597
614
  // 두 동작은 반드시 함께여야 한다.
@@ -873,9 +890,18 @@ export class CodexAppServerWorker {
873
890
  // ── Restart ─────────────────────────────────────────────────────
874
891
 
875
892
  private scheduleRestart(): void {
876
- if (this.destroyed || this.restartAttempts >= MAX_RESTART_ATTEMPTS) {
877
- if (this.restartAttempts >= MAX_RESTART_ATTEMPTS) {
893
+ if (this.destroyed) return;
894
+ if (this.restartAttempts >= MAX_RESTART_ATTEMPTS) {
895
+ if (!this.terminalFailureNotified) {
896
+ this.terminalFailureNotified = true;
878
897
  logger.warn(`worker ${this.config.tokenName} max restart attempts reached`);
898
+ try {
899
+ this.onTerminalFailure?.();
900
+ } catch (e) {
901
+ logger.warn(
902
+ `worker ${this.config.tokenName} terminal failure callback failed: ${(e as Error).message}`,
903
+ );
904
+ }
879
905
  }
880
906
  return;
881
907
  }