DeepSeek Harness 会话打不开:turn/end reason 为 null 与重复 id 写坏日志

故障排查发布于 2026-10-17作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSH 会话损坏turn/endreason nulltoolCallIdSessionFormatError会话空白
DeepSeek Harness 里会话点开就是空白、或直接报无法加载,常见三种写入端与读取端不一致:cancel 写入 turn/end 时 reason 落成 null、run_code 子调用 id 在一条会话内重复、pruner 在回合外写替换 tool/result。本文给出定位命令、字段级修复与预防插件。

DeepSeek Harness(DSH)里「会话打不开」有一类特别迷惑人的形态:文件其实没有坏,校验也能过,但打开就是空白或直接报无法加载。 本批讨论里这一类被拆成三条独立机制,共同点都是写入端与读取端对同一份日志的理解不一致:cancel() 把 turn/end 的 reason 写成 null(读取端读 reason.kind 直接抛错,错误边界把整块对话视图渲染成空 div,于是所有会话都看起来空白);run_code 的子调用 id 在同一条会话内重复(写端不校验、读端强制唯一,导致整条会话被整体拒绝);tool-result pruner 在回合之外写替换 tool/result(读端要求非追加写入必须落在开启的回合内,于是读端拒绝了自己写出来的内容)。三条都不需要「猜」,日志里都有可搜的判别特征,本文按「一条坏事件 → 重复 id → 回合外写入」逐个给出定位命令与字段级修复。

DeepSeek Harness 会话 turn/end 的 reason 为 null:一条坏事件让整个会话列表变空白

一句话结论:turn/end 的 reason 落成 null 之后,客户端在读 reason.kind 时抛错、错误边界把整块 conversation.view 渲染成空 div,所以「一个会话坏」会表现成「所有会话都空白」。 症状有两种程度:轻则对话区空白但轨迹视图正常,重则整个会话列表都变空白(#8832、#9156)。

DSH插件 机制:写入端抛错 + 读取端裸读,两侧各缺一个保护

据多名社区成员逐行核对源码,链路上有两处缺口,任意一处补上都不会崩,但两处都缺:

  1. 写入端:cancel(cause) 的入口不校验 cause;把它翻译成协议字段的函数只认识 user / parent / disposed / hook 四种取值,遇到不认识的值会在 catch 块内抛错,使本该被赋值的 turnEnds 变量停留在初始的 null,而 finally 无条件把它落盘:
turn/end { "kind": "aborted", "reason": null }   ← 违反协议
  1. 读取端:客户端多处直接读 end?.data.reason.kind / event.data.reason.kind,没有做空值保护;读到 null 即抛 Cannot read properties of null (reading 'kind')。据讨论中定位,读端这样的裸读点约有 11 处(#9156)。

还有一个常被忽略的事实:validateSessionEventData 不校验 turn/end 的 payload,所以 null 能顺利写进磁盘;而会话的加载校验又不检查这一项,于是「文件合法、读取崩溃」成立(#9699)。

DeepSeek Harness 编号定位:三步找到那条坏事件

关键判据是:会话日志里能搜到 "reason":null,而会话自身的加载校验会正常通过。 三步:

  1. 看症状组合。 预期同时满足:对话区空白 / 会话列表空白、轨迹视图仍可正常渲染、重载或重启后恢复一下又复发。这一组合基本可以定向到本条(#9156)。
  2. 在会话日志里搜坏值。 会话日志位于 ~/.dsh/storages/sessions/(Windows 为 %USERPROFILE%\.dsh\storages\sessions\)。对目标会话文件执行:
powershell
Select-String -Path "<会话文件>" -Pattern '"reason":null'
#   预期:命中若干行,即那条违反协议的 turn/end
  1. 确认是 turn/end 而不是别的字段。 看命中行附近的事件类型是否为 turn/end;只有 turn/end 的 reason 为 null 才会让整块视图崩(#8832)。

DSH插件 修复:把 reason 补成完整结构,并加一道预防

手工修复只有一个要点——补的形状必须完整**,写短了会从一个崩溃换成另一个崩溃。** 步骤:

  1. 先备份会话文件(复制一份到工作区外),再动手。
  2. 把命中的 "reason":null 改成协议要求的完整结构:
json
{"kind":"aborted","reason":{"kind":"hook","reason":"unknown"}}

注意不能简写成 {"kind":"aborted"}——讨论中实测,读端下一步会去读 reason.reason.kind,短写法会直接触发另一个崩溃(#8832)。

  1. 只改该字段,保持 seq 与帧结构不变:不重排事件、不重新编号、不改其它字段,否则会把「可修的脏值」变成「不可读的日志」。
  2. 重载页面,预期对话区恢复;若仍空白,检查是否还有第二处 "reason":null。
  3. 加预防。 社区插件 @argszero/dsh-cancel-cause-guard 在 agent/created 阶段包裹 cancel(),把越界的 cause 归一成 {kind:'hook', reason},从源头阻止新的 null 落盘:
bash
npm i @argszero/dsh-cancel-cause-guard
yaml
- insert:
    - id: cancel-cause-guard
      name: '@argszero/dsh-cancel-cause-guard'

边界要说清楚:预防插件只能阻止「新产生」的坏值,修不了已经落盘的 null 行。所以存量会话还得走第 2 步的手工修复(#9699)。

DeepSeek Harness 谁最容易写出 null:第三方插件的畸形 cause

被点名最多的是「第三方插件用裸字符串或不认识的 cause 调 agent.cancel()」。 据社区复核 master 一致的源码:ReactLoopAgent.cancel() 入口不校验 cause,abortedCancelCause(signal) 抛 assertNever 时 turnEnds 仍是 null,finally 无条件 append(#9699)。所以排查时把「事发前装过哪些会取消回合的插件」列一遍,往往能直接找到写坏日志的那一个。

DSH插件 重复的子调用 id:写入不报错,读取整条拒绝

一句话结论:run_code(PTC)的子调用 id 只要在一次 Session 内重复一次,写入时完全不报错,下次读取却会整体拒绝,整条会话永久打不开——一处重复赔掉整条会话。 讨论中提到的中招会话体量达到 3,569 个事件(#9677)。

DeepSeek Harness 机制:计数器作用域错位 + 写读不对称

据报告者与社区源级确认,两半原因是:

  1. 写端不保证唯一:ptc.ts 的 dispatches 计数器作用域是单次 execute()(每次从 1 重新开始),而 exec.callId 又由 provider 在重发调用时复用——两端都可能重复;
  2. 读端强制唯一:唯一性只在读取端折叠时被强制(会话格式 v4 的 subCallId 要求唯一),写端没有对应校验(#9677)。

DSH插件 编号定位与修复

  1. 定位:对目标会话文件统计子调用 id 的出现次数,大于 1 的即为冲突项:
powershell
Select-String -Path "<会话文件>" -Pattern '"subCallId":"([^"]+)"' -AllMatches |
  ForEach-Object { $_.Matches } | ForEach-Object { $_.Groups[1].Value } |
  Group-Object | Where-Object Count -gt 1 | Select-Object Count, Name
#   预期:列出重复的 subCallId(Count > 1)
  1. 预防(推荐,代价最小):装社区插件 @argszero/dsh-ptc-subcall-guard,它在写路径上改名,避免新会话出现重复 id。
  2. 修复已损坏的日志:需要对已落盘工件重新编号(把后出现的重复 id 加序号并同步四处表示),属离线手术,建议先备份再操作;社区有第三方会话修复工具可做这类重编号。
  3. 验证:修复后重新打开该会话,预期不再整体被拒;若仍有别的形状问题,会给出不同的校验错误,按错误继续处理。

DeepSeek Harness pruner 在回合外写替换:读端拒绝自家 writer 的输出

一句话结论:tool/result 的替换写入没有落在开启的回合内,读端就会判定非法——这不是「文件坏了」,而是组件之间对不变量的理解不一致。 报错形态:

SessionFormatError: tool/result is outside an open turn

据报告者定位与社区提供的合成最小复现:tool-result pruner 在会话空闲(没有开启回合)时追加了一条带 surfaceOp.replace 的替换 tool/result,而读取端对非 append 的 tool/result 要求必须存在开启的回合,于是读端拒绝了自己 writer 写出的内容(#8812)。

处置步骤:

  1. 定位:在会话文件里搜 surfaceOp,看带 replace 的那条 tool/result 之前有没有一条未闭合的 turn/start。
  2. 应急修复:截断最后一条 turn/end 之后的所有 prune 行(这些行是替换写入、原始内容仍在日志里,因此不会丢消息),同时删掉该会话的投影缓存让其重建(#8812)。
  3. 验证:重新打开会话,预期正常载入;再触发一次长会话让它自然剪枝,观察是否复发。
  4. 长期依赖:该行为属核心侧 pruner 与读端不变量不一致,插件层无法根治。

DSH插件 排查注意事项

三条都是「写读不一致」,共同排查原则是:先判「文件合法但读取崩」还是「读取校验拒绝」,再动手。 八条要点:

  1. 先看轨迹视图:对话区空白但轨迹正常,强烈指向 turn/end 的 reason 问题;轨迹也打不开则是整条被拒(重复 id / 回合外写入那一类);若文件里查不到坏值、只是打开后看不到内容,那是界面侧的另一族,见 DeepSeek Harness 会话显示异常(#9156)。
  2. 别信「官方校验过了就没问题」:validateSessionEventData 不检查 turn/end payload,加载校验也不查这一项(#9699)。
  3. 补字段必须补全:{"kind":"aborted"} 不是合法修复,会触发下一个崩溃(#8832)。
  4. 改日志只改该字段:保持 seq、帧结构与事件顺序,任何重排都会把可修脏值变成不可读日志。
  5. reason:null 与「重复 id」是两个不同指纹:前者搜 "reason":null,后者统计 subCallId,别混(#9677)。
  6. 预防优先于修复:cancel-cause-guard、ptc-subcall-guard 这类插件能挡住新坏值,存量仍需手工处理。
  7. 改动前一定备份:这三类修复都直接改会话文件,备份是唯一回退手段。
  8. 修复状态如实:turn/end 的 reason 与 pruner 回合外写入截至 0.2.1-alpha.* 仍未修;重复 id 有社区插件可绕,但核心侧写端校验仍缺。

排查会话问题时,往往要先把插件变量排掉——毕竟写坏日志的最常见来源就是第三方插件的畸形调用。如果你想先在界面上把「装了哪些插件、最近有没有装卸变动」看清,与其在各处手敲命令,不如用 DSH Plugin Hub 这类可视化插件市场:插件列表、装卸历史与系统日志集中在一处,定位「是谁写的这条坏事件」会快很多。

已安装插件列表

来源:Discussion #8832、Discussion #9156、Discussion #9060、Discussion #9699、Discussion #9646、Discussion #9677、Discussion #8812。

常见问题

DeepSeek Harness 里所有会话的对话区突然全空白,轨迹视图却还能看,重启才恢复,是会话文件坏了吗?

DeepSeek Harness 里所有会话对话区突然全空白、轨迹视图却正常、重启才恢复,文件本身往往是合法的,是读取端在合法输入上崩了。原因是一条 turn/end 的 reason 被写成 null,而客户端读 reason.kind 时没有做保护,抛错后错误边界把整块对话视图渲染成空 div,于是所有会话看起来都空白。定位办法是在会话日志里搜 "reason":null,修复时把该条补成 {"kind":"aborted","reason":{"kind":"hook","reason":"unknown"}} 这种形状完整的结构。

为什么第三方插件一调 DeepSeek Harness 的 agent.cancel() 就把会话日志写坏了?

第三方插件调 DeepSeek Harness 的 agent.cancel() 会把会话日志写坏,是因为 cancel 的入口不校验 cause,而把 cause 翻译成协议字段的函数只认识 user/parent/disposed/hook 四种取值,遇到不认识的值会在 catch 块里抛错,使本该被赋值的 turn-end 变量停在初始的 null,随后 finally 无条件把它落盘成 reason:null。协议要求这里必须是 {"kind":"aborted","reason":{...}} 的形状,所以一旦写成 null,读取端就会崩。

DeepSeek Harness 里 run_code 相关的会话打开报无法加载,日志里能看出什么特征?

DeepSeek Harness 里 run_code 相关会话打不开的特征,是子调用 id 在同一条会话里重复。run_code 的 dispatches 计数器作用域是单次 execute()、每次从 1 重新开始,而调用 id 由上游在重发时复用;唯一性只在读取端折叠阶段强制,写入端不校验,所以写入时不报错、读取时整条会话被拒。定位办法是统计会话里 subCallId 的出现次数,大于 1 的即为冲突项。

DeepSeek Harness 会话打开时报 SessionFormatError: tool/result is outside an open turn,是什么写的?

DeepSeek Harness 会话打开时报 SessionFormatError: tool/result is outside an open turn,是 tool-result pruner 写的。它在会话空闲、没有开启回合的时候追加了一条带 surfaceOp.replace 的替换 tool/result,而读取端对「非 append 的 tool/result」要求必须有开着的回合,于是读端拒绝了自己写出来的内容。应急处理是截断最后一条 turn/end 之后的 prune 行并删掉投影缓存,这样不会丢消息。

相关术语

turn/end 事件
turn/end 是 DeepSeek Harness 会话日志里标记一个回合结束的事件,按协议它必须携带 reason 字段说明结束原因(如 completed、aborted),且 aborted 时 reason 内部还要有具体 cause。reason 为 null 会违反该协议,导致读取该事件的客户端抛错。— https://github.com/deepseek-ai/deepseek-harness/discussions/9060
surfaceOp.replace
surfaceOp.replace 是 DeepSeek Harness 会话日志里表示「这条事件是对先前某条记录的替换」的标记,属于非追加写入。读取端对带该标记的 tool/result 有额外要求(必须位于一个开启的回合内),因此在回合之外写它会被读端判为非法。— https://github.com/deepseek-ai/deepseek-harness/discussions/8812
subCallId
subCallId 是 run_code(PTC)在一次执行里给每个子调用分配的标识,会话格式要求它在同一条 Session 内唯一。写入端不校验唯一性、读取端强制唯一,是「重复 id 让整条会话不可读」这一族的根源。— https://github.com/deepseek-ai/deepseek-harness/discussions/9677
validateSessionEventData
validateSessionEventData 是 DeepSeek Harness 在写盘或加载会话事件时执行的形状校验函数,用于拦截字段缺失或类型错误的事件。它当前不检查 turn/end 的 payload,因此 reason:null 这类非法值能顺利落盘。— https://github.com/deepseek-ai/deepseek-harness/discussions/9699

来源