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:
+51
-6
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user