@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.
- package/README.ko.md +2 -8
- package/README.md +2 -8
- package/bundle/dist/utils/providers/openai/codex-worker.js +38 -16
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +90 -35
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +172 -2
- package/bundle/src/utils/providers/openai/codex-worker.ts +45 -19
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +483 -4
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +172 -49
- package/bundle/web-dist/client/assets/{index-oLaOO8wP.js → index-SdI2T3b8.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-D8U3hI1f.js → logs-O-8hGStp.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-NKS90qFj.js → monit-DA8MAY-d.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-BuxVmofi.js → routes-COPjkgNr.js} +1 -1
- package/bundle/web-dist/client/assets/{show-C8Qv9_Ww.js → show-ClG4e0BP.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-ChWIXTeL.js → tokens-Cc5frn5f.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +1 -1
- 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 +3 -3
- package/skills/qgrid/references/openai-codex-runtime.md +16 -1
- package/skills/qgrid/references/verification-and-debugging.md +3 -1
|
@@ -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("
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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 를 정리.
|
|
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
|
|
584
|
-
//
|
|
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
|
|
877
|
-
|
|
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
|
}
|