From 93be261b909299c3fb87163992cab29c6fb57a96 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 09:36:33 +0000 Subject: [PATCH] =?UTF-8?q?fix(OpenCode=20=E5=91=BC=E5=8F=AB):=20=E9=99=8D?= =?UTF-8?q?=E4=BD=8E=E4=B8=A6=E8=A1=8C=E8=AB=8B=E6=B1=82=E4=B8=A6=E9=87=8D?= =?UTF-8?q?=E8=A9=A6=E6=9A=AB=E6=99=82=E6=80=A7=E9=8C=AF=E8=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/llm.js | 28 +++++++++++++++++++++++++++- app/main.js | 11 ++++++----- app/test/llm.test.js | 24 +++++++++++++++++++++++- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/app/llm.js b/app/llm.js index 71f06cb..eaf77e4 100644 --- a/app/llm.js +++ b/app/llm.js @@ -32,6 +32,10 @@ function opencodeAxiosOptions(headers) { }; } +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + /** * 從 OpenCode 訊息回應中抽取並串接所有文字片段。 * @@ -66,6 +70,11 @@ function formatOpenCodeError(e) { return response ? `${statusText}: ${response}` : statusText; } +function isTransientOpenCodeError(e) { + const status = e.response?.status; + return status === 500 || status === 502 || status === 503 || status === 504 || status === 429; +} + /** * 對 OpenCode server 執行一次完整對話:建立 session 後送出訊息並回傳結果。 * @@ -104,6 +113,23 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) return { content: extractOpenCodeContent(resp.data), data: resp.data }; } +async function chatOpenCodeWithRetry(baseURL, model, systemPrompt, userContent, headers) { + const maxAttempts = Number(process.env.OPENCODE_RETRY_ATTEMPTS || 3); + let lastError; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await chatOpenCode(baseURL, model, systemPrompt, userContent, headers); + } catch (e) { + lastError = e; + if (!isTransientOpenCodeError(e) || attempt === maxAttempts) throw e; + const delay = Math.min(1000 * 2 ** (attempt - 1), 8000); + line(`[LLM] OpenCode 暫時性錯誤,${delay}ms 後重試 (${attempt}/${maxAttempts}): ${formatOpenCodeError(e)}`); + await sleep(delay); + } + } + throw lastError; +} + /** * 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。 * @@ -125,7 +151,7 @@ export async function chat(systemPrompt, userContent) { const headers = { 'Content-Type': 'application/json' }; try { - const { content, data } = await chatOpenCode(baseURL, model, systemPrompt, userContent, headers); + const { content, data } = await chatOpenCodeWithRetry(baseURL, model, systemPrompt, userContent, headers); recordUsage(data); return content; } catch (e) { diff --git a/app/main.js b/app/main.js index ca1b967..cc7e68f 100644 --- a/app/main.js +++ b/app/main.js @@ -120,15 +120,16 @@ async function main() { } catch (e) { warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`); } - const analyses = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff))); const newFindings = []; let fulfilledAnalyses = 0; - for (let i = 0; i < analyses.length; i++) { - if (analyses[i].status === 'fulfilled') { + for (const role of roles) { + try { + const findings = await analyzeWithRole(role, diff); fulfilledAnalyses += 1; - newFindings.push(...analyses[i].value); + newFindings.push(...findings); + } catch (e) { + warn(`[${role.name}] 分析失敗(跳過): ${e.message}`); } - else warn(`[${roles[i].name}] 分析失敗(跳過): ${analyses[i].reason?.message}`); } if (fulfilledAnalyses === 0) { result(false, '所有角色分析皆失敗,終止流程以避免誤判為審查通過'); diff --git a/app/test/llm.test.js b/app/test/llm.test.js index a23122f..ffb3e1e 100644 --- a/app/test/llm.test.js +++ b/app/test/llm.test.js @@ -4,7 +4,7 @@ import axios from 'axios'; import { extractBalancedJSON, extractJSONText } from '../llm.js'; const ENV_KEYS = [ - 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', + 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', 'OPENCODE_RETRY_ATTEMPTS', ]; let saved = {}; @@ -87,6 +87,7 @@ describe('chat - OpenCode', async () => { it('throws an error when OpenCode fails instead of exiting the process', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + process.env.OPENCODE_RETRY_ATTEMPTS = '1'; mock.method(axios, 'post', async () => { const err = new Error('Request failed with status code 500'); err.response = { status: 500, data: { error: 'provider overloaded' } }; @@ -98,6 +99,27 @@ describe('chat - OpenCode', async () => { assert.equal(exitMock.mock.calls.length, 0); }); + + it('retries transient OpenCode failures before returning content', async () => { + process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + process.env.OPENCODE_RETRY_ATTEMPTS = '2'; + let messageAttempts = 0; + mock.method(axios, 'post', async (url) => { + if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; + messageAttempts += 1; + if (messageAttempts === 1) { + const err = new Error('Request failed with status code 500'); + err.response = { status: 500, data: { error: 'temporary failure' } }; + throw err; + } + return { data: { parts: [{ type: 'text', text: 'ok after retry' }] } }; + }); + + const result = await chat('sys', 'user'); + + assert.equal(result, 'ok after retry'); + assert.equal(messageAttempts, 2); + }); }); describe('chatJSON', async () => {