@cartanova/qgrid-cli 2.1.0 → 2.2.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/bundle/dist/application/qgrid/conv-routing.js +68 -0
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +16 -15
- package/bundle/dist/application/qgrid/qgrid.frame.js +17 -5
- package/bundle/dist/application/qgrid/qgrid.types.js +12 -3
- package/bundle/dist/application/qgrid/tool-emulation.js +11 -6
- package/bundle/dist/utils/providers/openai/codex-worker.js +140 -53
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +75 -21
- package/bundle/src/application/qgrid/conv-routing.ts +97 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +2 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +16 -5
- package/bundle/src/application/qgrid/qgrid.frame.ts +20 -5
- package/bundle/src/application/qgrid/qgrid.types.ts +14 -1
- package/bundle/src/application/qgrid/tool-emulation.ts +20 -5
- package/bundle/src/application/sonamu.generated.http +14 -2
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +29 -2
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +163 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +199 -39
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +59 -0
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +116 -27
- package/bundle/web-dist/client/assets/cost-CpjVoAqp.js +1 -0
- package/bundle/web-dist/client/assets/{index-ZV-tHpAg.js → index-DeThumA3.js} +2 -2
- package/bundle/web-dist/client/assets/logs-CtmOVH58.js +1 -0
- package/bundle/web-dist/client/assets/{routes-BMRwPj6F.js → routes-Cfoe9J0I.js} +1 -1
- package/bundle/web-dist/client/assets/show-C6OofoAL.js +30 -0
- package/bundle/web-dist/client/assets/{tokens-Cg1f0Ga2.js → tokens-BAZq8sTo.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/assets/cost-DzjfVtUr.js +24 -0
- package/bundle/web-dist/server/assets/{logs-Xt934T5f.js → logs-CHM0fhfO.js} +2 -7
- package/bundle/web-dist/server/assets/{routes-B7QYbNIv.js → routes-C9xFMJ_r.js} +1 -1
- package/bundle/web-dist/server/assets/{show-Dxz6UJnD.js → show-BZWqH09U.js} +2 -4
- package/bundle/web-dist/server/entry-server.generated.js +3 -3
- package/package.json +1 -1
- package/bundle/web-dist/client/assets/cost-DE_8cLD-.js +0 -1
- package/bundle/web-dist/client/assets/logs-HJYkbqom.js +0 -1
- package/bundle/web-dist/client/assets/show-DI6iWDzV.js +0 -30
- package/bundle/web-dist/server/assets/cost-DdHTzQDR.js +0 -14
|
@@ -54,6 +54,9 @@ export interface StreamCallbacks {
|
|
|
54
54
|
onTurnId?: (turnId: string) => void;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
type RefreshableWorkerCredentials = Pick<WorkerConfig, "accessToken" | "accountId" | "planType">;
|
|
58
|
+
type ActiveTurnAbort = (error: Error) => void;
|
|
59
|
+
|
|
57
60
|
// thread/start 공통 옵션
|
|
58
61
|
const THREAD_DEFAULTS = {
|
|
59
62
|
sandbox: "read-only",
|
|
@@ -63,10 +66,8 @@ const THREAD_DEFAULTS = {
|
|
|
63
66
|
},
|
|
64
67
|
} satisfies Partial<ThreadStartParams>;
|
|
65
68
|
|
|
66
|
-
const BASE_INSTRUCTIONS =
|
|
67
|
-
|
|
68
|
-
withSchema: "You are a helpful assistant. Respond using the provided output schema.",
|
|
69
|
-
} as const;
|
|
69
|
+
const BASE_INSTRUCTIONS =
|
|
70
|
+
"You are a helpful assistant. Do not use any tools such as shell, file operations, or web search. Respond with text only.";
|
|
70
71
|
|
|
71
72
|
// codex가 매 요청에 자동 주입하는 내장 tool(shell/web_search/spawn_agent 등 14개)과
|
|
72
73
|
// instruction 블록(permissions/environment_context/skills, ~10KB)을 비활성화
|
|
@@ -103,8 +104,19 @@ export class CodexAppServerWorker {
|
|
|
103
104
|
private ready = false;
|
|
104
105
|
private destroyed = false;
|
|
105
106
|
private busy = false;
|
|
107
|
+
private activeTurnAbort?: ActiveTurnAbort;
|
|
106
108
|
active = true;
|
|
107
109
|
onReady?: () => void;
|
|
110
|
+
// restart 시 rpc 객체가 새로 생성되므로, 핸들러를 보관했다가 매 spawn마다 재바인딩한다.
|
|
111
|
+
serverRequestHandler?: (method: string, params: unknown) => Promise<unknown>;
|
|
112
|
+
|
|
113
|
+
// thread 재사용(prompt cache): worker 가 생성한 thread 들의 메타.
|
|
114
|
+
// ephemeral thread 는 이 프로세스 메모리에만 존재하므로 restart 시 전부 무효.
|
|
115
|
+
private threadMeta = new Map<string, { lastUsedAt: number }>();
|
|
116
|
+
// spawn 카운터. restart 마다 증가 → conv 핸들의 epoch 와 대조해 stale thread 감지.
|
|
117
|
+
private epochCounter = 0;
|
|
118
|
+
private static readonly THREAD_IDLE_TTL_MS = 10 * 60_000;
|
|
119
|
+
private static readonly MAX_THREADS_PER_WORKER = 16;
|
|
108
120
|
|
|
109
121
|
constructor(private config: WorkerConfig) {
|
|
110
122
|
const suffix = config.workerIndex !== undefined ? `-${config.workerIndex}` : "";
|
|
@@ -114,6 +126,9 @@ export class CodexAppServerWorker {
|
|
|
114
126
|
// ── Lifecycle ───────────────────────────────────────────────────
|
|
115
127
|
|
|
116
128
|
private async spawnAndInit(): Promise<void> {
|
|
129
|
+
// 새 codex 프로세스 → 기존 ephemeral thread 전부 무효. epoch 증가로 stale 감지.
|
|
130
|
+
this.epochCounter++;
|
|
131
|
+
this.threadMeta.clear();
|
|
117
132
|
const cwd = `${this.codexHome}/cwd`;
|
|
118
133
|
mkdirSync(cwd, { recursive: true });
|
|
119
134
|
// codex 내장 tool/web_search/instruction 블록 비활성화
|
|
@@ -133,6 +148,9 @@ export class CodexAppServerWorker {
|
|
|
133
148
|
|
|
134
149
|
this.proc.on("exit", (code) => {
|
|
135
150
|
logger.info(`worker ${this.config.tokenName} exited (code=${code})`);
|
|
151
|
+
this.failActiveTurn(
|
|
152
|
+
new Error(`codex worker exited while turn was running (code=${code ?? "unknown"})`),
|
|
153
|
+
);
|
|
136
154
|
this.ready = false;
|
|
137
155
|
this.rpc = null;
|
|
138
156
|
this.proc = null;
|
|
@@ -140,6 +158,8 @@ export class CodexAppServerWorker {
|
|
|
140
158
|
});
|
|
141
159
|
|
|
142
160
|
this.rpc = new CodexRpcClient(this.proc);
|
|
161
|
+
// restart 후에도 refresh server-request 핸들러를 유지 (없으면 codex 토큰 회전 실패 → session expired)
|
|
162
|
+
this.bindServerRequestHandler();
|
|
143
163
|
|
|
144
164
|
await this.rpc.request("initialize", {
|
|
145
165
|
clientInfo: { name: "qgrid", version: "1.0.0" },
|
|
@@ -212,6 +232,7 @@ export class CodexAppServerWorker {
|
|
|
212
232
|
async kill(): Promise<void> {
|
|
213
233
|
this.destroyed = true;
|
|
214
234
|
this.ready = false;
|
|
235
|
+
this.failActiveTurn(new Error("codex worker stopped"));
|
|
215
236
|
this.rpc?.destroy();
|
|
216
237
|
|
|
217
238
|
if (this.proc) {
|
|
@@ -242,10 +263,31 @@ export class CodexAppServerWorker {
|
|
|
242
263
|
get tokenId(): number {
|
|
243
264
|
return this.config.tokenId;
|
|
244
265
|
}
|
|
266
|
+
get workerIndex(): number {
|
|
267
|
+
return this.config.workerIndex ?? 0;
|
|
268
|
+
}
|
|
245
269
|
get tokenName(): string {
|
|
246
270
|
return this.config.tokenName;
|
|
247
271
|
}
|
|
248
272
|
|
|
273
|
+
canReuseForToken(_tokenName: string, credentials: RefreshableWorkerCredentials): boolean {
|
|
274
|
+
return (
|
|
275
|
+
this.config.accountId === credentials.accountId &&
|
|
276
|
+
(this.config.planType ?? undefined) === (credentials.planType ?? undefined)
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
updateTokenState(tokenName: string, credentials: RefreshableWorkerCredentials): void {
|
|
281
|
+
this.config.tokenName = tokenName;
|
|
282
|
+
this.config.accessToken = credentials.accessToken;
|
|
283
|
+
this.config.accountId = credentials.accountId;
|
|
284
|
+
if (credentials.planType) {
|
|
285
|
+
this.config.planType = credentials.planType;
|
|
286
|
+
} else {
|
|
287
|
+
delete this.config.planType;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
249
291
|
// ── Rate limits ─────────────────────────────────────────────────
|
|
250
292
|
|
|
251
293
|
async getRateLimits(): Promise<unknown> {
|
|
@@ -256,6 +298,14 @@ export class CodexAppServerWorker {
|
|
|
256
298
|
// ── Server-request delegation ───────────────────────────────────
|
|
257
299
|
|
|
258
300
|
setServerRequestHandler(handler: (method: string, params: unknown) => Promise<unknown>): void {
|
|
301
|
+
this.serverRequestHandler = handler;
|
|
302
|
+
this.bindServerRequestHandler();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// spawnAndInit에서 rpc 생성 직후 호출. restart로 rpc가 새로 만들어져도 핸들러가 유지된다.
|
|
306
|
+
bindServerRequestHandler(): void {
|
|
307
|
+
const handler = this.serverRequestHandler;
|
|
308
|
+
if (!handler) return;
|
|
259
309
|
this.rpc?.onServerRequest("account/chatgptAuthTokens/refresh", (params) =>
|
|
260
310
|
handler("account/chatgptAuthTokens/refresh", params),
|
|
261
311
|
);
|
|
@@ -275,10 +325,12 @@ export class CodexAppServerWorker {
|
|
|
275
325
|
return !this.busy;
|
|
276
326
|
}
|
|
277
327
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
328
|
+
// 새 thread 생성 (thread/start + 필요 시 history inject). 첫 turn / 폴백 경로용.
|
|
329
|
+
// 후속 turn 은 createThread 없이 startTurnOnThread 만 호출해 conversation_id 를 고정한다.
|
|
330
|
+
// thread 의 기본 model 도 반환 (req.model 미지정 시 fallback).
|
|
331
|
+
async createThread(req: TurnRequest): Promise<{ threadId: string; model: string }> {
|
|
281
332
|
if (!this.rpc || !this.ready) throw new Error("worker not ready");
|
|
333
|
+
this.sweepIdleThreads();
|
|
282
334
|
|
|
283
335
|
const threadConfig: ThreadStartParams["config"] = {
|
|
284
336
|
...THREAD_DEFAULTS.config,
|
|
@@ -289,7 +341,7 @@ export class CodexAppServerWorker {
|
|
|
289
341
|
{
|
|
290
342
|
ephemeral: true,
|
|
291
343
|
cwd: `${this.codexHome}/cwd`,
|
|
292
|
-
baseInstructions:
|
|
344
|
+
baseInstructions: BASE_INSTRUCTIONS,
|
|
293
345
|
developerInstructions: req.developerInstructions ?? "",
|
|
294
346
|
sandbox: THREAD_DEFAULTS.sandbox,
|
|
295
347
|
approvalPolicy: THREAD_DEFAULTS.approvalPolicy,
|
|
@@ -298,13 +350,20 @@ export class CodexAppServerWorker {
|
|
|
298
350
|
);
|
|
299
351
|
|
|
300
352
|
const threadId = thread.id;
|
|
301
|
-
const model = req.model ?? threadModel;
|
|
302
353
|
if (req.history?.length) {
|
|
303
354
|
await this.rpc.request("thread/inject_items", {
|
|
304
355
|
threadId,
|
|
305
356
|
items: req.history,
|
|
306
357
|
} satisfies ThreadInjectItemsParams);
|
|
307
358
|
}
|
|
359
|
+
this.threadMeta.set(threadId, { lastUsedAt: Date.now() });
|
|
360
|
+
|
|
361
|
+
return { threadId, model: req.model ?? threadModel };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 기존 thread 에 turn 만 실행 (inject 없음). conversation_id 유지 → prompt cache 적중.
|
|
365
|
+
private async startTurnOnThread(threadId: string, req: TurnRequest): Promise<{ turnId: string }> {
|
|
366
|
+
if (!this.rpc || !this.ready) throw new Error("worker not ready");
|
|
308
367
|
|
|
309
368
|
const { turn } = await this.rpc.request<TurnStartResponse>("turn/start", {
|
|
310
369
|
threadId,
|
|
@@ -315,20 +374,79 @@ export class CodexAppServerWorker {
|
|
|
315
374
|
...(req.reasoningSummary ? { summary: req.reasoningSummary } : {}),
|
|
316
375
|
...(req.serviceTier ? { serviceTier: req.serviceTier } : {}),
|
|
317
376
|
});
|
|
318
|
-
|
|
319
|
-
|
|
377
|
+
const meta = this.threadMeta.get(threadId);
|
|
378
|
+
if (meta) meta.lastUsedAt = Date.now();
|
|
379
|
+
return { turnId: turn.id };
|
|
320
380
|
}
|
|
321
381
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
382
|
+
// existingThreadId 있으면 그 thread 재사용(후속 turn), 없으면 새 thread 생성(첫 turn).
|
|
383
|
+
// 호출 후 사용한 threadId 를 반환해 dispatcher 가 conv 핸들을 발급한다.
|
|
384
|
+
async executeTurn(
|
|
385
|
+
req: TurnRequest,
|
|
386
|
+
existingThreadId?: string,
|
|
387
|
+
): Promise<TurnResult & { threadId: string }> {
|
|
388
|
+
let threadId: string;
|
|
389
|
+
let model: string;
|
|
390
|
+
if (existingThreadId) {
|
|
391
|
+
threadId = existingThreadId;
|
|
392
|
+
model = req.model ?? "";
|
|
393
|
+
} else {
|
|
394
|
+
({ threadId, model } = await this.createThread(req));
|
|
395
|
+
}
|
|
396
|
+
await this.startTurnOnThread(threadId, req);
|
|
397
|
+
const result = await this.consumeTurnNotifications(threadId, model);
|
|
398
|
+
return { ...result, threadId };
|
|
325
399
|
}
|
|
326
400
|
|
|
327
|
-
async executeTurnStream(
|
|
328
|
-
|
|
401
|
+
async executeTurnStream(
|
|
402
|
+
req: TurnRequest,
|
|
403
|
+
cb: StreamCallbacks,
|
|
404
|
+
existingThreadId?: string,
|
|
405
|
+
): Promise<{ threadId: string }> {
|
|
406
|
+
let threadId: string;
|
|
407
|
+
let model: string;
|
|
408
|
+
if (existingThreadId) {
|
|
409
|
+
threadId = existingThreadId;
|
|
410
|
+
model = req.model ?? "";
|
|
411
|
+
} else {
|
|
412
|
+
({ threadId, model } = await this.createThread(req));
|
|
413
|
+
}
|
|
414
|
+
const { turnId } = await this.startTurnOnThread(threadId, req);
|
|
329
415
|
cb.onThreadId?.(threadId);
|
|
330
416
|
cb.onTurnId?.(turnId);
|
|
331
|
-
|
|
417
|
+
await this.consumeStreamNotifications(threadId, model, cb);
|
|
418
|
+
return { threadId };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── thread 재사용 지원 ───────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
get epoch(): number {
|
|
424
|
+
return this.epochCounter;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
hasThread(threadId: string): boolean {
|
|
428
|
+
return this.threadMeta.has(threadId);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// idle TTL 초과 / 개수 상한 초과 thread 를 정리. createThread 직전 lazy 호출.
|
|
432
|
+
// codex ephemeral thread 는 명시적 close RPC 없이 맵에서 제거 → 더 이상 turn 안 보냄.
|
|
433
|
+
sweepIdleThreads(): void {
|
|
434
|
+
const now = Date.now();
|
|
435
|
+
for (const [id, meta] of this.threadMeta) {
|
|
436
|
+
if (now - meta.lastUsedAt > CodexAppServerWorker.THREAD_IDLE_TTL_MS) {
|
|
437
|
+
this.threadMeta.delete(id);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// createThread 직전 호출 → 새 thread 1 개가 곧 추가된다. 추가 후에도 MAX 를 넘지 않도록
|
|
441
|
+
// MAX-1 이하로 줄여 둔다(그래야 생성 직후 정확히 MAX). LRU: lastUsedAt 오름차순으로 제거.
|
|
442
|
+
const limit = CodexAppServerWorker.MAX_THREADS_PER_WORKER - 1;
|
|
443
|
+
if (this.threadMeta.size > limit) {
|
|
444
|
+
const sorted = [...this.threadMeta.entries()].toSorted(
|
|
445
|
+
(a, b) => a[1].lastUsedAt - b[1].lastUsedAt,
|
|
446
|
+
);
|
|
447
|
+
const over = this.threadMeta.size - limit;
|
|
448
|
+
for (let i = 0; i < over; i++) this.threadMeta.delete(sorted[i]![0]);
|
|
449
|
+
}
|
|
332
450
|
}
|
|
333
451
|
|
|
334
452
|
async interruptTurn(threadId: string, turnId: string): Promise<void> {
|
|
@@ -338,11 +456,18 @@ export class CodexAppServerWorker {
|
|
|
338
456
|
} catch {}
|
|
339
457
|
}
|
|
340
458
|
|
|
459
|
+
private failActiveTurn(error: Error): void {
|
|
460
|
+
const abort = this.activeTurnAbort;
|
|
461
|
+
this.activeTurnAbort = undefined;
|
|
462
|
+
abort?.(error);
|
|
463
|
+
}
|
|
464
|
+
|
|
341
465
|
private consumeTurnNotifications(threadId: string, model: string): Promise<TurnResult> {
|
|
342
466
|
return new Promise<TurnResult>((resolve, reject) => {
|
|
467
|
+
let settled = false;
|
|
468
|
+
let abort: ActiveTurnAbort;
|
|
343
469
|
const timeout = setTimeout(() => {
|
|
344
|
-
|
|
345
|
-
reject(new Error("turn timeout (600s)"));
|
|
470
|
+
finishWithError(new Error("turn timeout (600s)"));
|
|
346
471
|
}, 600_000);
|
|
347
472
|
|
|
348
473
|
let text = "";
|
|
@@ -357,6 +482,7 @@ export class CodexAppServerWorker {
|
|
|
357
482
|
|
|
358
483
|
const cleanup = () => {
|
|
359
484
|
clearTimeout(timeout);
|
|
485
|
+
if (this.activeTurnAbort === abort) this.activeTurnAbort = undefined;
|
|
360
486
|
for (const n of [
|
|
361
487
|
"item/completed",
|
|
362
488
|
"item/agentMessage/delta",
|
|
@@ -368,6 +494,23 @@ export class CodexAppServerWorker {
|
|
|
368
494
|
}
|
|
369
495
|
};
|
|
370
496
|
|
|
497
|
+
const finishWithError = (error: Error) => {
|
|
498
|
+
if (settled) return;
|
|
499
|
+
settled = true;
|
|
500
|
+
cleanup();
|
|
501
|
+
reject(error);
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const finishWithResult = (result: TurnResult) => {
|
|
505
|
+
if (settled) return;
|
|
506
|
+
settled = true;
|
|
507
|
+
cleanup();
|
|
508
|
+
resolve(result);
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
abort = finishWithError;
|
|
512
|
+
this.activeTurnAbort = abort;
|
|
513
|
+
|
|
371
514
|
this.rpc!.onNotification("item/completed", (p) => {
|
|
372
515
|
if (p.threadId !== threadId) return;
|
|
373
516
|
if (p.item.type === "agentMessage") text = p.item.text;
|
|
@@ -375,22 +518,23 @@ export class CodexAppServerWorker {
|
|
|
375
518
|
|
|
376
519
|
this.rpc!.onNotification("thread/tokenUsage/updated", (p) => {
|
|
377
520
|
if (p.threadId !== threadId) return;
|
|
378
|
-
|
|
521
|
+
// thread 재사용 시 .total 은 대화 전체 누적이라, cold 였던 이전 turn 까지 섞여
|
|
522
|
+
// 이 요청 자체의 cache 적중률이 희석된다(예: turn1 cold 0% + turn2 90% → 48% 로 기록).
|
|
523
|
+
// .last 는 이번 turn 만의 usage 라 request_log 가 그 요청의 실제 토큰을 정확히 반영한다.
|
|
524
|
+
usage = p.tokenUsage.last;
|
|
379
525
|
});
|
|
380
526
|
|
|
381
527
|
this.rpc!.onNotification("turn/completed", (p) => {
|
|
382
528
|
if (p.threadId !== threadId) return;
|
|
383
529
|
durationMs = p.turn.durationMs ?? 0;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
else reject(new Error(`turn failed: ${JSON.stringify(p.turn.error)}`));
|
|
530
|
+
if (p.turn.status === "completed") finishWithResult({ text, usage, durationMs, model });
|
|
531
|
+
else finishWithError(new Error(`turn failed: ${JSON.stringify(p.turn.error)}`));
|
|
387
532
|
});
|
|
388
533
|
|
|
389
534
|
this.rpc!.onNotification("error", (p) => {
|
|
390
535
|
if (p.threadId !== threadId) return;
|
|
391
536
|
if (!p.willRetry) {
|
|
392
|
-
|
|
393
|
-
reject(new Error(`codex error: ${p.error.message}`));
|
|
537
|
+
finishWithError(new Error(`codex error: ${p.error.message}`));
|
|
394
538
|
}
|
|
395
539
|
});
|
|
396
540
|
});
|
|
@@ -402,10 +546,10 @@ export class CodexAppServerWorker {
|
|
|
402
546
|
cb: StreamCallbacks,
|
|
403
547
|
): Promise<void> {
|
|
404
548
|
return new Promise<void>((resolve, reject) => {
|
|
549
|
+
let settled = false;
|
|
550
|
+
let abort: ActiveTurnAbort;
|
|
405
551
|
const timeout = setTimeout(() => {
|
|
406
|
-
|
|
407
|
-
cb.onError(new Error("turn timeout (600s)"));
|
|
408
|
-
reject(new Error("turn timeout (600s)"));
|
|
552
|
+
finishWithError(new Error("turn timeout (600s)"));
|
|
409
553
|
}, 600_000);
|
|
410
554
|
|
|
411
555
|
let text = "";
|
|
@@ -419,6 +563,7 @@ export class CodexAppServerWorker {
|
|
|
419
563
|
|
|
420
564
|
const cleanup = () => {
|
|
421
565
|
clearTimeout(timeout);
|
|
566
|
+
if (this.activeTurnAbort === abort) this.activeTurnAbort = undefined;
|
|
422
567
|
for (const n of [
|
|
423
568
|
"item/completed",
|
|
424
569
|
"item/agentMessage/delta",
|
|
@@ -430,6 +575,25 @@ export class CodexAppServerWorker {
|
|
|
430
575
|
}
|
|
431
576
|
};
|
|
432
577
|
|
|
578
|
+
const finishWithError = (error: Error) => {
|
|
579
|
+
if (settled) return;
|
|
580
|
+
settled = true;
|
|
581
|
+
cleanup();
|
|
582
|
+
cb.onError(error);
|
|
583
|
+
reject(error);
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const finishWithComplete = (result: TurnResult) => {
|
|
587
|
+
if (settled) return;
|
|
588
|
+
settled = true;
|
|
589
|
+
cleanup();
|
|
590
|
+
cb.onComplete(result);
|
|
591
|
+
resolve();
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
abort = finishWithError;
|
|
595
|
+
this.activeTurnAbort = abort;
|
|
596
|
+
|
|
433
597
|
this.rpc!.onNotification("item/agentMessage/delta", (p) => {
|
|
434
598
|
if (p.threadId !== threadId) return;
|
|
435
599
|
text += p.delta;
|
|
@@ -443,29 +607,25 @@ export class CodexAppServerWorker {
|
|
|
443
607
|
|
|
444
608
|
this.rpc!.onNotification("thread/tokenUsage/updated", (p) => {
|
|
445
609
|
if (p.threadId !== threadId) return;
|
|
446
|
-
|
|
610
|
+
// thread 재사용 시 .total 은 대화 전체 누적이라, cold 였던 이전 turn 까지 섞여
|
|
611
|
+
// 이 요청 자체의 cache 적중률이 희석된다(예: turn1 cold 0% + turn2 90% → 48% 로 기록).
|
|
612
|
+
// .last 는 이번 turn 만의 usage 라 request_log 가 그 요청의 실제 토큰을 정확히 반영한다.
|
|
613
|
+
usage = p.tokenUsage.last;
|
|
447
614
|
});
|
|
448
615
|
|
|
449
616
|
this.rpc!.onNotification("turn/completed", (p) => {
|
|
450
617
|
if (p.threadId !== threadId) return;
|
|
451
|
-
cleanup();
|
|
452
618
|
if (p.turn.status === "completed") {
|
|
453
|
-
|
|
454
|
-
resolve();
|
|
619
|
+
finishWithComplete({ text, usage, durationMs: p.turn.durationMs ?? 0, model });
|
|
455
620
|
} else {
|
|
456
|
-
|
|
457
|
-
cb.onError(err);
|
|
458
|
-
reject(err);
|
|
621
|
+
finishWithError(new Error(`turn ${p.turn.status}: ${JSON.stringify(p.turn.error)}`));
|
|
459
622
|
}
|
|
460
623
|
});
|
|
461
624
|
|
|
462
625
|
this.rpc!.onNotification("error", (p) => {
|
|
463
626
|
if (p.threadId !== threadId) return;
|
|
464
627
|
if (!p.willRetry) {
|
|
465
|
-
|
|
466
|
-
const err = new Error(`codex error: ${p.error.message}`);
|
|
467
|
-
cb.onError(err);
|
|
468
|
-
reject(err);
|
|
628
|
+
finishWithError(new Error(`codex error: ${p.error.message}`));
|
|
469
629
|
}
|
|
470
630
|
});
|
|
471
631
|
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { type OpenAICredentials } from "../../../application/token/token.types";
|
|
4
|
+
import { type CodexAppServerWorker } from "./codex-worker";
|
|
5
|
+
import { OpenAIDispatcher } from "./openai-dispatcher";
|
|
6
|
+
|
|
7
|
+
function credentials(overrides: Partial<OpenAICredentials> = {}): OpenAICredentials {
|
|
8
|
+
return {
|
|
9
|
+
accessToken: "access",
|
|
10
|
+
refreshToken: "refresh",
|
|
11
|
+
accessTokenExpiresAt: Date.now() + 60_000,
|
|
12
|
+
accountId: "account",
|
|
13
|
+
planType: "plus",
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fakeWorker(reusable: boolean): CodexAppServerWorker {
|
|
19
|
+
return {
|
|
20
|
+
kill: vi.fn(async () => {}),
|
|
21
|
+
canReuseForToken: vi.fn(() => reusable),
|
|
22
|
+
updateTokenState: vi.fn(),
|
|
23
|
+
} as unknown as CodexAppServerWorker;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("OpenAIDispatcher token updates", () => {
|
|
27
|
+
it("keeps existing workers when only OpenAI auth tokens rotate", async () => {
|
|
28
|
+
const dispatcher = new OpenAIDispatcher();
|
|
29
|
+
const worker = fakeWorker(true);
|
|
30
|
+
const spawnWorkers = vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
31
|
+
dispatcher.workerPool.set(1, [worker]);
|
|
32
|
+
|
|
33
|
+
const rotated = credentials({
|
|
34
|
+
accessToken: "new-access",
|
|
35
|
+
refreshToken: "new-refresh",
|
|
36
|
+
idToken: "new-id",
|
|
37
|
+
accessTokenExpiresAt: Date.now() + 120_000,
|
|
38
|
+
});
|
|
39
|
+
await dispatcher.onTokenUpdated(1, "renamed-token", rotated);
|
|
40
|
+
|
|
41
|
+
expect(worker.kill).not.toHaveBeenCalled();
|
|
42
|
+
expect(worker.updateTokenState).toHaveBeenCalledWith("renamed-token", rotated);
|
|
43
|
+
expect(spawnWorkers).not.toHaveBeenCalled();
|
|
44
|
+
expect(dispatcher.workerPool.get(1)).toEqual([worker]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("restarts workers when the OpenAI login identity changes", async () => {
|
|
48
|
+
const dispatcher = new OpenAIDispatcher();
|
|
49
|
+
const worker = fakeWorker(false);
|
|
50
|
+
const spawnWorkers = vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
51
|
+
dispatcher.workerPool.set(1, [worker]);
|
|
52
|
+
|
|
53
|
+
const changedIdentity = credentials({ accountId: "other-account" });
|
|
54
|
+
await dispatcher.onTokenUpdated(1, "token", changedIdentity);
|
|
55
|
+
|
|
56
|
+
expect(worker.kill).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity);
|
|
58
|
+
});
|
|
59
|
+
});
|