feat(host): 镜像落盘缓存 —— 重启后镜像和累计指标都还在('没有内容映射'的根治)

问题:DSH 重启后内存里的镜像/计数清空,而活动会话的 .jsonl.zstd 还没落盘(宿主是批量写),
所以镜像一直空着。
做法:插件每 3 秒把 最近 40 行消息 + 标题 + 累计计数(turns/steps/out/in/cacheRead/decodeMs/lastTps)
写进 %DSH_HOME%/float-window/mirror-cache.json,apply 时恢复;dispose 时再存一次。
实测:建会话发两条消息 → 重启实例 → rows=2、turns=2、steps=2,消息原文都在。
另修:快照在'宿主活动会话列表里还没有该会话'时也照算指标(否则恢复出来的数字不显示);
清理 tick 计数器的双自增 bug(导致缓存保存节奏错乱)。
This commit is contained in:
2026-09-27 18:22:23 +08:00
parent e00d73be82
commit 4e666033d2
+51 -6
View File
@@ -232,6 +232,33 @@ export function apply(ctx, config) {
const counts = new Map() // sessionId -> 事件计数兜底(没有 sessionProjection 也能出数字)
const titles = new Map() // sessionId -> 会话标题(来自 session/title 事件)
const contextWindows = new Map() // sessionId -> 上下文窗口(来自 request/context 事件)
// 镜像落盘缓存:DSH 重启会把内存里的镜像/计数清空(会话日志又是批量落盘的、读不到活动会话),
// 所以自己持久化一份,重启后恢复,用户就不会看到"重启后镜像空空的"。
const mirrorCacheFile = path.join(dshHome(), 'float-window', 'mirror-cache.json')
const loadMirrorCache = () => {
try {
const j = JSON.parse(fs.readFileSync(mirrorCacheFile, 'utf8'))
for (const [id, v] of Object.entries(j?.sessions || {})) {
if (Array.isArray(v?.rows) && v.rows.length) rows.set(id, v.rows.slice(-MAX_ROWS))
if (v?.title) titles.set(id, String(v.title))
if (v?.counts) counts.set(id, { turns: 0, steps: 0, outTokens: 0, inTokens: 0, cacheRead: 0, decodeMs: 0, stepStart: 0, lastTps: null, ...v.counts })
}
if (j?.lastSessionId) lastSessionId = j.lastSessionId
log(`mirror cache restored: ${Object.keys(j?.sessions || {}).length} session(s)`)
} catch { }
}
const saveMirrorCache = () => {
try {
const out = { at: Date.now(), lastSessionId, sessions: {} }
const ids = [...rows.keys()].slice(-8)
for (const id of ids) {
out.sessions[id] = { title: titles.get(id) || null, rows: (rows.get(id) || []).slice(-40), counts: counts.get(id) || null }
}
fs.mkdirSync(path.dirname(mirrorCacheFile), { recursive: true })
fs.writeFileSync(mirrorCacheFile, JSON.stringify(out))
return ids.length
} catch (e) { log('mirror cache save failed: ' + String(e?.message || e)); return -1 }
}
let lastSessionId = null
let pinnedSessionId = null // 「固定」模式:用户在浮窗里选中的会话,不再被任何事件抢走
let frontSessionId = null // 「跟随前台」模式:客户端半上报的当前显示会话(uiSession.current)
@@ -248,8 +275,8 @@ export function apply(ctx, config) {
ctx.on('session/event', (session, event) => {
try {
const id = session?.id || session?.sessionId || 'unknown'
// 「跟随前台」模式下,事件不再改变当前会话(前台由客户端半上报),但仍记录"最新事件"兜底
if (!pinnedSessionId || followMode === 'latest') lastSessionId = id
// 「跟随前台」时前台由客户端半上报,但"最新事件"仍要记着(latest 模式 / front 的兜底)
if (followMode !== 'pinned') lastSessionId = id
const c = counter(id)
const type = String(event?.type || '')
const d2 = () => event?.data || event || {}
@@ -402,7 +429,8 @@ export function apply(ctx, config) {
follow: followMode,
title: (id && titles.get(id)) || session?.meta?.title || session?.title || null,
messages: (id && rows.get(id)) || [],
metrics: session ? metricsFor(session) : { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null },
// 即使当前会话还没出现在宿主的活动列表里(刚重启/镜像缓存恢复),也用恢复的累计数算指标
metrics: id ? metricsFor(session || { id }) : { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null },
settings: floatCfg,
// 会话列表:浮窗的切换器用它(多会话/多任务并行时挑一个跟随)
sessions: sessions.slice(-20).map((s) => {
@@ -421,7 +449,7 @@ export function apply(ctx, config) {
}
// ---------------------------------------------------------------- 路由
let tickCount = 0
let tickCount = 0 // 兼容旧引用(写状态用 tick 计数)
const sseClients = new Set()
const push = () => {
if (!sseClients.size) return
@@ -445,12 +473,16 @@ export function apply(ctx, config) {
} catch { }
}
const tick = setInterval(() => {
let tick = 0
const tickTimer = setInterval(() => {
push()
writeSnapshotFile()
if (++tickCount % 10 === 0) writeStatus()
tick++
if (tick % 3 === 0) { const n = saveMirrorCache(); if (tick % 30 === 0) log(`mirror cache saved (${n} session)`) }
if (tick % 10 === 0) writeStatus()
}, 1000)
ctx.effect(() => () => clearInterval(tick))
loadMirrorCache() // 恢复上次的镜像与累计指标(必须在 tick 起来之前/之后都行,只要在服务前)
// 浮窗 → 宿主:轮询 outbox.json
let lastOutboxText = ''
@@ -502,6 +534,7 @@ export function apply(ctx, config) {
}, 700)
ctx.effect(() => () => {
clearInterval(outboxTimer)
saveMirrorCache()
try { fs.rmSync(snapshotFile, { force: true }) } catch { }
try { fs.rmSync(outboxFile, { force: true }) } catch { }
})
@@ -855,6 +888,18 @@ border:1px solid rgba(120,140,180,.35);background:rgba(22,26,34,.92);color:#e6ea
// 同样必须**同步**注册:async 续体里的 ctx 已经失效(inactive context)。
log('services: ' + ['settings', 'sessions', 'tools', 'tokenMeter', 'sessionProjection', 'llm', 'shell', 'sandboxPolicy', 'webServer']
.map((n) => `${n}=${(() => { try { return ctx.get(n) ? 'y' : 'n' } catch { return '?' } })()}`).join(' '))
// 诊断:把 sessionController / apiProxy 暴露的方法名列出来(回填历史要用其中一个)
try {
const faces = {}
for (const n of ['sessionController', 'apiProxy', 'sessions', 'sessionProjection']) {
try {
const f = ctx.get(n)
if (f) faces[n] = Object.keys(f).slice(0, 80)
} catch { }
}
status.apiMethods = faces
log('api methods: ' + JSON.stringify(faces))
} catch { }
try {
const settings = ctx.get('settings')
if (!settings?.register) {