Agent 连续改几个文件,中间往往要调用好几次工具。程序突然退出后,如果只留下一句“正在执行”,就无法判断文件是否已经写入。直接重试可能会把同一个命令执行两遍。
Jupiter 1.0.8 给每次工具调用加了一份本地执行记录。重新打开会话时,先查它上次停在哪里,再把情况告诉模型。会话切换、后台压缩和桌面端发送失败时,也需要核对后台任务与当前界面、历史的状态。仓库保留了 DeepSeek-Reasonix(MIT)的上游来源与许可说明。
1. 工具执行记录
1.1 从准备调用到拿到结果
假设模型要执行一次写文件操作。Jupiter 会先写下调用意图,通过调用前的检查后,才进入工具实现;工具返回时,再补一条结果。源码里这几步分别叫 recordIntent()、recordExecutionStarted() 和 recordResult()。
记录按 JSONL 保存,一行一条。同一次调用共享 executionId,通过 seq 排顺序;callId 用来对应聊天记录里的工具调用。三种记录各自说明一件事:
| 最后一条记录 | 能确定什么 | 重启后怎么处理 |
|---|---|---|
intent |
准备调用,尚未记下执行开始 | 记为 interrupted_before_execution |
execution_started |
已进入执行流程,还没留下结果 | 记为 unknown_after_restart,先查实际状态 |
result / returned |
工具已返回,可能返回成功,也可能返回错误 | 保留结果;若聊天记录缺失,则补恢复说明 |
result / blocked_before_execution 等 |
调用在真正执行前被拦截或拒绝 | 保留对应原因,不把它说成已经执行 |
麻烦出在第二行。执行开始以后,命令可能已经写完文件,也可能刚做了一半。仅凭“没收到结果”判断失败,会把已经做过的操作再做一次。工具调用入口 runOneToolCall()把这些记录接在实际调用前后;开始记录写入失败时,工具实现不会继续运行。
1.2 记录落盘与持久化
如果执行进度只放在内存里,程序退出就没有了。执行记录用追加方式写文件,并调用 fsyncSync() 请求把数据同步到底层存储。下面是 durableAppendLine() 中的写入部分,直接摘自 1.0.8 源码:
mkdirSync(dirname(path), { recursive: true });
trimPartialTail(path);
const fd = openSync(path, "a", 0o600);
try {
writeSync(fd, line);
fsyncSync(fd);
} finally {
closeSync(fd);
}
文件权限设为 0o600,在支持这套权限的系统上,只允许当前用户读写。参数和结果在这份记录里只保存 SHA-256 哈希,避免再复制一份原文。聊天记录和工具数据另行保存。
2. 重启恢复
2.1 文件追加操作的恢复示例
假设工具要在 notes.txt 后面追加一行文字:
- Jupiter 写下
intent,记录“准备执行这次调用”。 - 工具开始前,写下
execution_started。 - 命令向文件追加文字,但程序在保存工具结果前退出。
- 重启后读到的最后状态仍是
execution_started。文件可能已经多了一行,需要先读取文件确认;直接重跑追加命令可能产生重复内容。
恢复逻辑在 recoverIncompleteExecutions() 的循环中:
for (const execution of pending) {
const outcome =
execution.latest.state === "execution_started"
? "unknown_after_restart"
: "interrupted_before_execution";
this.recordResult(execution.base.executionId, outcome);
recovered.push({ ...execution.base, outcome });
}
如果程序在第一步之后就退出,最后状态只有 intent,会得到“执行前中断”;停在第二步之后,则得到“重启后结果未知”。恢复时还会写下一条终态结果,所以再次载入同一份记录,不会不断把同一个调用恢复一遍。
2.2 补齐缺失的工具回复
模型发出 tool_calls 后,对话里需要有对应的工具回复。重启恰好发生在中间时,执行记录可能还在,聊天历史却缺了这条回复。
repairDanglingToolCalls()会按当前轮次和 callId 找到缺失项,补上恢复说明。对于结果未知的调用,说明会明确要求先检查现状,不要自动重复可能改变外部状态的操作。
另一个容易忽略的情况是:执行记录已经写了 returned,但工具回复没来得及进入聊天历史。由于记录只保存结果哈希,恢复函数会提示检查现状。聊天历史中已有的完整回复直接保留。

3. 会话切换与异步写回
3.1 会话绑定版本(epoch)
工具恢复解决的是程序退出的问题。还有一种情况:程序没有退出,用户在任务运行时切到了另一个会话。旧任务随后返回的内容,不能写进刚打开的新会话。
1.0.8 在 Loop 中维护 _sessionEpoch,可以把它理解成会话绑定的版本号。真正换了会话,这个数字就加一;只给同一个会话改名,则保留它。源码中的入口是:
if (opts.logicalSessionChanged) this._sessionEpoch++;
this._sessionName = sessionName;
this.log.setSessionPath(sessionName ? sessionPath(sessionName) : null);
一次异步工作开始时,保存当时的会话名和 epoch;写回之前再核对。比如在会话 A 发起整理,随后切到 B,即使后台工作稍后完成,它保存的绑定版本也已经过期,不能继续覆盖 B 的记录。rebindSession()还会一起调整日志和工具执行记录的保存位置,避免它们各自指向不同的会话。
3.2 后台压缩与自动标题
上下文压缩需要先读取一段历史,再生成较短的内容。这期间用户可能继续发消息。写回时除了检查是否换了会话,还要检查历史有没有被追加,否则压缩结果可能覆盖刚收到的新消息。
自动生成会话标题同样需要核对标签页、会话和绑定是否仍对应。1.0.6 阶段加入的回合占用标记(lease),则用来限制同一标签页里重叠的普通对话、轻量询问和远程消息:切会话、换工作区或关掉标签后,旧回合应当失效。这些检查在写回之前执行。
4. 发送失败后的消息与草稿恢复
桌面端发送消息时,通常会先把用户消息显示出来,再交给后台核心进程。如果后台没有启动,或者请求没有写出去,界面可以撤回这条临时消息,把文字放回输入框,方便再次发送。
但如果请求可能已经送达,只是回执没回来,就不能直接恢复成“可以再发一次”。否则用户一点重试,后台可能收到两份相同任务。1.0.6 将桌面 RPC 发送串行化,并返回具体失败阶段,让界面有依据区分“没有送出”和“是否送达还不清楚”。后者继续保留忙碌和审批状态,等待进一步处理。
桌面恢复事件也只在确实载入了历史消息后才标记为正在恢复会话,避免界面提前显示一个并未发生的恢复过程。各阶段改动可以对照1.0.6 至 1.0.8 更新日志。
5. 长任务中的用量、续写和日志
1.0.6 至 1.0.8 还调整了用量统计、续写和日志处理:
- 用量统计分开记录累计账单 token 和最后一次请求的上下文 token。任务发了多次请求后,这两个数用途不同:前者用于成本,后者用于判断当前上下文还剩多少空间。
- 可选的 Beta Prefix 续写在回答触及长度上限、且没有工具调用时,最多追加一次受控请求。它会检查上下文余量、预算、会话绑定和中止状态,再合并内容与用量;仍然截断或结束原因不明时会给出警告。
- shell、后台任务、Hook 和 MCP 子进程使用清理过的环境变量;诊断日志在持久化前对结构化敏感字段脱敏,减少凭据被传入无关子进程或写进日志的机会。
6. 对应代码与测试
本文代码对应 1.0.8 发布版本,链接固定到提交 1fcaf4c,方便以后更新实现时仍能回来看当时的逻辑。
- 执行记录:数据结构、落盘、恢复和聊天历史修补。
- 恢复测试:已开始的调用、只记录意图的调用、并行返回和文件尾部半行。测试还检查开始记录写入失败时,工具函数不会运行。
- Jupiter 保留的上游来源说明。
执行记录只保存调用状态,远程服务已完成的操作仍需在对应服务中处理。追加文件、发邮件等调用如果标为结果未知,重试前要先检查实际文件或服务状态。

Comments NOTHING