From 98da4d41431eeedab1c1812c96d8d82150b1cc7c Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 16:46:49 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(llm):=20OpenCode=20=E5=91=BC=E5=8F=AB?= =?UTF-8?q?=E5=8A=A0=E9=80=BE=E6=99=82=E3=80=81=E5=A4=B1=E6=95=97=E6=94=B9?= =?UTF-8?q?=E4=B8=9F=E4=BE=8B=E5=A4=96=E4=BB=A5=E5=84=AA=E9=9B=85=E9=99=8D?= =?UTF-8?q?=E7=B4=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencodeAxiosOptions 加 timeout(OPENCODE_TIMEOUT_MS,預設 120s),避免 server/模型停滯時請求無限等待;chat() 失敗改 throw 而非 process.exit(1),讓 Step5 的 Promise.allSettled 與去重/過濾的 try/catch fallback 能各自優雅降級,不再因單一 LLM 失敗砍掉整個流程。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/llm.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/llm.js b/app/llm.js index bd63588..2cae662 100644 --- a/app/llm.js +++ b/app/llm.js @@ -3,6 +3,10 @@ import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js'; import { recordUsage } from './usage.js'; import { line, error } from './log.js'; +// 單次 OpenCode HTTP 請求的逾時(毫秒)。預設 120 秒,可用 OPENCODE_TIMEOUT_MS 覆寫。 +// 沒有逾時時,server / 模型卡住會讓請求無限等待、整個流程停擺。 +const OPENCODE_TIMEOUT_MS = Number(process.env.OPENCODE_TIMEOUT_MS) || 120000; + /** * 將模型識別字串解析為 OpenCode API 所需的 provider 與 model 識別碼。 * @@ -23,12 +27,14 @@ function opencodeModelConfig(model) { * 供本模組所有 OpenCode HTTP 呼叫共用,集中管理連線設定。 * * @param {Record} headers - 要附加於請求的 HTTP 標頭。 - * @returns {{ headers: Record, httpsAgent: import('https').Agent }} axios 請求選項物件。 + * @returns {{ headers: Record, httpsAgent: import('https').Agent, timeout: number }} axios 請求選項物件。 + * @remarks 帶 `timeout`(`OPENCODE_TIMEOUT_MS`,預設 120s),避免 server/模型停滯時請求無限等待。 */ function opencodeAxiosOptions(headers) { return { headers, httpsAgent: getOpenCodeHttpsAgent(), + timeout: OPENCODE_TIMEOUT_MS, }; } @@ -91,13 +97,14 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) * 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。 * * 從設定取得 provider/baseURL/model;未設定 provider 時拋錯。成功時記錄 - * usage 並回傳內容。OpenCode 呼叫失敗時會記錄錯誤並以 `process.exit(1)` - * 終止整個行程(不會回傳)。 + * usage 並回傳內容。OpenCode 呼叫失敗(含逾時)時記錄錯誤並向外**拋出例外**, + * 交由呼叫端處理——多數呼叫端(去重、誤報過濾、補行號)有 try/catch fallback, + * 角色分析則在 Step5 以 `Promise.allSettled` 略過失敗的單一角色,達到優雅降級。 * * @param {string} systemPrompt - 系統提示詞。 * @param {string} userContent - 使用者輸入內容。 * @returns {Promise} 模型回應的純文字內容。 - * @throws {Error} 當未設定 OpenCode server(缺少 provider)時。 + * @throws {Error} 當未設定 OpenCode server(缺少 provider)時,或 OpenCode 呼叫失敗/逾時時。 */ export async function chat(systemPrompt, userContent) { const { provider, baseURL, model } = getLLMConfig(); @@ -112,10 +119,9 @@ export async function chat(systemPrompt, userContent) { recordUsage(data); return content; } catch (e) { - line(`[LLM] OpenCode 呼叫失敗: ${e.message}`); + error(`[LLM] OpenCode 呼叫失敗: ${e.message}`); + throw e; } - error('[LLM] OpenCode 呼叫失敗,終止流程'); - process.exit(1); } /** -- 2.53.0 From 14ef273f1a0459103f7dd973dbf8f7c6c92d7ea1 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 16:46:49 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(llm):=20=E8=A3=9C=20OpenCode=20?= =?UTF-8?q?=E9=80=BE=E6=99=82=E8=A8=AD=E5=AE=9A=E8=88=87=E5=A4=B1=E6=95=97?= =?UTF-8?q?=E4=B8=9F=E4=BE=8B=E5=A4=96=EF=BC=88=E4=B8=8D=E5=86=8D=20proces?= =?UTF-8?q?s.exit=EF=BC=89=E7=9A=84=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/test/llm.test.js | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/app/test/llm.test.js b/app/test/llm.test.js index b99dd94..169c872 100644 --- a/app/test/llm.test.js +++ b/app/test/llm.test.js @@ -85,14 +85,34 @@ describe('chat - OpenCode', async () => { assert.equal(result, 'hello world'); }); - it('calls process.exit(1) when OpenCode fails', async () => { + it('sets a request timeout on OpenCode calls so a stalled server fails fast', async () => { + process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + const timeouts = []; + mock.method(axios, 'post', async (url, _payload, opts) => { + timeouts.push(opts.timeout); + if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; + return { data: { parts: [{ type: 'text', text: 'ok' }] } }; + }); + + await chat('sys', 'user'); + + assert.equal(timeouts.length, 2); + for (const t of timeouts) { + assert.equal(typeof t, 'number'); + assert.ok(t > 0, 'OpenCode 請求必須帶正數 timeout,避免無限等待'); + } + }); + + it('throws (does not process.exit) when OpenCode fails, so callers can degrade gracefully', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; mock.method(axios, 'post', async () => { throw new Error('fail'); }); const exitMock = mock.method(process, 'exit', () => { throw new Error('exit:1'); }); - await assert.rejects(() => chat('sys', 'user'), /exit:1/); + // 失敗時向外拋出原始錯誤,而非 process.exit(1),讓 Step5 的 Promise.allSettled + // 與去重/過濾的 try/catch fallback 能各自處理,不會整個流程被砍掉。 + await assert.rejects(() => chat('sys', 'user'), /fail/); - assert.equal(exitMock.mock.calls[0].arguments[0], 1); + assert.equal(exitMock.mock.calls.length, 0, 'chat 不應再呼叫 process.exit'); }); }); -- 2.53.0