DeepSeek-Harness v0.1.5-alpha.1更新后旧会话打不开?5 步抢救 8MB 会话照抄

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.tsscanZstdFrames + 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 的字段 ------sourceEventSeqssurfaceOp{start,end}data.messageSeqsdata.shadowedSeqsdata.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),再决定是否深修))
历史列表正常但单个会话打不开 先备份移出会话目录止血(sessionssessions-backup),再决定是否深修

7.3 会话抢救检查清单

  • 报错来自 failed to observe session → 迁移按需触发,先备份移出目录止血
  • .dsh 数据前先改名/移出备份(沿用 Debug(07)原则:先备份,确认后再删)
  • 解码用项目自带 zstd.ts(多帧),不要对整个文件跑单帧解压
  • 修补用官方校验器当测试,迭代到绿灯;每轮修复后同步插入点之后所有事件的引用 seq 字段
  • 压缩写回必须是两帧(帧 1 = header 恰好一行,帧 2 = 事件)
  • 替换文件 → 重启服务 → curl :3080 返回 401 → UI 打开会话验证迁移

八、扩展阅读

本系列相关文章:

参考文献

相关推荐
倔强的石头1061 小时前
【深度学习】神经网络前向传播与反向传播推导
人工智能·深度学习·神经网络
行业研究员1 小时前
企业智能体安全厂商综合评估
人工智能·智能体安全
智能运维指南1 小时前
AI加持下的可观测平台与传统平台有什么本质区别?智能告警降噪和根因分析是否可靠?
运维·人工智能·可观测平台·嘉为蓝鲸
七牛云行业应用1 小时前
OpenCode 跑本地 Llama:编程 Agent 接入本地大模型的完整思路
人工智能·ai编程·llama
AI技术新视界1 小时前
从“只观不控”到“即时御防”:基于感知治理遥测(GAAT)的多 AI 智能体闭环治理架构
人工智能
Theo_xx1 小时前
声学感知基础:Day2(1).Chirp信号
人工智能·无线感知·声学感知
云雀衔光1 小时前
Java Spring AI MCP:在 Spring 里把 AI 接上你的业务系统
java·开发语言·人工智能·后端·测试工具·spring
小白说大模型1 小时前
去AI味提示词大全:25个实用Prompt帮你降低AI率
大数据·人工智能·pytorch·深度学习·机器学习·prompt
美狐美颜SDK开放平台1 小时前
从视频处理到实时渲染:直播APP中视频美颜SDK的关键技术解析
前端·人工智能·架构·实时互动·音视频·视频美颜sdk