fix(loop): checkpoint 恢复加自洽性判据(纵深防御,堵「旧窗口吞新消息」的最后一层)
- 背景:`loadCheckpoint` 恢复时会**整段替换**调用方带来的消息 ⇒ 任何「收尾落定前发起下一回合」的路径都会吞掉 用户刚发的消息。已有两层防护(收尾 defer 只让 tool_calls 可恢复 + 网关回合围栏),本次补 loop 侧判据 - 判据(保守版):**只有在能证明「窗口已被取代」时才改判**——快照窗口的最后一条用户消息**确实出现在本回合 带来的消息里**(说明同一对话谱系),且本回合的最后一条用户消息 ≠ 它(说明带来了更新的输入)⇒ 不恢复、全新回合。 判不出来一律维持既有崩溃语义(fail-open 落在「保留崩溃恢复」一侧) - 比对键=文本与图片引用按序拼接(纯图片消息也可区分,避免「刚发的一张图」被判成空键而漏判) - 复核:`internal/runtime/loop` 的 `-count=1`/`-race`/`-shuffle=on` 全绿;**未动任何既有崩溃用例**; 新增 4 条用例/9 条子用例(一致→仍恢复 / 有取代证据→不恢复且不含旧窗口 / 与网关围栏两条路径结论一致 / 边界表含重复播种·纯图片·无交集);**变异两点**(判据恒假 → 4 处红;恒真 → 10 处红,精确打红崩溃用例) - 待主人拍板(已登记):字面版判据(无交集也改判)会与「崩溃 + 用户随后发不相关新消息」的既有语义冲突; 更彻底的选项是「**恢复旧窗口并追加新消息**(union 而非替换)」——需改那条用例与设计口径
This commit is contained in:
1 parent
3d8822c922
commit
9b01efd796
2 files changed
+346
-2
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
@@ -64,8 +65,9 @@ func (l *Loop) saveCheckpoint(ctx context.Context, sessionID string, step int, m
|
||||
// 窗口、下一个要运行的循环步(checkpoint 步 + 1),以及
|
||||
// 是否找到了可恢复的 checkpoint。不存在 checkpoint,或最新的一个
|
||||
// 反映的是已完成/被阻塞的 run、或任何已落定的未完成退出(取消 / 报错 /
|
||||
// 预算耗尽 / 护栏拦截)时,它返回调用方的消息并置步为 1,使循环从新开始
|
||||
// (主人 2026-10-04 裁决:只有崩溃才允许恢复;见 resumableStopReason)。
|
||||
// 预算耗尽 / 护栏拦截)、或它的窗口已被调用方本次带来的消息取代
|
||||
// (见 supersededCheckpointWindow)时,它返回调用方的消息并置步为 1,使循环
|
||||
// 从新开始(主人 2026-10-04 裁决:只有崩溃才允许恢复;见 resumableStopReason)。
|
||||
//
|
||||
// 恢复是幂等的:checkpoint 精确保存了上一个已完成步结束时
|
||||
// 的消息窗口(assistant 工具调用及其结果已融合在内),
|
||||
@@ -93,6 +95,15 @@ func (l *Loop) loadCheckpoint(ctx context.Context, sessionID string, messages []
|
||||
l.log.Debug("checkpoint not resumable", "session", sessionID, "step", cp.Step, "stop_reason", st.StopReason)
|
||||
return messages, 1, false
|
||||
}
|
||||
// 纵深防御第三层(前两层:driveTurn 的收尾 defer 把未落定退出落定成不可恢复;
|
||||
// 网关的 session_turn_fence.go 让新回合等上一回合真正返回):快照的 stop reason
|
||||
// 说明它是崩溃遗留的在途 run,还要它的窗口与调用方本次带来的消息指向**同一条
|
||||
// 最新用户输入**才续接,否则当作全新回合。
|
||||
if supersededCheckpointWindow(st.Messages, messages) {
|
||||
l.log.Info("checkpoint window superseded", "session", sessionID, "step", cp.Step,
|
||||
"next_step", 1, "messages", len(messages))
|
||||
return messages, 1, false
|
||||
}
|
||||
l.log.Info("checkpoint resumed", "session", sessionID, "step", cp.Step, "next_step", cp.Step+1, "messages", len(st.Messages))
|
||||
return st.Messages, cp.Step + 1, true
|
||||
}
|
||||
@@ -115,3 +126,65 @@ func (l *Loop) loadCheckpoint(ctx context.Context, sessionID string, messages []
|
||||
func resumableStopReason(stopReason string) bool {
|
||||
return stopReason == agent.StopReasonToolCalls
|
||||
}
|
||||
|
||||
// supersededCheckpointWindow 报告快照窗口的最后一条用户消息是否已被调用方
|
||||
// 本次带来的**更新**用户消息取代——取代了就不恢复(纵深防御第三层)。
|
||||
//
|
||||
// 它挡的是「上一回合收尾落定之前就发起下一回合」这一类路径的最后一道口子。
|
||||
// 网关播种的新回合窗口是「该会话最近的持久化消息 + 刚发出的那条」
|
||||
// (gateway.startTurnMessages),被放弃/被中断回合的最后一条用户消息因此
|
||||
// **出现在**新窗口里,只是不再是最新的一条;此时续接旧快照就等于把用户刚发的
|
||||
// 消息整段吞掉(裁决 13.4.5 的最新性 / 完整性)。
|
||||
//
|
||||
// 判据刻意保守:只有**正面证据**——快照窗口的最后一条用户消息确实出现在本回合
|
||||
// 的消息里、而本回合又带来了更新的用户消息——才判为「已被取代」。判不出来的一律
|
||||
// 维持既有崩溃语义:快照窗口与调用方消息完全无交集(进程重启后用户直接发了条
|
||||
// 全新消息、历史不可得)是主人 2026-10-04 裁决明确保留的崩溃恢复代价
|
||||
// (resume_test.go 的 TestRun_Resume_FromCrashSnapshot_LoadLatestCalled),
|
||||
// 本判据不据它改判。
|
||||
//
|
||||
// 「同一条消息被重复播种」不会误判:判据比对的是两侧**最后一条**用户消息,
|
||||
// 重复的同一条消息让两侧的键相等,判为同源,照常恢复。
|
||||
func supersededCheckpointWindow(window, turn []model.Message) bool {
|
||||
windowLast := lastUserMessageKey(window)
|
||||
if windowLast == "" {
|
||||
return false
|
||||
}
|
||||
turnLast := lastUserMessageKey(turn)
|
||||
if turnLast == "" || turnLast == windowLast {
|
||||
return false
|
||||
}
|
||||
for _, m := range turn {
|
||||
if m.Role == model.RoleUser && userMessageKey(m) == windowLast {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// lastUserMessageKey 返回 messages 中最后一条用户消息的比对键;
|
||||
// 没有用户消息(或该条没有任何可比内容)时返回 ""。
|
||||
func lastUserMessageKey(messages []model.Message) string {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == model.RoleUser {
|
||||
return userMessageKey(messages[i])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// userMessageKey 返回一条用户消息的比对键:文本与图片引用按出现顺序拼接。
|
||||
// 纯图片消息(无文本)因此也能区分——只比文本会把「用户刚发的一张图」判成
|
||||
// 空键而漏判。角色与说话人不进键:这里只问「是不是同一条用户输入」。
|
||||
func userMessageKey(m model.Message) string {
|
||||
var b strings.Builder
|
||||
for _, part := range m.Content {
|
||||
if part.Type == model.ContentTypeImage {
|
||||
b.WriteString("\x00img:")
|
||||
b.WriteString(part.ImageURL)
|
||||
continue
|
||||
}
|
||||
b.WriteString(part.Text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
Reference in new issue
Block a user