@jsonstudio/llms 0.6.1449 → 0.6.1643
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/dist/conversion/codecs/gemini-openai-codec.js +6 -1
- package/dist/conversion/compat/actions/anthropic-claude-code-system-prompt.d.ts +4 -6
- package/dist/conversion/compat/actions/anthropic-claude-code-system-prompt.js +179 -41
- package/dist/conversion/compat/actions/antigravity-thought-signature-cache.js +73 -14
- package/dist/conversion/compat/actions/antigravity-thought-signature-prepare.js +165 -10
- package/dist/conversion/compat/actions/gemini-cli-request.js +72 -13
- package/dist/conversion/compat/antigravity-session-signature.d.ts +68 -1
- package/dist/conversion/compat/antigravity-session-signature.js +833 -21
- package/dist/conversion/compat/profiles/anthropic-claude-code.json +17 -0
- package/dist/conversion/compat/profiles/chat-gemini-cli.json +1 -0
- package/dist/conversion/hub/operation-table/semantic-mappers/gemini-mapper.js +33 -8
- package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +17 -1
- package/dist/conversion/hub/pipeline/compat/compat-profile-store.js +12 -3
- package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +1 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.js +24 -0
- package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage2_semantic_map/index.js +20 -0
- package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js +26 -1
- package/dist/conversion/hub/process/chat-process.js +300 -67
- package/dist/conversion/hub/response/provider-response.js +4 -3
- package/dist/conversion/shared/gemini-tool-utils.js +134 -9
- package/dist/conversion/shared/text-markup-normalizer.js +90 -1
- package/dist/conversion/shared/thought-signature-validator.d.ts +1 -1
- package/dist/conversion/shared/thought-signature-validator.js +2 -1
- package/dist/quota/apikey-reset.d.ts +17 -0
- package/dist/quota/apikey-reset.js +43 -0
- package/dist/quota/index.d.ts +2 -0
- package/dist/quota/index.js +1 -0
- package/dist/quota/quota-manager.d.ts +44 -0
- package/dist/quota/quota-manager.js +491 -0
- package/dist/quota/quota-state.d.ts +6 -0
- package/dist/quota/quota-state.js +167 -0
- package/dist/quota/types.d.ts +61 -0
- package/dist/quota/types.js +1 -0
- package/dist/router/virtual-router/bootstrap.js +103 -6
- package/dist/router/virtual-router/engine-health.js +104 -0
- package/dist/router/virtual-router/engine-selection/selection-deps.d.ts +18 -0
- package/dist/router/virtual-router/engine-selection/tier-priority.d.ts +1 -2
- package/dist/router/virtual-router/engine-selection/tier-priority.js +2 -2
- package/dist/router/virtual-router/engine-selection/tier-selection-select.js +34 -10
- package/dist/router/virtual-router/engine-selection/tier-selection.js +250 -6
- package/dist/router/virtual-router/engine-selection.js +2 -2
- package/dist/router/virtual-router/engine.d.ts +16 -1
- package/dist/router/virtual-router/engine.js +320 -42
- package/dist/router/virtual-router/features.js +20 -2
- package/dist/router/virtual-router/success-center.d.ts +10 -0
- package/dist/router/virtual-router/success-center.js +32 -0
- package/dist/router/virtual-router/types.d.ts +48 -0
- package/dist/servertool/clock/config.d.ts +2 -0
- package/dist/servertool/clock/config.js +10 -2
- package/dist/servertool/clock/daemon.js +3 -0
- package/dist/servertool/clock/ntp.d.ts +18 -0
- package/dist/servertool/clock/ntp.js +318 -0
- package/dist/servertool/clock/paths.d.ts +1 -0
- package/dist/servertool/clock/paths.js +3 -0
- package/dist/servertool/clock/state.d.ts +2 -0
- package/dist/servertool/clock/state.js +15 -2
- package/dist/servertool/clock/tasks.d.ts +1 -0
- package/dist/servertool/clock/tasks.js +24 -1
- package/dist/servertool/clock/types.d.ts +21 -0
- package/dist/servertool/engine.js +105 -1
- package/dist/servertool/handlers/antigravity-thought-signature-bootstrap.d.ts +1 -0
- package/dist/servertool/handlers/antigravity-thought-signature-bootstrap.js +201 -0
- package/dist/servertool/handlers/clock-auto.js +39 -4
- package/dist/servertool/handlers/clock.js +145 -16
- package/dist/servertool/handlers/followup-request-builder.js +84 -0
- package/dist/servertool/handlers/stop-message-auto.js +1 -1
- package/dist/servertool/server-side-tools.d.ts +1 -0
- package/dist/servertool/server-side-tools.js +1 -0
- package/dist/servertool/types.d.ts +2 -0
- package/dist/tools/apply-patch/execution-capturer.js +24 -3
- package/package.json +3 -2
|
@@ -4,6 +4,9 @@ import { RouteLoadBalancer } from './load-balancer.js';
|
|
|
4
4
|
import { RoutingClassifier } from './classifier.js';
|
|
5
5
|
import { buildRoutingFeatures } from './features.js';
|
|
6
6
|
import { ContextAdvisor } from './context-advisor.js';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
7
10
|
import { DEFAULT_ROUTE, ROUTE_PRIORITY, VirtualRouterError, VirtualRouterErrorCode } from './types.js';
|
|
8
11
|
import { getStatsCenter } from '../../telemetry/stats-center.js';
|
|
9
12
|
import { parseRoutingInstructions, applyRoutingInstructions, cleanMessagesFromRoutingInstructions } from './routing-instructions.js';
|
|
@@ -25,6 +28,13 @@ export class VirtualRouterEngine {
|
|
|
25
28
|
// Alias selection state (global within this VirtualRouterEngine instance).
|
|
26
29
|
// Used by alias-selection strategies to avoid rapid cross-alias switching.
|
|
27
30
|
aliasQueueStore = new Map();
|
|
31
|
+
// Antigravity alias session lease store:
|
|
32
|
+
// - Enforces that one auth alias is not shared across different sessions within a cooldown window.
|
|
33
|
+
// - Tracks lastSeenAt per alias runtimeKey (providerId.keyAlias).
|
|
34
|
+
antigravityAliasLeaseStore = new Map();
|
|
35
|
+
antigravitySessionAliasStore = new Map();
|
|
36
|
+
antigravityAliasReuseCooldownMs = 5 * 60_000;
|
|
37
|
+
antigravityLeasePersistence = { loadedOnce: false, loadedMtimeMs: null, flushTimer: null };
|
|
28
38
|
debug = console; // thin hook; host may monkey-patch for colored logging
|
|
29
39
|
healthConfig = null;
|
|
30
40
|
statsCenter = getStatsCenter();
|
|
@@ -68,7 +78,19 @@ export class VirtualRouterEngine {
|
|
|
68
78
|
this.routingInstructionState.clear();
|
|
69
79
|
}
|
|
70
80
|
if ('quotaView' in deps) {
|
|
81
|
+
const prevQuotaEnabled = Boolean(this.quotaView);
|
|
71
82
|
this.quotaView = deps.quotaView ?? undefined;
|
|
83
|
+
const nextQuotaEnabled = Boolean(this.quotaView);
|
|
84
|
+
// When quotaView is enabled, health/cooldown decisions must be driven by quotaView only.
|
|
85
|
+
// - Enabling quotaView: clear any legacy router-local cooldown TTLs immediately.
|
|
86
|
+
// - Disabling quotaView: reload legacy cooldown state from health snapshots.
|
|
87
|
+
if (!prevQuotaEnabled && nextQuotaEnabled) {
|
|
88
|
+
this.providerCooldowns.clear();
|
|
89
|
+
}
|
|
90
|
+
else if (prevQuotaEnabled && !nextQuotaEnabled) {
|
|
91
|
+
this.providerCooldowns.clear();
|
|
92
|
+
this.restoreHealthFromStore();
|
|
93
|
+
}
|
|
72
94
|
}
|
|
73
95
|
}
|
|
74
96
|
parseDirectProviderModel(model) {
|
|
@@ -100,6 +122,8 @@ export class VirtualRouterEngine {
|
|
|
100
122
|
this.providerCooldowns.clear();
|
|
101
123
|
this.restoreHealthFromStore();
|
|
102
124
|
this.loadBalancer = new RouteLoadBalancer(config.loadBalancing);
|
|
125
|
+
this.antigravityAliasReuseCooldownMs = this.resolveAntigravityAliasReuseCooldownMs(config);
|
|
126
|
+
this.hydrateAntigravityAliasLeaseStoreIfNeeded(true);
|
|
103
127
|
this.classifier = new RoutingClassifier(config.classifier);
|
|
104
128
|
this.contextRouting = config.contextRouting ?? { warnRatio: 0.9, hardLimit: false };
|
|
105
129
|
this.contextAdvisor.configure(this.contextRouting);
|
|
@@ -394,13 +418,13 @@ export class VirtualRouterEngine {
|
|
|
394
418
|
const quotaView = selectionDeps.quotaView;
|
|
395
419
|
const now = quotaView ? Date.now() : 0;
|
|
396
420
|
return candidateKeys.filter((key) => {
|
|
397
|
-
if (this.isProviderCoolingDown(key)) {
|
|
398
|
-
return false;
|
|
399
|
-
}
|
|
400
|
-
if (!this.healthManager.isAvailable(key)) {
|
|
401
|
-
return false;
|
|
402
|
-
}
|
|
403
421
|
if (!quotaView) {
|
|
422
|
+
if (this.isProviderCoolingDown(key)) {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
if (!this.healthManager.isAvailable(key)) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
404
428
|
return true;
|
|
405
429
|
}
|
|
406
430
|
const entry = quotaView(key);
|
|
@@ -493,6 +517,7 @@ export class VirtualRouterEngine {
|
|
|
493
517
|
...(this.webSearchForce ? { forceWebSearch: true } : {}),
|
|
494
518
|
...(forceVision ? { forceVision: true } : {})
|
|
495
519
|
};
|
|
520
|
+
this.recordAntigravitySessionLease(features.metadata, selection.providerKey, { commitSessionBinding: false });
|
|
496
521
|
this.incrementRouteStat(selection.routeUsed, selection.providerKey);
|
|
497
522
|
const routingMode = this.resolveRoutingMode([...metadataInstructions, ...instructions], routingState);
|
|
498
523
|
try {
|
|
@@ -520,37 +545,6 @@ export class VirtualRouterEngine {
|
|
|
520
545
|
this.debug?.log?.('[virtual-router-hit]', selection.routeUsed, selection.providerKey, target.modelId || '', hitReason ? `reason=${hitReason}` : '');
|
|
521
546
|
}
|
|
522
547
|
const didFallback = selection.routeUsed !== requestedRoute;
|
|
523
|
-
// 自动 sticky:对需要上下文 save/restore 的 Responses 会话,强制同一个 provider.key.model。
|
|
524
|
-
// 其它协议不启用粘滞,仅显式 routing 指令才会写入 stickyTarget。
|
|
525
|
-
const providerProtocol = metadata?.providerProtocol;
|
|
526
|
-
const serverToolRequired = metadata?.serverToolRequired === true;
|
|
527
|
-
const disableSticky = metadata &&
|
|
528
|
-
typeof metadata === 'object' &&
|
|
529
|
-
metadata.disableStickyRoutes === true;
|
|
530
|
-
const shouldAutoStickyForResponses = providerProtocol === 'openai-responses' && serverToolRequired && !disableSticky;
|
|
531
|
-
if (shouldAutoStickyForResponses) {
|
|
532
|
-
const stickyKeyForState = this.resolveStickyKey(metadata);
|
|
533
|
-
if (stickyKeyForState) {
|
|
534
|
-
const stateKey = stickyKeyForState;
|
|
535
|
-
const state = this.getRoutingInstructionState(stateKey);
|
|
536
|
-
if (!state.stickyTarget) {
|
|
537
|
-
const providerId = this.extractProviderId(selection.providerKey);
|
|
538
|
-
if (providerId) {
|
|
539
|
-
const parts = selection.providerKey.split('.');
|
|
540
|
-
const keyAlias = parts.length >= 3 ? parts[1] : undefined;
|
|
541
|
-
const modelId = target.modelId;
|
|
542
|
-
state.stickyTarget = {
|
|
543
|
-
provider: providerId,
|
|
544
|
-
keyAlias,
|
|
545
|
-
model: modelId,
|
|
546
|
-
// pathLength=3 表示 provider.key.model 形式,对应 alias 显式 sticky;
|
|
547
|
-
// 若缺少别名或模型,则退化为更短 pathLength。
|
|
548
|
-
pathLength: keyAlias && modelId ? 3 : keyAlias ? 2 : 1
|
|
549
|
-
};
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
548
|
return {
|
|
555
549
|
target,
|
|
556
550
|
decision: {
|
|
@@ -623,15 +617,16 @@ export class VirtualRouterEngine {
|
|
|
623
617
|
// ignore persistence errors
|
|
624
618
|
}
|
|
625
619
|
}
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
applyAntigravityRiskPolicyImpl(event, this.providerRegistry, this.healthManager, (key, ttl) => this.markProviderCooldown(key, ttl), this.debug);
|
|
620
|
+
// Quota routing mode: health/cooldown must be driven by quotaView only (host/core quota center).
|
|
621
|
+
// VirtualRouter must not produce or persist its own cooldown state in this mode.
|
|
629
622
|
// 当 Host 注入 quotaView 时,VirtualRouter 的入池/优先级决策应以 quota 为准;
|
|
630
623
|
// 此时不再在 engine-health 内部进行 429/backoff/series cooldown 等健康决策,
|
|
631
624
|
// 以避免与 daemon/quota-center 的长期熔断策略重复维护并导致日志噪声。
|
|
632
625
|
if (this.quotaView) {
|
|
633
626
|
return;
|
|
634
627
|
}
|
|
628
|
+
// Antigravity account safety policy uses router-local cooldown TTLs; only applies when quota routing is disabled.
|
|
629
|
+
applyAntigravityRiskPolicyImpl(event, this.providerRegistry, this.healthManager, (key, ttl) => this.markProviderCooldown(key, ttl), this.debug);
|
|
635
630
|
// 配额恢复事件优先处理:一旦识别到 virtualRouterQuotaRecovery,
|
|
636
631
|
// 直接清理健康状态/冷却 TTL,避免继续走常规错误映射逻辑。
|
|
637
632
|
const handledByQuota = applyQuotaRecoveryImpl(event, this.healthManager, (key) => this.clearProviderCooldown(key), this.debug);
|
|
@@ -649,6 +644,24 @@ export class VirtualRouterEngine {
|
|
|
649
644
|
}
|
|
650
645
|
this.handleProviderFailure(derived);
|
|
651
646
|
}
|
|
647
|
+
handleProviderSuccess(event) {
|
|
648
|
+
if (!event || typeof event !== 'object') {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const providerKey = event.runtime && typeof event.runtime.providerKey === 'string' ? event.runtime.providerKey.trim() : '';
|
|
652
|
+
if (!providerKey) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const metadata = event.metadata && typeof event.metadata === 'object' && !Array.isArray(event.metadata)
|
|
656
|
+
? event.metadata
|
|
657
|
+
: null;
|
|
658
|
+
if (!metadata) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (providerKey.toLowerCase().startsWith('antigravity.')) {
|
|
662
|
+
this.recordAntigravitySessionLease(metadata, providerKey, { commitSessionBinding: true });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
652
665
|
getStatus() {
|
|
653
666
|
const routes = {};
|
|
654
667
|
for (const [route, pools] of Object.entries(this.routing)) {
|
|
@@ -717,7 +730,10 @@ export class VirtualRouterEngine {
|
|
|
717
730
|
isProviderCoolingDown: (key) => this.isProviderCoolingDown(key),
|
|
718
731
|
resolveStickyKey: (m) => this.resolveStickyKey(m),
|
|
719
732
|
quotaView: this.quotaView,
|
|
720
|
-
aliasQueueStore: this.aliasQueueStore
|
|
733
|
+
aliasQueueStore: this.aliasQueueStore,
|
|
734
|
+
antigravityAliasLeaseStore: this.antigravityAliasLeaseStore,
|
|
735
|
+
antigravitySessionAliasStore: this.antigravitySessionAliasStore,
|
|
736
|
+
antigravityAliasReuseCooldownMs: this.antigravityAliasReuseCooldownMs
|
|
721
737
|
}, { routingState });
|
|
722
738
|
}
|
|
723
739
|
incrementRouteStat(routeName, providerKey) {
|
|
@@ -764,6 +780,262 @@ export class VirtualRouterEngine {
|
|
|
764
780
|
}
|
|
765
781
|
return undefined;
|
|
766
782
|
}
|
|
783
|
+
resolveAntigravityAliasReuseCooldownMs(config) {
|
|
784
|
+
const cfg = config.loadBalancing?.aliasSelection?.sessionLeaseCooldownMs;
|
|
785
|
+
if (typeof cfg === 'number' && Number.isFinite(cfg)) {
|
|
786
|
+
return Math.max(0, Math.floor(cfg));
|
|
787
|
+
}
|
|
788
|
+
return 5 * 60_000;
|
|
789
|
+
}
|
|
790
|
+
resolveAntigravityLeaseScope(providerKey) {
|
|
791
|
+
try {
|
|
792
|
+
const profile = this.providerRegistry.get(providerKey);
|
|
793
|
+
const modelId = typeof profile?.modelId === 'string' ? profile.modelId.trim().toLowerCase() : '';
|
|
794
|
+
if (modelId.startsWith('gemini-')) {
|
|
795
|
+
return 'gemini';
|
|
796
|
+
}
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
buildScopedSessionKey(sessionKey) {
|
|
804
|
+
return `${sessionKey}::gemini`;
|
|
805
|
+
}
|
|
806
|
+
buildAntigravityLeaseRuntimeKey(runtimeKey) {
|
|
807
|
+
return `${runtimeKey}::gemini`;
|
|
808
|
+
}
|
|
809
|
+
extractRuntimeKey(providerKey) {
|
|
810
|
+
const value = typeof providerKey === 'string' ? providerKey.trim() : '';
|
|
811
|
+
if (!value)
|
|
812
|
+
return null;
|
|
813
|
+
const firstDot = value.indexOf('.');
|
|
814
|
+
if (firstDot <= 0 || firstDot === value.length - 1)
|
|
815
|
+
return null;
|
|
816
|
+
const secondDot = value.indexOf('.', firstDot + 1);
|
|
817
|
+
if (secondDot <= firstDot + 1)
|
|
818
|
+
return null;
|
|
819
|
+
const providerId = value.slice(0, firstDot);
|
|
820
|
+
const alias = value.slice(firstDot + 1, secondDot);
|
|
821
|
+
if (!providerId || !alias)
|
|
822
|
+
return null;
|
|
823
|
+
return `${providerId}.${alias}`;
|
|
824
|
+
}
|
|
825
|
+
recordAntigravitySessionLease(metadata, providerKey, opts) {
|
|
826
|
+
// Per-request runtime override: allow callers (e.g. servertool followups) to disable
|
|
827
|
+
// Antigravity gemini session binding/lease without changing global config.
|
|
828
|
+
try {
|
|
829
|
+
const rt = metadata?.__rt;
|
|
830
|
+
if (rt && typeof rt === 'object' && !Array.isArray(rt)) {
|
|
831
|
+
const disable = rt.disableAntigravitySessionBinding;
|
|
832
|
+
const mode = rt.antigravitySessionBinding;
|
|
833
|
+
if (disable === true || mode === false) {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (typeof mode === 'string' && ['0', 'false', 'off', 'disabled', 'none'].includes(mode.trim().toLowerCase())) {
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
// ignore metadata parse errors
|
|
843
|
+
}
|
|
844
|
+
const sessionKey = this.resolveSessionScope(metadata);
|
|
845
|
+
if (!sessionKey) {
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const runtimeKeyBase = this.extractRuntimeKey(providerKey);
|
|
849
|
+
if (!runtimeKeyBase || !runtimeKeyBase.startsWith('antigravity.')) {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const commitSessionBinding = opts?.commitSessionBinding === true;
|
|
853
|
+
const scope = this.resolveAntigravityLeaseScope(providerKey);
|
|
854
|
+
// Policy: antigravity alias leasing/binding applies only to Gemini models.
|
|
855
|
+
if (scope !== 'gemini') {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const scopedSessionKey = this.buildScopedSessionKey(sessionKey);
|
|
859
|
+
const runtimeKey = this.buildAntigravityLeaseRuntimeKey(runtimeKeyBase);
|
|
860
|
+
const now = Date.now();
|
|
861
|
+
const cooldownMs = this.antigravityAliasReuseCooldownMs;
|
|
862
|
+
const existing = this.antigravityAliasLeaseStore.get(runtimeKey);
|
|
863
|
+
// Do not steal a lease from another active session. This prevents a "forced fallback" route
|
|
864
|
+
// (e.g. default route bypass) from evicting the original session and causing churn.
|
|
865
|
+
if (existing && existing.sessionKey !== scopedSessionKey && cooldownMs > 0 && now - existing.lastSeenAt < cooldownMs) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
this.antigravityAliasLeaseStore.set(runtimeKey, { sessionKey: scopedSessionKey, lastSeenAt: now });
|
|
869
|
+
if (commitSessionBinding) {
|
|
870
|
+
const previous = this.antigravitySessionAliasStore.get(scopedSessionKey);
|
|
871
|
+
this.antigravitySessionAliasStore.set(scopedSessionKey, runtimeKey);
|
|
872
|
+
try {
|
|
873
|
+
const raw = String(process.env.ROUTECODEX_STAGE_LOG || process.env.RCC_STAGE_LOG || '').trim().toLowerCase();
|
|
874
|
+
const enabled = raw !== '' && raw !== '0' && raw !== 'false' && raw !== 'no';
|
|
875
|
+
if (enabled && previous !== runtimeKey) {
|
|
876
|
+
this.debug?.log?.('[virtual-router][antigravity-session-binding] commit', JSON.stringify({ sessionKey: scopedSessionKey, runtimeKey, prev: previous ?? null }));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
// ignore logging errors
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
this.scheduleAntigravityAliasLeaseStoreFlush();
|
|
884
|
+
// Opportunistic cleanup: keep only active leases within cooldown window.
|
|
885
|
+
if (cooldownMs <= 0) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
for (const [key, entry] of this.antigravityAliasLeaseStore.entries()) {
|
|
889
|
+
if (now - entry.lastSeenAt >= cooldownMs) {
|
|
890
|
+
this.antigravityAliasLeaseStore.delete(key);
|
|
891
|
+
// Clear session mapping if it still points at the expired runtimeKey.
|
|
892
|
+
if (this.antigravitySessionAliasStore.get(entry.sessionKey) === key) {
|
|
893
|
+
this.antigravitySessionAliasStore.delete(entry.sessionKey);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
resolveAntigravityAliasLeasePersistPath() {
|
|
899
|
+
try {
|
|
900
|
+
const enabledRaw = process.env.ROUTECODEX_ANTIGRAVITY_ALIAS_LEASE_PERSIST;
|
|
901
|
+
const enabled = typeof enabledRaw === 'string' &&
|
|
902
|
+
['1', 'true', 'yes', 'on'].includes(enabledRaw.trim().toLowerCase());
|
|
903
|
+
if (!enabled) {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
const home = os.homedir();
|
|
907
|
+
if (!home)
|
|
908
|
+
return null;
|
|
909
|
+
return path.join(home, '.routecodex', 'state', 'antigravity-alias-leases.json');
|
|
910
|
+
}
|
|
911
|
+
catch {
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
hydrateAntigravityAliasLeaseStoreIfNeeded(force = false) {
|
|
916
|
+
const filePath = this.resolveAntigravityAliasLeasePersistPath();
|
|
917
|
+
if (!filePath) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
let stat = null;
|
|
921
|
+
try {
|
|
922
|
+
stat = fs.statSync(filePath);
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
this.antigravityLeasePersistence.loadedOnce = true;
|
|
926
|
+
this.antigravityLeasePersistence.loadedMtimeMs = null;
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const mtimeMs = typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : stat.mtime.getTime();
|
|
930
|
+
if (!force && this.antigravityLeasePersistence.loadedOnce && this.antigravityLeasePersistence.loadedMtimeMs === mtimeMs) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
let parsed;
|
|
934
|
+
try {
|
|
935
|
+
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
this.antigravityLeasePersistence.loadedOnce = true;
|
|
939
|
+
this.antigravityLeasePersistence.loadedMtimeMs = mtimeMs;
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
943
|
+
this.antigravityLeasePersistence.loadedOnce = true;
|
|
944
|
+
this.antigravityLeasePersistence.loadedMtimeMs = mtimeMs;
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const leasesRaw = parsed.leases;
|
|
948
|
+
if (!leasesRaw || typeof leasesRaw !== 'object' || Array.isArray(leasesRaw)) {
|
|
949
|
+
this.antigravityLeasePersistence.loadedOnce = true;
|
|
950
|
+
this.antigravityLeasePersistence.loadedMtimeMs = mtimeMs;
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const now = Date.now();
|
|
954
|
+
const cooldownMs = this.antigravityAliasReuseCooldownMs;
|
|
955
|
+
const nextLeaseStore = new Map();
|
|
956
|
+
for (const [runtimeKeyRaw, entryRaw] of Object.entries(leasesRaw)) {
|
|
957
|
+
const runtimeKeyBase = typeof runtimeKeyRaw === 'string' ? runtimeKeyRaw.trim() : '';
|
|
958
|
+
if (!runtimeKeyBase || !runtimeKeyBase.startsWith('antigravity.'))
|
|
959
|
+
continue;
|
|
960
|
+
if (!entryRaw || typeof entryRaw !== 'object' || Array.isArray(entryRaw))
|
|
961
|
+
continue;
|
|
962
|
+
const rawSessionKey = typeof entryRaw.sessionKey === 'string' ? String(entryRaw.sessionKey).trim() : '';
|
|
963
|
+
const lastSeenAt = typeof entryRaw.lastSeenAt === 'number' && Number.isFinite(entryRaw.lastSeenAt)
|
|
964
|
+
? Math.floor(entryRaw.lastSeenAt)
|
|
965
|
+
: 0;
|
|
966
|
+
if (!rawSessionKey || !lastSeenAt)
|
|
967
|
+
continue;
|
|
968
|
+
if (cooldownMs > 0 && now - lastSeenAt >= cooldownMs)
|
|
969
|
+
continue;
|
|
970
|
+
// Backwards compatible: legacy lease keys were unscoped ("antigravity.<alias>").
|
|
971
|
+
// Policy: only keep Gemini-scoped leases. Drop any non-gemini scoped entries.
|
|
972
|
+
const hasExplicitScope = runtimeKeyBase.includes('::');
|
|
973
|
+
if (hasExplicitScope && !runtimeKeyBase.endsWith('::gemini'))
|
|
974
|
+
continue;
|
|
975
|
+
const runtimeKey = hasExplicitScope ? runtimeKeyBase : this.buildAntigravityLeaseRuntimeKey(runtimeKeyBase);
|
|
976
|
+
if (rawSessionKey.includes('::') && !rawSessionKey.endsWith('::gemini'))
|
|
977
|
+
continue;
|
|
978
|
+
const sessionKey = rawSessionKey.includes('::') ? rawSessionKey : this.buildScopedSessionKey(rawSessionKey);
|
|
979
|
+
nextLeaseStore.set(runtimeKey, { sessionKey, lastSeenAt });
|
|
980
|
+
}
|
|
981
|
+
this.antigravityAliasLeaseStore.clear();
|
|
982
|
+
for (const [key, val] of nextLeaseStore.entries()) {
|
|
983
|
+
this.antigravityAliasLeaseStore.set(key, val);
|
|
984
|
+
}
|
|
985
|
+
this.antigravityLeasePersistence.loadedOnce = true;
|
|
986
|
+
this.antigravityLeasePersistence.loadedMtimeMs = mtimeMs;
|
|
987
|
+
}
|
|
988
|
+
scheduleAntigravityAliasLeaseStoreFlush() {
|
|
989
|
+
if (this.antigravityLeasePersistence.flushTimer) {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
this.antigravityLeasePersistence.flushTimer = setTimeout(() => {
|
|
993
|
+
this.antigravityLeasePersistence.flushTimer = null;
|
|
994
|
+
this.flushAntigravityAliasLeaseStoreSync();
|
|
995
|
+
}, 250);
|
|
996
|
+
this.antigravityLeasePersistence.flushTimer.unref?.();
|
|
997
|
+
}
|
|
998
|
+
flushAntigravityAliasLeaseStoreSync() {
|
|
999
|
+
const filePath = this.resolveAntigravityAliasLeasePersistPath();
|
|
1000
|
+
if (!filePath) {
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
try {
|
|
1004
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1005
|
+
}
|
|
1006
|
+
catch {
|
|
1007
|
+
// ignore
|
|
1008
|
+
}
|
|
1009
|
+
const now = Date.now();
|
|
1010
|
+
const cooldownMs = this.antigravityAliasReuseCooldownMs;
|
|
1011
|
+
const leases = {};
|
|
1012
|
+
for (const [runtimeKey, entry] of this.antigravityAliasLeaseStore.entries()) {
|
|
1013
|
+
if (!runtimeKey || !entry)
|
|
1014
|
+
continue;
|
|
1015
|
+
if (cooldownMs > 0 && now - entry.lastSeenAt >= cooldownMs)
|
|
1016
|
+
continue;
|
|
1017
|
+
if (!entry.sessionKey || !entry.lastSeenAt)
|
|
1018
|
+
continue;
|
|
1019
|
+
leases[runtimeKey] = { sessionKey: entry.sessionKey, lastSeenAt: entry.lastSeenAt };
|
|
1020
|
+
}
|
|
1021
|
+
try {
|
|
1022
|
+
const tmpPath = `${filePath}.tmp.${process.pid}.${now}`;
|
|
1023
|
+
fs.writeFileSync(tmpPath, JSON.stringify({ version: 1, updatedAt: now, leases }, null, 2), 'utf8');
|
|
1024
|
+
fs.renameSync(tmpPath, filePath);
|
|
1025
|
+
try {
|
|
1026
|
+
const stat = fs.statSync(filePath);
|
|
1027
|
+
const mtimeMs = typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : stat.mtime.getTime();
|
|
1028
|
+
this.antigravityLeasePersistence.loadedOnce = true;
|
|
1029
|
+
this.antigravityLeasePersistence.loadedMtimeMs = mtimeMs;
|
|
1030
|
+
}
|
|
1031
|
+
catch {
|
|
1032
|
+
// ignore
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
catch {
|
|
1036
|
+
// best-effort persistence: must not affect routing
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
767
1039
|
resolveStopMessageScope(metadata) {
|
|
768
1040
|
const sessionId = typeof metadata.sessionId === 'string' ? metadata.sessionId.trim() : '';
|
|
769
1041
|
if (sessionId) {
|
|
@@ -1133,7 +1405,8 @@ export class VirtualRouterEngine {
|
|
|
1133
1405
|
]));
|
|
1134
1406
|
const disabledModels = new Map(Array.from(state.disabledModels.entries()).map(([provider, models]) => [provider, new Set(models)]));
|
|
1135
1407
|
// 初始候选集合:sticky 池中的所有 key
|
|
1136
|
-
|
|
1408
|
+
// In quota routing mode, cooldown is controlled by quotaView only.
|
|
1409
|
+
let candidates = Array.from(stickyKeySet).filter((key) => (this.quotaView ? true : !this.isProviderCoolingDown(key)));
|
|
1137
1410
|
// 应用 provider 白名单 / 黑名单
|
|
1138
1411
|
if (allowedProviders.size > 0) {
|
|
1139
1412
|
candidates = candidates.filter((key) => {
|
|
@@ -1474,6 +1747,11 @@ export class VirtualRouterEngine {
|
|
|
1474
1747
|
if (!this.healthStore || typeof this.healthStore.loadInitialSnapshot !== 'function') {
|
|
1475
1748
|
return;
|
|
1476
1749
|
}
|
|
1750
|
+
// When quotaView is enabled, health/cooldown must be driven by quotaView only.
|
|
1751
|
+
// Do not restore legacy router-local cooldown TTLs from health snapshots.
|
|
1752
|
+
if (this.quotaView) {
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1477
1755
|
let snapshot = null;
|
|
1478
1756
|
try {
|
|
1479
1757
|
snapshot = this.healthStore.loadInitialSnapshot();
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import { detectExtendedThinkingKeyword, detectImageAttachment, detectKeyword, extractMessageText, getLatestMessageRole, getLatestUserMessage } from './message-utils.js';
|
|
2
|
+
import { extractAntigravityGeminiSessionId } from '../../conversion/compat/antigravity-session-signature.js';
|
|
2
3
|
import { detectCodingTool, detectLastAssistantToolCategory, detectVisionTool, detectWebTool, extractMeaningfulDeclaredToolNames } from './tool-signals.js';
|
|
3
4
|
import { computeRequestTokens } from './token-estimator.js';
|
|
4
5
|
const THINKING_KEYWORDS = ['let me think', 'chain of thought', 'cot', 'reason step', 'deliberate'];
|
|
5
6
|
export function buildRoutingFeatures(request, metadata) {
|
|
7
|
+
const antigravitySessionId = (() => {
|
|
8
|
+
try {
|
|
9
|
+
const messages = Array.isArray(request.messages) ? request.messages : [];
|
|
10
|
+
const contents = messages.map((msg) => {
|
|
11
|
+
const role = msg?.role === 'user' ? 'user' : 'assistant';
|
|
12
|
+
const text = msg ? extractMessageText(msg) : '';
|
|
13
|
+
return { role, parts: [{ text }] };
|
|
14
|
+
});
|
|
15
|
+
return extractAntigravityGeminiSessionId({ contents });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
6
21
|
const latestUserMessage = getLatestUserMessage(request.messages);
|
|
7
22
|
const latestMessageRole = getLatestMessageRole(request.messages);
|
|
8
23
|
const assistantMessages = request.messages.filter((msg) => msg.role === 'assistant');
|
|
@@ -14,7 +29,9 @@ export function buildRoutingFeatures(request, metadata) {
|
|
|
14
29
|
const estimatedTokens = computeRequestTokens(request, latestUserText);
|
|
15
30
|
const hasThinking = detectKeyword(normalizedUserText, THINKING_KEYWORDS);
|
|
16
31
|
const hasVisionTool = detectVisionTool(request);
|
|
17
|
-
|
|
32
|
+
// Vision routing must only trigger for the current user turn (latest message),
|
|
33
|
+
// not for historical user messages carrying images during tool/assistant followups.
|
|
34
|
+
const hasImageAttachment = latestMessageRole === 'user' && detectImageAttachment(latestUserMessage);
|
|
18
35
|
const hasCodingTool = detectCodingTool(request);
|
|
19
36
|
const hasWebTool = detectWebTool(request);
|
|
20
37
|
const hasThinkingKeyword = hasThinking || detectExtendedThinkingKeyword(normalizedUserText);
|
|
@@ -50,7 +67,8 @@ export function buildRoutingFeatures(request, metadata) {
|
|
|
50
67
|
lastAssistantToolLabel,
|
|
51
68
|
latestMessageFromUser: latestMessageRole === 'user',
|
|
52
69
|
metadata: {
|
|
53
|
-
...metadata
|
|
70
|
+
...metadata,
|
|
71
|
+
...(antigravitySessionId ? { antigravitySessionId } : {})
|
|
54
72
|
}
|
|
55
73
|
};
|
|
56
74
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ProviderSuccessEvent } from './types.js';
|
|
2
|
+
type ProviderSuccessListener = (event: ProviderSuccessEvent) => void;
|
|
3
|
+
export declare class ProviderSuccessCenter {
|
|
4
|
+
private readonly listeners;
|
|
5
|
+
subscribe(listener: ProviderSuccessListener): () => void;
|
|
6
|
+
emit(event: ProviderSuccessEvent): ProviderSuccessEvent;
|
|
7
|
+
private normalize;
|
|
8
|
+
}
|
|
9
|
+
export declare const providerSuccessCenter: ProviderSuccessCenter;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export class ProviderSuccessCenter {
|
|
2
|
+
listeners = new Set();
|
|
3
|
+
subscribe(listener) {
|
|
4
|
+
this.listeners.add(listener);
|
|
5
|
+
return () => {
|
|
6
|
+
this.listeners.delete(listener);
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
emit(event) {
|
|
10
|
+
const enriched = this.normalize(event);
|
|
11
|
+
for (const listener of this.listeners) {
|
|
12
|
+
try {
|
|
13
|
+
listener(enriched);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Listener failures should not break propagation
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return enriched;
|
|
20
|
+
}
|
|
21
|
+
normalize(event) {
|
|
22
|
+
const timestamp = typeof event.timestamp === 'number' ? event.timestamp : Date.now();
|
|
23
|
+
const runtime = event.runtime || {};
|
|
24
|
+
return {
|
|
25
|
+
runtime,
|
|
26
|
+
timestamp,
|
|
27
|
+
metadata: event.metadata,
|
|
28
|
+
details: event.details
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export const providerSuccessCenter = new ProviderSuccessCenter();
|
|
@@ -166,6 +166,19 @@ export interface AliasSelectionConfig {
|
|
|
166
166
|
* Per-provider overrides keyed by providerId (e.g. "antigravity").
|
|
167
167
|
*/
|
|
168
168
|
providers?: Record<string, AliasSelectionStrategy>;
|
|
169
|
+
/**
|
|
170
|
+
* Antigravity session isolation cooldown window (ms).
|
|
171
|
+
* Within this window, the same Antigravity auth alias must not be reused by a different session.
|
|
172
|
+
* Default: 300000 (5 minutes).
|
|
173
|
+
*/
|
|
174
|
+
sessionLeaseCooldownMs?: number;
|
|
175
|
+
/**
|
|
176
|
+
* Antigravity multi-alias session binding policy.
|
|
177
|
+
* - "lease" (default): prefer the session's last used alias, but can rotate to another alias when needed.
|
|
178
|
+
* - "strict": once a session binds to an alias, it will not switch to another alias; on failure it must
|
|
179
|
+
* fall back to other providers/routes rather than trying a different Antigravity alias.
|
|
180
|
+
*/
|
|
181
|
+
antigravitySessionBinding?: 'lease' | 'strict';
|
|
169
182
|
}
|
|
170
183
|
export interface ContextWeightedLoadBalancingConfig {
|
|
171
184
|
/**
|
|
@@ -238,6 +251,16 @@ export interface VirtualRouterClockConfig {
|
|
|
238
251
|
* Daemon tick interval (ms). 0 disables background cleanup tick (still cleans on load).
|
|
239
252
|
*/
|
|
240
253
|
tickMs?: number;
|
|
254
|
+
/**
|
|
255
|
+
* Allow clock hold flow for non-streaming (JSON) requests.
|
|
256
|
+
* Default: true.
|
|
257
|
+
*/
|
|
258
|
+
holdNonStreaming?: boolean;
|
|
259
|
+
/**
|
|
260
|
+
* Maximum time (ms) a request is allowed to hold waiting for due window.
|
|
261
|
+
* Default: 60s.
|
|
262
|
+
*/
|
|
263
|
+
holdMaxMs?: number;
|
|
241
264
|
}
|
|
242
265
|
export interface VirtualRouterConfig {
|
|
243
266
|
routing: RoutingPools;
|
|
@@ -284,6 +307,11 @@ export interface RouterMetadataInput {
|
|
|
284
307
|
providerProtocol?: string;
|
|
285
308
|
stage?: 'inbound' | 'outbound' | 'response';
|
|
286
309
|
routeHint?: string;
|
|
310
|
+
/**
|
|
311
|
+
* Antigravity-Manager alignment: stable sessionId derived from the first user message text.
|
|
312
|
+
* Used for Antigravity alias/session binding and thoughtSignature persistence.
|
|
313
|
+
*/
|
|
314
|
+
antigravitySessionId?: string;
|
|
287
315
|
/**
|
|
288
316
|
* Indicates that current routing decision is for a request which
|
|
289
317
|
* expects server-side tools orchestration (e.g. web_search).
|
|
@@ -481,6 +509,26 @@ export interface ProviderErrorEvent {
|
|
|
481
509
|
timestamp: number;
|
|
482
510
|
details?: Record<string, unknown>;
|
|
483
511
|
}
|
|
512
|
+
export interface ProviderSuccessRuntimeMetadata {
|
|
513
|
+
requestId: string;
|
|
514
|
+
routeName?: string;
|
|
515
|
+
providerKey?: string;
|
|
516
|
+
providerId?: string;
|
|
517
|
+
providerType?: string;
|
|
518
|
+
providerProtocol?: string;
|
|
519
|
+
pipelineId?: string;
|
|
520
|
+
target?: TargetMetadata | Record<string, unknown>;
|
|
521
|
+
}
|
|
522
|
+
export interface ProviderSuccessEvent {
|
|
523
|
+
runtime: ProviderSuccessRuntimeMetadata;
|
|
524
|
+
timestamp: number;
|
|
525
|
+
/**
|
|
526
|
+
* Optional request metadata snapshot (e.g. sessionId / conversationId).
|
|
527
|
+
* This must not contain provider-specific payload semantics.
|
|
528
|
+
*/
|
|
529
|
+
metadata?: Record<string, unknown>;
|
|
530
|
+
details?: Record<string, unknown>;
|
|
531
|
+
}
|
|
484
532
|
export interface FeatureBuilder {
|
|
485
533
|
build(request: StandardizedRequest, metadata: RouterMetadataInput): RoutingFeatures;
|
|
486
534
|
}
|
|
@@ -3,6 +3,8 @@ export declare const CLOCK_CONFIG_DEFAULTS: {
|
|
|
3
3
|
readonly retentionMs: number;
|
|
4
4
|
readonly dueWindowMs: 60000;
|
|
5
5
|
readonly tickMs: 60000;
|
|
6
|
+
readonly holdNonStreaming: true;
|
|
7
|
+
readonly holdMaxMs: 60000;
|
|
6
8
|
};
|
|
7
9
|
export declare function normalizeClockConfig(raw: unknown): ClockConfigSnapshot | null;
|
|
8
10
|
/**
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export const CLOCK_CONFIG_DEFAULTS = {
|
|
2
2
|
retentionMs: 20 * 60_000,
|
|
3
3
|
dueWindowMs: 60_000,
|
|
4
|
-
tickMs: 60_000
|
|
4
|
+
tickMs: 60_000,
|
|
5
|
+
holdNonStreaming: true,
|
|
6
|
+
holdMaxMs: 60_000
|
|
5
7
|
};
|
|
6
8
|
export function normalizeClockConfig(raw) {
|
|
7
9
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
@@ -23,7 +25,13 @@ export function normalizeClockConfig(raw) {
|
|
|
23
25
|
const tickMs = typeof record.tickMs === 'number' && Number.isFinite(record.tickMs) && record.tickMs >= 0
|
|
24
26
|
? Math.floor(record.tickMs)
|
|
25
27
|
: CLOCK_CONFIG_DEFAULTS.tickMs;
|
|
26
|
-
|
|
28
|
+
const holdNonStreaming = record.holdNonStreaming === true ||
|
|
29
|
+
(typeof record.holdNonStreaming === 'string' && record.holdNonStreaming.trim().toLowerCase() === 'true') ||
|
|
30
|
+
(typeof record.holdNonStreaming === 'number' && record.holdNonStreaming === 1);
|
|
31
|
+
const holdMaxMs = typeof record.holdMaxMs === 'number' && Number.isFinite(record.holdMaxMs) && record.holdMaxMs >= 0
|
|
32
|
+
? Math.floor(record.holdMaxMs)
|
|
33
|
+
: CLOCK_CONFIG_DEFAULTS.holdMaxMs;
|
|
34
|
+
return { enabled: true, retentionMs, dueWindowMs, tickMs, holdNonStreaming, holdMaxMs };
|
|
27
35
|
}
|
|
28
36
|
/**
|
|
29
37
|
* Resolve the effective clock config for a request/session.
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { readSessionDirEnv, resolveClockDir } from './paths.js';
|
|
4
4
|
import { cleanExpiredTasks, coerceState, nowMs } from './state.js';
|
|
5
5
|
import { readJsonFile, writeJsonFileAtomic } from './io.js';
|
|
6
|
+
import { startClockNtpSyncIfNeeded } from './ntp.js';
|
|
6
7
|
let daemonStarted = false;
|
|
7
8
|
let daemonTimer;
|
|
8
9
|
let daemonConfig;
|
|
@@ -16,6 +17,8 @@ export async function startClockDaemonIfNeeded(config) {
|
|
|
16
17
|
}
|
|
17
18
|
daemonStarted = true;
|
|
18
19
|
daemonConfig = config;
|
|
20
|
+
// Best-effort NTP sync (do not block daemon startup).
|
|
21
|
+
void startClockNtpSyncIfNeeded(config);
|
|
19
22
|
const tickOnce = async () => {
|
|
20
23
|
const effective = daemonConfig;
|
|
21
24
|
if (!effective)
|