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/README.en.md +3 -3
- package/README.md +3 -3
- package/lib/client.js +100 -28
- package/lib/client.js.map +2 -2
- package/lib/index.js +217 -85
- package/lib/index.js.map +2 -2
- package/package.json +1 -1
- package/src/client/index.jsx +104 -28
- package/src/client/logic.js +29 -0
- package/src/index.js +264 -86
- package/src/title-persist-index.js +13 -3
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
|
|
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 (
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
1468
|
-
if (
|
|
1469
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
2067
|
-
const m =
|
|
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 =
|
|
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
|
-
|
|
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) {
|