oc-retry-guard 插件(严重错误自动重试·事不过三2分钟周期)+ 会话面板 v11 排版(日期降序+sessionID)
- 新增 oc-retry-guard.ts:http.response 钩子读 body 识别 finish reason 类错误, 命中后按 2分钟窗口/最多3次 计数,自动用缓存 body 追加 nudge 重发同端点并替换 response; 超 3 次或超 120s 自动归零,事不过三后 STOP。 - tui.js 会话选择器 v11:从按项目分组改为全局按 time.updated 降序,title 末尾加 [sessionID前8位]。 - sync_wsl.py ITEMS 增加 oc-retry-guard 同步项。
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
// oc-retry-guard —— 模型严重错误自动重试 server 插件(2026-10-19,路线B 自动重发)
|
||||
//
|
||||
// 背景:OC 二进制里流式解析层会抛 "OpenAI Chat received content after the
|
||||
// finish reason" 这类严重错误(错误发生在响应体流解析阶段,HTTP 层是 200,
|
||||
// 因此 http.response 钩子必须自己读 response body 来识别错误特征)。
|
||||
// 用户要求(路线B):模型出这种错时,插件自动替它重发请求(补一条 nudge),
|
||||
// 事不过三 —— 2 分钟为周期,周期内最多重试 3 次,3 次都失败才真正停下;
|
||||
// 超过 2 分钟计数器归零。
|
||||
//
|
||||
// 技术链路(OC 2.0.27 二进制反查实锤):
|
||||
// http.response 钩子 payload = {...D, request, response}
|
||||
// - request: 原始 fetch Request(含 url / headers / method)
|
||||
// - response: Response 对象(可 .clone().text() 读 body 识别错误特征)
|
||||
// 钩子 return 改写后的 { ...D, request, response } 后,OC 用
|
||||
// Sy(we, ot.response) 消费改后的 response。
|
||||
// ⇒ 命中错误时:插件自己用 request 的 url+headers+缓存 body 重新 fetch 一次
|
||||
// (把 nudge 拼进 messages 末尾),再把重发成功的 response 替换掉原 response
|
||||
// 回传给 OC —— 实现「模型出错后自动续上,用户无感知」。
|
||||
//
|
||||
// 计数语义(事不过三 + 2 分钟周期):
|
||||
// retryState: Map<sessionID, { count, firstAt }>
|
||||
// 命中错误时:
|
||||
// now = Date.now()
|
||||
// if (!state || now - state.firstAt > 120000) state = {count:0, firstAt:now}
|
||||
// if (state.count >= 3) → 停下(替换 response 为原错误 response,记 stopped 日志)
|
||||
// else state.count++ → 自动重发,记 retried 日志
|
||||
// 2 分钟后(now - firstAt > 120000)下一轮命中时自动归零重置。
|
||||
//
|
||||
// 契约(与 ai-mcp / compaction-cn 同款 V2 插件铁律,勿破坏):
|
||||
// - named export 必须是逗号表达式安全形态(返回 hooks 对象会崩)
|
||||
// - default 导出 { id, setup },setup 必须返回 void、绝不抛
|
||||
//
|
||||
// 部署(三份同步 + node --experimental-strip-types 校验):
|
||||
// 源码真源 : D:\桌面\源码\oc_session\oc-retry-guard\oc-retry-guard.ts
|
||||
// 本机生效 : C:\Users\ypshidifu\.config\opencode\plugins\oc-retry-guard.ts
|
||||
// WSL 生效 : /root/.config/opencode/plugins/oc-retry-guard.ts
|
||||
|
||||
import { appendFileSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
const LOG_FILE = join(homedir(), ".config", "opencode", "oc-retry-guard.log")
|
||||
|
||||
// 严重错误特征(命中任一即触发重试判定)
|
||||
const ERR_SIGS = [
|
||||
"content after the finish reason",
|
||||
"stream ended without finish_reason",
|
||||
"received content after the finish",
|
||||
"unexpected finish_reason",
|
||||
]
|
||||
// 仅对 LLM 聊天端点生效(避免误伤 /api/session、config 等非模型请求)
|
||||
const CHAT_URL_RE = /\/(chat|completions|messages|generate)\/?|\/v1\//
|
||||
|
||||
// nudge 文案(英文,对任何模型都通用;要求保持原语言、不译代码/路径/URL)
|
||||
const NUDGE =
|
||||
"<retry-nudge>\n" +
|
||||
"Your previous output was cut off by an upstream error " +
|
||||
'("received content after the finish reason"). Please CONTINUE from where you ' +
|
||||
"stopped, or re-output the incomplete part in full. Keep your original reply " +
|
||||
"language. Do not translate code, file paths, commands, URLs or error text.\n" +
|
||||
"</retry-nudge>"
|
||||
|
||||
// 窗口与上限
|
||||
const WINDOW_MS = 120000 // 2 分钟
|
||||
const MAX_RETRY = 3 // 事不过三
|
||||
// 单次重发预算(避免上游卡死拖垮 TUI)
|
||||
const RETRY_FETCH_TIMEOUT_MS = 90000
|
||||
|
||||
// sessionID → 上一条聊天请求的原始 body(供命中错误时重建)
|
||||
const lastReqBody = new Map<string, { body: string; at: number }>()
|
||||
// sessionID → { count, firstAt }
|
||||
const retryState = new Map<string, { count: number; firstAt: number }>()
|
||||
|
||||
function fileLog(msg: string) {
|
||||
try {
|
||||
appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`)
|
||||
} catch {
|
||||
/* 日志失败绝不阻塞主链路 */
|
||||
}
|
||||
}
|
||||
|
||||
function msgText(m: any): string {
|
||||
try {
|
||||
if (!m) return ""
|
||||
if (typeof m.content === "string") return m.content
|
||||
if (Array.isArray(m.content)) {
|
||||
return m.content.map((c: any) => (c && typeof c.text === "string" ? c.text : "")).join("")
|
||||
}
|
||||
return ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// 给请求体末尾追加一条 nudge user 消息(保持原语言、不译代码/路径/URL)
|
||||
function appendNudge(body: string): string {
|
||||
try {
|
||||
const obj = JSON.parse(body)
|
||||
if (!obj || !Array.isArray(obj.messages)) return body
|
||||
obj.messages.push({ role: "user", content: NUDGE })
|
||||
return JSON.stringify(obj)
|
||||
} catch {
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
// 命中错误时自动重发:用同一 url+headers 重发(body 追加 nudge),
|
||||
// 超时/异常返回 null(回退到原 response)
|
||||
async function retryFetch(url: string, headers: any, body: string, timeoutMs: number): Promise<Response | null> {
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(headers && headers instanceof Headers
|
||||
? Object.fromEntries(Array.from(headers.entries()))
|
||||
: headers && typeof headers === "object"
|
||||
? headers
|
||||
: {}),
|
||||
},
|
||||
body,
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
clearTimeout(timer)
|
||||
return res
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const OcRetryGuard: any = async (_ctx: any) => (await ({} as any), undefined as any)
|
||||
|
||||
export default {
|
||||
id: "oc-retry-guard",
|
||||
async setup(ctx: any): Promise<void> {
|
||||
try {
|
||||
const session = ctx && ctx.session
|
||||
if (!session || typeof session.hook !== "function") {
|
||||
fileLog("session.hook 不可用,未注册")
|
||||
return
|
||||
}
|
||||
|
||||
// ① http.request:缓存每个 session 的上一条聊天请求体(命中错误时重建用)
|
||||
await session.hook("http.request", (f: any) => {
|
||||
try {
|
||||
if (!f || !f.request || !f.request.url) return
|
||||
const url = String(f.request.url)
|
||||
if (!CHAT_URL_RE.test(url)) return
|
||||
const ct = String((f.request.headers && f.request.headers.get && f.request.headers.get("content-type")) || "")
|
||||
if (ct && ct.indexOf("json") < 0) return
|
||||
const sid = String(f.sessionID || "?")
|
||||
// 仅缓存成功发出的请求 body(供后续命中错误时重发)
|
||||
// request 钩子在请求出网前触发,body 还在 Request 里;不消费流,
|
||||
// 改为在 response 钩子命中时用 f.request 缓存的 body 字段。
|
||||
// 这里只做占位:OC 在 request 钩子后可改写 f.request,
|
||||
// 真正的 body 缓存放在 response 钩子(那里 request 已被 OC 定型)。
|
||||
void sid
|
||||
return
|
||||
} catch {
|
||||
/* 吞掉,绝不阻塞 */
|
||||
}
|
||||
})
|
||||
|
||||
// ② http.response:识别严重错误 → 按「事不过三 + 2 分钟周期」决定自动重发
|
||||
await session.hook("http.response", async (f: any) => {
|
||||
try {
|
||||
if (!f || !f.request || !f.response) return
|
||||
const url = String(f.request.url || "")
|
||||
if (!CHAT_URL_RE.test(url)) return
|
||||
const resp: any = f.response
|
||||
if (!resp || typeof resp.clone !== "function") return
|
||||
|
||||
// 缓存原始 body(供本 session 后续命中错误时重建;用 request 的 body)
|
||||
const sid = String(f.sessionID || "?")
|
||||
let origBody = ""
|
||||
try {
|
||||
origBody = f.request && f.request.clone ? await f.request.clone().text() : ""
|
||||
} catch {
|
||||
origBody = ""
|
||||
}
|
||||
if (origBody && origBody.length > 0) {
|
||||
lastReqBody.set(sid, { body: origBody, at: Date.now() })
|
||||
}
|
||||
|
||||
// 读响应体识别错误特征(HTTP 层是 200,错误藏在 body 文本里)
|
||||
let bodyText = ""
|
||||
try {
|
||||
bodyText = await resp.clone().text()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const isErrSig = ERR_SIGS.some((s) => bodyText.indexOf(s) >= 0)
|
||||
const isHttpErr = (resp.status || 0) >= 500
|
||||
if (!isErrSig && !isHttpErr) return // 成功响应,原样放行
|
||||
|
||||
// 事不过三 + 2 分钟周期计数
|
||||
const now = Date.now()
|
||||
let st = retryState.get(sid)
|
||||
if (!st || now - st.firstAt > WINDOW_MS) {
|
||||
st = { count: 0, firstAt: now }
|
||||
}
|
||||
if (st.count >= MAX_RETRY) {
|
||||
fileLog(
|
||||
`STOP session=${sid}(2 分钟内已重试 ${st.count} 次,模型确实出问题,本轮不再补救)status=${resp.status} sig=${isErrSig}`,
|
||||
)
|
||||
retryState.set(sid, st)
|
||||
return // 回退到原 response(让 OC 显示原始错误)
|
||||
}
|
||||
st.count += 1
|
||||
retryState.set(sid, st)
|
||||
|
||||
// 自动重发:用缓存 body + nudge 重新 POST 同一端点
|
||||
fileLog(
|
||||
`RETRY session=${sid} 第${st.count}/${MAX_RETRY}次 status=${resp.status} sig=${isErrSig}(自动重发中…)`,
|
||||
)
|
||||
const cached = lastReqBody.get(sid)
|
||||
const newBody = cached ? appendNudge(cached.body) : origBody ? appendNudge(origBody) : ""
|
||||
if (!newBody) {
|
||||
fileLog(`RETRY session=${sid} 无可用 body,跳过重发`)
|
||||
return
|
||||
}
|
||||
const hdrs = f.request.headers
|
||||
const newResp = await retryFetch(url, hdrs, newBody, RETRY_FETCH_TIMEOUT_MS)
|
||||
if (!newResp) {
|
||||
fileLog(`RETRY session=${sid} 重发失败(超时/网络),回退原 response`)
|
||||
return
|
||||
}
|
||||
// 重发成功:把新 response 替换掉原 response 回传给 OC(OC 消费 ot.response)
|
||||
f.response = newResp
|
||||
fileLog(
|
||||
`RETRY session=${sid} 第${st.count}次重发成功 status=${newResp.status}(已替换 response,模型自动续上)`,
|
||||
)
|
||||
} catch (e: any) {
|
||||
fileLog("http.response 钩子异常(已吞掉): " + (e && e.message ? e.message : String(e)))
|
||||
}
|
||||
})
|
||||
|
||||
fileLog("oc-retry-guard 已注册(http.request 缓存 + http.response 自动重发,事不过三 2分钟周期)")
|
||||
} catch (e: any) {
|
||||
fileLog("setup 异常(已吞掉,不影响其它插件): " + (e && e.message ? e.message : String(e)))
|
||||
}
|
||||
// setup 必须返回 void
|
||||
return
|
||||
},
|
||||
}
|
||||
@@ -30,6 +30,8 @@ ITEMS = [
|
||||
"/root/.config/opencode/plugins/oc-session-all/tui.js"),
|
||||
("mcpcfg", os.path.join(WIN, "mcpcfg.txt"),
|
||||
"/root/.config/opencode/mcpcfg.txt"),
|
||||
("oc-retry-guard", os.path.join(WIN, "plugins", "oc-retry-guard.ts"),
|
||||
"/root/.config/opencode/plugins/oc-retry-guard.ts"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -732,27 +732,26 @@ export default Plugin.define({
|
||||
return
|
||||
}
|
||||
|
||||
const grouped = new Map()
|
||||
for (const session of visible) {
|
||||
const name = projectDisplayName(session, projectMap)
|
||||
const list = grouped.get(name) ?? []
|
||||
list.push(session)
|
||||
grouped.set(name, list)
|
||||
// v11 排版(2026-10-19 用户明令):
|
||||
// ① 全部会话按日期从近到远排序(不再按项目分组 + 按项目字母序)
|
||||
// ② title 里插入 session ID(短前缀 8 字符,避免整行被 ID 挤爆)
|
||||
const visibleSorted = visible.sort(
|
||||
(a, b) => ((b.time?.updated ?? 0) - (a.time?.updated ?? 0)),
|
||||
)
|
||||
const shortID = (id) => {
|
||||
const s = String(id || "")
|
||||
return s.length > 12 ? s.slice(0, 8) + "…" : s
|
||||
}
|
||||
for (const list of grouped.values()) {
|
||||
list.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
|
||||
}
|
||||
const orderedProjects = [...grouped.keys()].sort((a, b) => a.localeCompare(b))
|
||||
|
||||
const options = orderedProjects.flatMap((project) =>
|
||||
grouped.get(project).map((session) => ({
|
||||
title: `${project} ${
|
||||
session.time?.updated ? formatDateTime(session.time.updated) : "未知时间"
|
||||
} │ ${session.title || "无标题"}`,
|
||||
const options = visibleSorted.map((session) => {
|
||||
const project = projectDisplayName(session, projectMap)
|
||||
const ts = session.time?.updated ? formatDateTime(session.time.updated) : "未知时间"
|
||||
const idTag = shortID(session.id)
|
||||
return {
|
||||
title: `${project} ${ts} │ ${session.title || "无标题"} [${idTag}]`,
|
||||
value: session.id,
|
||||
category: project,
|
||||
})),
|
||||
)
|
||||
}
|
||||
})
|
||||
log("[oc-session-all] picker options built: " + options.length)
|
||||
|
||||
const selected = await context.ui.dialog.select({
|
||||
|
||||
Reference in New Issue
Block a user