跨会话 / 同项目时间线(设计文档)
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.mjsscope=project + GUI scope/分支切换 +/diff --project), 与本文 §3–§6 一致;与实现的偏差已在各节以"实现注"标注。 所有事实均已对照 deepseek-harness 源码与 dsh-checkpoint-rewind 核实。
1. 动机与现状
现状(v0.1.0):时间线按 (sessionId, cwd) 过滤当前会话的检查点。问题:
/rewindfork 后时间线"重置":子会话是新sessionId,父会话的历史检查点 不可见;用户回退后想继续看"之前发生了什么"做不到。- 同项目新会话不可见旧会话检查点:同一 workspace(同一
workspaceKey)换一个 会话继续开发,新会话的时间线是空的。 - 快照本身按 workspace 存储(
$DSH_HOME/dsh-checkpoint-rewind/<key-hash>/), 但记录被(sessionId, cwd)的过滤逻辑隔开。
目标:按 workspaceKey 合并同一项目的全部检查点;沿 /rewind fork 血缘
(SessionHeader.parentSession → sessionQuery.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/endseq(rewind 补记,fork 边界);stepEndSeq:该 step 的step/endseq。- 配额清理按 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
workspaceKeyOf:path.resolve+ Windows 小写(大小写不敏感,既有函数)。- 每条视图增加
sessionId(现recordView刻意不含;项目视图需要)+branchId(血缘根会话 id,见 3.2)。
3.2 血缘组织(分支)
以当前会话为起点 traceSession(currentId):
complete: true→root为血缘根;沿ancestors[](近→远)与descendants[](递归)得到本血缘树的所有会话。complete: false→unresolvedParentId:父链超出可见语料(会话被清/持久层 不可见);不视为错误,血缘树以可见部分为根,UI 标注"更早历史不可见"。- 血缘可能包含多个 root(兄弟 fork 的父链汇合)——按
createdAt排序。 forkSeq边界:血缘树中父子会话衔接点 = 父会话记录中forkSeq对应的 turn/end 位置;合并时间线中在衔接处插入"分支标记"。
会话 id 是跨重启稳定的(
session-<n>mint 自持久 header),血缘可跨进程重启 复现。traceSession的live/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.mjsprojectRecords(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=project(scope缺省 =session,现状不变)。 返回{records[], branches[{id, sessionId, title?, isCurrent, root}], markers[]}。summary/file-diff的from/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 已全部完成)
- M-A 扁平合并:
scope=project只做 key 过滤 + 全序 +sessionId透出; API/命令/GUI scope 切换。单测覆盖排序/去重/寻址跨会话。→lib/project.mjsprojectRecords/resolveRecordProject+recordView.sessionId。 - M-B 血缘组织:
mergeLineage+ 分支标记 + GUI 分支线;sessionQuery可选 注入。集成测试用ctx.sessions.create(…, { meta: { parentSession } })造 fork。 →lib/project.mjsmergeLineage/buildBranches;GUI 分支下拉 + 摘要区 fork/root-missing 标记(实现注:分支"线"以标记行呈现,未做节点间连线)。 - M-C 跨会话意图命名与标题:冷会话事件经
readSession按需读取(带缓存与 失败降级);GUI 分支下拉显示readTitle。→lib/service.mjslabelIndexFor/branchTitlesFor(请求级缓存)。 - 收尾: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)