@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.
- package/README.ko.md +2 -9
- package/README.md +2 -9
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -1
- package/bundle/dist/application/qgrid/qgrid.frame.js +12 -3
- package/bundle/dist/application/request-log/request-log.model.js +3 -2
- package/bundle/dist/application/request-log/request-log.types.js +4 -2
- package/bundle/dist/application/sonamu.generated.js +8 -1
- package/bundle/dist/application/sonamu.generated.sso.js +2 -1
- package/bundle/dist/i18n/sd.generated.js +2 -1
- package/bundle/dist/migrations/20260806141701_alter_request_logs_add1_alter1.js +15 -0
- 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/application/qgrid/qgrid-oauth-redirect.test.ts +60 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +14 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +3 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +22 -5
- package/bundle/src/application/request-log/request-log.entity.json +8 -0
- package/bundle/src/application/request-log/request-log.model.test.ts +3 -1
- package/bundle/src/application/request-log/request-log.model.ts +7 -1
- package/bundle/src/application/request-log/request-log.types.ts +5 -0
- package/bundle/src/application/sonamu.generated.http +1 -2
- package/bundle/src/application/sonamu.generated.sso.ts +1 -0
- package/bundle/src/application/sonamu.generated.ts +13 -0
- package/bundle/src/i18n/sd.generated.ts +1 -0
- package/bundle/src/migrations/20260806141701_alter_request_logs_add1_alter1.ts +15 -0
- 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-GA9b-zy4.css +1 -0
- package/bundle/web-dist/client/assets/{index-CkBeqjBm.js → index-YowQRE2y.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-d9HdibiE.js → logs-o8mm530D.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-C8qYvOMj.js → monit-B51yXcqk.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-DT0AY2Lq.js → routes-BqaUiBc0.js} +1 -1
- package/bundle/web-dist/client/assets/{sd.generated-DbBC73_z.js → sd.generated-CjW3rfJ8.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-BWyAypSh.js → services.generated-D3MH3EfY.js} +1 -1
- package/bundle/web-dist/client/assets/show-LAFY13v6.js +30 -0
- package/bundle/web-dist/client/assets/tokens-B9Ig7-9Z.js +1 -0
- package/bundle/web-dist/client/assets/{use-update-token-mutation-DeDBiZiM.js → use-update-token-mutation-CLhv6SUL.js} +1 -1
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BCJr868T.js → lazyRouteComponent-PRxXWW3t.js} +1 -1
- package/bundle/web-dist/server/assets/{logs-33YNafdW.js → logs-Dbk2Ig2z.js} +3 -3
- package/bundle/web-dist/server/assets/{monit--9mVsjNU.js → monit-Hm9dIPhx.js} +2 -2
- package/bundle/web-dist/server/assets/{routes-BK3Xh1UB.js → routes-DztxeUjk.js} +3 -3
- package/bundle/web-dist/server/assets/{sd.generated-C3gcvRcU.js → sd.generated-ek-tz-7H.js} +1 -0
- package/bundle/web-dist/server/assets/{services.generated-B2_Auwvk.js → services.generated-1Kcul41h.js} +1 -1
- package/bundle/web-dist/server/assets/{show-Dxxxe608.js → show-BHUSw7qv.js} +39 -13
- package/bundle/web-dist/server/assets/{tokens-BWPVvdfi.js → tokens-DHU9lUT2.js} +18 -15
- package/bundle/web-dist/server/assets/{use-update-token-mutation-CNBgTysy.js → use-update-token-mutation-axqnye4L.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +8 -8
- 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 +2 -3
- package/skills/qgrid/references/openai-codex-runtime.md +3 -1
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +1 -1
- package/bundle/web-dist/client/assets/index-B-cZKEyf.css +0 -1
- package/bundle/web-dist/client/assets/show-DPG1oKpW.js +0 -30
- 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 {
|
|
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("
|
|
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
|
-
|
|
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)
|
|
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,
|