Skip to content

Commit 9d2b8b0

Browse files
authored
Merge pull request #126 from souloss/feat/proxy-retry
feat(proxy): LLM proxy retry engine (serial/race/stagger) + RetryConfigModal
2 parents 6984933 + e788ded commit 9d2b8b0

20 files changed

Lines changed: 2312 additions & 31 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ coverage
1010
.idea/
1111
.DS_Store
1212
.omc/
13+
.omo/
1314
tmp/
1415

1516
# Sensitive signing-related files

cli.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -488,12 +488,18 @@ async function runCliMode(extraClaudeArgs = [], cwd, noOpen = false) {
488488
// Win 上 `start` 是 cmd.exe 内置不是 .exe,必须 shell:true;用 spawn + 数组让 Node 自己 escape。
489489
// 第二个 arg '""' 是 `start` 的 window-title 占位(否则 start 会把 URL 当 title)。
490490
const { spawn } = await import('node:child_process');
491+
// Headless/container envs may lack xdg-open/open/start → spawn asynchronously emits an 'error' event,
492+
// which try/catch cannot catch (it only catches synchronous throws); without a handler the process
493+
// would crash. Swallow it here as a best-effort fallback.
494+
let child;
491495
if (process.platform === 'win32') {
492-
spawn('cmd.exe', ['/c', 'start', '""', url], { stdio: 'ignore', detached: true, windowsHide: true }).unref();
496+
child = spawn('cmd.exe', ['/c', 'start', '""', url], { stdio: 'ignore', detached: true, windowsHide: true });
493497
} else {
494498
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
495-
spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref();
499+
child = spawn(cmd, [url], { stdio: 'ignore', detached: true });
496500
}
501+
child.on('error', () => {});
502+
child.unref();
497503
} catch {}
498504
}
499505

@@ -671,12 +677,18 @@ async function runSdkMode(extraClaudeArgs = [], cwd, noOpen = false) {
671677
// Win 上 `start` 是 cmd.exe 内置不是 .exe,必须 shell:true;用 spawn + 数组让 Node 自己 escape。
672678
// 第二个 arg '""' 是 `start` 的 window-title 占位(否则 start 会把 URL 当 title)。
673679
const { spawn } = await import('node:child_process');
680+
// Headless/container envs may lack xdg-open/open/start → spawn asynchronously emits an 'error' event,
681+
// which try/catch cannot catch (it only catches synchronous throws); without a handler the process
682+
// would crash. Swallow it here as a best-effort fallback.
683+
let child;
674684
if (process.platform === 'win32') {
675-
spawn('cmd.exe', ['/c', 'start', '""', url], { stdio: 'ignore', detached: true, windowsHide: true }).unref();
685+
child = spawn('cmd.exe', ['/c', 'start', '""', url], { stdio: 'ignore', detached: true, windowsHide: true });
676686
} else {
677687
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
678-
spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref();
688+
child = spawn(cmd, [url], { stdio: 'ignore', detached: true });
679689
}
690+
child.on('error', () => {});
691+
child.unref();
680692
} catch {}
681693
}
682694

history.md

Lines changed: 19 additions & 0 deletions
Large diffs are not rendered by default.

server/i18n.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2974,7 +2974,12 @@ const i18nData = {
29742974
"th": "บอท IM '{id}' กำลังทำงานอยู่แล้ว (pid {pid}); ปฏิเสธการเริ่มอินสแตนซ์ที่สอง",
29752975
"tr": "IM botu '{id}' zaten çalışıyor (pid {pid}); ikinci bir örnek başlatılması reddedildi.",
29762976
"uk": "IM-бот '{id}' вже запущено (pid {pid}); запуск другого екземпляра відхилено."
2977-
}
2977+
},
2978+
"server.proxyStats.starting": { "zh": "代理重试统计已启动", "en": "Proxy retry stats started", "zh-TW": "代理重試統計已啟動", "ko": "프록시 재시도 통계가 시작됨", "ja": "プロキシリトライ統計を開始しました", "de": "Proxy-Wiederholungsstatistiken gestartet", "es": "Estadísticas de reintentos de proxy iniciadas", "fr": "Statistiques de relances du proxy démarrées", "it": "Statistiche retry del proxy avviate", "da": "Proxy-genforsøgsstatistik startet", "pl": "Statystyki ponownych prób proxy uruchomione", "ru": "Статистика повторов прокси запущена", "ar": "بدأت إحصائيات إعادة محاولات الوكيل", "no": "Proxy-retry-statistikk startet", "pt-BR": "Estatísticas de retry do proxy iniciadas", "th": "เริ่มสถิติการลองใหม่ของพร็อกซีแล้ว", "tr": "Proxy yeniden deneme istatistikleri başladı", "uk": "Статистику повторів проксі запущено" },
2979+
"server.proxyStats.modeLabel": { "zh": "重试模式", "en": "Retry mode", "zh-TW": "重試模式", "ko": "재시도 모드", "ja": "リトライモード", "de": "Wiederholungsmodus", "es": "Modo de reintento", "fr": "Mode de relance", "it": "Modalità retry", "da": "Genforsøgstilstand", "pl": "Tryb ponownych prób", "ru": "Режим повторов", "ar": "وضع إعادة المحاولة", "no": "Retry-modus", "pt-BR": "Modo de retry", "th": "โหมดการลองใหม่", "tr": "Yeniden deneme modu", "uk": "Режим повторів" },
2980+
"server.proxyStats.disabled": { "zh": "代理重试已禁用", "en": "Proxy retry disabled", "zh-TW": "代理重試已停用", "ko": "프록시 재시도 비활성화됨", "ja": "プロキシリトライは無効です", "de": "Proxy-Wiederholung deaktiviert", "es": "Reintento de proxy deshabilitado", "fr": "Relance du proxy désactivée", "it": "Retry del proxy disattivato", "da": "Proxy-genforsøg deaktiveret", "pl": "Ponowne próby proxy wyłączone", "ru": "Повторы прокси отключены", "ar": "تم تعطيل إعادة محاولات الوكيل", "no": "Proxy-retry deaktivert", "pt-BR": "Retry do proxy desativado", "th": "ปิดการลองใหม่ของพร็อกซีแล้ว", "tr": "Proxy yeniden deneme devre dışı", "uk": "Повтори проксі вимкнено" },
2981+
"server.proxyStats.configLoaded": { "zh": "代理重试配置已加载", "en": "Proxy retry config loaded", "zh-TW": "代理重試設定已載入", "ko": "프록시 재시도 설정이 로드됨", "ja": "プロキシリトライ設定を読み込みました", "de": "Proxy-Wiederholungskonfiguration geladen", "es": "Configuración de reintento de proxy cargada", "fr": "Configuration de relance du proxy chargée", "it": "Configurazione retry del proxy caricata", "da": "Proxy-genforsøgskonfiguration indlæst", "pl": "Konfiguracja ponownych prób proxy załadowana", "ru": "Конфигурация повторов прокси загружена", "ar": "تم تحميل تكوين إعادة محاولات الوكيل", "no": "Proxy-retry-konfigurasjon lastet", "pt-BR": "Configuração de retry do proxy carregada", "th": "โหลดการตั้งค่าการลองใหม่ของพร็อกซีแล้ว", "tr": "Proxy yeniden deneme yapılandırması yüklendi", "uk": "Конфігурацію повторів проксі завантажено" },
2982+
"server.proxyStats.refreshed": { "zh": "代理统计已刷新", "en": "Proxy stats refreshed", "zh-TW": "代理統計已重新整理", "ko": "프록시 통계가 새로고침됨", "ja": "プロキシ統計を更新しました", "de": "Proxy-Statistiken aktualisiert", "es": "Estadísticas de proxy actualizadas", "fr": "Statistiques du proxy actualisées", "it": "Statistiche del proxy aggiornate", "da": "Proxy-statistik opdateret", "pl": "Statystyki proxy odświeżone", "ru": "Статистика прокси обновлена", "ar": "تم تحديث إحصائيات الوكيل", "no": "Proxy-statistikk oppdatert", "pt-BR": "Estatísticas do proxy atualizadas", "th": "รีเฟรชสถิติพร็อกซีแล้ว", "tr": "Proxy istatistikleri yenilendi", "uk": "Статистику проксі оновлено" }
29782983
};
29792984

29802985
// 将 { key: { lang: text } } 转换为 { lang: { key: text } }

server/interceptor.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { V2Writer } from './lib/v2/v2-writer.js';
1919
import { reportSwallowed } from './lib/error-report.js';
2020
import { latestMainSessionDir, sessionHasCompletedMainTurn } from './lib/v2/session-select.js';
2121
import { sanitizePathComponent } from './lib/v2/layout.js';
22+
import { setRetryConfigPath, loadRetryConfig, DEFAULT_RETRY_CONFIG } from './lib/proxy-retry.js';
2223

2324

2425

@@ -81,6 +82,26 @@ export let _cachedHaikuModel = null;
8182
const PROFILE_PATH = join(LOG_DIR, 'profile.json');
8283
let _activeProfile = null; // { id, name, baseURL?, apiKey?, effort?, ANTHROPIC_MODEL?, ANTHROPIC_DEFAULT_OPUS_MODEL?, ANTHROPIC_DEFAULT_SONNET_MODEL?, ANTHROPIC_DEFAULT_HAIKU_MODEL?, activeModel?(legacy) }
8384

85+
// ── 代理重试配置(运行时热切换,对齐 profile 模式)──
86+
// retry-config.json 存全局共享的重试配置(mode/interval/maxRetries/maxConcurrent 等)。
87+
// env(CCV_PROXY_RETRY_*)仍是启动默认/兜底,文件字段覆盖 env(文件优先)。
88+
// watchFile 1.5s 跨 ccv 进程同步;proxy.js 经 namespace import 读 _retryConfigState live binding。
89+
const RETRY_CONFIG_PATH = join(LOG_DIR, 'retry-config.json');
90+
let _retryConfigState = { ...DEFAULT_RETRY_CONFIG }; // 可变,由 _loadRetryConfigState 刷新
91+
92+
// 把配置文件路径注入 proxy-retry.js(其 resolveRetryConfig 在 fileOverride=true 时读此路径)。
93+
// 在模块加载阶段同步注入,确保后续 _loadRetryConfigState() 调用时路径已就绪。
94+
setRetryConfigPath(RETRY_CONFIG_PATH);
95+
96+
/** 重读 retry-config.json + env 合并,刷新 _retryConfigState(live binding 消费方即取到新值)。 */
97+
function _loadRetryConfigState() {
98+
try {
99+
_retryConfigState = loadRetryConfig();
100+
} catch (err) {
101+
if (process.env.CCV_DEBUG) console.error('[ccv retry-config] _loadRetryConfigState failed:', err && err.message);
102+
}
103+
}
104+
84105
// 启动时捕获的原始配置(首次 API 请求时记录,不可变)
85106
let _defaultConfig = null; // { origin, authType, model }
86107

@@ -200,7 +221,7 @@ function _replaceProxyAuthHeaders(headers, apiKey) {
200221
return { headers: newHeaders, matchedAuthKey, matchedXApiKey };
201222
}
202223

203-
export { _activeProfile, _defaultConfig, _loadProxyProfile, PROFILE_PATH, setActiveProfileForWorkspace, getActiveProfileId };
224+
export { _activeProfile, _defaultConfig, _loadProxyProfile, PROFILE_PATH, setActiveProfileForWorkspace, getActiveProfileId, RETRY_CONFIG_PATH, _retryConfigState, _loadRetryConfigState };
204225

205226
// 1.7.0: the v1 single-file write path is retired — logs live in per-session
206227
// v2 dirs owned by V2Writer. Only the project binding (name + dir) remains
@@ -420,6 +441,11 @@ _syncContinuationMode(); // seed from the CLI env at module load (`ccv -c`)
420441
_loadProxyProfile();
421442
try { watchFile(PROFILE_PATH, { interval: 1500 }, _loadProxyProfile); } catch { }
422443

444+
// Retry config: initial load + watchFile cross-process sync (UI writes
445+
// retry-config.json → hot-reloaded within 1.5s, mirroring PROFILE_PATH above).
446+
_loadRetryConfigState();
447+
try { watchFile(RETRY_CONFIG_PATH, { interval: 1500 }, _loadRetryConfigState); } catch { }
448+
423449
// Kept as an awaited boot barrier for callers; nothing asynchronous remains
424450
// since the v1 resume flow retired (v2 sessions key off wire session_ids).
425451
const _initPromise = Promise.resolve();

0 commit comments

Comments
 (0)