dsh-sessions-manager 3.6.0 → 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, fingerprintOf, isPersistableFingerprint } 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
@@ -179,9 +167,15 @@ export function apply(ctx) {
179
167
  const stat = statsById.get(id)
180
168
  const entry = store && store[id]
181
169
  if (!stat || !entry) continue
182
- const fp = fingerprintOf(stat)
183
- if (!isPersistableFingerprint(fp)) continue
184
- if (fp && entry.fingerprint === fp) {
170
+ // 持久索引只认可跨重启的指纹(legacy 的文件指纹 / handle 世代的 sz 指纹,
171
+ // 见 session-meta-cache.persistFingerprintOf——issue #8 之前 handle 世代
172
+ // 从不落盘,导致每次重启都全量重新解码)。
173
+ const fp = persistFingerprintOf(stat)
174
+ if (!fp) continue
175
+ // v3.6.2 #1:title 为 null 的条目必须是「投影确实成功过」(resolved)才可信。
176
+ // v3.6.1 把投影失败当结果落盘的污染条目(null 标题、无标记)在这里被丢弃,
177
+ // 调用方按未命中处理 → 重新入队预热,索引自愈。
178
+ if (entry.fingerprint === fp && (entry.title || entry.resolved)) {
185
179
  hits.set(id, { title: entry.title, cwd: entry.cwd, createdAt: entry.createdAt })
186
180
  }
187
181
  }
@@ -192,16 +186,20 @@ export function apply(ctx) {
192
186
  // 队列内部已串行化 + 原子替换)。**必须 await** —— 它是持久写入,浮动写入会在
193
187
  // 调用方/测试 teardown 已开始清理状态目录时落地(`ENOTEMPTY: rmdir .../state`),
194
188
  // 且"响应已返回但索引没落盘"时进程若恰好退出就会丢条目。
195
- // ⚠️ revision 指纹(SessionHandle 世代)绝不落盘:它只在当前 service
196
- // 实例内有意义,跨进程比较无意义,误用会把陈旧数据当新鲜数据。
189
+ // 指纹用 persistFingerprintOf:legacy 文件指纹 + handle 世代 sz(sizeBytes) 指纹
190
+ // 都可跨重启;rev: 指纹仍被排除(实例内 opaque token,issue #8)。
191
+ // v3.6.2 #1:decoded 的值是 { meta, resolved } —— resolved=false(投影失败)
192
+ // 的条目绝不落盘;null 标题只有 resolved=true 才允许(否则读取端会当污染丢弃)。
197
193
  async function persistDecoded(decoded, statsById) {
198
194
  if (!decoded || !decoded.size) return
199
195
  const batch = {}
200
196
  const now = Date.now()
201
- for (const [id, meta] of decoded) {
202
- const fp = fingerprintOf(statsById.get(id))
203
- if (!isPersistableFingerprint(fp)) continue
204
- batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
197
+ for (const [id, pack] of decoded) {
198
+ if (!pack || !pack.resolved) continue
199
+ const meta = pack.meta
200
+ const fp = persistFingerprintOf(statsById.get(id))
201
+ if (!fp) continue
202
+ batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now, resolved: 1 }
205
203
  }
206
204
  if (!Object.keys(batch).length) return
207
205
  try { await titleIndex.merge(batch) } catch (e) { /* 索引写失败不影响本次响应 */ }
@@ -284,65 +282,193 @@ export function apply(ctx) {
284
282
 
285
283
  // Resolve one session's display metadata.
286
284
  //
287
- // 成本模型(issue #1):下面的解码路径会把整本 .jsonl.zstd 逐帧解压、逐行
288
- // JSON.parse,只为折叠出标题——大库上一次全表要几秒阻塞式 CPU。日志内容没变
289
- // 就意味着折叠结果不可能变(legacy 用 (mtime, size) 文件指纹;SessionHandle
290
- // 世代用官方 snapshot.revision),命中即直接复用,跳过整本解码。
285
+ // 成本模型(issue #1 → issue #8):runtime 的标题投影(readTitleSnapshots)
286
+ // 在 0.1.x 上对每条非 live 会话都是**整本日志解码**(SessionCorpus.projectMany
287
+ // → inspectPersisted)。列表构建绝不能在请求路径上同步触发它——issue #8 实测:
288
+ // 87 本日志 / 249MB 的库,一次冷启动全量投影把宿主打满 ~90s CPU。
291
289
  //
292
- // 0.1.3-alpha 兼容(避免放大官方已知的历史会话加载性能回退):
293
- // - cwd/createdAt 优先来自 list() 快照的 snapshot.header;
294
- // - 标题优先来自批量 readTitleSnapshots;
295
- // - **标题缺失绝不单独触发整本日志解码**——无标题就显示「(无标题)」。
296
- // 只有在拿不到 cwd(工作区归属失效)或 runtime 完全没有标题投影能力时
297
- // 才回退到日志解码,且该解码走 inspectSession 分块折叠,不做整本驻留。
298
- async function resolveOne(id, usage, opts = {}) {
299
- const key = String(id)
300
- const statInfo = usage ? (usage.statsById ? usage.statsById.get(key) : null)
301
- || { mtimeMs: usage.mtimeById && usage.mtimeById.get(key), size: usage.sizeById && usage.sizeById.get(key) } : null
302
- const cached = metaCache.get(key, statInfo)
303
- if (cached) return buildItem(key, cached, usage, opts.exposeUsage)
304
-
305
- let meta = { title: null, cwd: null, createdAt: null }
306
- // 第一来源:list() 返回的 SessionPersistenceSnapshot.header(0.1.3+ 官方
307
- // 契约里 header 携带 cwd/createdAt,无需任何日志读取)。
308
- if (opts.listHeader) {
309
- if (typeof opts.listHeader.cwd === 'string') meta.cwd = opts.listHeader.cwd
310
- if (opts.listHeader.createdAt != null) meta.createdAt = opts.listHeader.createdAt
290
+ // 因此请求路径只消费两级缓存(metaCache 内存缓存 → titleIndex 持久索引),
291
+ // 缺失的会话交给后台预热队列分批补齐(见 enqueueWarm/runWarm);侧栏高频
292
+ // 轮询 + 管理面板刷新会让补齐结果自然浮现。
293
+ const EMPTY_META = { title: null, cwd: null, createdAt: null }
294
+
295
+ // —— 冷启动后台预热(issue #8)—————————————————————————————————————
296
+ // 单飞队列:请求路径发现缓存未命中的会话,把 { stat, header } 快照丢进
297
+ // warmQueue,由 runWarm 以小批次消费。每批之间 setImmediate 让步事件循环,
298
+ // 绝不独占宿主主线程;结果写 metaCache + 持久标题索引。stat 是入队时刻的
299
+ // 快照——若日志在预热期间又变了,下次请求的指纹比对会再次判未命中并重新
300
+ // 入队,天然自愈。
301
+ const WARM_CHUNK = 4
302
+ const warmQueue = new Map()
303
+ let warmRunning = false
304
+ let warmKickTimer = null
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
311
359
  }
312
- if (opts.preloaded !== undefined) {
313
- const projected = metaFromSnapshot(unwrapSnapshot(opts.preloaded))
314
- if (projected.title) meta.title = projected.title
315
- if (!meta.cwd && projected.cwd) meta.cwd = projected.cwd
316
- if (!meta.createdAt && projected.createdAt) meta.createdAt = projected.createdAt
317
- } else if (typeof sq.readTitleSnapshot === 'function') {
318
- try {
319
- const projected = metaFromSnapshot(await sq.readTitleSnapshot(id))
320
- if (projected.title) meta.title = projected.title
321
- if (!meta.cwd && projected.cwd) meta.cwd = projected.cwd
322
- if (!meta.createdAt && projected.createdAt) meta.createdAt = projected.createdAt
323
- } catch (e) { /* fall through */ }
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
+
373
+ function scheduleWarm() {
374
+ if (warmKickTimer || warmRunning) return
375
+ warmKickTimer = setTimeout(() => { warmKickTimer = null; runWarm() }, 25)
376
+ // unref:预热绝不反过来把宿主进程钉住(与 autoArchive「无定时器」同一原则)。
377
+ if (typeof warmKickTimer.unref === 'function') warmKickTimer.unref()
378
+ }
379
+
380
+ function enqueueWarm(ids, statsById, entryById) {
381
+ if (!ids || !ids.length) return
382
+ // 无投影 API 的 runtime:预热注定无果,不入队(占位行为退回「列表不带标题」
383
+ // 的诚实形态,而不是让退避定时器永远转下去)。
384
+ if (!warmHasApi) return
385
+ for (const id of ids) {
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)
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
399
+ warmQueue.set(key, {
400
+ stat,
401
+ header: (entry && entry.header) || null,
402
+ })
324
403
  }
325
- // cwd 缺失 → 工作区归属失效,值得一次解码兜底(cwd 在 header 里,通常
326
- // 快照已带回,这里只在快照缺 cwd 时发生)。runtime 完全没有标题投影能力
327
- // 时(老后端无 readTitleSnapshot),解码同时兜底标题。
328
- const projectionAvailable = typeof sq.readTitleSnapshot === 'function' || typeof sq.readTitleSnapshots === 'function'
329
- if (!meta.cwd || (!meta.title && !projectionAvailable)) {
330
- try {
331
- let foldedTitle = null
332
- const summary = await persistence.inspectSession(key, {
333
- onEvents: (events) => { if (!foldedTitle) foldedTitle = foldTitle(events) },
334
- })
335
- if (summary && summary.meta) {
336
- if (!meta.cwd) meta.cwd = summary.meta.cwd || null
337
- if (!meta.createdAt) meta.createdAt = summary.meta.createdAt || null
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)
338
423
  }
339
- if (!meta.title && foldedTitle) meta.title = foldedTitle
340
- } catch (e2) { /* keep what we have */ }
424
+ })
425
+ } catch (e) { /* 补写失败无害:索引只是加速器 */ }
426
+ }
427
+
428
+ async function runWarm() {
429
+ if (warmRunning) return
430
+ warmRunning = true
431
+ try {
432
+ while (warmQueue.size) {
433
+ const batch = [...warmQueue.entries()].slice(0, WARM_CHUNK)
434
+ for (const [id] of batch) warmQueue.delete(id)
435
+ try {
436
+ const ids = batch.map(([id]) => id)
437
+ const resultById = await projectTitlesStatus(ids)
438
+ const decoded = new Map()
439
+ const statsById = new Map()
440
+ for (const [id, desc] of batch) {
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)
449
+ // cwd/createdAt 永远以最新 list header 为权威(投影的 session 头可能
450
+ // 陈旧;移动会话后尤其如此),投影只负责标题。
451
+ if (desc.header) {
452
+ if (typeof desc.header.cwd === 'string') meta.cwd = desc.header.cwd
453
+ if (desc.header.createdAt != null) meta.createdAt = desc.header.createdAt
454
+ }
455
+ metaCache.set(id, desc.stat, meta)
456
+ statsById.set(id, desc.stat)
457
+ decoded.set(id, { meta, resolved: true })
458
+ }
459
+ await persistDecoded(decoded, statsById)
460
+ // #7:回收站条目删除时可能没有标题(软删除只记录);预热解出来后补写。
461
+ await backfillTrashTitles(decoded)
462
+ } catch (e) { /* 单批失败不影响后续批次:索引/缓存只是加速器 */ }
463
+ // 批间让步:保证宿主事件循环(RPC/心跳/其他插件)始终可调度。
464
+ await new Promise((resolve) => setImmediate(resolve))
465
+ }
466
+ } finally {
467
+ warmRunning = false
468
+ // 预热期间又有新请求入队 → 再排一轮。
469
+ if (warmQueue.size) scheduleWarm()
470
+ scheduleWarmRetry()
341
471
  }
342
- metaCache.set(key, statInfo, meta)
343
- // 本条是「真解码」出来的:交给调用方回写持久标题索引(P4 冷启动加速)。
344
- if (opts.collectDecoded && statInfo) opts.collectDecoded(key, meta)
345
- return buildItem(key, meta, usage, opts.exposeUsage)
346
472
  }
347
473
 
348
474
  // Disk usage + last-write time for every session, in one pass. Also produces
@@ -373,10 +499,13 @@ export function apply(ctx) {
373
499
  const id = entry && entry.id != null ? String(entry.id) : (header && header.id != null ? String(header.id) : null)
374
500
  if (!id) return
375
501
  // SessionHandle 世代:snapshot(header/revision/sizeBytes)是权威轻量
376
- // 观察,绝不再绕道私有磁盘路径补 stat。
502
+ // 观察,绝不再绕道私有磁盘路径补 stat。revision 进内存缓存指纹;
503
+ // sizeBytes 供 persistFingerprintOf 派生跨重启的 sz 持久指纹(issue #8)。
377
504
  if (entry && typeof entry.revision === 'string' && entry.revision) {
378
505
  if (Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
379
- statsById.set(id, { revision: entry.revision })
506
+ statsById.set(id, Number.isFinite(entry.sizeBytes)
507
+ ? { revision: entry.revision, sizeBytes: Number(entry.sizeBytes) }
508
+ : { revision: entry.revision })
380
509
  return
381
510
  }
382
511
  if (entry && Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
@@ -491,25 +620,21 @@ export function apply(ctx) {
491
620
  title = header.title || (header.meta && header.meta.title) || null
492
621
  }
493
622
  if (!title) {
494
- // 标题兜底优先走单会话标题投影(快照级,不读日志);投影也没有时才
495
- // 分块解码日志折叠标题(inspectSession 分块,不整本驻留内存)。
496
- if (typeof sq.readTitleSnapshot === 'function') {
497
- try {
498
- const snap = unwrapSnapshot(await sq.readTitleSnapshot(sid))
499
- if (snap && snap.title && snap.title.title) title = String(snap.title.title)
500
- if (snap && snap.session) { if (!cwd) cwd = snap.session.cwd || null }
501
- } catch (e) { /* fall through */ }
502
- }
503
- }
504
- if (!title) {
623
+ // v3.6.2 #7:删除路径**绝不**做标题投影/分块解码——issue #8 的教训:
624
+ // 大日志上单条兜底投影也是整本解压,删除 = 秒级卡宿主。只消费现成缓存:
625
+ // metaCache(用 list 快照算指纹)→ 持久索引 → 侧栏权威缓存。都没有就存
626
+ // null(诚实占位,不再把 cwd/裸 id 冒充标题永久写进回收站),
627
+ // 后台预热解出标题后由 backfillTrashTitles 补写。
505
628
  try {
506
- let folded = null
507
- const summary = await persistence.inspectSession(sid, {
508
- onEvents: (events) => { if (!folded) folded = foldTitle(events) },
509
- })
510
- if (summary && summary.meta && !cwd) cwd = summary.meta.cwd || null
511
- 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
+ }
512
636
  } catch (e) { /* best-effort */ }
637
+ if (!title) { const at = authorityTitleCache.get(sid); if (at) title = String(at) }
513
638
  }
514
639
  } catch (e) { /* best-effort */ }
515
640
  // Record in the trash index only — the log stays in its workspace dir.
@@ -521,7 +646,9 @@ export function apply(ctx) {
521
646
  const archived = await mutateArchived((list) => ({ next: null, value: list.includes(sid) })).catch(() => false)
522
647
  await mutateTrash((store) => {
523
648
  const entry = {
524
- 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,
525
652
  header: header || null, originalPath: removedPath || null,
526
653
  sizeBytes: persistenceEntry && Number.isFinite(persistenceEntry.sizeBytes) ? persistenceEntry.sizeBytes : null,
527
654
  wasArchived: archived, deletedAt: Date.now(),
@@ -1187,24 +1314,53 @@ export function apply(ctx) {
1187
1314
  })
1188
1315
  }
1189
1316
 
1190
- // 批量投影:一次调用把多条会话的标题/header 拿出来,避免逐条触发整本解码。
1191
- // 老 runtime 没有 readTitleSnapshots 时返回空 Map,调用方自然回退到逐条投影
1192
- // (功能不受影响,只是少了这层优化——插件不能假设对方的 runtime 版本)。
1193
- 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) {
1194
1326
  const out = new Map()
1327
+ const markAll = (resolved) => { for (const id of ids) out.set(String(id), { resolved, snapshot: null }) }
1195
1328
  if (!ids || !ids.length) return out
1196
- if (typeof sq.readTitleSnapshots !== 'function') return out
1197
- try {
1198
- const results = await sq.readTitleSnapshots(ids)
1199
- if (!Array.isArray(results)) return out
1200
- results.forEach((result, index) => {
1201
- const id = String(ids[index])
1202
- out.set(id, unwrapSnapshot(result))
1203
- })
1204
- } 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)
1205
1359
  return out
1206
1360
  }
1207
1361
 
1362
+ // 兼容旧调用点(暂无):保留名字不导出,避免误用回「失败即 null」的旧语义。
1363
+
1208
1364
  // opts.usage: expose sizeBytes + updatedAt on each item (storage analysis and
1209
1365
  // the auto-archive sweep need them; the panel list does not).
1210
1366
  //
@@ -1212,7 +1368,8 @@ export function apply(ctx) {
1212
1368
  // 1. sp.list() 只调一次(原先列了两遍目录)
1213
1369
  // 2. 变更令牌优先来自 list 快照:legacy 走 locate+stat,SessionHandle 世代
1214
1370
  // 直接用 snapshot.revision(无 locate 可用,也绝不绕私有路径补 stat)
1215
- // 3. 未命中缓存的会话走**一次**批量投影(sq.readTitleSnapshots),而不是逐条
1371
+ // 3. 缓存未命中的会话**绝不在请求路径上同步投影**(issue #8:runtime 的
1372
+ // readTitleSnapshots 每条都是整本解码)——入队后台预热,先以占位元数据返回
1216
1373
  async function allSessionItemsDetailed(opts = {}) {
1217
1374
  let entries = []
1218
1375
  let headersOk = false
@@ -1242,37 +1399,59 @@ export function apply(ctx) {
1242
1399
  wsByPath = {}
1243
1400
  try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
1244
1401
  const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
1245
- const items = []
1246
1402
  const usage = await collectUsage(entries)
1247
- // 先按指纹把「缓存命中」与「需要解码」分开,只对后者做批量投影。
1403
+ // 先按指纹把「缓存命中」与「需要补齐」分开。
1248
1404
  const statsById = new Map(visibleIds.map((id) => [
1249
1405
  id,
1250
1406
  (usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
1251
1407
  ]))
1252
- const { missing } = metaCache.partition(visibleIds, statsById)
1408
+ const { cached, missing } = metaCache.partition(visibleIds, statsById)
1253
1409
  // P4:missing 里先查持久标题索引(冷启动跳过整本解码),命中的回填内存缓存。
1410
+ // cwd/createdAt 回填时以最新 list header 为权威——持久条目里的 cwd 可能因
1411
+ // 移动会话而陈旧(sz 指纹对 cwd 无感知),标题才是索引的产出物。
1254
1412
  const persisted = await hydrateFromPersist(missing, statsById)
1255
- for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
1413
+ for (const [id, meta] of persisted) {
1414
+ const entry = entryById.get(id)
1415
+ const header = entry && entry.header ? entry.header : null
1416
+ metaCache.set(id, statsById.get(id), {
1417
+ title: meta.title,
1418
+ cwd: (header && typeof header.cwd === 'string' && header.cwd) ? header.cwd : meta.cwd,
1419
+ createdAt: (header && header.createdAt != null) ? header.createdAt : meta.createdAt,
1420
+ })
1421
+ }
1256
1422
  const stillMissing = missing.filter((id) => !persisted.has(id))
1257
- const snapshotById = await projectTitles(stillMissing)
1258
- const decoded = new Map()
1259
- const collectDecoded = (id, meta) => { decoded.set(id, meta) }
1260
- const CHUNK = 6
1261
- for (let i = 0; i < visibleIds.length; i += CHUNK) {
1262
- // Arrow wrapper on purpose: Array#map passes (value, index, array), and
1263
- // resolveOne's second and third arguments are fixed here.
1264
- const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map((id) => {
1423
+ // issue #8:缺失部分交后台预热(分批 + 让步),请求路径零投影、零日志解码。
1424
+ if (stillMissing.length) enqueueWarm(stillMissing, statsById, entryById)
1425
+ const items = []
1426
+ for (const id of visibleIds) {
1427
+ // metaCache 优先:persisted 命中已带 header 权威 cwd/createdAt 回填进
1428
+ // metaCache(persisted.get 里的 cwd 可能因移动会话而陈旧)。
1429
+ // 完全未命中的会话也不能丢 cwd/createdAt——它们来自 list header,零成本
1430
+ // 且是工作区归属/创建时间的权威来源;只有标题允许占位等后台预热(issue #8)。
1431
+ let meta = metaCache.get(id, statsById.get(id)) || persisted.get(id) || null
1432
+ if (!meta) {
1433
+ meta = { title: null, cwd: null, createdAt: null }
1265
1434
  const entry = entryById.get(id)
1266
- return resolveOne(id, usage, {
1267
- exposeUsage: !!(opts && opts.usage),
1268
- listHeader: entry ? entry.header : null,
1269
- preloaded: snapshotById.has(id) ? snapshotById.get(id) : undefined,
1270
- collectDecoded,
1271
- })
1272
- }))
1273
- for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
1435
+ const header = entry && entry.header ? entry.header : null
1436
+ if (header) {
1437
+ if (typeof header.cwd === 'string') meta.cwd = header.cwd
1438
+ if (header.createdAt != null) meta.createdAt = header.createdAt
1439
+ }
1440
+ }
1441
+ const it = buildItem(id, meta, usage, !!(opts && opts.usage))
1442
+ items.push({ ...it, archived: currentArchived.has(it.sessionId) })
1443
+ }
1444
+ if (opts && opts.onlyArchived) {
1445
+ // /archived-sessions/list 的语义:只返回归档集里仍然存在的会话,
1446
+ // 且不带 archived 布尔注记(旧路由的输出形状)。
1447
+ const archivedItems = []
1448
+ for (const it of items) {
1449
+ if (!it.archived) continue
1450
+ const { archived: _drop, ...rest } = it
1451
+ archivedItems.push(rest)
1452
+ }
1453
+ return { items: archivedItems, usage }
1274
1454
  }
1275
- await persistDecoded(decoded, statsById)
1276
1455
  // Annotate stars; GC only when we have a trustworthy id baseline, so a
1277
1456
  // failing sp.list() can never wipe the whole index.
1278
1457
  let starredSet = new Set()
@@ -1360,6 +1539,7 @@ export function apply(ctx) {
1360
1539
  const activeTombstones = store.purgedSessionIds.map(String).filter((id) => !present.has(id))
1361
1540
  if (ids.length) {
1362
1541
  const usage = await collectUsage(entries)
1542
+ const entryById = new Map(entries.map((entry) => [entry.id, entry]))
1363
1543
  const statsById = new Map(ids.map((id) => [
1364
1544
  id,
1365
1545
  (usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
@@ -1367,30 +1547,23 @@ export function apply(ctx) {
1367
1547
  const { cached, missing } = metaCache.partition(ids, statsById)
1368
1548
  // P4:与列表构建共用持久标题索引,冷启动零解码。
1369
1549
  const persisted = await hydrateFromPersist(missing, statsById)
1370
- for (const [id, meta] of persisted) metaCache.set(id, statsById.get(id), meta)
1550
+ for (const [id, meta] of persisted) {
1551
+ const entry = entryById.get(id)
1552
+ const header = entry && entry.header ? entry.header : null
1553
+ metaCache.set(id, statsById.get(id), {
1554
+ title: meta.title,
1555
+ cwd: (header && typeof header.cwd === 'string' && header.cwd) ? header.cwd : meta.cwd,
1556
+ createdAt: (header && header.createdAt != null) ? header.createdAt : meta.createdAt,
1557
+ })
1558
+ }
1371
1559
  const rest = missing.filter((id) => !persisted.has(id))
1372
- const snapshotById = await projectTitles(rest)
1373
- const decoded = new Map()
1374
- const collectDecoded = (id, meta) => { decoded.set(id, meta) }
1560
+ // issue #8:缺失标题不在请求路径上同步投影(runtime 投影 = 逐条整本解码),
1561
+ // 交后台预热补齐;侧栏高频轮询会让补齐结果自然浮现。
1562
+ if (rest.length) enqueueWarm(rest, statsById, entryById)
1375
1563
  for (const id of ids) {
1376
- let meta = cached.get(id) || persisted.get(id) || null
1377
- if (!meta) {
1378
- const entry = entries.find((e) => e.id === id)
1379
- const snapshot = snapshotById.has(id)
1380
- ? snapshotById.get(id)
1381
- : (typeof sq.readTitleSnapshot === 'function' ? await sq.readTitleSnapshot(id).catch(() => null) : null)
1382
- const next = metaFromSnapshot(snapshot)
1383
- if (entry && entry.header) {
1384
- if (!next.cwd && typeof entry.header.cwd === 'string') next.cwd = entry.header.cwd
1385
- if (!next.createdAt && entry.header.createdAt != null) next.createdAt = entry.header.createdAt
1386
- }
1387
- metaCache.set(id, statsById.get(id), next)
1388
- if (statsById.get(id)) collectDecoded(id, next)
1389
- meta = next
1390
- }
1564
+ const meta = metaCache.get(id, statsById.get(id)) || persisted.get(id) || null
1391
1565
  if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
1392
1566
  }
1393
- await persistDecoded(decoded, statsById)
1394
1567
  }
1395
1568
  // 血缘分类(issue #6):结构分类(子代理 / fork 分支)来自 list snapshot
1396
1569
  // 的 header,零解码;空白判定见下方 refineEmptyLineage(0.1.3 起体积法
@@ -1406,6 +1579,9 @@ export function apply(ctx) {
1406
1579
  trashedSessionIds: store.items.map((item) => String(item.sessionId)),
1407
1580
  purgedSessionIds: activeTombstones,
1408
1581
  lineage,
1582
+ // v3.6.2:还有预热/退避重试在途 → client 缩短轮询节拍,预热一完成就把
1583
+ // 补齐的标题送回(含面板自动刷新)。无预热 API 时恒 false,绝不假忙。
1584
+ warmPending: warmPendingNow(),
1409
1585
  }
1410
1586
  }
1411
1587
 
@@ -1418,38 +1594,51 @@ export function apply(ctx) {
1418
1594
  // 的候选走官方 inspectSession 解码,按事件类型判定。超过上限的日志必有
1419
1595
  // 内容,直接跳过(维持体积法/结构分类的结果)。解码失败按「非空」处理:
1420
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
+ }
1421
1618
  async function refineEmptyLineage(lineage, entries) {
1422
1619
  if (!persistence || typeof persistence.inspectSession !== 'function') return
1620
+ const limiter = createLimiter(4)
1621
+ const todo = []
1423
1622
  for (const entry of entries) {
1424
1623
  const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
1425
1624
  if (size === null || size > EMPTY_DECODE_LIMIT) continue
1426
1625
  const id = String(entry.id)
1427
- let scanned = emptyScanCache.get(id)
1428
- if (!scanned || scanned.sizeBytes !== size) {
1429
- let isEmpty = false
1430
- try {
1431
- const types = []
1432
- await persistence.inspectSession(id, { onEvents: (batch) => {
1433
- for (const ev of batch || []) if (types.length < 64) types.push(ev && ev.type)
1434
- } })
1435
- isEmpty = isEmptyEventTypes(types)
1436
- } catch (e) { isEmpty = false }
1437
- scanned = { sizeBytes: size, empty: isEmpty }
1438
- emptyScanCache.set(id, scanned)
1439
- }
1440
- if (scanned.empty) {
1441
- // 结构分类没建条目的普通会话也要补上(0.1.3 空会话 header 无血缘字段,
1442
- // classifyLineage 对它们返回 null)。
1443
- const info = lineage[id] || (lineage[id] = { origin: null, parentSession: null, delegationDepth: 0, empty: false })
1444
- info.empty = true
1445
- } else if (lineage[id]) {
1446
- // 旧体积法在小日志上的假阳性(如 alpha.2 单事件日志)在这里纠正;
1447
- // 纠正后若无任何血缘结构,条目整个撤掉,维持「普通会话不产生条目」。
1448
- const info = lineage[id]
1449
- info.empty = false
1450
- if (!info.origin && !info.parentSession) delete lineage[id]
1451
- }
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 })
1452
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
+ })))
1453
1642
  // 缓存只保留「本轮仍参与判定」的会话:已彻底删除或日志膨胀超过解码
1454
1643
  // 上限的条目不再有用,淘汰掉防止 Map 随历史会话无限增长。
1455
1644
  const live = new Set()
@@ -1639,12 +1828,13 @@ export function apply(ctx) {
1639
1828
  const idStrs = ids.map(String).filter((id) => !hidden.has(id) && (materialized.has(id) || (live && live.get(id))))
1640
1829
  wsByPath = {}
1641
1830
  try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
1642
- const items = []
1643
- const CHUNK = 6
1644
- for (let i = 0; i < idStrs.length; i += CHUNK) {
1645
- const res2 = await Promise.all(idStrs.slice(i, i + CHUNK).map((id) => resolveOne(id)))
1646
- items.push.apply(items, res2)
1647
- }
1831
+ // issue #8:归档列表也复用「缓存直读 + 后台预热」的统一管线。
1832
+ // 旧实现逐条 resolveOne(id)(无 header、无指纹)——冷启动对每条归档
1833
+ // 会话整本解码,且连进程内缓存都永远命中不了。
1834
+ const wanted = new Set(idStrs)
1835
+ const detailed = await allSessionItemsDetailed()
1836
+ const items = detailed.items.filter((it) => wanted.has(String(it.sessionId)) && it.archived)
1837
+ for (const it of items) delete it.archived
1648
1838
  json(res, { items })
1649
1839
  } catch (e) {
1650
1840
  json(res, { error: String((e && e.message) || e) }, 500)
@@ -1847,7 +2037,7 @@ export function apply(ctx) {
1847
2037
  path: '/archived-sessions/sessions',
1848
2038
  handler: async (req, res) => {
1849
2039
  try {
1850
- json(res, { items: await allSessionItems() })
2040
+ json(res, { items: await allSessionItems(), warmPending: warmPendingNow() })
1851
2041
  } catch (e) {
1852
2042
  json(res, { error: String((e && e.message) || e) }, 500)
1853
2043
  }
@@ -1934,7 +2124,8 @@ export function apply(ctx) {
1934
2124
  // 子代理血缘树(管理视图数据面):给定父会话,返回 origin==='subagent'
1935
2125
  // 的递归子树。DSH 原生目录(ui-subagent)只读,这里补管理能力的数据来源。
1936
2126
  // 只读官方 header 血缘字段(parentSession/origin/delegationDepth/createdAt),
1937
- // 标题走既有批量投影(readTitleSnapshots),零整本日志解码。
2127
+ // 标题走 v3.6.2 的纯缓存读取(metaCache/持久索引/权威缓存),未命中入后台
2128
+ // 预热、本轮返回 null;请求路径零解码。
1938
2129
  // live 标记 = 会话在内存里活着(正在运行或已打开):批量清理必须跳过。
1939
2130
  disposers.push(ctx.webServer.register({
1940
2131
  kind: 'exact',
@@ -1970,9 +2161,29 @@ export function apply(ctx) {
1970
2161
  })
1971
2162
  if (Number.isFinite(sizeBytes)) sizeById.set(id, sizeBytes)
1972
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()
1973
2171
  try {
1974
2172
  for (const entry of await persistence.listEntries()) {
1975
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
+ }
1976
2187
  if (entry && entry.id != null) persistedIds.add(String(entry.id))
1977
2188
  }
1978
2189
  } catch (e) { /* best-effort */ }
@@ -2020,11 +2231,19 @@ export function apply(ctx) {
2020
2231
  const allIds = [rootId]
2021
2232
  const walk = (n) => { allIds.push(n.sessionId); n.children.forEach(walk) }
2022
2233
  nodes.forEach(walk)
2023
- 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)
2024
2243
  const titleOf = (id) => {
2025
- const snap = titles.get(id)
2026
- const m = snap ? metaFromSnapshot(snap) : null
2027
- 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)
2028
2247
  }
2029
2248
  const fill = (n) => { n.title = titleOf(n.sessionId); n.children.forEach(fill) }
2030
2249
  nodes.forEach(fill)