@cartanova/qgrid-cli 2.5.7 → 2.5.9

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;
@@ -661,6 +733,39 @@ function unsubscribedIds(request: ReturnType<typeof vi.fn>): string[] {
661
733
  .map(([, params]) => (params as { threadId: string }).threadId);
662
734
  }
663
735
 
736
+ describe("CodexAppServerWorker partial thread creation cleanup", () => {
737
+ it.each([
738
+ ["text", false],
739
+ ["image", true],
740
+ ])("unsubscribes an untracked %s thread when history injection fails", async (_, imageGeneration) => {
741
+ const { worker, request } = createWorkerWithNotificationRpc(async (method) => {
742
+ if (method === "thread/start") {
743
+ return { thread: { id: "partially-created" }, model: "gpt-test" };
744
+ }
745
+ if (method === "thread/inject_items") throw new Error("history injection failed");
746
+ if (method === "thread/unsubscribe") return {};
747
+ throw new Error(`unexpected request: ${method}`);
748
+ });
749
+
750
+ await expect(
751
+ worker.createThread({
752
+ input: [{ type: "text", text: "current", text_elements: [] }],
753
+ history: [{ type: "text", text: "previous", text_elements: [] }],
754
+ imageGeneration,
755
+ }),
756
+ ).rejects.toThrow("history injection failed");
757
+
758
+ expect(unsubscribedIds(request)).toEqual(["partially-created"]);
759
+ expect(
760
+ (
761
+ worker as unknown as {
762
+ threadMeta: Map<string, { lastUsedAt: number }>;
763
+ }
764
+ ).threadMeta.has("partially-created"),
765
+ ).toBe(false);
766
+ });
767
+ });
768
+
664
769
  describe("CodexAppServerWorker thread unsubscribe on eviction", () => {
665
770
  type ThreadMetaInternals = {
666
771
  threadMeta: Map<string, { lastUsedAt: number }>;
@@ -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
 
@@ -453,20 +457,28 @@ export class CodexAppServerWorker {
453
457
  );
454
458
 
455
459
  const threadId = thread.id;
456
- if (req.history?.length) {
457
- await this.rpc.request("thread/inject_items", {
458
- threadId,
459
- items: req.history,
460
- } satisfies ThreadInjectItemsParams);
461
- }
462
- // 이미지 turn 은 재사용에서 제외(R8): base64 가 thread 메모리·캐시 prefix 에
463
- // 상주하는 것을 막고, 이미지 thread 를 1 회용으로 둔다. threadMeta 미등록 →
464
- // 후속 reuse 조회에서 이 thread 는 후보가 되지 않는다.
465
- if (!req.imageGeneration) {
466
- this.threadMeta.set(threadId, { lastUsedAt: Date.now() });
467
- }
460
+ try {
461
+ if (req.history?.length) {
462
+ await this.rpc.request("thread/inject_items", {
463
+ threadId,
464
+ items: req.history,
465
+ } satisfies ThreadInjectItemsParams);
466
+ }
467
+ // 이미지 turn 은 재사용에서 제외(R8): base64 가 thread 메모리·캐시 prefix 에
468
+ // 상주하는 것을 막고, 이미지 thread 를 1 회용으로 둔다. threadMeta 미등록 →
469
+ // 후속 reuse 조회에서 이 thread 는 후보가 되지 않는다.
470
+ if (!req.imageGeneration) {
471
+ this.threadMeta.set(threadId, { lastUsedAt: Date.now() });
472
+ }
468
473
 
469
- return { threadId, model: req.model ?? threadModel };
474
+ return { threadId, model: req.model ?? threadModel };
475
+ } catch (error) {
476
+ // thread/start 는 현재 RPC 커넥션을 구독자로 등록한다. 생성 후 초기화가
477
+ // 끝나기 전에 실패하면 executeTurn 의 finally 와 lazy sweep 어느 쪽도 이
478
+ // thread 를 볼 수 없으므로 여기서 즉시 재사용 차단과 구독 해제를 함께 한다.
479
+ this.evictThread(threadId);
480
+ throw error;
481
+ }
470
482
  }
471
483
 
472
484
  // 기존 thread 에 turn 만 실행 (inject 없음). conversation_id 유지 → prompt cache 적중.
@@ -865,9 +877,18 @@ export class CodexAppServerWorker {
865
877
  // ── Restart ─────────────────────────────────────────────────────
866
878
 
867
879
  private scheduleRestart(): void {
868
- if (this.destroyed || this.restartAttempts >= MAX_RESTART_ATTEMPTS) {
869
- if (this.restartAttempts >= MAX_RESTART_ATTEMPTS) {
880
+ if (this.destroyed) return;
881
+ if (this.restartAttempts >= MAX_RESTART_ATTEMPTS) {
882
+ if (!this.terminalFailureNotified) {
883
+ this.terminalFailureNotified = true;
870
884
  logger.warn(`worker ${this.config.tokenName} max restart attempts reached`);
885
+ try {
886
+ this.onTerminalFailure?.();
887
+ } catch (e) {
888
+ logger.warn(
889
+ `worker ${this.config.tokenName} terminal failure callback failed: ${(e as Error).message}`,
890
+ );
891
+ }
871
892
  }
872
893
  return;
873
894
  }