@cartanova/qgrid-cli 2.5.8 → 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.
- package/bundle/dist/utils/providers/openai/codex-worker.js +16 -3
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +86 -35
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +72 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +15 -2
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +442 -4
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +163 -49
- package/bundle/web-dist/client/assets/{index-oLaOO8wP.js → index-CkBeqjBm.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-D8U3hI1f.js → logs-d9HdibiE.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-NKS90qFj.js → monit-C8qYvOMj.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-BuxVmofi.js → routes-DT0AY2Lq.js} +1 -1
- package/bundle/web-dist/client/assets/{show-C8Qv9_Ww.js → show-DPG1oKpW.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-ChWIXTeL.js → tokens-Dz7hmKFm.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/cli-env-and-server-boot.md +2 -2
- package/skills/qgrid/references/openai-codex-runtime.md +13 -0
- package/skills/qgrid/references/verification-and-debugging.md +3 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenAIDispatcher — codex app-server worker pool + 토큰 라우팅.
|
|
3
3
|
*
|
|
4
|
-
* - 토큰당 CodexAppServerWorker 자동 확장 (기본
|
|
4
|
+
* - 토큰당 CodexAppServerWorker 자동 확장 (기본 1~3, hard cap 20)
|
|
5
5
|
* - idle worker round-robin 선택 + 큐 대기 (전부 busy 시)
|
|
6
6
|
* - TokenSubscriber 이벤트로 worker pool 동기화
|
|
7
7
|
* - backpressure: 큐 full 또는 timeout 시 SERVER_BUSY
|
|
@@ -127,6 +127,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
127
127
|
queue: QueueItem[] = [];
|
|
128
128
|
readonly poolConfig: OpenAIWorkerPoolConfig;
|
|
129
129
|
private readonly quotaBlockedTokenIds = new Set<number>();
|
|
130
|
+
private readonly startingTokenIds = new Set<number>();
|
|
130
131
|
private readonly rateLimitsGenerationByToken = new Map<number, number>();
|
|
131
132
|
private readonly weightedSelector = new SmoothWeightedRoundRobin();
|
|
132
133
|
private readonly workerCursorByToken = new Map<number, number>();
|
|
@@ -169,13 +170,12 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
169
170
|
}),
|
|
170
171
|
);
|
|
171
172
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
173
|
+
// autoscale off도 fixed min의 health maintenance는 계속 필요하다.
|
|
174
|
+
this.scalerTimer = setInterval(
|
|
175
|
+
() => this.requestAutoscaleEvaluation(),
|
|
176
|
+
this.poolConfig.scaleIntervalMs,
|
|
177
|
+
);
|
|
178
|
+
this.scalerTimer.unref?.();
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
async stop(): Promise<void> {
|
|
@@ -196,6 +196,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
196
196
|
this.rateLimitsCache.clear();
|
|
197
197
|
this.rateLimitsGenerationByToken.clear();
|
|
198
198
|
this.quotaBlockedTokenIds.clear();
|
|
199
|
+
this.startingTokenIds.clear();
|
|
199
200
|
}
|
|
200
201
|
|
|
201
202
|
// ── Token events (from TokenSubscriber) ─────────────────────────
|
|
@@ -224,7 +225,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
224
225
|
this.invalidateRateLimitsCache(id);
|
|
225
226
|
this.quotaBlockedTokenIds.delete(id);
|
|
226
227
|
await Promise.allSettled(workers.map((w) => w.kill()));
|
|
227
|
-
if (this.
|
|
228
|
+
if (!this.hasActiveTokenCandidate(new Set())) {
|
|
228
229
|
this.rejectAllQueued("NO_OPENAI_WORKERS");
|
|
229
230
|
}
|
|
230
231
|
if (workers.length > 0) {
|
|
@@ -276,7 +277,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
276
277
|
w.active = false;
|
|
277
278
|
});
|
|
278
279
|
if (changed) this.weightedSelector.resetScores();
|
|
279
|
-
if (this.
|
|
280
|
+
if (!this.hasActiveTokenCandidate(new Set())) {
|
|
280
281
|
this.rejectAllQueued("NO_ACTIVE_WORKERS");
|
|
281
282
|
}
|
|
282
283
|
const label = tokenName ?? `token ${id}`;
|
|
@@ -499,8 +500,15 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
499
500
|
excludedTokenIds = new Set<number>(),
|
|
500
501
|
): Promise<T> {
|
|
501
502
|
const eligibility = await this.resolveTokenEligibility(excludedTokenIds);
|
|
502
|
-
if (
|
|
503
|
-
|
|
503
|
+
if (
|
|
504
|
+
eligibility.readyActiveTokenIds.size === 0 &&
|
|
505
|
+
!this.hasActiveTokenCandidate(excludedTokenIds)
|
|
506
|
+
) {
|
|
507
|
+
throw new Error("NO_OPENAI_WORKERS");
|
|
508
|
+
}
|
|
509
|
+
if (eligibility.readyActiveTokenIds.size > 0 && eligibility.eligibleTokenIds.size === 0) {
|
|
510
|
+
this.throwQuotaThresholdExceeded(eligibility);
|
|
511
|
+
}
|
|
504
512
|
if (this.queue.length >= MAX_QUEUE_SIZE) {
|
|
505
513
|
throw new Error("SERVER_BUSY");
|
|
506
514
|
}
|
|
@@ -527,6 +535,9 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
527
535
|
};
|
|
528
536
|
|
|
529
537
|
this.queue.push(item);
|
|
538
|
+
// eligibility 확인과 queue 삽입 사이에 worker가 ready/idle이 된 경우의 lost wake-up을
|
|
539
|
+
// 닫는다. fixed mode에서도 idle worker가 즉시 queue를 가져가야 한다.
|
|
540
|
+
this.requestDrainQueue();
|
|
530
541
|
this.requestAutoscaleEvaluation();
|
|
531
542
|
});
|
|
532
543
|
}
|
|
@@ -698,14 +709,31 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
698
709
|
quotaThreshold?: number | null,
|
|
699
710
|
weight = 1,
|
|
700
711
|
): Promise<void> {
|
|
712
|
+
if (this.startingTokenIds.has(tokenId)) return;
|
|
713
|
+
this.startingTokenIds.add(tokenId);
|
|
701
714
|
this.setTokenMetadata(tokenId, tokenName, credentials, quotaThreshold, weight);
|
|
715
|
+
const expectedMetadata = this.tokenMetadata.get(tokenId);
|
|
702
716
|
const workers: CodexAppServerWorker[] = [];
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
717
|
+
try {
|
|
718
|
+
for (let i = 0; i < this.poolConfig.minWorkersPerToken; i++) {
|
|
719
|
+
if (i > 0) await sleep(SPAWN_INTERVAL_MS);
|
|
720
|
+
const worker = await this.spawnSingleWorker(tokenId, tokenName, credentials, i);
|
|
721
|
+
if (worker) workers.push(worker);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (
|
|
725
|
+
!this.stopped &&
|
|
726
|
+
expectedMetadata?.active &&
|
|
727
|
+
this.tokenMetadata.get(tokenId) === expectedMetadata &&
|
|
728
|
+
!this.workerPool.has(tokenId)
|
|
729
|
+
) {
|
|
730
|
+
if (workers.length > 0) this.workerPool.set(tokenId, workers);
|
|
731
|
+
} else {
|
|
732
|
+
await Promise.allSettled(workers.map((worker) => worker.kill()));
|
|
733
|
+
}
|
|
734
|
+
} finally {
|
|
735
|
+
this.startingTokenIds.delete(tokenId);
|
|
707
736
|
}
|
|
708
|
-
if (workers.length > 0) this.workerPool.set(tokenId, workers);
|
|
709
737
|
}
|
|
710
738
|
|
|
711
739
|
async spawnSingleWorker(
|
|
@@ -724,6 +752,13 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
724
752
|
});
|
|
725
753
|
|
|
726
754
|
worker.onReady = () => this.requestDrainQueue();
|
|
755
|
+
worker.onTerminalFailure = () => {
|
|
756
|
+
void this.handleWorkerTerminalFailure(worker).catch((e) =>
|
|
757
|
+
logger.warn(
|
|
758
|
+
`terminal worker cleanup failed: ${tokenName}[${workerIndex}]: ${(e as Error).message}`,
|
|
759
|
+
),
|
|
760
|
+
);
|
|
761
|
+
};
|
|
727
762
|
|
|
728
763
|
worker.setServerRequestHandler(async (method) => {
|
|
729
764
|
if (method === "account/chatgptAuthTokens/refresh") {
|
|
@@ -744,9 +779,13 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
744
779
|
}
|
|
745
780
|
|
|
746
781
|
async evaluateAutoscaling(): Promise<void> {
|
|
782
|
+
if (this.stopped) return;
|
|
783
|
+
const minimumDeficitTokenIds = await this.ensureMinimumWorkersOneStep();
|
|
747
784
|
if (!this.poolConfig.autoscale || this.stopped) return;
|
|
748
785
|
if (this.queue.length > 0) {
|
|
749
|
-
|
|
786
|
+
// baseline 복구를 시도한 token만 같은 evaluation의 demand expansion에서 제외한다.
|
|
787
|
+
// 다른 정상 token의 확장까지 막지는 않는다.
|
|
788
|
+
await this.scaleUpOneStep(minimumDeficitTokenIds);
|
|
750
789
|
return;
|
|
751
790
|
}
|
|
752
791
|
if (Date.now() - this.lastRequestAt >= this.poolConfig.scaleDownIdleMs) {
|
|
@@ -754,11 +793,18 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
754
793
|
}
|
|
755
794
|
}
|
|
756
795
|
|
|
757
|
-
async scaleUpOneStep(): Promise<void> {
|
|
796
|
+
async scaleUpOneStep(excludedTokenIds = new Set<number>()): Promise<void> {
|
|
758
797
|
if (!this.poolConfig.autoscale || this.stopped) return;
|
|
759
798
|
await this.runScaling(async () => {
|
|
760
799
|
const candidates = [...this.tokenMetadata.entries()].filter(([tokenId, metadata]) => {
|
|
761
|
-
if (
|
|
800
|
+
if (
|
|
801
|
+
!metadata.active ||
|
|
802
|
+
this.startingTokenIds.has(tokenId) ||
|
|
803
|
+
excludedTokenIds.has(tokenId) ||
|
|
804
|
+
this.quotaBlockedTokenIds.has(tokenId)
|
|
805
|
+
) {
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
762
808
|
return (this.workerPool.get(tokenId)?.length ?? 0) < this.poolConfig.maxWorkersPerToken;
|
|
763
809
|
});
|
|
764
810
|
if (candidates.length === 0) return;
|
|
@@ -781,34 +827,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
781
827
|
}
|
|
782
828
|
|
|
783
829
|
const spawned = await Promise.all(
|
|
784
|
-
candidates.map(
|
|
785
|
-
const workers = this.workerPool.get(tokenId) ?? [];
|
|
786
|
-
const workerIndex = this.nextAvailableWorkerIndex(workers);
|
|
787
|
-
if (workerIndex >= this.poolConfig.maxWorkersPerToken) return null;
|
|
788
|
-
const worker = await this.spawnSingleWorker(
|
|
789
|
-
tokenId,
|
|
790
|
-
metadata.name,
|
|
791
|
-
metadata.credentials,
|
|
792
|
-
workerIndex,
|
|
793
|
-
);
|
|
794
|
-
if (!worker) return null;
|
|
795
|
-
|
|
796
|
-
const currentMetadata = this.tokenMetadata.get(tokenId);
|
|
797
|
-
const currentWorkers = this.workerPool.get(tokenId);
|
|
798
|
-
if (
|
|
799
|
-
!currentMetadata?.active ||
|
|
800
|
-
this.quotaBlockedTokenIds.has(tokenId) ||
|
|
801
|
-
currentMetadata !== metadata ||
|
|
802
|
-
!currentWorkers ||
|
|
803
|
-
currentWorkers.some((entry) => entry.workerIndex === workerIndex)
|
|
804
|
-
) {
|
|
805
|
-
await worker.kill().catch(() => {});
|
|
806
|
-
return null;
|
|
807
|
-
}
|
|
808
|
-
currentWorkers.push(worker);
|
|
809
|
-
currentWorkers.sort((a, b) => a.workerIndex - b.workerIndex);
|
|
810
|
-
return worker;
|
|
811
|
-
}),
|
|
830
|
+
candidates.map(([tokenId, metadata]) => this.spawnAndCommitWorker(tokenId, metadata, true)),
|
|
812
831
|
);
|
|
813
832
|
|
|
814
833
|
const added = spawned.filter((worker) => worker !== null).length;
|
|
@@ -828,6 +847,38 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
828
847
|
});
|
|
829
848
|
}
|
|
830
849
|
|
|
850
|
+
async ensureMinimumWorkersOneStep(): Promise<Set<number>> {
|
|
851
|
+
const minimumDeficitTokenIds = new Set<number>();
|
|
852
|
+
if (this.stopped) return minimumDeficitTokenIds;
|
|
853
|
+
await this.runScaling(async () => {
|
|
854
|
+
const candidates = [...this.tokenMetadata.entries()].filter(([tokenId, metadata]) => {
|
|
855
|
+
if (!metadata.active || this.startingTokenIds.has(tokenId)) return false;
|
|
856
|
+
return (this.workerPool.get(tokenId)?.length ?? 0) < this.poolConfig.minWorkersPerToken;
|
|
857
|
+
});
|
|
858
|
+
if (candidates.length === 0) return;
|
|
859
|
+
candidates.forEach(([tokenId]) => minimumDeficitTokenIds.add(tokenId));
|
|
860
|
+
|
|
861
|
+
const spawned = await Promise.all(
|
|
862
|
+
candidates.map(([tokenId, metadata]) =>
|
|
863
|
+
this.spawnAndCommitWorker(tokenId, metadata, false),
|
|
864
|
+
),
|
|
865
|
+
);
|
|
866
|
+
const added = spawned.filter((worker) => worker !== null).length;
|
|
867
|
+
if (added > 0) {
|
|
868
|
+
logger.info(`openai worker pool repaired: +${added} workers (${this.workerCount} total)`);
|
|
869
|
+
this.requestDrainQueue();
|
|
870
|
+
if (this.queue.length > 0) {
|
|
871
|
+
const timer = setTimeout(
|
|
872
|
+
() => this.requestAutoscaleEvaluation(),
|
|
873
|
+
Math.min(this.poolConfig.scaleIntervalMs, 1_000),
|
|
874
|
+
);
|
|
875
|
+
timer.unref?.();
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
return minimumDeficitTokenIds;
|
|
880
|
+
}
|
|
881
|
+
|
|
831
882
|
async scaleDownOneStep(): Promise<void> {
|
|
832
883
|
if (!this.poolConfig.autoscale || this.stopped) return;
|
|
833
884
|
await this.runScaling(async () => {
|
|
@@ -862,8 +913,65 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
862
913
|
return this.poolConfig.maxWorkersPerToken;
|
|
863
914
|
}
|
|
864
915
|
|
|
916
|
+
private async spawnAndCommitWorker(
|
|
917
|
+
tokenId: number,
|
|
918
|
+
metadata: TokenMetadata,
|
|
919
|
+
respectQuotaBlock: boolean,
|
|
920
|
+
): Promise<CodexAppServerWorker | null> {
|
|
921
|
+
const expectedWorkers = this.workerPool.get(tokenId);
|
|
922
|
+
const workerIndex = this.nextAvailableWorkerIndex(expectedWorkers ?? []);
|
|
923
|
+
if (workerIndex >= this.poolConfig.maxWorkersPerToken) return null;
|
|
924
|
+
|
|
925
|
+
const worker = await this.spawnSingleWorker(
|
|
926
|
+
tokenId,
|
|
927
|
+
metadata.name,
|
|
928
|
+
metadata.credentials,
|
|
929
|
+
workerIndex,
|
|
930
|
+
);
|
|
931
|
+
if (!worker) return null;
|
|
932
|
+
|
|
933
|
+
const currentMetadata = this.tokenMetadata.get(tokenId);
|
|
934
|
+
const currentWorkers = this.workerPool.get(tokenId);
|
|
935
|
+
if (
|
|
936
|
+
!currentMetadata?.active ||
|
|
937
|
+
currentMetadata !== metadata ||
|
|
938
|
+
currentWorkers !== expectedWorkers ||
|
|
939
|
+
(respectQuotaBlock && this.quotaBlockedTokenIds.has(tokenId)) ||
|
|
940
|
+
currentWorkers?.some((entry) => entry.workerIndex === workerIndex)
|
|
941
|
+
) {
|
|
942
|
+
await worker.kill().catch(() => {});
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const nextWorkers = currentWorkers ?? [];
|
|
947
|
+
nextWorkers.push(worker);
|
|
948
|
+
nextWorkers.sort((a, b) => a.workerIndex - b.workerIndex);
|
|
949
|
+
if (!currentWorkers) this.workerPool.set(tokenId, nextWorkers);
|
|
950
|
+
return worker;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async handleWorkerTerminalFailure(worker: CodexAppServerWorker): Promise<void> {
|
|
954
|
+
const workers = this.workerPool.get(worker.tokenId);
|
|
955
|
+
if (!workers?.includes(worker)) return;
|
|
956
|
+
|
|
957
|
+
await worker.kill().catch(() => {});
|
|
958
|
+
|
|
959
|
+
const currentWorkers = this.workerPool.get(worker.tokenId);
|
|
960
|
+
if (!currentWorkers?.includes(worker)) return;
|
|
961
|
+
const remaining = currentWorkers.filter((entry) => entry !== worker);
|
|
962
|
+
if (remaining.length > 0) {
|
|
963
|
+
this.workerPool.set(worker.tokenId, remaining);
|
|
964
|
+
} else {
|
|
965
|
+
this.workerPool.delete(worker.tokenId);
|
|
966
|
+
}
|
|
967
|
+
logger.warn(
|
|
968
|
+
`terminal worker removed: ${worker.tokenName}[${worker.workerIndex}] (${remaining.length} remaining)`,
|
|
969
|
+
);
|
|
970
|
+
this.requestAutoscaleEvaluation();
|
|
971
|
+
}
|
|
972
|
+
|
|
865
973
|
private requestAutoscaleEvaluation(): void {
|
|
866
|
-
if (
|
|
974
|
+
if (this.stopped) return;
|
|
867
975
|
void this.evaluateAutoscaling().catch((e) =>
|
|
868
976
|
logger.warn(`openai autoscale evaluation failed: ${(e as Error).message}`),
|
|
869
977
|
);
|
|
@@ -936,6 +1044,12 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
936
1044
|
this.weightedSelector.setToken(id, weight);
|
|
937
1045
|
}
|
|
938
1046
|
|
|
1047
|
+
private hasActiveTokenCandidate(excludedTokenIds: Set<number>): boolean {
|
|
1048
|
+
return [...this.tokenMetadata.entries()].some(
|
|
1049
|
+
([tokenId, metadata]) => metadata.active && !excludedTokenIds.has(tokenId),
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
939
1053
|
private getQuotaThreshold(tokenId: number): number | null | undefined {
|
|
940
1054
|
return this.tokenMetadata.get(tokenId)?.quotaThreshold;
|
|
941
1055
|
}
|