@cartanova/qgrid-cli 2.5.9 → 2.6.0

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.
Files changed (58) hide show
  1. package/README.ko.md +2 -9
  2. package/README.md +2 -9
  3. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -1
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +12 -3
  5. package/bundle/dist/application/request-log/request-log.model.js +3 -2
  6. package/bundle/dist/application/request-log/request-log.types.js +4 -2
  7. package/bundle/dist/application/sonamu.generated.js +8 -1
  8. package/bundle/dist/application/sonamu.generated.sso.js +2 -1
  9. package/bundle/dist/i18n/sd.generated.js +2 -1
  10. package/bundle/dist/migrations/20260806141701_alter_request_logs_add1_alter1.js +15 -0
  11. package/bundle/dist/utils/providers/openai/codex-worker.js +23 -14
  12. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +5 -1
  13. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +60 -0
  14. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +14 -0
  15. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +3 -0
  16. package/bundle/src/application/qgrid/qgrid.frame.ts +22 -5
  17. package/bundle/src/application/request-log/request-log.entity.json +8 -0
  18. package/bundle/src/application/request-log/request-log.model.test.ts +3 -1
  19. package/bundle/src/application/request-log/request-log.model.ts +7 -1
  20. package/bundle/src/application/request-log/request-log.types.ts +5 -0
  21. package/bundle/src/application/sonamu.generated.http +1 -2
  22. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  23. package/bundle/src/application/sonamu.generated.ts +13 -0
  24. package/bundle/src/i18n/sd.generated.ts +1 -0
  25. package/bundle/src/migrations/20260806141701_alter_request_logs_add1_alter1.ts +15 -0
  26. package/bundle/src/utils/providers/openai/codex-worker.test.ts +100 -2
  27. package/bundle/src/utils/providers/openai/codex-worker.ts +30 -17
  28. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +41 -0
  29. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +9 -0
  30. package/bundle/web-dist/client/assets/index-GA9b-zy4.css +1 -0
  31. package/bundle/web-dist/client/assets/{index-CkBeqjBm.js → index-YowQRE2y.js} +3 -3
  32. package/bundle/web-dist/client/assets/{logs-d9HdibiE.js → logs-o8mm530D.js} +1 -1
  33. package/bundle/web-dist/client/assets/{monit-C8qYvOMj.js → monit-B51yXcqk.js} +1 -1
  34. package/bundle/web-dist/client/assets/{routes-DT0AY2Lq.js → routes-BqaUiBc0.js} +1 -1
  35. package/bundle/web-dist/client/assets/{sd.generated-DbBC73_z.js → sd.generated-CjW3rfJ8.js} +1 -1
  36. package/bundle/web-dist/client/assets/{services.generated-BWyAypSh.js → services.generated-D3MH3EfY.js} +1 -1
  37. package/bundle/web-dist/client/assets/show-LAFY13v6.js +30 -0
  38. package/bundle/web-dist/client/assets/tokens-B9Ig7-9Z.js +1 -0
  39. package/bundle/web-dist/client/assets/{use-update-token-mutation-DeDBiZiM.js → use-update-token-mutation-CLhv6SUL.js} +1 -1
  40. package/bundle/web-dist/client/index.html +3 -3
  41. package/bundle/web-dist/server/assets/{lazyRouteComponent-BCJr868T.js → lazyRouteComponent-PRxXWW3t.js} +1 -1
  42. package/bundle/web-dist/server/assets/{logs-33YNafdW.js → logs-Dbk2Ig2z.js} +3 -3
  43. package/bundle/web-dist/server/assets/{monit--9mVsjNU.js → monit-Hm9dIPhx.js} +2 -2
  44. package/bundle/web-dist/server/assets/{routes-BK3Xh1UB.js → routes-DztxeUjk.js} +3 -3
  45. package/bundle/web-dist/server/assets/{sd.generated-C3gcvRcU.js → sd.generated-ek-tz-7H.js} +1 -0
  46. package/bundle/web-dist/server/assets/{services.generated-B2_Auwvk.js → services.generated-1Kcul41h.js} +1 -1
  47. package/bundle/web-dist/server/assets/{show-Dxxxe608.js → show-BHUSw7qv.js} +39 -13
  48. package/bundle/web-dist/server/assets/{tokens-BWPVvdfi.js → tokens-DHU9lUT2.js} +18 -15
  49. package/bundle/web-dist/server/assets/{use-update-token-mutation-CNBgTysy.js → use-update-token-mutation-axqnye4L.js} +1 -1
  50. package/bundle/web-dist/server/entry-server.generated.js +8 -8
  51. package/package.json +1 -1
  52. package/skills/qgrid/references/ai-sdk-provider-contract.md +1 -1
  53. package/skills/qgrid/references/cli-env-and-server-boot.md +2 -3
  54. package/skills/qgrid/references/openai-codex-runtime.md +3 -1
  55. package/skills/qgrid/references/token-auth-quota-lifecycle.md +1 -1
  56. package/bundle/web-dist/client/assets/index-B-cZKEyf.css +0 -1
  57. package/bundle/web-dist/client/assets/show-DPG1oKpW.js +0 -30
  58. package/bundle/web-dist/client/assets/tokens-Dz7hmKFm.js +0 -1
@@ -97,6 +97,13 @@
97
97
  "nullable": true,
98
98
  "desc": "inject_items 히스토리"
99
99
  },
100
+ {
101
+ "name": "tools",
102
+ "type": "json",
103
+ "id": "ToolDefinitions",
104
+ "nullable": true,
105
+ "desc": "요청에 장착된 tool 정의 목록"
106
+ },
100
107
  {
101
108
  "name": "status",
102
109
  "type": "enum",
@@ -153,6 +160,7 @@
153
160
  "image_cost_method",
154
161
  "effort",
155
162
  "history",
163
+ "tools",
156
164
  "status",
157
165
  "error_message",
158
166
  "tool_call_count",
@@ -113,7 +113,7 @@ describe("RequestLogModel run lifecycle", () => {
113
113
  vi.restoreAllMocks();
114
114
  });
115
115
 
116
- it("stores the requested model but no serving model when a run starts", async () => {
116
+ it("stores the requested model and attached tools but no serving model when a run starts", async () => {
117
117
  const ubRegister = vi.fn();
118
118
  const transaction = vi.fn(
119
119
  async (cb: (trx: { ubUpsert: () => Promise<number[]> }) => Promise<number>) =>
@@ -130,6 +130,7 @@ describe("RequestLogModel run lifecycle", () => {
130
130
  RequestLogModel.createRun({
131
131
  user_prompt: "hi",
132
132
  requested_model_name: "openai/gpt-5-codex",
133
+ tools: [{ name: "getWeather", description: "Get weather", inputSchema: { type: "object" } }],
133
134
  }),
134
135
  ).resolves.toBe(17);
135
136
 
@@ -139,6 +140,7 @@ describe("RequestLogModel run lifecycle", () => {
139
140
  status: "running",
140
141
  requested_model_name: "openai/gpt-5-codex",
141
142
  model_name: null,
143
+ tools: [{ name: "getWeather", description: "Get weather", inputSchema: { type: "object" } }],
142
144
  }),
143
145
  );
144
146
  });
@@ -14,7 +14,11 @@ import { SD } from "../../i18n/sd.generated";
14
14
  import { calculateCostUsd } from "../../utils/providers/common/model-cost";
15
15
  import { type RequestLogSubsetKey, type RequestLogSubsetMapping } from "../sonamu.generated";
16
16
  import { requestLogLoaderQueries, requestLogSubsetQueries } from "../sonamu.generated.sso";
17
- import { type RequestLogListParams, type RequestLogSaveParams } from "./request-log.types";
17
+ import {
18
+ type RequestLogListParams,
19
+ type RequestLogSaveParams,
20
+ type ToolDefinitions,
21
+ } from "./request-log.types";
18
22
 
19
23
  // cost_usd는 정수 micro-USD로 저장. 실제 USD = cost_usd / MICRO_USD.
20
24
  export const MICRO_USD = 1_000_000;
@@ -287,6 +291,7 @@ class RequestLogModelClass extends BaseModelClass<
287
291
  effort?: string | null;
288
292
  project_name?: string | null;
289
293
  history?: unknown;
294
+ tools?: ToolDefinitions;
290
295
  is_image_generation?: boolean;
291
296
  }): Promise<number> {
292
297
  const wdb = this.getPuri("w");
@@ -309,6 +314,7 @@ class RequestLogModelClass extends BaseModelClass<
309
314
  // 이미지 turn 식별(R13). run 경로(tools+image 조합)도 auto 경로와 동일하게 마킹.
310
315
  is_image_generation: params.is_image_generation ?? false,
311
316
  ...(params.history !== undefined ? { history: params.history as { type: string }[] } : {}),
317
+ ...(params.tools !== undefined ? { tools: params.tools } : {}),
312
318
  });
313
319
  return wdb.transaction(async (trx) => {
314
320
  const ids = await trx.ubUpsert("request_logs");
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
 
3
+ import { QgridTool } from "../qgrid/qgrid.types";
3
4
  import { RequestLogBaseListParams, RequestLogBaseSchema } from "../sonamu.generated";
4
5
 
5
6
  export const HistoryItems = z.array(
@@ -15,6 +16,10 @@ export const HistoryItems = z.array(
15
16
  );
16
17
  export type HistoryItems = z.infer<typeof HistoryItems>;
17
18
 
19
+ // 요청에 장착된 tool 정의 목록 — 저장 형태는 QgridTool 계약을 그대로 따른다.
20
+ export const ToolDefinitions = z.array(QgridTool);
21
+ export type ToolDefinitions = z.infer<typeof ToolDefinitions>;
22
+
18
23
  // RequestLog - ListParams
19
24
  // project_name NULL 분기: IS NULL만 조회(unassigned 필터) 또는 IS NOT NULL만(distinct 조회).
20
25
  export const RequestLogListParams = RequestLogBaseListParams.extend({
@@ -199,6 +199,7 @@ Content-Type: application/json
199
199
  "image_cost_method": null,
200
200
  "effort": null,
201
201
  "history": null,
202
+ "tools": null,
202
203
  "status": "running",
203
204
  "error_message": null,
204
205
  "tool_call_count": 0,
@@ -252,7 +253,6 @@ Content-Type: application/json
252
253
  "serviceTier": "fast",
253
254
  "history": "HISTORY",
254
255
  "projectName": "PROJECTNAME",
255
- "isStep": false,
256
256
  "logger": false,
257
257
  "runContext": {
258
258
  "requestLogId": 0,
@@ -313,7 +313,6 @@ Content-Type: application/json
313
313
  "serviceTier": "fast",
314
314
  "history": "HISTORY",
315
315
  "projectName": "PROJECTNAME",
316
- "isStep": false,
317
316
  "logger": false,
318
317
  "runContext": {
319
318
  "requestLogId": 0,
@@ -42,6 +42,7 @@ export const requestLogSubsetQueries = {
42
42
  image_cost_method: "request_logs.image_cost_method",
43
43
  effort: "request_logs.effort",
44
44
  history: "request_logs.history",
45
+ tools: "request_logs.tools",
45
46
  status: "request_logs.status",
46
47
  error_message: "request_logs.error_message",
47
48
  tool_call_count: "request_logs.tool_call_count",
@@ -42,6 +42,16 @@ const TokenCredentials = z.union([
42
42
  ]);
43
43
  type TokenCredentials = z.infer<typeof TokenCredentials>;
44
44
 
45
+ // CustomScalar: ToolDefinitions
46
+ const ToolDefinitions = z.array(
47
+ z.object({
48
+ name: z.string(),
49
+ description: z.string().optional(),
50
+ inputSchema: z.unknown(),
51
+ }),
52
+ );
53
+ type ToolDefinitions = z.infer<typeof ToolDefinitions>;
54
+
45
55
  // Enums: RequestLog
46
56
  export const RequestLogOrderBy = z.enum(["id-desc"]).describe("RequestLogOrderBy");
47
57
  export type RequestLogOrderBy = z.infer<typeof RequestLogOrderBy>;
@@ -111,6 +121,7 @@ export const RequestLogBaseSchema = z.object({
111
121
  image_cost_method: z.string().max(100).nullable(),
112
122
  effort: z.string().max(10).nullable(),
113
123
  history: HistoryItems.nullable(),
124
+ tools: ToolDefinitions.nullable(),
114
125
  status: RequestLogStatus,
115
126
  error_message: z.string().nullable(),
116
127
  tool_call_count: z.int(),
@@ -141,6 +152,7 @@ export type RequestLogBaseSchema = z.infer<typeof RequestLogBaseSchema> & {
141
152
  "image_cost_method",
142
153
  "effort",
143
154
  "history",
155
+ "tools",
144
156
  "status",
145
157
  "error_message",
146
158
  "tool_call_count",
@@ -308,6 +320,7 @@ export const RequestLogSubsetA = z.object({
308
320
  image_cost_method: z.string().max(100).nullable(),
309
321
  effort: z.string().max(10).nullable(),
310
322
  history: HistoryItems.nullable(),
323
+ tools: ToolDefinitions.nullable(),
311
324
  status: RequestLogStatus,
312
325
  error_message: z.string().nullable(),
313
326
  tool_call_count: z.int(),
@@ -234,6 +234,7 @@ const entityLabels = {
234
234
  "entity.RequestLog.image_cost_method": "이미지 비용 추정 방식",
235
235
  "entity.RequestLog.effort": "effort level",
236
236
  "entity.RequestLog.history": "inject_items 히스토리",
237
+ "entity.RequestLog.tools": "요청에 장착된 tool 정의 목록",
237
238
  "entity.RequestLog.status": "실행 상태",
238
239
  "entity.RequestLog.error_message": "에러 메시지",
239
240
  "entity.RequestLog.tool_call_count": "tool call 횟수",
@@ -0,0 +1,15 @@
1
+ import { type Knex } from "knex";
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ await knex.schema.alterTable("request_logs", (table) => {
5
+ // add
6
+ table.jsonb("tools").nullable();
7
+ });
8
+ }
9
+
10
+ export async function down(knex: Knex): Promise<void> {
11
+ await knex.schema.alterTable("request_logs", (table) => {
12
+ // rollback - add
13
+ table.dropColumns("tools");
14
+ });
15
+ }
@@ -382,6 +382,92 @@ describe("CodexAppServerWorker TTFT", () => {
382
382
  });
383
383
  });
384
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
+
385
471
  describe("CodexAppServerWorker prompt prefix", () => {
386
472
  it("uses the same base instructions with and without an output schema", async () => {
387
473
  type WorkerInternals = {
@@ -800,7 +886,7 @@ describe("CodexAppServerWorker thread unsubscribe on eviction", () => {
800
886
  expect(unsubscribedIds(request).toSorted()).toEqual(["stale-1", "stale-2"]);
801
887
  });
802
888
 
803
- it("sends thread/unsubscribe for LRU-evicted threads over the cap", () => {
889
+ it("caps periodic LRU cleanup at the configured maximum", () => {
804
890
  const { worker, request, meta } = createWorkerWithUnsubscribeSpy();
805
891
  const now = Date.now();
806
892
  for (let i = 0; i < 17; i++) {
@@ -809,7 +895,19 @@ describe("CodexAppServerWorker thread unsubscribe on eviction", () => {
809
895
 
810
896
  worker.sweepIdleThreads();
811
897
 
812
- // 상한(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
+
813
911
  expect(meta.size).toBe(15);
814
912
  expect(unsubscribedIds(request).toSorted()).toEqual(["t-0", "t-1"]);
815
913
  });
@@ -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,