fix(loop): 所有非崩溃退出一律废弃可恢复快照(主人 2026-10-04 裁决「修」)
- 原状:只有「被取消」的回合把 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:
1 parent
5c28ace70d
commit
88d7dd0026
5 files changed
+448
-47
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user