fix(loop): 所有非崩溃退出一律废弃可恢复快照(主人 2026-10-04 裁决「修」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 原状:只有「被取消」的回合把 checkpoint 落定为不可恢复;**模型/工具报错、预算耗尽**等未落定退出
  同样留下可恢复快照 ⇒ 下一条消息被旧窗口整段顶掉(用户看到自己刚发的消息像丢了)
- 修法:收尾 defer 改为「**回合未落定就废弃快照**」(不再只看 ctx 是否取消),新增 StopReasonAborted;
  checkpoint.resumableStopReason 收紧为「**只有 `tool_calls` 可恢复**」(空 stop reason 不再算);
  终态快照改用 context.WithoutCancel 写(修「取消擦肩最终答案」时终态写失败、中途快照留任最新)
- 语义边界(写进用例注释):**只有崩溃**(进程猝死、函数不返回)才允许恢复;控制流一旦回到调用方
  就不得留可恢复态。原 resume_test 的用例改名为**崩溃恢复**用例并保留断言(含「新消息不被顶掉」的反面断言在新用例里)
- 复核:build/vet 绿 + loop 包测试与 `-race` 全绿;新增 6 条(模型报错/工具失败+模型报错/预算耗尽/
  空 stop reason/取消擦肩最终答案)+ 端到端 TestInterruptThenChatKeepsLatestContextEndToEnd 绿;
  **4 条变异反证**(含把废弃快照写回 tool_calls → 精确复现真实劫持现场)均已还原
- 附:修 session_turn_fence.go 里引用旧函数名的陈旧注释
This commit is contained in:
AskaEth committed 2026-10-04 14:17:58 +08:00
1 parent 5c28ace70d
commit 88d7dd0026
5 files changed
+448 -47

No files matched your search

+41 -24
View File
@@ -59,11 +59,13 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
l.emitTurnBoundary(ctx, model.StreamKindTurnStart)
defer l.emitTurnBoundary(ctx, model.StreamKindTurnEnd)
// 恢复被中断的 run(design §1 durable execution):该会话有
// 可恢复的 checkpoint 时,重建其上下文并从下一步继续,
// 而不是从全新对话重启。不同的
// sessionID —— 或已完成 run 留下的 checkpoint —— 落到
// 全新开始(LoadLatest 未命中,或 stop reason 不可恢复)。
// 恢复**崩溃遗留**的 run(design/04 §1 durable execution):该会话最新
// 快照若是崩溃(进程猝死、回合从未写出终态、收尾 defer 根本没执行)留下的
// 中途 tool_calls,就重建其上下文并从下一步继续,而不是从全新对话重启。
// 其余一切——不同的 sessionID、已完成 run 的终态、以及任何**已落定的
// 未完成退出**(取消 / 模型报错 / 工具报错 / 预算耗尽 / 护栏拦截)留下的
// 快照——都落到全新开始,让本次消息自己组最新上下文(主人 2026-10-04
// 裁决:只有崩溃才允许恢复;见 resumableStopReason 与 driveTurn 的收尾)。
msgs, startStep, _ := l.loadCheckpoint(ctx, sessionID, messages)
msgs = l.resolveImages(ctx, msgs)
res := &Result{}
@@ -83,18 +85,22 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Message, startStep int, res *Result) (*Result, error) {
toolState := &toolLoopState{prevFP: make(map[string]bool)}
step := startStep
// 被用户中断(design/15 §3)的回合不留下「可恢复的在途态」:design/15
// 要求中断后**立即**按最新上下文处理下一条消息,而一个被放弃的回合若把
// 可恢复 checkpoint 留在该会话上,下一条消息会被它顶掉——继承旧窗口、
// 丢掉刚发出的那条,上下文既不最新也不完整(裁决 13.4.5)。因此取消后
// 把该会话的快照落定为不可恢复态(快照本身保留,仍可查询)。
//
// 只在回合**未落定**时标记(落定路径已写入终态快照,其 stop reason 本就
// 不可恢复),因此这里不会改写一个已交付答案的回合。
// 未落定退出的回合不留下「可恢复的在途态」(裁决 13.4.5:下一条消息必须
// 看到自己的最新上下文,而不是被放弃回合的旧窗口顶掉)。判别口径(主人
// 2026-10-04 裁决,「只有崩溃才允许恢复」):**崩溃**时本 defer 根本不执行,
// 中途快照留给下回合续接(design/04 §1 durable execution);**任何已落定的
// 未完成退出**(取消 / 模型报错 / 工具报错;预算耗尽、安全网终止与护栏拦截
// 经 settleAndReturn 写终态)都落定为不可恢复。故只按 res.StopReason 判别:
// 为空 = 本回合没落定,废弃其快照;非空 = 终态已写出,绝不改写。
defer func() {
if ctx.Err() != nil && res.StopReason == "" {
l.abandonCanceledCheckpoint(ctx, sessionID, step, msgs)
if res.StopReason != "" {
return
}
reason := StopReasonAborted
if ctx.Err() != nil {
reason = StopReasonCanceled
}
l.abandonCheckpoint(ctx, sessionID, step, msgs, reason)
}()
for ; step <= l.maxRounds; step++ {
if err := ctx.Err(); err != nil {
@@ -151,8 +157,12 @@ func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Mes
// settleAndReturn 落定一个已得出 Result 的回合:以该路径的
// stop reason 持久化终态 checkpoint、触发后台记忆提取、
// 上报 turn 钩子(情绪标签写回 Result.Mood),然后把结果交还调用方。
//
// 终态快照脱离 ctx 的取消信号写入:落定是一个**已经发生**的决定,若恰在
// 此时收到取消而写失败,最新快照会停在中途的 tool_calls 上,下一条消息便
// 会被旧窗口顶掉——与 driveTurn 收尾 defer 要挡的是同一类缺陷。
func (l *Loop) settleAndReturn(ctx context.Context, sessionID string, msgs []model.Message, step int, stopReason string, res *Result) (*Result, error) {
l.saveCheckpoint(ctx, sessionID, step, msgs, stopReason)
l.saveCheckpoint(context.WithoutCancel(ctx), sessionID, step, msgs, stopReason)
l.extractMemoryAsync(ctx, msgs, step)
l.settleTurn(ctx, sessionID, msgs, res)
return res, nil
@@ -161,18 +171,25 @@ func (l *Loop) settleAndReturn(ctx context.Context, sessionID string, msgs []mod
// StopReasonCanceled 标记一个因用户中断(design/15 §3「停下/算了/换个话题」)
// 而放弃的回合。它不是可恢复的在途态:design/15 要求中断后立即按**最新**
// 上下文处理下一条消息,因此这种快照绝不能被下一条消息续接
// (见 driveTurn 与 abandonCanceledCheckpoint)。
// (见 driveTurn 与 abandonCheckpoint)。
const StopReasonCanceled = "canceled"
// abandonCanceledCheckpoint 把一个被取消回合的 checkpoint 落定为不可恢复态
// (stop reason = StopReasonCanceled),使该会话的下一条消息从它自己的最新
// 上下文开始,而不是被放弃回合的旧窗口顶掉(裁决 13.4.5:上下文一致性 /
// 完整性 / 最新性)。快照本身保留在 store 里,仍可查询。
// StopReasonAborted 标记一个**非取消**的未落定退出:回合在得出终态之前因
// 模型报错、工具报错等原因把控制流交还了调用方。它与 StopReasonCanceled
// 一样使该会话的 checkpoint 不可恢复——主人 2026-10-04 裁决:只有崩溃
// (进程猝死、该回合从未写出终态快照,因而收尾 defer 根本没执行)才允许
// 下一回合续接旧窗口(见 driveTurn 与 abandonCheckpoint)。
const StopReasonAborted = "aborted"
// abandonCheckpoint 把一个未落定回合的 checkpoint 落定为不可恢复态
// (stop reason = reason),使该会话的下一条消息从它自己的最新上下文开始,
// 而不是被放弃回合的旧窗口顶掉(裁决 13.4.5:上下文一致性 / 完整性 /
// 最新性)。快照本身保留在 store 里,仍可查询。
//
// 写入必须脱离已取消的 ctx(否则 ExecContext 必然立即失败),因此这里用
// WithoutCancel 派生背景上下文,保留原 ctx 的值而丢弃取消信号。失败只记日志,
// 绝不让这个收尾动作把中断路径变成错误。
func (l *Loop) abandonCanceledCheckpoint(ctx context.Context, sessionID string, step int, msgs []model.Message) {
l.log.Info("checkpoint abandoned", "session", sessionID, "step", step, "stop_reason", StopReasonCanceled)
l.saveCheckpoint(context.WithoutCancel(ctx), sessionID, step, msgs, StopReasonCanceled)
func (l *Loop) abandonCheckpoint(ctx context.Context, sessionID string, step int, msgs []model.Message, reason string) {
l.log.Info("checkpoint abandoned", "session", sessionID, "step", step, "stop_reason", reason)
l.saveCheckpoint(context.WithoutCancel(ctx), sessionID, step, msgs, reason)
}