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
@@ -130,10 +130,18 @@ func TestRun_NewSession_NotResumed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_Resume_LoadLatestCalled 断言 Run 向 store 索要该会话
|
||||
// 最新的 checkpoint 并据此恢复(LoadLatest → tool_calls checkpoint →
|
||||
// 从下一步继续)。
|
||||
func TestRun_Resume_LoadLatestCalled(t *testing.T) {
|
||||
// TestRun_Resume_FromCrashSnapshot_LoadLatestCalled 断言 Run 向 store 索要该
|
||||
// 会话最新的 checkpoint,并据**崩溃遗留**的 tool_calls 快照从下一步继续。
|
||||
//
|
||||
// 本用例建模的是崩溃恢复:夹具里的 tool_calls 快照只可能由崩溃留下——任何
|
||||
// 已落定的未完成退出(取消 / 模型报错 / 工具报错 / 预算耗尽 / 安全网终止 /
|
||||
// 护栏拦截)都被 driveTurn 落定为 canceled / aborted 或各自的终态 stop
|
||||
// reason,因而不可恢复(主人 2026-10-04 裁决:**只有崩溃才允许恢复**,且
|
||||
// 崩溃口径是「该回合从未写出终态快照」)。因此这里调用方的新消息被恢复出的
|
||||
// 旧窗口顶掉,是裁决**明确保留**的崩溃语义,不是我们擅自放宽;反面
|
||||
// (已落定的未完成退出不得顶掉下一条消息)由 TestRun_Resume_AbortedSnapshotNotResumed
|
||||
// 与 resume_abandon_test.go、run_cancel_test.go 的用例钉住。
|
||||
func TestRun_Resume_FromCrashSnapshot_LoadLatestCalled(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "final"}}}}
|
||||
@@ -160,12 +168,83 @@ func TestRun_Resume_LoadLatestCalled(t *testing.T) {
|
||||
if res.Steps != 3 {
|
||||
t.Fatalf("Steps = %d, want 3 (resumed at step 3)", res.Steps)
|
||||
}
|
||||
// 崩溃恢复的代价(裁决保留):喂给模型的正是崩溃前的窗口,调用方新传入的
|
||||
// 「new」被它顶掉——因此崩溃恢复只适用于「进程猝死、该回合从未写出终态
|
||||
// 快照」的情形,已落定的未完成退出见下一条用例。
|
||||
got := ad.reqs[0].Messages
|
||||
if len(got) != 2 || messageText(got[1]) != "keep going" {
|
||||
t.Fatalf("resumed request = %+v, want [system, 'keep going']", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_Resume_AbortedSnapshotNotResumed 是上一条崩溃用例的**反面**:
|
||||
// 同一夹具、同样是最新快照,但它的 stop reason 是 aborted(模型报错 / 工具
|
||||
// 报错等已落定的未完成退出)。这种快照绝不可恢复——调用方的新消息必须自己
|
||||
// 组上下文(主人 2026-10-04 裁决:只有崩溃才允许恢复;已落定的未完成退出
|
||||
// 不得让下一条消息被旧窗口劫持)。
|
||||
func TestRun_Resume_AbortedSnapshotNotResumed(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "final"}}}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
|
||||
buf, err := json.Marshal(checkpointState{
|
||||
Step: 2,
|
||||
Messages: []model.Message{model.TextMessage(model.RoleUser, "abandoned window")},
|
||||
StopReason: StopReasonAborted,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal checkpoint: %v", err)
|
||||
}
|
||||
fake := &fakeCheckpointStore{latest: &persist.Checkpoint{ID: "sess:2", AgentID: "sess", Step: 2, State: buf}}
|
||||
l := New(Options{Agent: a, Checkpoint: fake, MaxRounds: 8})
|
||||
|
||||
res, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "new")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Steps != 1 {
|
||||
t.Fatalf("Steps = %d, want 1(aborted 快照不可恢复,必须从步 1 全新开始)", res.Steps)
|
||||
}
|
||||
got := ad.reqs[0].Messages
|
||||
if len(got) != 2 || messageText(got[1]) != "new" {
|
||||
t.Fatalf("request = %+v, want [system, 'new'](新消息不得被旧窗口顶掉)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_Resume_EmptyStopReasonNotResumed:stop reason 为空的快照无法证明是
|
||||
// 崩溃遗留(崩溃留下的快照必带 tool_calls),保守起见同样不恢复——否则任何
|
||||
// 漏写 stop reason 的旧行都会劫持下一条消息(主人 2026-10-04 裁决:只有
|
||||
// 崩溃才允许恢复)。
|
||||
func TestRun_Resume_EmptyStopReasonNotResumed(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "final"}}}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
|
||||
buf, err := json.Marshal(checkpointState{
|
||||
Step: 2,
|
||||
Messages: []model.Message{model.TextMessage(model.RoleUser, "unmarked window")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal checkpoint: %v", err)
|
||||
}
|
||||
fake := &fakeCheckpointStore{latest: &persist.Checkpoint{ID: "sess:2", AgentID: "sess", Step: 2, State: buf}}
|
||||
l := New(Options{Agent: a, Checkpoint: fake, MaxRounds: 8})
|
||||
|
||||
res, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "new")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Steps != 1 {
|
||||
t.Fatalf("Steps = %d, want 1(无 stop reason 的快照不可恢复)", res.Steps)
|
||||
}
|
||||
got := ad.reqs[0].Messages
|
||||
if len(got) != 2 || messageText(got[1]) != "new" {
|
||||
t.Fatalf("request = %+v, want [system, 'new'](新消息不得被旧窗口顶掉)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_Resume_NotFound_Fresh 断言全新路径上仍会调用 LoadLatest,
|
||||
// 且 ErrNotFound 会以调用方的消息从步 1 完全重启
|
||||
// (没有可复活的 checkpoint)。
|
||||
|
||||
Reference in new issue
Block a user