跨会话 / 同项目时间线(设计文档)

August 16, 2026 · View on GitHub

状态:已实现(0.2.0,2026-08-16)。本文是 dsh-checkpoint-diff"跨会话 / 同项目时间线"的决策记录:数据源、合并算法、血缘组织、API/GUI 形态、降级 与安全边界。实现分片 M-A/M-B/M-C 已全部落地(lib/project.mjs + lib/service.mjs scope=project + GUI scope/分支切换 + /diff --project), 与本文 §3–§6 一致;与实现的偏差已在各节以"实现注"标注。 所有事实均已对照 deepseek-harness 源码与 dsh-checkpoint-rewind 核实。

1. 动机与现状

现状(v0.1.0):时间线按 (sessionId, cwd) 过滤当前会话的检查点。问题:

  1. /rewind fork 后时间线"重置":子会话是新 sessionId,父会话的历史检查点 不可见;用户回退后想继续看"之前发生了什么"做不到。
  2. 同项目新会话不可见旧会话检查点:同一 workspace(同一 workspaceKey)换一个 会话继续开发,新会话的时间线是空的。
  3. 快照本身按 workspace 存储($DSH_HOME/dsh-checkpoint-rewind/<key-hash>/), 但记录被 (sessionId, cwd) 的过滤逻辑隔开。

目标:按 workspaceKey 合并同一项目的全部检查点;沿 /rewind fork 血缘SessionHeader.parentSessionsessionQuery.traceSession)组织会话分支; 每条记录仍携带归属会话,UI/命令可区分"本会话 / 本项目"。

非目标:不改 rewind(快照生产者);不引入任何写路径;不合并不同 workspace。

2. 事实基础(已探查)

2.1 检查点记录(checkpoints 域,消费方硬契约)

{ id, sessionId, cwd, seq, time, provider: 'git'|'copy',
  triggerTool, turn, step, files, bytes, ref,
  stepEndSeq?, forkSeq? }
  • seq:会话内单调序号;forkSeq:该 turn 的 turn/end seq(rewind 补记,fork 边界); stepEndSeq:该 step 的 step/end seq。
  • 配额清理按 sessionId 计(maxSnapshots 每会话 50 条;全局字节软配额 maxSnapshotBytes,每会话最新一条保留)——跨会话合并后,历史会话的旧节点 被清理的概率更高,UI 必须优雅降级。

2.2 会话元数据(@deepseek-ai/dsh-session

  • SessionHeader{ version, id, createdAt, cwd?, parentSession?, seedLength?, origin?, delegationDepth?, agentPreset? }
  • ctx.sessions(live 内存库)get(id) 只返回存活会话;冷会话需要 persistence。
  • session.events 是冻结的只读快照({type, seq, time, data, …});意图命名只读它。

2.3 会话查询服务(@deepseek-ai/dsh-session-query,宿主 ctx.sessionQuery

方法用途(本设计)
traceSession(id)血缘:{target, ancestors[], descendants[], complete, root?/unresolvedParentId?}SessionRecord = {header, live, persisted}
readSession(id)冷会话全量事件(SessionLogSnapshot {session, events})——跨会话意图命名的事件来源
readTitle(Snapshot)(id)会话标题(UI 分支标签)
filterSessions / listSessions候选会话枚举(按 cwd 过滤)

sessionQuery 是宿主可选服务:ctx.get('sessionQuery'),缺席时功能降级(见 §6)。

2.4 rewind 的 fork 语义

  • /rewind 恢复后从该 turn 边界 fork 新会话ctx.sessions.create(id, { seed, meta: { cwd, parentSession: 父会话 id } }),子会话 header 带 parentSession
  • 记录上的 forkSeq 是 fork 边界(turn/end seq);子会话时间线"从零开始"是 rewind 的既定行为(本设计只是读取侧合并展示,不改它)。

3. 合并模型

3.1 记录集合

scope = 'session'  → 现状:(sessionId == 当前会话) && (key(cwd) == key(当前 cwd))
scope = 'project'  →  (key(record.cwd) == key(当前 cwd))   // 跨会话、跨 fork
  • workspaceKeyOfpath.resolve + Windows 小写(大小写不敏感,既有函数)。
  • 每条视图增加 sessionId(现 recordView 刻意不含;项目视图需要)+ branchId (血缘根会话 id,见 3.2)。

3.2 血缘组织(分支)

以当前会话为起点 traceSession(currentId)

  • complete: trueroot 为血缘根;沿 ancestors[](近→远)与 descendants[](递归)得到本血缘树的所有会话。
  • complete: falseunresolvedParentId:父链超出可见语料(会话被清/持久层 不可见);不视为错误,血缘树以可见部分为根,UI 标注"更早历史不可见"。
  • 血缘可能包含多个 root(兄弟 fork 的父链汇合)——按 createdAt 排序。
  • forkSeq 边界:血缘树中父子会话衔接点 = 父会话记录中 forkSeq 对应的 turn/end 位置;合并时间线中在衔接处插入"分支标记"。

会话 id 是跨重启稳定的(session-<n> mint 自持久 header),血缘可跨进程重启 复现。traceSessionlive/persisted 标志让 UI 区分"活会话/历史会话"。

3.3 排序与去重

  • 全序:time 升序;同 time(seq, id)。fork 处父子记录天然按 time 交错, 无需特殊重排;分支标记只在渲染层叠加。
  • 同一 snapshot 不会出现在两个会话(记录只有一份,sessionId 归属唯一)。
  • guard 记录triggerTool: 'rewind')保留在原会话位置,UI 沿用标记。

4. 查询管线(新)

scope=project 请求
 ├─ key = workspaceKeyOf(current.cwd)
 ├─ 全表扫描 entries → 按 key 过滤(O(全表),量级小;未来可投影单元化)
 ├─ 当前会话血缘:traceSession(currentId)(sessionQuery 可选)
 ├─ 会话标题:readTitle*(血缘内会话,可选)
 ├─ 意图 label:
 │    ├─ live 会话:session.events(现状路径,零额外开销)
 │    └─ 冷会话:readSession(id).events(按需、逐会话一次、失败降级)
 └─ 视图:recordView + {sessionId, branchId, label?, sessionTitle?}

新增纯函数(放 lib/,全部可单测):

  • lib/project.mjs
    • projectRecords(entries, key) — key 过滤 + 全序排序。
    • mergeLineage(records, trace) — 血缘树 → 分支标记序列({atId, kind: 'fork'|'root-missing', fromSession?, toSession?})。
    • branchIdOf(record, lineageRoots) — 记录归属分支(按血缘 root 分组; 不在血缘内的同 key 会话归为"旁支",branchId = 会话自身)。

5. API / GUI / 命令形态

5.1 HTTP API(向后兼容)

  • GET /api/timeline?session=<id>&scope=projectscope 缺省 = session,现状不变)。 返回 {records[], branches[{id, sessionId, title?, isCurrent, root}], markers[]}
  • summary / file-difffrom/to 寻址跨会话生效(id 前缀在项目范围内 解析);解析歧义时优先本会话记录。
  • 跨 provider 与缺失降级规则不变(记录级校验,与归属会话无关)。

5.2 /diff 命令

  • /diff(本会话,现状);/diff --project(项目范围,血缘树头部列出各分支)。
  • from/to 输出行附带 (会话短id) 与分支标记。

5.3 GUI 面板

  • 工具栏加 scope 切换("本会话 / 本项目")+ 分支下拉(血缘树会话,标题或短 id)。
  • 时间线节点颜色/图标区分分支;fork 衔接处渲染分支线。
  • 树形文件视图与逐行 diff 不变(只换数据源范围)。

6. 降级矩阵

条件行为
sessionQuery 未挂载scope=project 退化为"同 key 扁平合并"(无分支组织、无冷会话标题/label)
traceSession 抛错/不完整血缘以可见部分组织,标注 unresolvedParentId;不失败
冷会话事件不可读该会话记录 label 回退 triggerTool(现状格式)
记录被配额/clear/gc 清理缺失节点照旧优雅降级(与现状一致)
血缘根记录也被清理分支标记基于 header 血缘重算,与记录存活无关
同一 cwd 的旁支会话(无血缘)归入"旁支"分组(branchId = 自身),不丢失

7. 安全边界(不可放松)

  • 全部读取路径不变:git ref 40/64-hex 校验、copy ref UUID + rel 拒绝 ..、 只读 GET、命令只读。合并只发生在记录/header 的只读投影上
  • 不新增任何对 sessions/storage/git 的写调用;不 fork、不恢复、不删除。
  • readSession/traceSession 返回的是宿主的脱敏快照(查询服务自带授权/清理), 本插件不做二次外传。

8. 实施分片(0.2.0 已全部完成)

  1. M-A 扁平合并scope=project 只做 key 过滤 + 全序 + sessionId 透出; API/命令/GUI scope 切换。单测覆盖排序/去重/寻址跨会话。→ lib/project.mjs projectRecords/resolveRecordProject + recordView.sessionId
  2. M-B 血缘组织mergeLineage + 分支标记 + GUI 分支线;sessionQuery 可选 注入。集成测试用 ctx.sessions.create(…, { meta: { parentSession } }) 造 fork。 → lib/project.mjs mergeLineage/buildBranches;GUI 分支下拉 + 摘要区 fork/root-missing 标记(实现注:分支"线"以标记行呈现,未做节点间连线)。
  3. M-C 跨会话意图命名与标题:冷会话事件经 readSession 按需读取(带缓存与 失败降级);GUI 分支下拉显示 readTitle。→ lib/service.mjs labelIndexFor/branchTitlesFor(请求级缓存)。
  4. 收尾:README/CHANGELOG/架构图更新;集成测试全绿;发布 0.2.0。

9. 参考

  • packages/session-query/session-query/src/{index,tracing,types}.ts(harness 源码)
  • packages/core/session/src/types.ts(SessionHeader / SessionEventMap)
  • rewind lib/checkpoints.mjs(prunePlan 按 sessionId)、index.mjs(fork 语义)
  • 本仓库 lib/workspace.mjs(workspaceKeyOf / snapshotBaseDir)