DeepSeek Harness 会话打不开:turn/end reason 为 null 与重复 id 写坏日志
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插件 机制:写入端抛错 + 读取端裸读,两侧各缺一个保护
据多名社区成员逐行核对源码,链路上有两处缺口,任意一处补上都不会崩,但两处都缺:
- 写入端:
cancel(cause)的入口不校验cause;把它翻译成协议字段的函数只认识user/parent/disposed/hook四种取值,遇到不认识的值会在 catch 块内抛错,使本该被赋值的turnEnds变量停留在初始的null,而finally无条件把它落盘:
turn/end { "kind": "aborted", "reason": null } ← 违反协议
- 读取端:客户端多处直接读
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,而会话自身的加载校验会正常通过。 三步:
- 看症状组合。 预期同时满足:对话区空白 / 会话列表空白、轨迹视图仍可正常渲染、重载或重启后恢复一下又复发。这一组合基本可以定向到本条(#9156)。
- 在会话日志里搜坏值。 会话日志位于
~/.dsh/storages/sessions/(Windows 为%USERPROFILE%\.dsh\storages\sessions\)。对目标会话文件执行:
Select-String -Path "<会话文件>" -Pattern '"reason":null'
# 预期:命中若干行,即那条违反协议的 turn/end
- 确认是
turn/end而不是别的字段。 看命中行附近的事件类型是否为turn/end;只有turn/end的reason为null才会让整块视图崩(#8832)。
DSH插件 修复:把 reason 补成完整结构,并加一道预防
手工修复只有一个要点——补的形状必须完整**,写短了会从一个崩溃换成另一个崩溃。** 步骤:
- 先备份会话文件(复制一份到工作区外),再动手。
- 把命中的
"reason":null改成协议要求的完整结构:
{"kind":"aborted","reason":{"kind":"hook","reason":"unknown"}}
注意不能简写成 {"kind":"aborted"}——讨论中实测,读端下一步会去读 reason.reason.kind,短写法会直接触发另一个崩溃(#8832)。
- 只改该字段,保持 seq 与帧结构不变:不重排事件、不重新编号、不改其它字段,否则会把「可修的脏值」变成「不可读的日志」。
- 重载页面,预期对话区恢复;若仍空白,检查是否还有第二处
"reason":null。 - 加预防。 社区插件
@argszero/dsh-cancel-cause-guard在agent/created阶段包裹cancel(),把越界的 cause 归一成{kind:'hook', reason},从源头阻止新的null落盘:
npm i @argszero/dsh-cancel-cause-guard
- 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 机制:计数器作用域错位 + 写读不对称
据报告者与社区源级确认,两半原因是:
- 写端不保证唯一:
ptc.ts的dispatches计数器作用域是单次execute()(每次从 1 重新开始),而exec.callId又由 provider 在重发调用时复用——两端都可能重复; - 读端强制唯一:唯一性只在读取端折叠时被强制(会话格式 v4 的
subCallId要求唯一),写端没有对应校验(#9677)。
DSH插件 编号定位与修复
- 定位:对目标会话文件统计子调用 id 的出现次数,大于 1 的即为冲突项:
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)
- 预防(推荐,代价最小):装社区插件
@argszero/dsh-ptc-subcall-guard,它在写路径上改名,避免新会话出现重复 id。 - 修复已损坏的日志:需要对已落盘工件重新编号(把后出现的重复 id 加序号并同步四处表示),属离线手术,建议先备份再操作;社区有第三方会话修复工具可做这类重编号。
- 验证:修复后重新打开该会话,预期不再整体被拒;若仍有别的形状问题,会给出不同的校验错误,按错误继续处理。
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)。
处置步骤:
- 定位:在会话文件里搜
surfaceOp,看带replace的那条tool/result之前有没有一条未闭合的turn/start。 - 应急修复:截断最后一条
turn/end之后的所有 prune 行(这些行是替换写入、原始内容仍在日志里,因此不会丢消息),同时删掉该会话的投影缓存让其重建(#8812)。 - 验证:重新打开会话,预期正常载入;再触发一次长会话让它自然剪枝,观察是否复发。
- 长期依赖:该行为属核心侧 pruner 与读端不变量不一致,插件层无法根治。
DSH插件 排查注意事项
三条都是「写读不一致」,共同排查原则是:先判「文件合法但读取崩」还是「读取校验拒绝」,再动手。 八条要点:
- 先看轨迹视图:对话区空白但轨迹正常,强烈指向
turn/end的reason问题;轨迹也打不开则是整条被拒(重复 id / 回合外写入那一类);若文件里查不到坏值、只是打开后看不到内容,那是界面侧的另一族,见 DeepSeek Harness 会话显示异常(#9156)。 - 别信「官方校验过了就没问题」:
validateSessionEventData不检查turn/endpayload,加载校验也不查这一项(#9699)。 - 补字段必须补全:
{"kind":"aborted"}不是合法修复,会触发下一个崩溃(#8832)。 - 改日志只改该字段:保持 seq、帧结构与事件顺序,任何重排都会把可修脏值变成不可读日志。
reason:null与「重复 id」是两个不同指纹:前者搜"reason":null,后者统计subCallId,别混(#9677)。- 预防优先于修复:
cancel-cause-guard、ptc-subcall-guard这类插件能挡住新坏值,存量仍需手工处理。 - 改动前一定备份:这三类修复都直接改会话文件,备份是唯一回退手段。
- 修复状态如实:
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 里所有会话对话区突然全空白、轨迹视图却正常、重启才恢复,文件本身往往是合法的,是读取端在合法输入上崩了。原因是一条 turn/end 的 reason 被写成 null,而客户端读 reason.kind 时没有做保护,抛错后错误边界把整块对话视图渲染成空 div,于是所有会话看起来都空白。定位办法是在会话日志里搜 "reason":null,修复时把该条补成 {"kind":"aborted","reason":{"kind":"hook","reason":"unknown"}} 这种形状完整的结构。
第三方插件调 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 相关会话打不开的特征,是子调用 id 在同一条会话里重复。run_code 的 dispatches 计数器作用域是单次 execute()、每次从 1 重新开始,而调用 id 由上游在重发时复用;唯一性只在读取端折叠阶段强制,写入端不校验,所以写入时不报错、读取时整条会话被拒。定位办法是统计会话里 subCallId 的出现次数,大于 1 的即为冲突项。
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
来源
- #8832 — 一条 turn/end 的 reason 为 null 会让整个会话列表变空白(附定位与修复建议)· deepseek-ai(GitHub Discussions)
- #9156 — Conversation pane goes blank on all sessions — TypeError: Cannot read properties of null (reading 'kind')· deepseek-ai(GitHub Discussions)
- #9060 — cancel() 把 turn/end 写成 reason:null(违反 TurnEndReasonMap 协议)· deepseek-ai(GitHub Discussions)
- #9699 — ReactLoopAgent.cancel() 不校验 cause,写入 {"reason":null} 并永久崩掉 Chat UI· deepseek-ai(GitHub Discussions)
- #9646 — DSH Bug 报告:单条 turn/end 的 reason: null 会让整个 Chat 视图永久崩溃,并牵连所有会话· deepseek-ai(GitHub Discussions)
- #9677 — One repeated PTC sub-call id makes an entire Session unloadable (data loss)· deepseek-ai(GitHub Discussions)
- #8812 — tool-result pruner appends replacement tool/result outside any open turn· deepseek-ai(GitHub Discussions)