dsh-sessions-manager 3.6.1 → 3.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -18,7 +18,7 @@ import { createPendingMoveStore } from './pending-moves.js'
18
18
  import { sweepStaleStateTemps } from './state-temp-sweep.js'
19
19
  import { aggregateStorage } from './storage-stats.js'
20
20
  import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
21
- import { createSessionMetaCache, persistFingerprintOf } from './session-meta-cache.js'
21
+ import { createSessionMetaCache, fingerprintOf, persistFingerprintOf } from './session-meta-cache.js'
22
22
  import { createTitleIndexStore } from './title-persist-index.js'
23
23
  import { createPersistenceAdapter } from './compat/persistence.js'
24
24
  import { detectCapabilities, requireCapability } from './compat/capabilities.js'
@@ -135,20 +135,8 @@ function getActiveSessionId(context) {
135
135
  return null
136
136
  }
137
137
 
138
- function foldTitle(events) {
139
- let found = null
140
- let firstUser = null
141
- for (const ev of events) {
142
- if (ev.type === 'session/title' && ev.data && typeof ev.data.title === 'string' && ev.data.title.length) {
143
- found = ev.data.title
144
- }
145
- if (firstUser === null && ev.type === 'user/message' && ev.data && Array.isArray(ev.data.content)) {
146
- const txt = ev.data.content.filter((b) => b && b.type === 'text').map((b) => b.text).filter(Boolean).join(' ').trim()
147
- if (txt) firstUser = txt
148
- }
149
- }
150
- return found || firstUser || null
151
- }
138
+ // foldTitle(v3.6.1 删除路径的兜底折叠)随 v3.6.2 #7 的「删除路径零解码」一并
139
+ // 移除;标题折叠逻辑的权威实现现在是 runtime 投影(src/index.js projectTitlesStatus)。
152
140
 
153
141
  export function apply(ctx) {
154
142
  const w = ctx.workspaceRegistry
@@ -184,7 +172,10 @@ export function apply(ctx) {
184
172
  // 从不落盘,导致每次重启都全量重新解码)。
185
173
  const fp = persistFingerprintOf(stat)
186
174
  if (!fp) continue
187
- if (fp && entry.fingerprint === fp) {
175
+ // v3.6.2 #1:title 为 null 的条目必须是「投影确实成功过」(resolved)才可信。
176
+ // v3.6.1 把投影失败当结果落盘的污染条目(null 标题、无标记)在这里被丢弃,
177
+ // 调用方按未命中处理 → 重新入队预热,索引自愈。
178
+ if (entry.fingerprint === fp && (entry.title || entry.resolved)) {
188
179
  hits.set(id, { title: entry.title, cwd: entry.cwd, createdAt: entry.createdAt })
189
180
  }
190
181
  }
@@ -197,14 +188,18 @@ export function apply(ctx) {
197
188
  // 且"响应已返回但索引没落盘"时进程若恰好退出就会丢条目。
198
189
  // 指纹用 persistFingerprintOf:legacy 文件指纹 + handle 世代 sz(sizeBytes) 指纹
199
190
  // 都可跨重启;rev: 指纹仍被排除(实例内 opaque token,issue #8)。
191
+ // v3.6.2 #1:decoded 的值是 { meta, resolved } —— resolved=false(投影失败)
192
+ // 的条目绝不落盘;null 标题只有 resolved=true 才允许(否则读取端会当污染丢弃)。
200
193
  async function persistDecoded(decoded, statsById) {
201
194
  if (!decoded || !decoded.size) return
202
195
  const batch = {}
203
196
  const now = Date.now()
204
- for (const [id, meta] of decoded) {
197
+ for (const [id, pack] of decoded) {
198
+ if (!pack || !pack.resolved) continue
199
+ const meta = pack.meta
205
200
  const fp = persistFingerprintOf(statsById.get(id))
206
201
  if (!fp) continue
207
- batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
202
+ batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now, resolved: 1 }
208
203
  }
209
204
  if (!Object.keys(batch).length) return
210
205
  try { await titleIndex.merge(batch) } catch (e) { /* 索引写失败不影响本次响应 */ }
@@ -308,6 +303,73 @@ export function apply(ctx) {
308
303
  let warmRunning = false
309
304
  let warmKickTimer = null
310
305
 
306
+ // v3.6.2 #1:投影失败的三态处理(issue #8 审计)。旧实现把「投影失败」和
307
+ // 「会话真没标题」混为同一个 null → 失败被写进缓存/持久索引,跨重启固化成
308
+ // 永久占位且永不重试。现在:
309
+ // - resolved(fulfilled,title 可以是 null = 事实无标题)→ 正常入缓存+落盘;
310
+ // - failed(rejected / 批量异常 / 结果缺条)→ 不缓存、不落盘,按 id 计数退避。
311
+ // 退避:15s×2^n 递增,最多 WARM_MAX_ATTEMPTS 次;此后 parked 一小时后再探一次
312
+ // (坏日志长期停摆时不至于永久静默)。日志指纹任何变化都清零计数重新武装。
313
+ const WARM_MAX_ATTEMPTS = 5
314
+ const warmFail = new Map() // id → { count, fp, nextAt }
315
+
316
+ function warmFpOf(stat) {
317
+ return fingerprintOf(stat) || persistFingerprintOf(stat)
318
+ }
319
+
320
+ function warmBlocked(id, fp) {
321
+ const rec = warmFail.get(id)
322
+ if (!rec) return 0
323
+ if (fp && rec.fp && rec.fp !== fp) { warmFail.delete(id); return 0 } // 内容变了 → 重新武装
324
+ const now = Date.now()
325
+ return rec.nextAt > now ? rec.nextAt - now : 0
326
+ }
327
+
328
+ function warmMarkFailure(id, stat, header) {
329
+ const rec = warmFail.get(id) || { count: 0, fp: null, nextAt: 0 }
330
+ rec.count += 1
331
+ rec.fp = warmFpOf(stat) || rec.fp
332
+ // 退避到点后由 scheduleWarmRetry 原样重新入队(含本轮的 stat/header 快照)。
333
+ if (stat) rec.stat = stat
334
+ if (header) rec.header = header
335
+ const backoff = rec.count >= WARM_MAX_ATTEMPTS
336
+ ? 60 * 60 * 1000
337
+ : Math.min(5 * 60 * 1000, 15 * 1000 * Math.pow(2, rec.count))
338
+ rec.nextAt = Date.now() + backoff
339
+ warmFail.set(id, rec)
340
+ }
341
+
342
+ // 队列里还有「未放弃」的活 → 侧栏/面板应缩短轮询节拍(client 消费)。
343
+ function warmPendingNow() {
344
+ if (!warmHasApi) return false
345
+ if (warmRunning || warmQueue.size) return true
346
+ for (const rec of warmFail.values()) if (rec.nextAt > Date.now()) return true
347
+ return false
348
+ }
349
+
350
+ // 退避到点的条目由一个 unref 定时器重新入队(每轮只挂最近的一个)。
351
+ let warmRetryTimer = null
352
+ function scheduleWarmRetry() {
353
+ if (warmRetryTimer) return
354
+ let soonest = 0
355
+ for (const rec of warmFail.values()) {
356
+ const wait = rec.nextAt - Date.now()
357
+ if (wait <= 0) { soonest = 1; break }
358
+ if (!soonest || wait < soonest) soonest = wait
359
+ }
360
+ if (!soonest) return
361
+ warmRetryTimer = setTimeout(() => {
362
+ warmRetryTimer = null
363
+ // 到点的条目重新入队(enqueue 时会重新校验 blocked/fp)。
364
+ const now = Date.now()
365
+ for (const [id, rec] of warmFail) {
366
+ if (rec.nextAt <= now && !warmQueue.has(id)) warmQueue.set(id, { stat: rec.stat || null, header: rec.header || null })
367
+ }
368
+ if (warmQueue.size) scheduleWarm()
369
+ }, soonest)
370
+ if (typeof warmRetryTimer.unref === 'function') warmRetryTimer.unref()
371
+ }
372
+
311
373
  function scheduleWarm() {
312
374
  if (warmKickTimer || warmRunning) return
313
375
  warmKickTimer = setTimeout(() => { warmKickTimer = null; runWarm() }, 25)
@@ -317,15 +379,50 @@ export function apply(ctx) {
317
379
 
318
380
  function enqueueWarm(ids, statsById, entryById) {
319
381
  if (!ids || !ids.length) return
382
+ // 无投影 API 的 runtime:预热注定无果,不入队(占位行为退回「列表不带标题」
383
+ // 的诚实形态,而不是让退避定时器永远转下去)。
384
+ if (!warmHasApi) return
320
385
  for (const id of ids) {
321
386
  const key = String(id)
387
+ const stat = statsById ? (statsById.get(key) || null) : null
388
+ // #9:任何指纹都算不出来的会话(live 未落盘等)既进不了内存缓存也进不了
389
+ // 持久索引——投影结果注定无处安放,每轮轮询重复入队只是白烧 CPU(churn)。
390
+ // 跳过;这类行的标题由 DSH 自己的 live 渲染保证。
391
+ const fp = warmFpOf(stat)
392
+ if (!fp) continue
393
+ // #1:仍在失败退避窗口内的条目不重复入队(runWarm 会按 blocked 跳过)。
394
+ const blockedMs = warmBlocked(key, fp)
322
395
  const entry = entryById ? entryById.get(key) : null
396
+ const rec = warmFail.get(key)
397
+ if (rec) { rec.stat = stat || rec.stat; if (entry) rec.header = entry.header || rec.header }
398
+ if (blockedMs > 0) continue
323
399
  warmQueue.set(key, {
324
- stat: statsById ? (statsById.get(key) || null) : null,
400
+ stat,
325
401
  header: (entry && entry.header) || null,
326
402
  })
327
403
  }
328
- scheduleWarm()
404
+ if (warmQueue.size) scheduleWarm()
405
+ scheduleWarmRetry()
406
+ }
407
+
408
+ // #7:软删除时标题可能还没预热出来(回收站条目落一条 title:null 的诚实记录),
409
+ // 之后任何一轮 warm 解出该会话标题就补写进回收站索引——只补空标题,
410
+ // 绝不覆盖已有值。回收站渲染端(t.title || t.sessionId)随下次拉取自然更新。
411
+ async function backfillTrashTitles(decoded) {
412
+ if (!decoded || !decoded.size) return
413
+ try {
414
+ const items = await readTrash()
415
+ const worth = items.some((t) => !t.title && decoded.get(String(t.sessionId)) && decoded.get(String(t.sessionId)).meta && decoded.get(String(t.sessionId)).meta.title)
416
+ if (!worth) return
417
+ await mutateTrash((store) => {
418
+ for (const item of store.items) {
419
+ if (item.title) continue
420
+ const pack = decoded.get(String(item.sessionId))
421
+ const t = pack && pack.meta && pack.meta.title
422
+ if (t) item.title = String(t)
423
+ }
424
+ })
425
+ } catch (e) { /* 补写失败无害:索引只是加速器 */ }
329
426
  }
330
427
 
331
428
  async function runWarm() {
@@ -337,12 +434,18 @@ export function apply(ctx) {
337
434
  for (const [id] of batch) warmQueue.delete(id)
338
435
  try {
339
436
  const ids = batch.map(([id]) => id)
340
- const snapshotById = await projectTitles(ids)
437
+ const resultById = await projectTitlesStatus(ids)
341
438
  const decoded = new Map()
342
439
  const statsById = new Map()
343
440
  for (const [id, desc] of batch) {
344
- const snapshot = snapshotById.has(id) ? snapshotById.get(id) : null
345
- const meta = metaFromSnapshot(snapshot)
441
+ const r = resultById.get(id)
442
+ if (!r || !r.resolved) {
443
+ // 投影失败:绝不写缓存/持久索引(否则失败被固化为永久占位)。
444
+ warmMarkFailure(id, desc.stat, desc.header)
445
+ continue
446
+ }
447
+ warmFail.delete(id)
448
+ const meta = metaFromSnapshot(r.snapshot || null)
346
449
  // cwd/createdAt 永远以最新 list header 为权威(投影的 session 头可能
347
450
  // 陈旧;移动会话后尤其如此),投影只负责标题。
348
451
  if (desc.header) {
@@ -351,9 +454,11 @@ export function apply(ctx) {
351
454
  }
352
455
  metaCache.set(id, desc.stat, meta)
353
456
  statsById.set(id, desc.stat)
354
- if (desc.stat) decoded.set(id, meta)
457
+ decoded.set(id, { meta, resolved: true })
355
458
  }
356
459
  await persistDecoded(decoded, statsById)
460
+ // #7:回收站条目删除时可能没有标题(软删除只记录);预热解出来后补写。
461
+ await backfillTrashTitles(decoded)
357
462
  } catch (e) { /* 单批失败不影响后续批次:索引/缓存只是加速器 */ }
358
463
  // 批间让步:保证宿主事件循环(RPC/心跳/其他插件)始终可调度。
359
464
  await new Promise((resolve) => setImmediate(resolve))
@@ -362,6 +467,7 @@ export function apply(ctx) {
362
467
  warmRunning = false
363
468
  // 预热期间又有新请求入队 → 再排一轮。
364
469
  if (warmQueue.size) scheduleWarm()
470
+ scheduleWarmRetry()
365
471
  }
366
472
  }
367
473
 
@@ -514,25 +620,21 @@ export function apply(ctx) {
514
620
  title = header.title || (header.meta && header.meta.title) || null
515
621
  }
516
622
  if (!title) {
517
- // 标题兜底优先走单会话标题投影(快照级,不读日志);投影也没有时才
518
- // 分块解码日志折叠标题(inspectSession 分块,不整本驻留内存)。
519
- if (typeof sq.readTitleSnapshot === 'function') {
520
- try {
521
- const snap = unwrapSnapshot(await sq.readTitleSnapshot(sid))
522
- if (snap && snap.title && snap.title.title) title = String(snap.title.title)
523
- if (snap && snap.session) { if (!cwd) cwd = snap.session.cwd || null }
524
- } catch (e) { /* fall through */ }
525
- }
526
- }
527
- if (!title) {
623
+ // v3.6.2 #7:删除路径**绝不**做标题投影/分块解码——issue #8 的教训:
624
+ // 大日志上单条兜底投影也是整本解压,删除 = 秒级卡宿主。只消费现成缓存:
625
+ // metaCache(用 list 快照算指纹)→ 持久索引 → 侧栏权威缓存。都没有就存
626
+ // null(诚实占位,不再把 cwd/裸 id 冒充标题永久写进回收站),
627
+ // 后台预热解出标题后由 backfillTrashTitles 补写。
528
628
  try {
529
- let folded = null
530
- const summary = await persistence.inspectSession(sid, {
531
- onEvents: (events) => { if (!folded) folded = foldTitle(events) },
532
- })
533
- if (summary && summary.meta && !cwd) cwd = summary.meta.cwd || null
534
- title = folded
629
+ const delStat = persistenceEntry && typeof persistenceEntry.revision === 'string' && persistenceEntry.revision
630
+ ? { revision: persistenceEntry.revision, sizeBytes: Number.isFinite(persistenceEntry.sizeBytes) ? Number(persistenceEntry.sizeBytes) : undefined }
631
+ : (removedPath ? await stat(removedPath).then((st) => ({ mtimeMs: st.mtimeMs, size: st.size })) : null)
632
+ if (delStat) {
633
+ const m = metaCache.get(sid, delStat) || (await hydrateFromPersist([sid], new Map([[sid, delStat]]))).get(sid)
634
+ if (m && m.title) title = String(m.title)
635
+ }
535
636
  } catch (e) { /* best-effort */ }
637
+ if (!title) { const at = authorityTitleCache.get(sid); if (at) title = String(at) }
536
638
  }
537
639
  } catch (e) { /* best-effort */ }
538
640
  // Record in the trash index only — the log stays in its workspace dir.
@@ -544,7 +646,9 @@ export function apply(ctx) {
544
646
  const archived = await mutateArchived((list) => ({ next: null, value: list.includes(sid) })).catch(() => false)
545
647
  await mutateTrash((store) => {
546
648
  const entry = {
547
- sessionId: sid, title: title || cwd || sid, cwd: cwd || null,
649
+ // v3.6.2 #7:不再把 cwd/裸 id 冒充标题永久定格——存 null,预热解出后
650
+ // backfillTrashTitles 补写;渲染端(t.title || t.sessionId)自然降级。
651
+ sessionId: sid, title: title || null, cwd: cwd || null,
548
652
  header: header || null, originalPath: removedPath || null,
549
653
  sizeBytes: persistenceEntry && Number.isFinite(persistenceEntry.sizeBytes) ? persistenceEntry.sizeBytes : null,
550
654
  wasArchived: archived, deletedAt: Date.now(),
@@ -1210,24 +1314,53 @@ export function apply(ctx) {
1210
1314
  })
1211
1315
  }
1212
1316
 
1213
- // 批量投影:一次调用把多条会话的标题/header 拿出来,避免逐条触发整本解码。
1214
- // 老 runtime 没有 readTitleSnapshots 时返回空 Map,调用方自然回退到逐条投影
1215
- // (功能不受影响,只是少了这层优化——插件不能假设对方的 runtime 版本)。
1216
- async function projectTitles(ids) {
1317
+ // 批量投影(v3.6.2 #1 三态版)。返回 Map<id, { resolved, snapshot|null }>:
1318
+ // resolved=true —— fulfilled 快照(title 可能为 null:会话真的没标题,
1319
+ // 这是事实,可以缓存/落盘);
1320
+ // resolved=false —— rejected / 批量异常 / 结果缺条 / 无投影 API。
1321
+ // 调用方(runWarm)只缓存 resolved 的条目;失败的进退避重试,绝不落盘——
1322
+ // 否则「一次失败」被固化为跨重启的永久占位(v3.6.1 审计 #1)。
1323
+ const warmHasApi = typeof sq.readTitleSnapshots === 'function' || typeof sq.readTitleSnapshot === 'function'
1324
+
1325
+ async function projectTitlesStatus(ids) {
1217
1326
  const out = new Map()
1327
+ const markAll = (resolved) => { for (const id of ids) out.set(String(id), { resolved, snapshot: null }) }
1218
1328
  if (!ids || !ids.length) return out
1219
- if (typeof sq.readTitleSnapshots !== 'function') return out
1220
- try {
1221
- const results = await sq.readTitleSnapshots(ids)
1222
- if (!Array.isArray(results)) return out
1223
- results.forEach((result, index) => {
1224
- const id = String(ids[index])
1225
- out.set(id, unwrapSnapshot(result))
1226
- })
1227
- } catch (e) { /* 批量失败:逐条回退 */ }
1329
+ if (!warmHasApi) { markAll(false); return out }
1330
+ if (typeof sq.readTitleSnapshots === 'function') {
1331
+ let results = null
1332
+ try { results = await sq.readTitleSnapshots(ids) } catch (e) { results = null }
1333
+ if (Array.isArray(results)) {
1334
+ results.forEach((result, index) => {
1335
+ const id = String(ids[index])
1336
+ if (result && result.status === 'fulfilled') out.set(id, { resolved: true, snapshot: unwrapSnapshot(result) })
1337
+ else if (result && result.status === 'rejected') out.set(id, { resolved: false, snapshot: null })
1338
+ else if (result) out.set(id, { resolved: true, snapshot: unwrapSnapshot(result) }) // 最老形态:直接是快照
1339
+ else out.set(id, { resolved: false, snapshot: null })
1340
+ })
1341
+ // 结果数组短于请求(runtime 行为异常)→ 缺的条目按失败处理。
1342
+ for (const id of ids) if (!out.has(String(id))) out.set(String(id), { resolved: false, snapshot: null })
1343
+ return out
1344
+ }
1345
+ // 批量形态不认识:退回逐条。
1346
+ }
1347
+ if (typeof sq.readTitleSnapshot === 'function') {
1348
+ for (const raw of ids) {
1349
+ const id = String(raw)
1350
+ try {
1351
+ const r = await sq.readTitleSnapshot(id)
1352
+ if (r && r.status === 'rejected') out.set(id, { resolved: false, snapshot: null })
1353
+ else out.set(id, { resolved: true, snapshot: unwrapSnapshot(r) })
1354
+ } catch (e) { out.set(id, { resolved: false, snapshot: null }) }
1355
+ }
1356
+ return out
1357
+ }
1358
+ markAll(false)
1228
1359
  return out
1229
1360
  }
1230
1361
 
1362
+ // 兼容旧调用点(暂无):保留名字不导出,避免误用回「失败即 null」的旧语义。
1363
+
1231
1364
  // opts.usage: expose sizeBytes + updatedAt on each item (storage analysis and
1232
1365
  // the auto-archive sweep need them; the panel list does not).
1233
1366
  //
@@ -1446,6 +1579,9 @@ export function apply(ctx) {
1446
1579
  trashedSessionIds: store.items.map((item) => String(item.sessionId)),
1447
1580
  purgedSessionIds: activeTombstones,
1448
1581
  lineage,
1582
+ // v3.6.2:还有预热/退避重试在途 → client 缩短轮询节拍,预热一完成就把
1583
+ // 补齐的标题送回(含面板自动刷新)。无预热 API 时恒 false,绝不假忙。
1584
+ warmPending: warmPendingNow(),
1449
1585
  }
1450
1586
  }
1451
1587
 
@@ -1458,38 +1594,51 @@ export function apply(ctx) {
1458
1594
  // 的候选走官方 inspectSession 解码,按事件类型判定。超过上限的日志必有
1459
1595
  // 内容,直接跳过(维持体积法/结构分类的结果)。解码失败按「非空」处理:
1460
1596
  // 宁可漏判一个空会话,不能把有内容的会话错标成空。
1597
+ // v3.6.2(分支标签延迟审计 #1 的速效半边):旧实现把全部 ≤8KB 候选**串行**
1598
+ // 解码在 sidebar-state 的请求路径里——冷启动(进程内缓存全空)时零解码成本
1599
+ // 的血缘数据被这串慢解码挡在 return 之前,这正是「⑂ 分支」chip 晚出/32s 台阶
1600
+ // 的直接门控。现在:并行限流 4 路 + 每请求预算 REFINE_BUDGET 条(超预算的
1601
+ // 小日志下一拍继续,逐拍收敛;已缓存的条目零成本照常应用,不影响正确性)。
1602
+ // 彻底解绑(精判挪进后台队列)是 3.7.0 的响应契约改造,不在热修范围。
1603
+ const REFINE_BUDGET = 40
1604
+ function applyEmptyScan(lineage, id, empty) {
1605
+ if (empty) {
1606
+ // 结构分类没建条目的普通会话也要补上(0.1.3 空会话 header 无血缘字段,
1607
+ // classifyLineage 对它们返回 null)。
1608
+ const info = lineage[id] || (lineage[id] = { origin: null, parentSession: null, delegationDepth: 0, empty: false })
1609
+ info.empty = true
1610
+ } else if (lineage[id]) {
1611
+ // 旧体积法在小日志上的假阳性(如 alpha.2 单事件日志)在这里纠正;
1612
+ // 纠正后若无任何血缘结构,条目整个撤掉,维持「普通会话不产生条目」。
1613
+ const info = lineage[id]
1614
+ info.empty = false
1615
+ if (!info.origin && !info.parentSession) delete lineage[id]
1616
+ }
1617
+ }
1461
1618
  async function refineEmptyLineage(lineage, entries) {
1462
1619
  if (!persistence || typeof persistence.inspectSession !== 'function') return
1620
+ const limiter = createLimiter(4)
1621
+ const todo = []
1463
1622
  for (const entry of entries) {
1464
1623
  const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
1465
1624
  if (size === null || size > EMPTY_DECODE_LIMIT) continue
1466
1625
  const id = String(entry.id)
1467
- let scanned = emptyScanCache.get(id)
1468
- if (!scanned || scanned.sizeBytes !== size) {
1469
- let isEmpty = false
1470
- try {
1471
- const types = []
1472
- await persistence.inspectSession(id, { onEvents: (batch) => {
1473
- for (const ev of batch || []) if (types.length < 64) types.push(ev && ev.type)
1474
- } })
1475
- isEmpty = isEmptyEventTypes(types)
1476
- } catch (e) { isEmpty = false }
1477
- scanned = { sizeBytes: size, empty: isEmpty }
1478
- emptyScanCache.set(id, scanned)
1479
- }
1480
- if (scanned.empty) {
1481
- // 结构分类没建条目的普通会话也要补上(0.1.3 空会话 header 无血缘字段,
1482
- // classifyLineage 对它们返回 null)。
1483
- const info = lineage[id] || (lineage[id] = { origin: null, parentSession: null, delegationDepth: 0, empty: false })
1484
- info.empty = true
1485
- } else if (lineage[id]) {
1486
- // 旧体积法在小日志上的假阳性(如 alpha.2 单事件日志)在这里纠正;
1487
- // 纠正后若无任何血缘结构,条目整个撤掉,维持「普通会话不产生条目」。
1488
- const info = lineage[id]
1489
- info.empty = false
1490
- if (!info.origin && !info.parentSession) delete lineage[id]
1491
- }
1626
+ const scanned = emptyScanCache.get(id)
1627
+ if (scanned && scanned.sizeBytes === size) { applyEmptyScan(lineage, id, scanned.empty); continue }
1628
+ if (todo.length < REFINE_BUDGET) todo.push({ id, size })
1492
1629
  }
1630
+ await Promise.all(todo.map(({ id, size }) => limiter(async () => {
1631
+ let isEmpty = false
1632
+ try {
1633
+ const types = []
1634
+ await persistence.inspectSession(id, { onEvents: (batch) => {
1635
+ for (const ev of batch || []) if (types.length < 64) types.push(ev && ev.type)
1636
+ } })
1637
+ isEmpty = isEmptyEventTypes(types)
1638
+ } catch (e) { isEmpty = false }
1639
+ emptyScanCache.set(id, { sizeBytes: size, empty: isEmpty })
1640
+ applyEmptyScan(lineage, id, isEmpty)
1641
+ })))
1493
1642
  // 缓存只保留「本轮仍参与判定」的会话:已彻底删除或日志膨胀超过解码
1494
1643
  // 上限的条目不再有用,淘汰掉防止 Map 随历史会话无限增长。
1495
1644
  const live = new Set()
@@ -1888,7 +2037,7 @@ export function apply(ctx) {
1888
2037
  path: '/archived-sessions/sessions',
1889
2038
  handler: async (req, res) => {
1890
2039
  try {
1891
- json(res, { items: await allSessionItems() })
2040
+ json(res, { items: await allSessionItems(), warmPending: warmPendingNow() })
1892
2041
  } catch (e) {
1893
2042
  json(res, { error: String((e && e.message) || e) }, 500)
1894
2043
  }
@@ -1975,7 +2124,8 @@ export function apply(ctx) {
1975
2124
  // 子代理血缘树(管理视图数据面):给定父会话,返回 origin==='subagent'
1976
2125
  // 的递归子树。DSH 原生目录(ui-subagent)只读,这里补管理能力的数据来源。
1977
2126
  // 只读官方 header 血缘字段(parentSession/origin/delegationDepth/createdAt),
1978
- // 标题走既有批量投影(readTitleSnapshots),零整本日志解码。
2127
+ // 标题走 v3.6.2 的纯缓存读取(metaCache/持久索引/权威缓存),未命中入后台
2128
+ // 预热、本轮返回 null;请求路径零解码。
1979
2129
  // live 标记 = 会话在内存里活着(正在运行或已打开):批量清理必须跳过。
1980
2130
  disposers.push(ctx.webServer.register({
1981
2131
  kind: 'exact',
@@ -2011,9 +2161,29 @@ export function apply(ctx) {
2011
2161
  })
2012
2162
  if (Number.isFinite(sizeBytes)) sizeById.set(id, sizeBytes)
2013
2163
  }
2164
+ // v3.6.2 #4:标题只读缓存(metaCache → 持久索引 → 权威缓存),未命中
2165
+ // 入后台预热、本轮返回 null。旧实现在请求路径直调 projectTitles——
2166
+ // 展开一个 N 子树的父会话 = N 次整本解码同步在请求路径上(issue #8
2167
+ // 要消灭的形态在本路由的残留)。这里顺手记下指纹与原始 header,供
2168
+ // 缓存读取与 enqueueWarm 使用。
2169
+ const lineageStats = new Map()
2170
+ const lineageHeaders = new Map()
2014
2171
  try {
2015
2172
  for (const entry of await persistence.listEntries()) {
2016
2173
  addHeader(entry.header, entry.sizeBytes)
2174
+ const eid = entry && entry.id != null ? String(entry.id) : null
2175
+ if (eid) {
2176
+ lineageHeaders.set(eid, { header: (entry && entry.header) || null })
2177
+ // 只有 handle 世代(snapshot 带 revision)才建 stats;legacy 的列表快照
2178
+ // 本就没有这些观测,其标题经 sidebarAuthority 的 authorityTitleCache
2179
+ // 兜住。刻意不在这里合成 sz-only stat——会和列表构建的 mtime:size
2180
+ // 指纹在同一索引里乒乓覆盖,引发交替重复解码。
2181
+ if (entry && typeof entry.revision === 'string' && entry.revision) {
2182
+ lineageStats.set(eid, Number.isFinite(entry.sizeBytes)
2183
+ ? { revision: entry.revision, sizeBytes: Number(entry.sizeBytes) }
2184
+ : { revision: entry.revision })
2185
+ }
2186
+ }
2017
2187
  if (entry && entry.id != null) persistedIds.add(String(entry.id))
2018
2188
  }
2019
2189
  } catch (e) { /* best-effort */ }
@@ -2061,11 +2231,19 @@ export function apply(ctx) {
2061
2231
  const allIds = [rootId]
2062
2232
  const walk = (n) => { allIds.push(n.sessionId); n.children.forEach(walk) }
2063
2233
  nodes.forEach(walk)
2064
- const titles = await projectTitles(allIds)
2234
+ // v3.6.2 #4:缓存读取 + 预热入队,请求路径零解码。#12:标题按
2235
+ // MAX_TITLE 截断,与列表输出口径一致。
2236
+ const lineageIds = allIds.map(String)
2237
+ const { cached: lineageCached, missing: lineageMissing } = metaCache.partition(lineageIds, lineageStats)
2238
+ const lineagePersisted = await hydrateFromPersist(lineageMissing, lineageStats)
2239
+ for (const [id, meta] of lineagePersisted) metaCache.set(id, lineageStats.get(id), meta)
2240
+ const lineageRest = lineageMissing.filter((id) => !lineagePersisted.has(id))
2241
+ if (lineageRest.length) enqueueWarm(lineageRest, lineageStats, lineageHeaders)
2242
+ const truncateTitle = (t) => (t ? (t.length > MAX_TITLE ? t.slice(0, MAX_TITLE) + '…' : t) : null)
2065
2243
  const titleOf = (id) => {
2066
- const snap = titles.get(id)
2067
- const m = snap ? metaFromSnapshot(snap) : null
2068
- return (m && m.title) || null
2244
+ const key = String(id)
2245
+ const m = lineageCached.get(key) || lineagePersisted.get(key)
2246
+ return truncateTitle((m && m.title) || authorityTitleCache.get(key) || null)
2069
2247
  }
2070
2248
  const fill = (n) => { n.title = titleOf(n.sessionId); n.children.forEach(fill) }
2071
2249
  nodes.forEach(fill)
@@ -6,9 +6,12 @@
6
6
  // 一次索引读 + 指纹比对,指纹没变的会话零解码。
7
7
  //
8
8
  // 结构仿 trash 的原子写索引:{ schemaVersion, entries: { [sessionId]: entry } }
9
- // entry = { title, cwd, createdAt, fingerprint, updatedAt }
9
+ // entry = { title, cwd, createdAt, fingerprint, updatedAt, resolved? }
10
10
  // fingerprint 即 session-meta-cache.js 的 fingerprintOf(stat) 产出
11
11
  // ("<mtimeMs>:<size>"),比对一致即可信任条目内容。
12
+ // resolved(schema v2,v3.6.2 #1):标记「投影确实成功过」。title 为 null 的
13
+ // 条目只有带这个标记才可信——没标记的 null 标题条目是 v3.6.1 之前「投影失败
14
+ // 被当结果缓存」的污染源,读取时必须丢弃并重新预热。
12
15
  //
13
16
  // 写入时机由调用方决定(列表构建收尾批量回写、purge 时清理),本模块只
14
17
  // 提供:读取缓存、合并写入(串行化 + 原子替换)、按 id 删除。任何文件
@@ -20,7 +23,7 @@
20
23
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
21
24
  import { dirname, join } from 'node:path'
22
25
 
23
- export const TITLE_INDEX_SCHEMA_VERSION = 1
26
+ export const TITLE_INDEX_SCHEMA_VERSION = 2
24
27
 
25
28
  const MAX_ENTRIES = 20000
26
29
 
@@ -37,9 +40,16 @@ export function normalizeEntry(raw) {
37
40
  const createdAt = typeof raw.createdAt === 'number' ? raw.createdAt : null
38
41
  const fingerprint = typeof raw.fingerprint === 'string' && raw.fingerprint ? raw.fingerprint : null
39
42
  const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
43
+ const resolved = raw.resolved === true || raw.resolved === 1
40
44
  if (!fingerprint || fingerprint.startsWith('rev:')) return null
41
45
  if (!title && !cwd) return null
42
- return { title, cwd, createdAt, fingerprint, updatedAt }
46
+ // v3.6.2 #1:null 标题只有「投影确实成功过」(会话真的没有标题事件)才可信。
47
+ // 无 resolved 标记的 null 标题条目 = v3.6.1 的失败污染源(或 v1 旧数据),
48
+ // 一律丢弃 → 下次按未命中重新预热,索引自愈。
49
+ if (!title && !resolved) return null
50
+ const entry = { title, cwd, createdAt, fingerprint, updatedAt }
51
+ if (resolved) entry.resolved = 1
52
+ return entry
43
53
  }
44
54
 
45
55
  export function normalizeTitleIndex(raw) {