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 后面追加一行文字:

  1. Jupiter 写下 intent,记录“准备执行这次调用”。
  2. 工具开始前,写下 execution_started。
  3. 命令向文件追加文字,但程序在保存工具结果前退出。
  4. 重启后读到的最后状态仍是 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,但工具回复没来得及进入聊天历史。由于记录只保存结果哈希,恢复函数会提示检查现状。聊天历史中已有的完整回复直接保留。

工具调用从准备、开始执行到返回结果,以及重启时结果未知的状态示意
工具执行记录的简化示意,绘制于2026年10月29日。结果未知的调用先检查现状,再决定是否继续。

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,方便以后更新实现时仍能回来看当时的逻辑。

执行记录只保存调用状态,远程服务已完成的操作仍需在对应服务中处理。追加文件、发邮件等调用如果标为结果未知,重试前要先检查实际文件或服务状态。