@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
|
@@ -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,14 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
744
779
|
}
|
|
745
780
|
|
|
746
781
|
async evaluateAutoscaling(): Promise<void> {
|
|
782
|
+
if (this.stopped) return;
|
|
783
|
+
this.sweepIdleWorkerThreads();
|
|
784
|
+
const minimumDeficitTokenIds = await this.ensureMinimumWorkersOneStep();
|
|
747
785
|
if (!this.poolConfig.autoscale || this.stopped) return;
|
|
748
786
|
if (this.queue.length > 0) {
|
|
749
|
-
|
|
787
|
+
// baseline 복구를 시도한 token만 같은 evaluation의 demand expansion에서 제외한다.
|
|
788
|
+
// 다른 정상 token의 확장까지 막지는 않는다.
|
|
789
|
+
await this.scaleUpOneStep(minimumDeficitTokenIds);
|
|
750
790
|
return;
|
|
751
791
|
}
|
|
752
792
|
if (Date.now() - this.lastRequestAt >= this.poolConfig.scaleDownIdleMs) {
|
|
@@ -754,11 +794,18 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
754
794
|
}
|
|
755
795
|
}
|
|
756
796
|
|
|
757
|
-
async scaleUpOneStep(): Promise<void> {
|
|
797
|
+
async scaleUpOneStep(excludedTokenIds = new Set<number>()): Promise<void> {
|
|
758
798
|
if (!this.poolConfig.autoscale || this.stopped) return;
|
|
759
799
|
await this.runScaling(async () => {
|
|
760
800
|
const candidates = [...this.tokenMetadata.entries()].filter(([tokenId, metadata]) => {
|
|
761
|
-
if (
|
|
801
|
+
if (
|
|
802
|
+
!metadata.active ||
|
|
803
|
+
this.startingTokenIds.has(tokenId) ||
|
|
804
|
+
excludedTokenIds.has(tokenId) ||
|
|
805
|
+
this.quotaBlockedTokenIds.has(tokenId)
|
|
806
|
+
) {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
762
809
|
return (this.workerPool.get(tokenId)?.length ?? 0) < this.poolConfig.maxWorkersPerToken;
|
|
763
810
|
});
|
|
764
811
|
if (candidates.length === 0) return;
|
|
@@ -781,34 +828,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
781
828
|
}
|
|
782
829
|
|
|
783
830
|
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
|
-
}),
|
|
831
|
+
candidates.map(([tokenId, metadata]) => this.spawnAndCommitWorker(tokenId, metadata, true)),
|
|
812
832
|
);
|
|
813
833
|
|
|
814
834
|
const added = spawned.filter((worker) => worker !== null).length;
|
|
@@ -828,6 +848,38 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
828
848
|
});
|
|
829
849
|
}
|
|
830
850
|
|
|
851
|
+
async ensureMinimumWorkersOneStep(): Promise<Set<number>> {
|
|
852
|
+
const minimumDeficitTokenIds = new Set<number>();
|
|
853
|
+
if (this.stopped) return minimumDeficitTokenIds;
|
|
854
|
+
await this.runScaling(async () => {
|
|
855
|
+
const candidates = [...this.tokenMetadata.entries()].filter(([tokenId, metadata]) => {
|
|
856
|
+
if (!metadata.active || this.startingTokenIds.has(tokenId)) return false;
|
|
857
|
+
return (this.workerPool.get(tokenId)?.length ?? 0) < this.poolConfig.minWorkersPerToken;
|
|
858
|
+
});
|
|
859
|
+
if (candidates.length === 0) return;
|
|
860
|
+
candidates.forEach(([tokenId]) => minimumDeficitTokenIds.add(tokenId));
|
|
861
|
+
|
|
862
|
+
const spawned = await Promise.all(
|
|
863
|
+
candidates.map(([tokenId, metadata]) =>
|
|
864
|
+
this.spawnAndCommitWorker(tokenId, metadata, false),
|
|
865
|
+
),
|
|
866
|
+
);
|
|
867
|
+
const added = spawned.filter((worker) => worker !== null).length;
|
|
868
|
+
if (added > 0) {
|
|
869
|
+
logger.info(`openai worker pool repaired: +${added} workers (${this.workerCount} total)`);
|
|
870
|
+
this.requestDrainQueue();
|
|
871
|
+
if (this.queue.length > 0) {
|
|
872
|
+
const timer = setTimeout(
|
|
873
|
+
() => this.requestAutoscaleEvaluation(),
|
|
874
|
+
Math.min(this.poolConfig.scaleIntervalMs, 1_000),
|
|
875
|
+
);
|
|
876
|
+
timer.unref?.();
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
return minimumDeficitTokenIds;
|
|
881
|
+
}
|
|
882
|
+
|
|
831
883
|
async scaleDownOneStep(): Promise<void> {
|
|
832
884
|
if (!this.poolConfig.autoscale || this.stopped) return;
|
|
833
885
|
await this.runScaling(async () => {
|
|
@@ -862,8 +914,73 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
862
914
|
return this.poolConfig.maxWorkersPerToken;
|
|
863
915
|
}
|
|
864
916
|
|
|
917
|
+
private sweepIdleWorkerThreads(): void {
|
|
918
|
+
for (const worker of [...this.workerPool.values()].flat()) {
|
|
919
|
+
// turn 중 unsubscribe하면 해당 connection으로 notification이 더 오지 않아
|
|
920
|
+
// 실행 중 요청이 timeout될 수 있다. ready + idle worker만 sweep한다.
|
|
921
|
+
if (worker.isReady && worker.hasCapacity) worker.sweepIdleThreads();
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
private async spawnAndCommitWorker(
|
|
926
|
+
tokenId: number,
|
|
927
|
+
metadata: TokenMetadata,
|
|
928
|
+
respectQuotaBlock: boolean,
|
|
929
|
+
): Promise<CodexAppServerWorker | null> {
|
|
930
|
+
const expectedWorkers = this.workerPool.get(tokenId);
|
|
931
|
+
const workerIndex = this.nextAvailableWorkerIndex(expectedWorkers ?? []);
|
|
932
|
+
if (workerIndex >= this.poolConfig.maxWorkersPerToken) return null;
|
|
933
|
+
|
|
934
|
+
const worker = await this.spawnSingleWorker(
|
|
935
|
+
tokenId,
|
|
936
|
+
metadata.name,
|
|
937
|
+
metadata.credentials,
|
|
938
|
+
workerIndex,
|
|
939
|
+
);
|
|
940
|
+
if (!worker) return null;
|
|
941
|
+
|
|
942
|
+
const currentMetadata = this.tokenMetadata.get(tokenId);
|
|
943
|
+
const currentWorkers = this.workerPool.get(tokenId);
|
|
944
|
+
if (
|
|
945
|
+
!currentMetadata?.active ||
|
|
946
|
+
currentMetadata !== metadata ||
|
|
947
|
+
currentWorkers !== expectedWorkers ||
|
|
948
|
+
(respectQuotaBlock && this.quotaBlockedTokenIds.has(tokenId)) ||
|
|
949
|
+
currentWorkers?.some((entry) => entry.workerIndex === workerIndex)
|
|
950
|
+
) {
|
|
951
|
+
await worker.kill().catch(() => {});
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const nextWorkers = currentWorkers ?? [];
|
|
956
|
+
nextWorkers.push(worker);
|
|
957
|
+
nextWorkers.sort((a, b) => a.workerIndex - b.workerIndex);
|
|
958
|
+
if (!currentWorkers) this.workerPool.set(tokenId, nextWorkers);
|
|
959
|
+
return worker;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async handleWorkerTerminalFailure(worker: CodexAppServerWorker): Promise<void> {
|
|
963
|
+
const workers = this.workerPool.get(worker.tokenId);
|
|
964
|
+
if (!workers?.includes(worker)) return;
|
|
965
|
+
|
|
966
|
+
await worker.kill().catch(() => {});
|
|
967
|
+
|
|
968
|
+
const currentWorkers = this.workerPool.get(worker.tokenId);
|
|
969
|
+
if (!currentWorkers?.includes(worker)) return;
|
|
970
|
+
const remaining = currentWorkers.filter((entry) => entry !== worker);
|
|
971
|
+
if (remaining.length > 0) {
|
|
972
|
+
this.workerPool.set(worker.tokenId, remaining);
|
|
973
|
+
} else {
|
|
974
|
+
this.workerPool.delete(worker.tokenId);
|
|
975
|
+
}
|
|
976
|
+
logger.warn(
|
|
977
|
+
`terminal worker removed: ${worker.tokenName}[${worker.workerIndex}] (${remaining.length} remaining)`,
|
|
978
|
+
);
|
|
979
|
+
this.requestAutoscaleEvaluation();
|
|
980
|
+
}
|
|
981
|
+
|
|
865
982
|
private requestAutoscaleEvaluation(): void {
|
|
866
|
-
if (
|
|
983
|
+
if (this.stopped) return;
|
|
867
984
|
void this.evaluateAutoscaling().catch((e) =>
|
|
868
985
|
logger.warn(`openai autoscale evaluation failed: ${(e as Error).message}`),
|
|
869
986
|
);
|
|
@@ -936,6 +1053,12 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
936
1053
|
this.weightedSelector.setToken(id, weight);
|
|
937
1054
|
}
|
|
938
1055
|
|
|
1056
|
+
private hasActiveTokenCandidate(excludedTokenIds: Set<number>): boolean {
|
|
1057
|
+
return [...this.tokenMetadata.entries()].some(
|
|
1058
|
+
([tokenId, metadata]) => metadata.active && !excludedTokenIds.has(tokenId),
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
939
1062
|
private getQuotaThreshold(tokenId: number): number | null | undefined {
|
|
940
1063
|
return this.tokenMetadata.get(tokenId)?.quotaThreshold;
|
|
941
1064
|
}
|