DeepSeek-Harness v0.1.5-alpha.1更新后旧会话打不开?v2→v3 迁移拒绝------补 turn/end + seq 重排,8MB 会话抢救实录
Windows 10 · DeepSeek Harness 0.1.3-alpha.1 → 0.1.5-alpha.1 · Node v24.15.0 / pnpm 11.7.0 · zstd 拼接帧容器 · 最后更新 2026-09-09
一、这篇日志解决什么问题
一句话定位: 按上一期(Debug(07))的「三层残留」流程完成大版本更新、服务也正常启动了,但历史会话打开就报「Session migration from v2 to v3 refuses the transformed artifact」------会话格式从 v2 迁移到 v3 时被迁移器拒绝,一个 8MB 的真实会话打不开。本文记录从报错到救回会话的完整过程:定位迁移器校验逻辑、解码 zstd 拼接帧容器、补齐缺失的 turn/end 事件、踩平 seq 引用与压缩格式两个隐藏坑。
跳读指南:
- 只想抄修复命令 → 七、速查卡
- 想看主报错(迁移拒绝)的根因与修补思路 → 四、Debug #1
- 想看修补过程的隐藏坑(seq 引用同步)→ 五、Debug #2
- 想看压缩写回的格式坑(第一帧必须是 header)→ 六、Debug #3
- 想理解「为什么官方迁移器宁可拒绝也不自动修」→ 三、机制
阅读前提:
- 读过或经历过本系列 Debug(07)(大版本更新的三层残留排查),知道
git pull后还要处理构建产物、插件、存储缓存 - 部署过 git clone 版 DeepSeek Harness,
.dsh主目录与仓库同级(<项目根>\DeepSeek-Harness+<项目根>\.dsh) - 会跑
pnpm exec tsx这类项目内脚本
读完能得到:
- v2→v3 会话迁移机制:何时触发、校验什么、为什么拒绝后源文件原样保留
- 一套「会话文件抢救」的操作流:解码 zstd 拼接帧 → 校验器当测试跑 → 迭代修补 → 两帧压缩写回
- 两个容易踩的隐藏坑:事件插入后的 seq 引用同步、zstd 容器「第一帧 = header」的格式契约

二、背景:三层残留跑完了,还有一个「打不开」的会话
2026-09-09 例行更新 DeepSeek Harness,远端已领先 879 个提交,版本从 dsh-v0.1.3-alpha.1 跳到 dsh-v0.1.5-alpha.1。按 Debug(07)沉淀的流程执行:
bash
git pull
pnpm clean # ① 清旧编译产物
pnpm install # ② 同步依赖
pnpm run build # ③ 重新构建
cd <项目根>\.dsh\profiles\web
pnpm update dshmarket # ④ 升级第三方插件
pnpm dsh web # ⑤ 启动成功,curl :3080 返回 401
一切顺利------直到打开 WorkOS 工作区的历史会话。9 月 6 日开始的一个 8MB 大会话(里面是正在推进的任务上下文),一点就报:
历史加载失败:failed to observe session "session-a3c58a05-...":
Session migration from v2 to v3 refuses the transformed artifact:
turn/start 90 does not open expected turn 89; source v2 artifact remains
unchanged (raw log: <项目根>\.dsh\sessions\<工作区编码目录>\session-a3c58a05-...\session.v2.jsonl.zstd)
(gateway/internal)
任务进程需要这个会话继续跑。快速止血先把会话目录移出备份(UI 历史列表立即干净),但根治必须让迁移器接受这个文件------于是开始挖迁移逻辑。
三、机制:为什么迁移器宁可「拒绝」也不「修补」
3.1 迁移是按需触发的
会话文件不是启动时一次性迁移的。服务启动时只扫描每个会话的 header (列表显示用);真正的 v2→v3 内容迁移在 observe(打开/恢复)会话时才执行。所以表现为:服务正常、其他会话正常、单独这个会话打不开。
3.2 迁移 = 转换 + 关系校验,校验不过就整体拒绝
v2→v3 迁移器(packages/session/session-format-v2-to-v3)把每个事件从 v2 格式转换到 v3 格式,转换完成后对整个产物 跑一遍跨事件关系校验(复用 session-format-v0-to-v1 的 released validator)。校验器逐事件维护状态机,检查:
| 关系 | 规则 |
|---|---|
| turn 配平 | turn/start N 时要求前一 turn 已 turn/end 关闭、且 N 严格递增 |
| step 归属 | step/start/end 必须落在当前 open turn 内、step 号连续 |
| tool 生命周期 | 每个 tool/call 必须有先前广告(assistant/message 的 tool-call)且最终配对 tool/result |
| surface 引用 | compaction/summary 的 shadowedSeqs 必须精确等于当前 surface 上的节点序列 |
任一关系不满足 → 抛 SessionFormatError → 迁移器拒绝落盘转换产物,源 v2 文件原样保留。
3.3 保守拒绝是设计,不是缺陷
仓库源码注释和迁移测试都印证了这个设计取向:结构可疑的记录一律走拒绝路径,不做猜测性转换("records that are similar in appearance but structurally wrong ... take the rejection path")。原因很实际:会话是任务上下文,猜错修补比打不开更糟------打不开至少数据无损,猜错可能静默丢上下文。所以官方迁移器对「看起来像但结构不对」的文件唯一动作就是:保留源文件 + 报错。
这意味着:这类文件只能靠外部工具人工修------而修的过程恰好可以复用官方校验器当「测试」。
四、Debug #1 --- 迁移拒绝:writer 漏写了四个 turn/end
报错日志
Error: dsh: plugin tree failed to load: failed to apply loader entry
include (cordis:include): ...workspace (@deepseek-ai/dsh-workspace):
corrupt Zstandard session log: first frame is not exactly one header line
这是把文件放回后服务启动阶段报的错(Debug #3 详述,先跳过)。Debug #1 的真实报错是文章开头那条 observe 失败。为了解它,先解码文件看内容。
根因
第一步:理解 v2 文件的物理格式。 .zstd 后缀会让人以为它是「一个 zstd 文件」,实际它是 zstd 拼接帧容器 :writer 每次追加一批记录就压缩成一帧拼到文件尾(支持崩溃恢复时丢弃尾部残帧)。报错的 8MB 文件里共有 3490 帧 ,解码后是 6340 行 JSONL(26.4MB)。直接对整个文件跑单帧解压只会得到第一帧------只有 header 一行。
解码要复用项目自己的帧扫描器(packages/session/session-persistence-jsonl/src/zstd.ts 的 scanZstdFrames + decompressZstdFrame,Node 24 的 node:zlib 原生支持 zstd),用 pnpm exec tsx 跑一个临时脚本即可。
第二步:用校验器的语言定位结构缺口。 报错说 turn/start 90 does not open expected turn 89------翻译成状态机语言:遇到 turn/start 90 时,turn 89 还开着(没有 turn/end)。全文件扫描所有 turn 配平:
turn/start 计数:109
turn/end 计数:105
缺口:turn 89、97、103、104 没有 turn/end
四个缺口的模式完全一致。对比正常 turn 的结尾:
正常:step/end N → turn/end N → agent/inbox/spliced → turn/start N+1
缺口:step/end N → agent/inbox/spliced → turn/start N+1 ← turn/end N 缺失
而每个出问题 turn 的最后一个 step 都含 assistant/attempt 事件 (一次 assistant 尝试被中断的记录)。时间戳佐证:turn 89 的最后事件与 turn 90 的开始相隔约 7 分钟------是中断后用户继续任务的续写点。
结论 :v2 writer 在「assistant attempt 中断 → 用户继续」的场景下漏写了 turn/end(v2 时代写入校验没覆盖这条路径),文件本身数据完整,只是格式缺了 4 个关闭事件。文件不是损坏------是 v2 writer 的边界 bug 留下的记录,被 v3 更严的校验拦下。
对比表
| 维度 | 正常 turn 结尾 | 出问题的 turn 结尾 |
|---|---|---|
| 事件序列 | step/end → turn/end → spliced → turn/start | step/end → spliced → turn/start(无 turn/end) |
| 末 step 特征 | 普通 message/step 流程 | 含 assistant/attempt(中断场景) |
| v3 校验 | 通过 | does not open expected turn 拒绝 |
代码修复
写一个迭代修补脚本:把官方校验器当测试跑,报什么错修什么,直到绿灯:
ts
// 伪代码:repair-session.ts
import { assertReleasedArtifactRelationships } from '.../session-format-v0-to-v1/src/relationships.ts'
// v2 的关系校验扩展(照抄 session-format-v1-to-v2 的定义)
const EXT = { stepEvents: new Set(['assistant/attempt']), preservedSourceTitleRequestText: true }
while (true) {
renumberSeqs(events) // 每个事件 seq = 数组下标(要求稠密)
try {
assertReleasedArtifactRelationships({ header, events }, EXT)
break // 绿灯,退出
} catch (err) {
const m = /turn\/start (\d+) does not open expected turn (\d+)/.exec(err.message)
// 在缺口 turn 的 spliced 事件前插入 turn/end(格式照抄正常事件):
// {"type":"turn/end","seq":<n>,"time":<上一事件时间>,"data":{"turn":<n>,"reason":{"kind":"completed"}}}
events.splice(insertAt, 0, turnEndEvent)
}
}
4 轮迭代分别补上 turn 89、97、103、104 的 turn/end,第 5 轮校验通过。修补全程在备份副本上进行(源文件 .orig 始终未动)。
验证
round 5: RELATIONSHIP VALIDATION PASSED
五、Debug #2 --- 修补后校验报 compaction/summary 不匹配:seq 引用没同步(假阳性)
报错日志
前 4 个 turn/end 补上后,第 5 轮校验报了一个完全不同的错:
compaction/summary shadowedSeqs do not name an exact current surface span
根因
先用 surface 模拟器(独立复刻校验器的 surface 维护逻辑)跑未修补的源文件 ------0 失败,说明 compaction 记录本身没问题。这个错是我修出来的。
JSONL 事件日志的 seq 是全局稠密索引 (校验器要求 seq === 数组下标),所以插入 1 个事件后,插入点之后的所有事件 seq 必须整体 +1。我只重排了事件的 seq 字段,却漏了事件内部引用 seq 的字段 ------sourceEventSeqs、surfaceOp{start,end}、data.messageSeqs、data.shadowedSeqs、data.shadowedRange{start,end}、data.sourceEventSeq 这些字段里存的是「被引用事件的 seq 值」,不是偏移量。插入点之后的节点 seq 全部 +1 了,引用它们的字段值却没跟着 +1 → 校验器按新 seq 去 surface 上找,错位 1 → 假阳性。
对比表
| 维度 | 未同步引用(第一版修补) | 同步引用(修正版) |
|---|---|---|
| 事件 seq 字段 | 重排 ✓ | 重排 ✓ |
| 内部引用字段(shadowedSeqs 等) | 保持旧值 ✗ | 值 ≥ 插入点的 +1 ✓ |
| 校验结果 | compaction/summary 假阳性 | 通过 |
代码修复
插入事件后,对插入点之后的每个事件做一次引用同步:
ts
function bumpRefs(e, P) { // P = 插入点(原 seq)
const bump = v => (typeof v === 'number' && v >= P ? v + 1 : v)
if (Array.isArray(e.sourceEventSeqs)) e.sourceEventSeqs.forEach((_, i) => e.sourceEventSeqs[i] = bump(e.sourceEventSeqs[i]))
if (e.surfaceOp?.op === 'replace') { e.surfaceOp.start = bump(e.surfaceOp.start); e.surfaceOp.end = bump(e.surfaceOp.end) }
const d = e.data
if (d) {
if (Array.isArray(d.messageSeqs)) d.messageSeqs.forEach((_, i) => d.messageSeqs[i] = bump(d.messageSeqs[i]))
if (Array.isArray(d.shadowedSeqs)) d.shadowedSeqs.forEach((_, i) => d.shadowedSeqs[i] = bump(d.shadowedSeqs[i]))
if (d.shadowedRange) { d.shadowedRange.start = bump(d.shadowedRange.start); d.shadowedRange.end = bump(d.shadowedRange.end) }
if (typeof d.sourceEventSeq === 'number') d.sourceEventSeq = bump(d.sourceEventSeq)
}
}
判断规则:字段值是「被引用事件的旧 seq」,如果 ≥ 插入点,说明被引用事件也移到了插入点之后(它的新 seq = 旧 seq + 1),引用要跟着 +1;小于插入点的引用不受影响。事件日志语义保证引用总是指向更早的事件,所以「插入点后的事件引用插入点前的节点」天然不会发生跨侧断裂。
验证
round 5: RELATIONSHIP VALIDATION PASSED ← 修正引用同步后通过
六、Debug #3 --- 文件放回后服务起不来:zstd 第一帧必须是 header 一行
报错日志
修补后的 JSONL 压缩成 .zstd、放回会话目录、重启服务------直接崩在启动阶段:
Error: dsh: plugin tree failed to load: ... workspace (@deepseek-ai/dsh-workspace):
corrupt Zstandard session log: first frame is not exactly one header line
at assertZstdHeaderFrame (.../session-persistence-jsonl/src/index.ts:77)
根因
我第一版压缩把整个文件(header + 全部 6340 行事件)压成了一个单帧 。而持久化层对容器有格式契约(assertZstdHeaderFrame):第一帧解码后必须恰好是 header 一行------这是「读恢复」设计的一部分:服务启动扫描会话列表时,只需要解第一帧拿 header 就能列标题,不必解整个大文件。
单帧整压违反了契约:第一帧 = 全部内容(几千行),header 检查直接判 corrupt。
对比表
| 维度 | 单帧整压(错误) | 两帧格式(正确) |
|---|---|---|
| 帧 1 | header + 全部事件(几千行) | 恰好 header 一行 |
| 帧 2 | --- | 全部事件 |
| 启动扫描 | first frame is not exactly one header line |
正常读 header |
代码修复
按容器契约重压:帧 1 = header 行,帧 2 = 全部事件行。
ts
import { compressZstdFrame } from '.../session-persistence-jsonl/src/zstd.ts'
const nl = text.indexOf('\n')
const headerFrame = await compressZstdFrame(text.slice(0, nl + 1)) // 帧1:header 恰好一行
const eventsFrame = await compressZstdFrame(text.slice(nl + 1)) // 帧2:全部事件
writeFileSync(out, Buffer.concat([headerFrame, eventsFrame]))
教训:不要凭文件后缀假设格式 。
.zstd文件既不是「一个 zstd 帧」,帧内也不是「任意内容」------writer 的追加式容器有自己的帧语义(第一帧固定是 header),复制替换前先读一遍持久化层的格式代码。
验证
bash
pnpm dsh web # 启动成功
curl http://127.0.0.1:3080/ # 401(正常)
浏览器打开 WorkOS 工作区 → 点开会话 → v2→v3 迁移成功,历史内容完整加载 ,任务进程恢复可用。原始文件与全部中间产物留在备份目录(.orig、.full.jsonl、.repaired.jsonl),确认稳定后清理。
七、速查卡
7.1 文件与路径汇总
| 内容 | 路径 | 说明 |
|---|---|---|
| 会话持久化数据 | <项目根>\.dsh\sessions\<工作区编码目录>\<会话ID>\ |
真实会话,含 session.v2.jsonl.zstd |
| 会话文件物理格式 | zstd 拼接帧容器 | 每批一帧,帧内是完整 JSONL 行;第一帧 = header 一行 |
| 帧扫描/解码工具 | packages/session/session-persistence-jsonl/src/zstd.ts |
scanZstdFrames / decompressZstdFrame / compressZstdFrame |
| v2 关系校验器 | packages/session/session-format-v0-to-v1/src/relationships.ts |
assertReleasedArtifactRelationships(v2 extensions 见 v1-to-v2/validation.ts) |
| v2→v3 迁移器 | packages/session/session-format-v2-to-v3/src/ |
migration / validation / payload |
7.2 常见报错 → 解决方案
| 报错特征 | 解决 |
|---|---|
Session migration from v2 to v3 refuses the transformed artifact: turn/start N does not open expected turn M |
v2 writer 漏写 turn/end → 解码后迭代补事件([Debug #1](#1) compaction/summary shadowedSeqs do not name an exact current surface span(修补过程中) 插入事件后未同步引用 seq 字段(Debug #2) corrupt Zstandard session log: first frame is not exactly one header line 压缩格式错:第一帧必须是 header 一行(Debug #3) 历史列表正常但单个会话打不开 先备份移出会话目录止血(sessions → sessions-backup),再决定是否深修)) |
compaction/summary shadowedSeqs do not name an exact current surface span(修补过程中) |
插入事件后未同步引用 seq 字段([Debug #2](#1) compaction/summary shadowedSeqs do not name an exact current surface span(修补过程中) 插入事件后未同步引用 seq 字段(Debug #2) corrupt Zstandard session log: first frame is not exactly one header line 压缩格式错:第一帧必须是 header 一行(Debug #3) 历史列表正常但单个会话打不开 先备份移出会话目录止血(sessions → sessions-backup),再决定是否深修)) |
corrupt Zstandard session log: first frame is not exactly one header line |
压缩格式错:第一帧必须是 header 一行([Debug #3](#1) compaction/summary shadowedSeqs do not name an exact current surface span(修补过程中) 插入事件后未同步引用 seq 字段(Debug #2) corrupt Zstandard session log: first frame is not exactly one header line 压缩格式错:第一帧必须是 header 一行(Debug #3) 历史列表正常但单个会话打不开 先备份移出会话目录止血(sessions → sessions-backup),再决定是否深修)) |
| 历史列表正常但单个会话打不开 | 先备份移出会话目录止血(sessions → sessions-backup),再决定是否深修 |
7.3 会话抢救检查清单
- 报错来自
failed to observe session→ 迁移按需触发,先备份移出目录止血 - 动
.dsh数据前先改名/移出备份(沿用 Debug(07)原则:先备份,确认后再删) - 解码用项目自带
zstd.ts(多帧),不要对整个文件跑单帧解压 - 修补用官方校验器当测试,迭代到绿灯;每轮修复后同步插入点之后所有事件的引用 seq 字段
- 压缩写回必须是两帧(帧 1 = header 恰好一行,帧 2 = 事件)
- 替换文件 → 重启服务 →
curl :3080返回 401 → UI 打开会话验证迁移
八、扩展阅读
本系列相关文章:
- Debug(07):DeepSeek-Harness 更新后启动连环报错?pnpm clean + 插件升级 + 缓存重建------大版本升级的三层残留排查 --- 本文的直接前篇:本次更新跑完的三层残留流程出自该文
- Debug(06):AI 写代码反复返工?先冻结契约再让 AI 动手------26 份 ADR 的实战提炼 --- 同系列方法论篇:排障修的是「代码层」还是「规则层」,先分类再动手
- Debug(05):Claude Code 一用 Bash 就刷屏 hook 报错 node 找不到?Git Bash 的 PATH 里根本没有 node --- 同系列排障日志,环境层问题排查
参考文献
- deepseek-ai/deepseek-harness --- GitHub --- 官方仓库,本文全部源码排查依据(session-format 系列包)
- zstd 帧格式规范 --- Frame_Header / Block 结构说明
- Debug(07)大版本三层残留排查 --- 本系列前篇,更新的标准流程来源