@cartanova/qgrid-cli 2.5.6 → 2.5.7
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 +0 -1
- package/README.md +0 -1
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +7 -7
- package/bundle/dist/application/qgrid/qgrid.frame.js +1 -8
- package/bundle/dist/application/qgrid/qgrid.types.js +2 -15
- package/bundle/dist/application/qgrid/tool-emulation-schema.js +39 -19
- package/bundle/dist/application/qgrid/tool-emulation.js +31 -44
- package/bundle/dist/utils/providers/openai/codex-worker.js +16 -7
- package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +2 -3
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +22 -8
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +8 -8
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +2 -17
- package/bundle/src/application/qgrid/qgrid.frame.ts +0 -10
- package/bundle/src/application/qgrid/qgrid.types.ts +23 -45
- package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +35 -15
- package/bundle/src/application/qgrid/tool-emulation-schema.ts +49 -32
- package/bundle/src/application/qgrid/tool-emulation.test.ts +92 -144
- package/bundle/src/application/qgrid/tool-emulation.ts +64 -76
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +147 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +29 -5
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +13 -4
- package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +1 -7
- package/bundle/web-dist/client/assets/index-B-cZKEyf.css +1 -0
- package/bundle/web-dist/client/assets/{index-DYwq7a0y.js → index-Bm7RpZcx.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-BRpHY7bH.js → logs-BrJdk9XJ.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-BBnFUnAg.js → monit-BuFCoBCr.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-DJDlOo-P.js → routes-d-Fwxdg8.js} +1 -1
- package/bundle/web-dist/client/assets/{show-Dn-exALi.js → show-Z5UX7FtU.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-DaMfQeAo.js → tokens-OA2V76xd.js} +1 -1
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/entry-server.generated.js +1 -1
- package/package.json +1 -1
- package/skills/qgrid/SKILL.md +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/decision-rationale.md +1 -1
- package/skills/qgrid/references/openai-codex-runtime.md +6 -3
- package/skills/qgrid/references/repo-map.md +1 -1
- package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
- package/skills/qgrid/references/tool-calling-and-multiturn.md +44 -26
- package/skills/qgrid/references/verification-and-debugging.md +2 -2
- package/bundle/web-dist/client/assets/index-yZcjDoV0.css +0 -1
|
@@ -22,34 +22,42 @@ const logger = getLogger(["qgrid", "tool-emulation"]);
|
|
|
22
22
|
|
|
23
23
|
type EmulationResult = Omit<QueryOutput, "content" | "finishReason" | "runContext">;
|
|
24
24
|
type ToolCall = { toolName: string; args: string };
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
z.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
25
|
+
|
|
26
|
+
// envelope 는 {result: <union>} 한 겹 구조 — 스키마 근거는 tool-emulation-schema.ts 참조.
|
|
27
|
+
// answer 타입만 요청 형태에 따라 다르다: tools-only 는 string(그대로 반환),
|
|
28
|
+
// tools+jsonSchema 는 사용자 스키마의 JSON 값(JSON.stringify 로 반환).
|
|
29
|
+
function buildEnvelopeSchema(answerKind: AnswerKind) {
|
|
30
|
+
return z.strictObject({
|
|
31
|
+
result: z.discriminatedUnion("action", [
|
|
32
|
+
z.strictObject({
|
|
33
|
+
action: z.literal("answer"),
|
|
34
|
+
answer:
|
|
35
|
+
answerKind === "text"
|
|
36
|
+
? z.string()
|
|
37
|
+
: z.json().refine((answer) => answer !== null, {
|
|
38
|
+
message: "answer must be non-null",
|
|
39
|
+
}),
|
|
40
|
+
toolCalls: z.null(),
|
|
41
|
+
}),
|
|
42
|
+
z.strictObject({
|
|
43
|
+
action: z.literal("tool_call"),
|
|
44
|
+
answer: z.null(),
|
|
45
|
+
toolCalls: z
|
|
46
|
+
.array(
|
|
47
|
+
z.strictObject({
|
|
48
|
+
toolName: z.string(),
|
|
49
|
+
args: z.string(),
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
.min(1),
|
|
53
|
+
}),
|
|
54
|
+
]),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const TEXT_ENVELOPE = buildEnvelopeSchema("text");
|
|
59
|
+
const JSON_ENVELOPE = buildEnvelopeSchema("json");
|
|
60
|
+
type EnvelopeResponse = z.infer<typeof JSON_ENVELOPE>["result"];
|
|
53
61
|
|
|
54
62
|
export class ToolCallEmulationError extends Error {
|
|
55
63
|
constructor(message: string) {
|
|
@@ -64,70 +72,46 @@ interface EmulationImage {
|
|
|
64
72
|
revisedPrompt?: string | null;
|
|
65
73
|
}
|
|
66
74
|
|
|
75
|
+
// answer 인코딩 종류. envelope 디코더는 한 벌이고 이 값은 answer 브랜치의 타입과
|
|
76
|
+
// 최종 직렬화(text: 그대로 / json: JSON.stringify)만 가른다.
|
|
77
|
+
export type AnswerKind = "text" | "json";
|
|
78
|
+
|
|
67
79
|
interface ToolCallEmulationOptions {
|
|
68
80
|
threadCoord?: QgridThreadCoord;
|
|
69
81
|
images?: EmulationImage[];
|
|
70
|
-
|
|
82
|
+
// 필수: 호출부가 answer 인코딩을 항상 명시해야 한다 (input.jsonSchema 유무로 판정).
|
|
83
|
+
answerKind: AnswerKind;
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
export function applyToolCallEmulation(
|
|
74
87
|
result: EmulationResult,
|
|
75
|
-
tools
|
|
76
|
-
options: ToolCallEmulationOptions
|
|
88
|
+
tools: QgridTool[] | undefined,
|
|
89
|
+
options: ToolCallEmulationOptions,
|
|
77
90
|
): QueryOutput {
|
|
78
|
-
const { threadCoord, images,
|
|
91
|
+
const { threadCoord, images, answerKind } = options;
|
|
79
92
|
const runContext = threadCoord ? { threadCoord } : undefined;
|
|
80
93
|
|
|
81
94
|
if (!tools?.length) {
|
|
82
95
|
return answerOutput(result, result.text, images, runContext);
|
|
83
96
|
}
|
|
84
97
|
|
|
85
|
-
|
|
86
|
-
return applyLegacyResponse(result, tools, images, runContext);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const parsed = parseStructuredResponse(result.text, tools);
|
|
98
|
+
const parsed = parseEnvelope(result.text, tools, answerKind);
|
|
90
99
|
if (parsed.action === "tool_call") {
|
|
91
100
|
return toolCallOutput(result, parsed.toolCalls, images, runContext);
|
|
92
101
|
}
|
|
93
102
|
|
|
94
|
-
return answerOutput(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
*/
|
|
101
|
-
function applyLegacyResponse(
|
|
102
|
-
result: EmulationResult,
|
|
103
|
-
tools: QgridTool[],
|
|
104
|
-
images: EmulationImage[] | undefined,
|
|
105
|
-
runContext: QueryOutput["runContext"],
|
|
106
|
-
): QueryOutput {
|
|
107
|
-
let parsed: LegacyToolCallResponse;
|
|
108
|
-
try {
|
|
109
|
-
parsed = JSON.parse(result.text) as LegacyToolCallResponse;
|
|
110
|
-
} catch (error) {
|
|
111
|
-
logger.warn(
|
|
112
|
-
`tool-call emulation parse failed, falling back to text: ${(error as Error).message}`,
|
|
113
|
-
);
|
|
114
|
-
return answerOutput(result, result.text, images, runContext);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (parsed.action === "tool_call") {
|
|
118
|
-
const toolCalls = parsed.toolCalls ?? [];
|
|
119
|
-
const unknownTool = findUnknownTool(toolCalls, tools);
|
|
120
|
-
if (unknownTool !== undefined) throw new Error(`unknown emulated tool: ${unknownTool}`);
|
|
121
|
-
return toolCallOutput(result, toolCalls, images, runContext);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return answerOutput(result, parsed.answer ?? result.text, images, runContext);
|
|
103
|
+
return answerOutput(
|
|
104
|
+
result,
|
|
105
|
+
answerKind === "text" ? (parsed.answer as string) : JSON.stringify(parsed.answer),
|
|
106
|
+
images,
|
|
107
|
+
runContext,
|
|
108
|
+
);
|
|
125
109
|
}
|
|
126
110
|
|
|
127
|
-
function
|
|
111
|
+
function parseEnvelope(text: string, tools: QgridTool[], answerKind: AnswerKind) {
|
|
128
112
|
if (Buffer.byteLength(text, "utf8") > STRUCTURED_ENVELOPE_LIMITS.maxUtf8Bytes) {
|
|
129
113
|
throw new ToolCallEmulationError(
|
|
130
|
-
`
|
|
114
|
+
`tool envelope exceeds UTF-8 byte limit of ${STRUCTURED_ENVELOPE_LIMITS.maxUtf8Bytes}`,
|
|
131
115
|
);
|
|
132
116
|
}
|
|
133
117
|
|
|
@@ -135,23 +119,27 @@ function parseStructuredResponse(text: string, tools: QgridTool[]) {
|
|
|
135
119
|
try {
|
|
136
120
|
value = JSON.parse(text);
|
|
137
121
|
} catch (error) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
)
|
|
122
|
+
// 관용 폴백 없음: envelope 는 양 provider 의 structured output 으로 강제되는 내부
|
|
123
|
+
// 계약이라 여기 도달한 비 JSON 은 버그다. 과거의 텍스트 구제는 퇴화 봉투를 답변으로
|
|
124
|
+
// 흘려보내 조용한 오염을 만들었다(2026-07 medpath, 13.5k 건).
|
|
125
|
+
logger.warn(`tool envelope parse failed: ${(error as Error).message}`);
|
|
126
|
+
throw new ToolCallEmulationError(`invalid tool envelope: ${(error as Error).message}`);
|
|
141
127
|
}
|
|
142
128
|
|
|
143
129
|
assertStructuredEnvelopeComplexity(value);
|
|
144
|
-
const
|
|
130
|
+
const schema = answerKind === "text" ? TEXT_ENVELOPE : JSON_ENVELOPE;
|
|
131
|
+
const validation = schema.safeParse(value);
|
|
145
132
|
if (!validation.success) {
|
|
146
133
|
const issue = validation.error.issues[0];
|
|
147
134
|
const path = issue && issue.path.length > 0 ? ` at ${issue.path.join(".")}` : "";
|
|
148
135
|
const detail = issue?.message ?? "unknown validation issue";
|
|
149
|
-
|
|
136
|
+
logger.warn(`tool envelope validation failed${path}: ${detail}`);
|
|
137
|
+
throw new ToolCallEmulationError(`invalid tool envelope${path}: ${detail}`);
|
|
150
138
|
}
|
|
151
139
|
|
|
152
140
|
// Zod clones JSON objects and drops own "__proto__" keys. The parsed JSON is safe to return
|
|
153
141
|
// after validation and preserves the caller's exact structured value.
|
|
154
|
-
const response = value as
|
|
142
|
+
const response = (value as { result: EnvelopeResponse }).result;
|
|
155
143
|
if (response.action === "tool_call") {
|
|
156
144
|
const unknownTool = findUnknownTool(response.toolCalls, tools);
|
|
157
145
|
if (unknownTool !== undefined) {
|
|
@@ -654,3 +654,150 @@ describe("CodexAppServerWorker image generation thread", () => {
|
|
|
654
654
|
expect(params?.baseInstructions).toContain("Respond with text only");
|
|
655
655
|
});
|
|
656
656
|
});
|
|
657
|
+
|
|
658
|
+
function unsubscribedIds(request: ReturnType<typeof vi.fn>): string[] {
|
|
659
|
+
return request.mock.calls
|
|
660
|
+
.filter(([method]) => method === "thread/unsubscribe")
|
|
661
|
+
.map(([, params]) => (params as { threadId: string }).threadId);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
describe("CodexAppServerWorker thread unsubscribe on eviction", () => {
|
|
665
|
+
type ThreadMetaInternals = {
|
|
666
|
+
threadMeta: Map<string, { lastUsedAt: number }>;
|
|
667
|
+
rpc: { request: ReturnType<typeof vi.fn> };
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
function createWorkerWithUnsubscribeSpy() {
|
|
671
|
+
const worker = new CodexAppServerWorker({
|
|
672
|
+
tokenId: 1,
|
|
673
|
+
tokenName: "token",
|
|
674
|
+
accessToken: "access",
|
|
675
|
+
accountId: "account",
|
|
676
|
+
});
|
|
677
|
+
const request = vi.fn(async (_method: string, _params?: unknown) => ({}));
|
|
678
|
+
(worker as unknown as ThreadMetaInternals).rpc = { request };
|
|
679
|
+
return { worker, request, meta: (worker as unknown as ThreadMetaInternals).threadMeta };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
it("sends thread/unsubscribe for TTL-expired threads", () => {
|
|
683
|
+
const { worker, request, meta } = createWorkerWithUnsubscribeSpy();
|
|
684
|
+
const now = Date.now();
|
|
685
|
+
meta.set("stale-1", { lastUsedAt: now - 11 * 60_000 });
|
|
686
|
+
meta.set("stale-2", { lastUsedAt: now - 20 * 60_000 });
|
|
687
|
+
meta.set("fresh", { lastUsedAt: now });
|
|
688
|
+
|
|
689
|
+
worker.sweepIdleThreads();
|
|
690
|
+
|
|
691
|
+
// 맵 제거(라우팅 차단)와 구독 해제가 함께 일어나야 codex 30분 언로드가 발동한다.
|
|
692
|
+
expect(meta.has("stale-1")).toBe(false);
|
|
693
|
+
expect(meta.has("stale-2")).toBe(false);
|
|
694
|
+
expect(meta.has("fresh")).toBe(true);
|
|
695
|
+
expect(unsubscribedIds(request).toSorted()).toEqual(["stale-1", "stale-2"]);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("sends thread/unsubscribe for LRU-evicted threads over the cap", () => {
|
|
699
|
+
const { worker, request, meta } = createWorkerWithUnsubscribeSpy();
|
|
700
|
+
const now = Date.now();
|
|
701
|
+
for (let i = 0; i < 17; i++) {
|
|
702
|
+
meta.set(`t-${i}`, { lastUsedAt: now - (17 - i) * 1_000 });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
worker.sweepIdleThreads();
|
|
706
|
+
|
|
707
|
+
// 상한(MAX-1=15)으로 줄이며 가장 오래된 2개가 축출·구독 해제된다.
|
|
708
|
+
expect(meta.size).toBe(15);
|
|
709
|
+
expect(unsubscribedIds(request).toSorted()).toEqual(["t-0", "t-1"]);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("does not unsubscribe surviving threads", () => {
|
|
713
|
+
const { worker, request, meta } = createWorkerWithUnsubscribeSpy();
|
|
714
|
+
meta.set("live", { lastUsedAt: Date.now() });
|
|
715
|
+
|
|
716
|
+
worker.sweepIdleThreads();
|
|
717
|
+
|
|
718
|
+
expect(unsubscribedIds(request)).toEqual([]);
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
describe("CodexAppServerWorker image thread unsubscribe", () => {
|
|
723
|
+
const IMAGE_B64 = "iVBORw0KGgoBAgMEBQYHCA==";
|
|
724
|
+
|
|
725
|
+
it("unsubscribes the one-shot image thread right after a successful turn", async () => {
|
|
726
|
+
const { worker, request } = createWorkerWithNotificationRpc(
|
|
727
|
+
async (method, _params, handlers) => {
|
|
728
|
+
if (method === "thread/start") return { thread: { id: "t1" }, model: "gpt-test" };
|
|
729
|
+
if (method === "thread/unsubscribe") return {};
|
|
730
|
+
if (method === "turn/start") {
|
|
731
|
+
handlers.get("item/completed")?.({
|
|
732
|
+
threadId: "t1",
|
|
733
|
+
item: { type: "imageGeneration", id: "img-a", status: "generating", result: IMAGE_B64, revisedPrompt: null },
|
|
734
|
+
} as never);
|
|
735
|
+
handlers.get("turn/completed")?.({
|
|
736
|
+
threadId: "t1",
|
|
737
|
+
turn: { status: "completed", durationMs: 100 },
|
|
738
|
+
} as never);
|
|
739
|
+
return { turn: { id: "turn-1" } };
|
|
740
|
+
}
|
|
741
|
+
throw new Error(`unexpected request: ${method}`);
|
|
742
|
+
},
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
await worker.executeTurn({
|
|
746
|
+
input: [{ type: "text", text: "img", text_elements: [] }],
|
|
747
|
+
imageGeneration: true,
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// 이미지 thread 는 threadMeta 미등록이라 sweep 이 못 잡는다 — turn 직후 즉시 구독 해제.
|
|
751
|
+
expect(unsubscribedIds(request)).toEqual(["t1"]);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it("unsubscribes the image thread even when the turn fails", async () => {
|
|
755
|
+
const { worker, request } = createWorkerWithNotificationRpc(
|
|
756
|
+
async (method, _params, handlers) => {
|
|
757
|
+
if (method === "thread/start") return { thread: { id: "t1" }, model: "gpt-test" };
|
|
758
|
+
if (method === "thread/unsubscribe") return {};
|
|
759
|
+
if (method === "turn/start") {
|
|
760
|
+
handlers.get("turn/completed")?.({
|
|
761
|
+
threadId: "t1",
|
|
762
|
+
turn: { status: "failed", durationMs: 100, error: { message: "boom" } },
|
|
763
|
+
} as never);
|
|
764
|
+
return { turn: { id: "turn-1" } };
|
|
765
|
+
}
|
|
766
|
+
throw new Error(`unexpected request: ${method}`);
|
|
767
|
+
},
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
await expect(
|
|
771
|
+
worker.executeTurn({
|
|
772
|
+
input: [{ type: "text", text: "img", text_elements: [] }],
|
|
773
|
+
imageGeneration: true,
|
|
774
|
+
}),
|
|
775
|
+
).rejects.toThrow("turn failed");
|
|
776
|
+
|
|
777
|
+
// 실패해도 1 회용 thread 인 것은 같다 — base64 없이도 히스토리가 상주하므로 해제.
|
|
778
|
+
expect(unsubscribedIds(request)).toEqual(["t1"]);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it("keeps text threads subscribed so reuse turns still receive notifications", async () => {
|
|
782
|
+
const { worker, request } = createWorkerWithNotificationRpc(
|
|
783
|
+
async (method, _params, handlers) => {
|
|
784
|
+
if (method === "thread/start") return { thread: { id: "t1" }, model: "gpt-test" };
|
|
785
|
+
if (method === "turn/start") {
|
|
786
|
+
handlers.get("turn/completed")?.({
|
|
787
|
+
threadId: "t1",
|
|
788
|
+
turn: { status: "completed", durationMs: 100 },
|
|
789
|
+
} as never);
|
|
790
|
+
return { turn: { id: "turn-1" } };
|
|
791
|
+
}
|
|
792
|
+
throw new Error(`unexpected request: ${method}`);
|
|
793
|
+
},
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
await worker.executeTurn({
|
|
797
|
+
input: [{ type: "text", text: "hello", text_elements: [] }],
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
// 텍스트 thread 는 재사용 후보 — 구독을 유지해야 후속 turn 알림을 받는다.
|
|
801
|
+
expect(unsubscribedIds(request)).toEqual([]);
|
|
802
|
+
});
|
|
803
|
+
});
|
|
@@ -506,13 +506,18 @@ export class CodexAppServerWorker {
|
|
|
506
506
|
try {
|
|
507
507
|
ttftTracker.markStart();
|
|
508
508
|
await this.startTurnOnThread(threadId, req);
|
|
509
|
+
const result = await resultPromise;
|
|
510
|
+
return { ...result, threadId };
|
|
509
511
|
} catch (e) {
|
|
510
512
|
this.failActiveTurn(e as Error);
|
|
511
513
|
await resultPromise.catch(() => {});
|
|
512
514
|
throw e;
|
|
515
|
+
} finally {
|
|
516
|
+
// 이미지 thread 는 1 회용 cold thread 인데 threadMeta 에 등록되지 않아
|
|
517
|
+
// sweep 이 못 잡는다. MB 급 base64 가 상주하므로 turn 종료 즉시(성공/실패
|
|
518
|
+
// 불문) 구독을 끊어 codex 30분 언로드 대상으로 만든다.
|
|
519
|
+
if (req.imageGeneration) this.unsubscribeThread(threadId);
|
|
513
520
|
}
|
|
514
|
-
const result = await resultPromise;
|
|
515
|
-
return { ...result, threadId };
|
|
516
521
|
}
|
|
517
522
|
|
|
518
523
|
async executeTurnStream(
|
|
@@ -556,12 +561,15 @@ export class CodexAppServerWorker {
|
|
|
556
561
|
}
|
|
557
562
|
|
|
558
563
|
// idle TTL 초과 / 개수 상한 초과 thread 를 정리. createThread 직전 lazy 호출.
|
|
559
|
-
//
|
|
564
|
+
// 맵 제거(재사용 라우팅 차단)와 함께 thread/unsubscribe 를 보낸다 — codex 는
|
|
565
|
+
// "구독자 0 + idle" 이 30분(THREAD_UNLOADING_DELAY, 하드코딩) 지속된 thread 를
|
|
566
|
+
// 메모리에서 자동 언로드하는데, thread/start 가 이 커넥션을 자동 구독자로 등록해
|
|
567
|
+
// 두기 때문에 unsubscribe 없이는 그 언로드가 영원히 발동하지 않는다(SON-516 실증).
|
|
560
568
|
sweepIdleThreads(): void {
|
|
561
569
|
const now = Date.now();
|
|
562
570
|
for (const [id, meta] of this.threadMeta) {
|
|
563
571
|
if (now - meta.lastUsedAt > CodexAppServerWorker.THREAD_IDLE_TTL_MS) {
|
|
564
|
-
this.
|
|
572
|
+
this.evictThread(id);
|
|
565
573
|
}
|
|
566
574
|
}
|
|
567
575
|
// createThread 직전 호출 → 새 thread 1 개가 곧 추가된다. 추가 후에도 MAX 를 넘지 않도록
|
|
@@ -572,10 +580,26 @@ export class CodexAppServerWorker {
|
|
|
572
580
|
(a, b) => a[1].lastUsedAt - b[1].lastUsedAt,
|
|
573
581
|
);
|
|
574
582
|
const over = this.threadMeta.size - limit;
|
|
575
|
-
for (let i = 0; i < over; i++) this.
|
|
583
|
+
for (let i = 0; i < over; i++) this.evictThread(sorted[i]![0]);
|
|
576
584
|
}
|
|
577
585
|
}
|
|
578
586
|
|
|
587
|
+
// thread 재사용 포기: 맵 제거(라우팅 차단)와 구독 해제를 한 단위로 묶는다.
|
|
588
|
+
// unsubscribe 한 thread 에 turn 을 보내면 알림이 이 커넥션으로 오지 않으므로
|
|
589
|
+
// 두 동작은 반드시 함께여야 한다.
|
|
590
|
+
private evictThread(threadId: string): void {
|
|
591
|
+
this.threadMeta.delete(threadId);
|
|
592
|
+
this.unsubscribeThread(threadId);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// 재사용을 포기한 thread 의 구독을 끊는다. 구독자가 없어진 idle thread 는 codex 가
|
|
596
|
+
// 30분 뒤 메모리에서 자동 언로드한다(SON-516 실증). fire-and-forget: 실패해도
|
|
597
|
+
// 무해(restart 시 프로세스째 회수). threadMeta 에 없는 이미지 1회용 thread 는
|
|
598
|
+
// evictThread 대신 이 메서드를 직접 호출한다.
|
|
599
|
+
private unsubscribeThread(threadId: string): void {
|
|
600
|
+
this.rpc?.request("thread/unsubscribe", { threadId }).catch(() => {});
|
|
601
|
+
}
|
|
602
|
+
|
|
579
603
|
async interruptTurn(threadId: string, turnId: string): Promise<void> {
|
|
580
604
|
if (!this.rpc || !this.ready) return;
|
|
581
605
|
try {
|
|
@@ -215,18 +215,19 @@ describe("OpenAIDispatcher worker capacity", () => {
|
|
|
215
215
|
expect(makeOpenAIWorkerId(1, 10)).not.toBe(makeOpenAIWorkerId(2, 0));
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
-
it("defaults to autoscaling from
|
|
218
|
+
it("defaults to autoscaling from 1 to 3 workers per token", () => {
|
|
219
|
+
// dev0 OOM 사고(SON-516) 응급처치로 기본값을 5-15 에서 1-3 으로 축소 (f6d1333).
|
|
219
220
|
expect(resolveOpenAIWorkerPoolConfig({})).toMatchObject({
|
|
220
221
|
autoscale: true,
|
|
221
|
-
minWorkersPerToken:
|
|
222
|
-
maxWorkersPerToken:
|
|
222
|
+
minWorkersPerToken: 1,
|
|
223
|
+
maxWorkersPerToken: 3,
|
|
223
224
|
});
|
|
224
225
|
});
|
|
225
226
|
|
|
226
227
|
it("keeps an explicit fixed worker mode for emergency rollback", () => {
|
|
227
228
|
expect(
|
|
228
229
|
resolveOpenAIWorkerPoolConfig({
|
|
229
|
-
|
|
230
|
+
QGRID_OPENAI_MIN_WORKERS_PER_TOKEN: "5",
|
|
230
231
|
QGRID_OPENAI_AUTOSCALE: "false",
|
|
231
232
|
}),
|
|
232
233
|
).toMatchObject({
|
|
@@ -236,6 +237,14 @@ describe("OpenAIDispatcher worker capacity", () => {
|
|
|
236
237
|
});
|
|
237
238
|
});
|
|
238
239
|
|
|
240
|
+
it("ignores the removed QGRID_WORKERS_PER_TOKEN variable", () => {
|
|
241
|
+
// 과거엔 min 의 폴백이었다 — dev0 사고 조사(SON-516)에서 이름과 달리 min 에만
|
|
242
|
+
// 꽂히는 함정으로 확인되어 제거. QGRID_OPENAI_MIN_WORKERS_PER_TOKEN 하나로 통일.
|
|
243
|
+
expect(
|
|
244
|
+
resolveOpenAIWorkerPoolConfig({ QGRID_WORKERS_PER_TOKEN: "15" }),
|
|
245
|
+
).toMatchObject({ minWorkersPerToken: 1, maxWorkersPerToken: 3 });
|
|
246
|
+
});
|
|
247
|
+
|
|
239
248
|
it("estimates worker RSS from the dev0 measurement model", () => {
|
|
240
249
|
expect(estimateOpenAIWorkerRssGiB(50)).toBeCloseTo(8.56, 2);
|
|
241
250
|
expect(estimateOpenAIWorkerRssGiB(60)).toBeCloseTo(10.13, 2);
|
|
@@ -36,15 +36,9 @@ export function resolveOpenAIWorkerPoolConfig(
|
|
|
36
36
|
env: Record<string, string | undefined> = process.env,
|
|
37
37
|
): OpenAIWorkerPoolConfig {
|
|
38
38
|
const autoscale = env.QGRID_OPENAI_AUTOSCALE !== "false" && env.QGRID_OPENAI_AUTOSCALE !== "0";
|
|
39
|
-
const legacyWorkers = boundedInteger(
|
|
40
|
-
env.QGRID_WORKERS_PER_TOKEN,
|
|
41
|
-
DEFAULT_OPENAI_MIN_WORKERS_PER_TOKEN,
|
|
42
|
-
1,
|
|
43
|
-
MAX_OPENAI_WORKERS_PER_TOKEN,
|
|
44
|
-
);
|
|
45
39
|
const minWorkersPerToken = boundedInteger(
|
|
46
40
|
env.QGRID_OPENAI_MIN_WORKERS_PER_TOKEN,
|
|
47
|
-
|
|
41
|
+
DEFAULT_OPENAI_MIN_WORKERS_PER_TOKEN,
|
|
48
42
|
1,
|
|
49
43
|
MAX_OPENAI_WORKERS_PER_TOKEN,
|
|
50
44
|
);
|