Repository navigation
Session 日志可被空 toolCallId 永久锁死:崩溃恢复每次都在同一 seq 自锁 #9731
guwenwu112233-dot
started this conversation in
General
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
发给DeepSeek_空toolCallId锁死会话.md
Session 日志可被空
toolCallId永久锁死:崩溃恢复每次都在同一 seq 自锁产品:DSH Desktop 0.11.0(Windows / win32-x64)
组件:
dsh-session-persistence-jsonl+session-format-v4校验 + agent-loopinterruptedTurnClosers恢复会话格式:v4(
session.v4.jsonl.zstd)触发供应商:任意 OpenAI 兼容网关,本例为
agnes(api: openai-completions,模型agnes-3.0-flash)实际发生时间:2026-10-15 10:36(本地)
严重度建议:高(会话不可恢复损坏、无用户侧绕行手段、每次恢复重试都复现同一失败)
1. 摘要
当 OpenAI 兼容供应商返回的 tool call 带有空字符串
id(或空name)时,DSH 的写路径只校验"该字段是字符串",空串被放行并落盘。此后:tool/result错误,但合成结果必然要过校验toolCallId === source.callId,而来源callId是空串;即:一个上游畸形字段,把一份会话日志变成永久不可恢复。
2. 实际错误
3. 触发链路(实测)
HTTP 503 Service is too busy,模型失去检索能力(该事件本身正常落盘于 seq 824,isError: true)。write工具直接落一个脚本,但经过 OpenAI 兼容网关返回的 tool call 里id、name均为空串,参数还多了一个空键({"write":"","content":...})。assistant/message(seq 827)内嵌的 tool-call block 与tool/call(seq 828)身份同为空。tool/result(seq 829)时校验失败,轮次整体作废,文件回滚并停在 seq 828(该会话物理日志里不存在 seq 829)。日志尾部(
session.v4.jsonl.zstd,已解压):{"type":"assistant/message","seq":827,...,"data":{"message":{"role":"assistant","content":[ {"type":"text","text":"<text omitted>"}, {"type":"tool-call","id":"","name":"","arguments":"{\"write\":\"\",\"content\":\"import urllib.request…\"}"} ],"source":{"kind":"model","provider":"agnes","model":"agnes-3.0-flash", "replayState":{"response":{"kind":"pi-ai","version":2,"api":"openai-completions","stopReason":"toolUse"}}}}}} {"type":"tool/call","seq":828,"data":{"turn":7,"step":6,"callId":"","name":"","arguments":"{\"write\":\"\",\"content\":\"…\",\"file_path\":\"…\"}"}}4. 关键代码
4.1 校验器(
app.asar行 889062 与 907730,两份 bundle 各一份)这条校验本身是正确的(它已经明确检查
toolCallId.length === 0与sourceCallId !== toolCallId)。真正的缺口在写入侧:它允许把callId === ""的 tool call 落盘,等价于给此后所有tool/result埋下一个永远无法满足的前置条件。4.2 v4 bundle 中合成 closer 的实际构造(
app.asar行 880522–880561,ToolCallRecovery.results())pendingCalls的来源是同一个 v4 bundle 里ToolCallRecovery.observe()对assistant/message的收集:this.pendingCalls.set(block.id, { turn, step })(行 880492)。block.id为空串时,这一对toolCallId/source.callId就是两个空串——在任何写入之前就已经注定不合规。4.3 官方文档中承诺的恢复路径(
app.asar行 892285,dsh-session-persistence-jsonlREADME 中文章节「恢复与崩溃恢复」)结论:恢复机制本身是健康的(合成 closer 并把它们作为普通批次 append),但 4.2 生成的 closer 引用的是空
callId的来源,于是在这批 append 上直接撞上 4.1 的校验。openTurnClosers的契约(v4 bundle 行 880420)也决定了没有"先不闭合"这条退路:未闭合的turn只有"合成 closer 关闭"一种结局。5. 最小复现
"id": ""的 OpenAI 兼容工具调用供应商(或直接构造该响应)。write),使会话日志出现:{"type":"assistant/message","seq":N,"data":{"message":{"content":[{"type":"tool-call","id":"","name":"","arguments":"{}"}]}}} {"type":"tool/call","seq":N+1,"data":{"callId":"","name":"","arguments":"{}"}}tool/result落盘,或直接触发崩溃恢复(关闭应用再以写模式重新打开该会话)。预期:会话被拒绝读取/写入,抛
format v4 tool/result at seq N+2 requires toolCallId matching its tool source;此后每次恢复都在同一 seq 失败。崩溃恢复在results()里生成的合成结果会同时带上两个空身份(toolCallId: ""、source.callId: ""),其消息 id 也会退化成…-tool-result--<seq>这种双横线形式。6. 影响
7. 修复建议(按优先级)
callId非空。上游缺失时不要写入空身份,而是当场合成一个稳定 id(例如call_recovered_<seq>),并让它同时进入assistant/message的 tool-call block 与tool/call行——这样后续任何tool/result(包括崩溃恢复合成的)都能满足 4.1 的等式。name同理;空name目前也会落盘并让工具注册表查找失败。ToolCallRecovery.results()生成toolCallId/source.callId前,对空callId做一次归一(合成 id),使interruptedTurnClosers在任何历史数据上都能收尾;assertV4ToolResultMessage对"来源为空"的历史记录应按可恢复尾部处理,而不是让整个 artifact 读取失败。id/空name的 tool call 落盘后触发恢复"为 fixture,断言会话仍可打开、能正常收尾,且不产生空身份。openai-completions网关返回的 tool callid必须非空(本例agnes走该网关且返回了空 id),说明 DSH 侧会归一或拒绝。8. 本地已验证的缓解(非官方修复)
把坏记录的身份补成真值即可解锁恢复(下面的
call_1f0a7c93b4e24d6a8f21c05d是修复脚本新生成的合成 id,非任何既有标识):seq 827内嵌 tool-call block:id/name补为call_1f0a7c93b4e24d6a8f21c05d/write;seq 828tool/call:callId/name同上,参数归一为{"content":…,"file_path":…}(与该会话其余 145 条同形);seq 829tool/result:toolCallId === source.callId === call_1f0a7c93b4e24d6a8f21c05d,isError: true,sourceEventSeqs: [828],surfaceOp: "append";seq 0..829稠密;146 个 tool call ↔ 146 个 result 全配对;zstd 单帧可读。修复后自检全部通过,恢复路径的阻塞点解除。(备份与原文件逐字节同期保留。)
9. 完整证据链
以下输出均由只读脚本
evidence_dump.py --redact生成。主机路径、会话 id、消息 id、供应商 responseId 与助手/标题文本已隐去;可复核字段(工具名、参数键、配对 id、seq、行结构、工具错误文案)原样保留。不带--redact运行时输出本机真实值,便于在自己环境核对。A. 结构性事实
A.1 损坏的原始日志(崩溃后未恢复的物理状态)
要点:物理日志的最大 seq 是 828,
seq稠密(0..828),不存在 seq 829。也就是说报错里点名的tool/result at seq 829从未成功落盘——失败发生在写入校验阶段,而不是读取/解码阶段。A.2 坏记录原文(seq 828,脱敏)
{"type":"tool/call","seq":828,"time":1792031787744,"data":{"turn":7,"step":6,"callId":"","name":"","arguments":{"write":"","content":"import urllib.request, ssl, json, os, time, urllib.parse\n\nct …<2909 chars total>","file_path":"<workspace>\\<redacted>.py"}}}注意
callId与name都是空串,且arguments里多出一个空键"write": ""——这是上游 OpenAI 兼容网关返回的畸形 tool call 被原样落盘的结果。对应的assistant/message(seq 827)内嵌 block 同样是空身份:{"type":"assistant/message","seq":827,"time":1792031787742,"data":{"turn":7,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"<text omitted>"},{"type":"tool-call","id":"","name":"","arguments":"{\"write\":\"\",\"content\":\"import urllib.request, ssl, json, os, time, urllib.parse\\n\\nctx = s …<malformed payload>"}],"source":"<provider metadata omitted>","id":"<message-id>"}},"surfaceOp":"append"}…B. 修复后的三行证据链
B.1
assistant/messageseq 827 —— 内嵌 tool-call block 已带上真实身份{"type":"assistant/message","seq":827,"time":1792031787742,"data":{"turn":7,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"<text omitted>"},{"type":"tool-call","id":"call_1f0a7c93b4e24d6a8f21c05d","name":"write","arguments":"{\"content\":\"import urllib.request, ssl, json, os, time, urllib.parse\\n\\nctx = ssl._create_ …<normalized payload>"}],"source":{"kind":"model","provider":"agnes","model":"agnes-3.0-flash","replayState":{"response":{"kind":"pi-ai","version":2,"api":"openai-completions","provider":"agnes","model":"agnes-3.0-flash","responseId":"<provider-response-id>","stopReason":"toolUse"},"blocks":[{"type":"text"},{"type":"tool-call"}]}},"id":"<message-id>"},"usage":{"inputTokens":611,"outputTokens":1055,"totalTokens":133762,"cacheReadTokens":132096},"stream":[{"type":"chunk","time":1792031767786,"chunk":{"type":"block-start","index":0,"blockType":"text"…B.2
tool/callseq 828 —— 参数归一为{content, file_path}{"type":"tool/call","seq":828,"time":1792031787744,"data":{"turn":7,"step":6,"callId":"call_1f0a7c93b4e24d6a8f21c05d","name":"write","arguments":{"content":"import urllib.request, ssl, json, os, time, urllib.parse\n\nct …<2909 chars total>","file_path":"<workspace>\\<redacted>.py"}}}B.3
tool/resultseq 829 —— 补上的结果,身份与被修好的来源一致{"type":"tool/result","seq":829,"time":1792031787745,"data":{"turn":7,"step":6,"message":{"role":"tool","source":{"kind":"tool","callId":"call_1f0a7c93b4e24d6a8f21c05d"},"toolCallId":"call_1f0a7c93b4e24d6a8f21c05d","content":[{"type":"text","text":"Error: run failed before this tool result was recorded (session log repair)"}],"isError":true,"id":"<message-id>"},"error":{"name":"SessionRepair","code":"TOOL_RESULT_RECOVERED"}},"sourceEventSeqs":[828],"surfaceOp":"append"}C. 全量校验结果(修复后)
校验覆盖:
seq稠密(0..829)、146 个tool/call↔ 146 个tool/result一一配对、每条结果的toolCallId === source.callId、sourceEventSeqs === [对应 call 的 seq]、assistant/message内嵌 block 的 id 都能找到对应 call 行、事件时间单调。D. 复核脚本(只读,可直接保存运行)
用法:
不带
--redact时输出本机真实值,便于维护者在自己环境里核对。脚本只读,不写任何文件。All reactions