model-manager:队列排空写 flush 标记,热重载交 TUI 侧接力,补 setup ctx 形态诊断

This commit is contained in:
youpaishidifu
2026-10-19 16:59:21 +08:00
parent ab619d1ee9
commit 907b6c06a0
+55 -8
View File
@@ -16,7 +16,11 @@
// 因此采用「外部子进程执行 DB 写」方案:
// 1. OC 内部 daemon 每 5s 轮询 outbox 队列文件(普通 JSON 文件,不碰 DB)
// 2. 命中操作 → 把 SQLite 写 fork 给外部 Python 子进程执行(独立连接,安全)
// 3. 子进程写完 → OC 调 POST /api/location/reload 热重载(/models 立即生效)
// 3. 队列排空 → 写 flush 标记 → 由 TUI 侧 tui.js 执行 location.reload 热重载
// (2026-10-19 实测定论:V2 server 插件 ctx 没有 client.location / serverUrl,
// 进程内 reload 无通道 —— ai-mcp 2026-09-24 也报过同款「没有可用的 reload 通道」;
// 而 TUI 插件 ctx 的 client.location 实测可用,见 oc-session-all.log 的
// 「location.reload OK」。所以热重载的正主在 tui.js,本插件只负责写 DB + 发标记)
//
// ── 操作语义 ──────────────────────────────────────────────────────────────
// disable(provider, modelID) = 从 catalog body 里 DELETE provider.models[modelID]
@@ -50,6 +54,9 @@ const LOG_FILE = join(homedir(), ".config", "opencode", "model-manager.log")
const OUTBOX = join(homedir(), ".config", "opencode", "model-manager.outbox.jsonl")
// 出厂备份(reset 用:首次操作前自动把 pristine catalog 存一份)
const PRISTINE = join(homedir(), ".config", "opencode", "model-manager.pristine.json")
// flush 标记:daemon 把队列排空并写完 DB 后落盘 { at, lastOp, serverReload };
// tui.js 轮询到 at >= 自己入队时刻 就执行 location.reload(TUI 侧才有 reload 通道)
const FLUSH = join(homedir(), ".config", "opencode", "model-manager.last-flush.json")
// 数据库路径
const DB_PATH = join(homedir(), ".local", "share", "opencode", "opencode.db")
// SQLite 写执行器(Python,OC 外部子进程跑,绕 OC 自身 DB 写锁)
@@ -119,10 +126,10 @@ async function execDbWrite(op: string, payload: any): Promise<string> {
})
}
// 热重载 OC(让 /models 立即反映 catalog 变更)
// 通道整段抄自 ai-mcp 的 doReload(MCP 对调已验证此链路可用):
// 热重载(进程内尽力通道,2026-10-19 实测:V2 server 插件 ctx 两条通道都没有,
// 保留尝试只为将来宿主补上 client/serverUrl 时能自动生效):
// 进程内 SDK client.location.reload 三种调用形态 → HTTP POST ctx.serverUrl /api/location/reload
// 关键差异:用 ctx.serverUrl(OC 自己告知插件的服务地址),绝不写死端口
// 正常路径见 processOutbox 尾部:写 flush 标记 → tui.js 用 TUI ctx 的 client.location 热重载
// V2 上下文可能有 client 属性:优先 ctx.client(若宿主提供),否则回退 ctx 本身(抄 ai-mcp pickClient)
function pickClient(ctx: any) {
if (!ctx) return ctx
@@ -159,11 +166,21 @@ async function doReload(ctx: any): Promise<boolean> {
lastErr = e
}
}
const msg = lastErr ? lastErr.message : "没有可用的 reload 通道(ctx 无 location/serverUrl)"
fileLog("hot reload 失败(需重启 OC 生效): " + msg)
const msg = lastErr ? lastErr.message : "进程内无 reload 通道(V2 server ctx 无 client.location/serverUrl,已实测)"
fileLog("进程内 hot reload 未成功: " + msg + " → 已写 flush 标记,交 TUI 侧 tui.js 热重载(看 oc-session-all.log)")
return false
}
// 队列深度(只数行,不改文件)
function queueDepth(): number {
try {
if (!existsSync(OUTBOX)) return 0
return readFileSync(OUTBOX, "utf-8").split("\n").filter((l) => l.trim()).length
} catch {
return -1 // 读失败时按「未知」处理,宁可多等一轮
}
}
// 从 outbox 消费一条操作并执行
async function processOutbox(ctx: any): Promise<void> {
if (!existsSync(OUTBOX)) return
@@ -199,8 +216,23 @@ async function processOutbox(ctx: any): Promise<void> {
}
const res = await execDbWrite(op, payload)
fileLog(`DB 写结果 op=${op}: ${res}`)
await doReload(ctx)
const depth = queueDepth()
fileLog(`DB 写结果 op=${op}: ${res},队列剩余=${depth}`)
if (depth !== 0) {
// 还有排队的操作(整场停用会连着 9 条):先不 reload,等全部写完一次性热重载
fileLog("队列未排空,暂不 reload(等最后一条写完再一次性热重载)")
return
}
// 队列已排空:先试进程内通道(V2 server ctx 实测无通道,失败会写日志),
// 再落 flush 标记 —— TUI 侧 tui.js 轮询到标记后用它自己的 client.location 热重载
const serverReload = await doReload(ctx)
try {
writeFileSync(FLUSH, JSON.stringify({ at: Date.now(), lastOp: op, serverReload: !!serverReload }))
fileLog("flush 标记已写(TUI 侧将据此触发热重载): serverReload=" + !!serverReload)
} catch (e: any) {
fileLog("flush 标记写入失败: " + (e && e.message ? e.message : String(e)))
}
}
// SQLite 写执行器(Python,子进程独立连接)
@@ -317,6 +349,21 @@ export default {
async setup(ctx: any): Promise<void> {
try {
fileLog("model-manager setup 启动")
// ctx 形态诊断(一次性):回答「为什么进程内 reload 无通道」——
// 2026-10-19 实测 keys 里没有 client/serverUrl/location,热重载只能靠 TUI 侧
try {
const c: any = ctx
fileLog(
"setup ctx 诊断: keys=" +
JSON.stringify(Object.keys(c || {})) +
" | client=" + typeof (c && c.client) +
" | location=" + typeof (c && c.location) +
" | serverUrl=" + typeof (c && c.serverUrl) +
" | app=" + typeof (c && c.app),
)
} catch {
/* 诊断失败不影响功能 */
}
ensureDbExec()
// 后台 daemon:每 5s 轮询 outbox(普通文件读写,不碰 DB 锁)