Take the checkpoint closure: retention on rewind, HTTP tests, eligibility pin (N3 WP3) #94

Merged
weichen merged 1 commits from feature/n3-checkpoint-wp3 into main 2026-10-07 16:00:34 +00:00
5 changed files with 189 additions and 4 deletions
+7 -2
View File
@@ -2,6 +2,7 @@ package runmgr
import (
"fmt"
"log"
"path/filepath"
"strings"
@@ -75,14 +76,18 @@ func (m *Manager) RewindFiles(owner, convID, messageID string) (checkpoint.Resul
if err != nil {
return checkpoint.Result{}, err
}
log, err := m.OpenCheckpoints(owner, convID)
cpLog, err := m.OpenCheckpoints(owner, convID)
if err != nil {
return checkpoint.Result{}, err
}
res, err := log.Rewind(messageID, ordered)
res, err := cpLog.Rewind(messageID, ordered)
if err != nil {
return checkpoint.Result{}, err
}
// 保留策略(N3 WP3):成功的回滚后按 anchor 数裁到 200,日志有界。
if cerr := cpLog.Compact(200); cerr != nil {
log.Printf("[runmgr] checkpoint compact %s: %v", convID, cerr)
}
note := rewindNote(res)
msg := core.Message{
ConversationID: convID, Role: core.RoleSystem,
+140
View File
@@ -0,0 +1,140 @@
package server
import (
"net/http"
"os"
"path/filepath"
"testing"
"time"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/plugins"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
core "agent-system-core"
)
// ---- N3 WP3:rewind HTTP 层(happy + busy 409)----
// happy:经 API 回滚被追踪写入,restored/skipped 上抛,文件内容退回。
func TestRewindFilesHTTPHappy(t *testing.T) {
dir := t.TempDir()
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
_ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}})
reg := tools.NewRegistry()
reg.RegisterBuiltin()
setSvc := settings.NewService(st)
profSvc := profiles.New(st)
profSvc.EnsureDefault()
mgr := runmgr.New(st, bus.New(), reg, profSvc, setSvc, &runtime.MockRunner{}, &runtime.MockSubagent{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
ws := t.TempDir()
mgr.SetWorkspaceRoot(ws)
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
srv := New(st, mgr, reg, profSvc, setSvc, ph)
srv.Bus = cmdbus.New(st, nil, nil)
h := srv.Handler()
_, convID := setupConversation(t, h)
runID := postMessage(t, h, convID, "写个文件")
// 等 run 终态(mock run 是异步的;不回等会撞到会话占用 409)。
deadline := time.Now().Add(2 * time.Second)
for {
run, err := st.GetRunByID(runID)
if err == nil && (run.Status == core.RunCompleted || run.Status == core.RunFailed || run.Status == core.RunCancelled) {
break
}
if time.Now().After(deadline) {
t.Fatalf("run 未终态:%+v", run)
}
time.Sleep(10 * time.Millisecond)
}
// 找到用户消息(anchor)并造一条被追踪写入。
var userMsgID string
msgs, err := st.ListMessages(convID)
if err != nil {
t.Fatal(err)
}
for _, m := range msgs {
if m.Role == core.RoleUser {
userMsgID = m.ID
}
}
if userMsgID == "" {
t.Fatal("no user message")
}
sandbox := filepath.Join(ws, store.OwnerDefault, convID)
if err := os.MkdirAll(sandbox, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sandbox, "tracked.txt"), []byte("写入后"), 0o644); err != nil {
t.Fatal(err)
}
cpLog, err := mgr.OpenCheckpoints(store.OwnerDefault, convID)
if err != nil {
t.Fatal(err)
}
if err := cpLog.Record(userMsgID, sandbox, "tracked.txt", []byte("写入前"), true, []byte("写入后")); err != nil {
t.Fatal(err)
}
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/rewind", map[string]any{"messageId": userMsgID})
if code != http.StatusOK {
t.Fatalf("rewind status %d: %v", code, out)
}
restored, _ := out["restored"].([]any)
if len(restored) != 1 || restored[0] != "tracked.txt" {
t.Fatalf("restored: %v", out)
}
body, err := os.ReadFile(filepath.Join(sandbox, "tracked.txt"))
if err != nil || string(body) != "写入前" {
t.Fatalf("文件未退回:%v %q", err, body)
}
}
// busy:会话被占时回滚返回 409。
func TestRewindFilesHTTPBusy409(t *testing.T) {
dir := t.TempDir()
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
_ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}})
reg := tools.NewRegistry()
reg.RegisterBuiltin()
setSvc := settings.NewService(st)
profSvc := profiles.New(st)
profSvc.EnsureDefault()
release := make(chan struct{})
blocking := blockingRunner{block: make(chan struct{}, 1), release: release}
defer close(release)
mgr := runmgr.New(st, bus.New(), reg, profSvc, setSvc, blocking, blocking, &runtime.MockRunner{}, &runtime.MockSubagent{})
t.Cleanup(mgr.Shutdown)
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
srv := New(st, mgr, reg, profSvc, setSvc, ph)
srv.Bus = cmdbus.New(st, nil, nil)
h := srv.Handler()
_, convID := setupConversation(t, h)
postMessage(t, h, convID, "先占住")
var userMsgID string
msgs, _ := st.ListMessages(convID)
for _, m := range msgs {
if m.Role == core.RoleUser {
userMsgID = m.ID
}
}
code, _ := doJSON(t, h, "POST", "/api/conversations/"+convID+"/rewind", map[string]any{"messageId": userMsgID})
if code != http.StatusConflict {
t.Fatalf("busy rewind 应 409,得到 %d", code)
}
}
+40
View File
@@ -0,0 +1,40 @@
package tools
import (
"encoding/json"
"testing"
core "agent-system-core"
)
// ---- N3 WP3:backend 自家工具面的回滚资格钉住(验收② 产品侧)----
type checkpointNoteCtx struct {
*fakeFSCtx
noted []string
}
func (c *checkpointNoteCtx) NoteFileCheckpoint(rel string, before []byte, existed bool, after []byte) error {
c.noted = append(c.noted, rel)
return nil
}
// backend 工具面里只有 fs_write_file 写检查点(产品面不暴露编辑工具是刻意的
// 收窄);fs_read_file / fs_list_dir 永不记录。
func TestBackendCheckpointEligibilityIsWriteFileOnly(t *testing.T) {
r := newFSRegistry(t)
ctx := &checkpointNoteCtx{fakeFSCtx: &fakeFSCtx{root: t.TempDir(), owner: "o1", conv: "c1", fsToolsOn: true}}
exec := func(name, args string) {
t.Helper()
r.Execute(ctx, core.ToolCallRequest{CallID: "c-" + name, Name: name, Arguments: json.RawMessage(args)})
}
exec("fs_write_file", `{"path":"a.txt","content":"x"}`)
if len(ctx.noted) != 1 || ctx.noted[0] != "a.txt" {
t.Fatalf("fs_write_file 必须写检查点:%v", ctx.noted)
}
exec("fs_read_file", `{"path":"a.txt"}`)
exec("fs_list_dir", `{"path":"."}`)
if len(ctx.noted) != 1 {
t.Fatalf("读类工具不得写检查点:%v", ctx.noted)
}
}