ci / vet-and-test (pull_request) Successful in 41s
Why: run terminal status persists before the conversation claim is released; on the slow CI runner the rewind POST landed inside that window and got a 409. Retry the POST until the claim frees (bounded). Evidence: go test -count=5 ./internal/server/ -run TestRewindFilesHTTPHappy green; full suite green. Impact: test-only. Refs: Agent-System-Backend#94 #97
155 lines
4.9 KiB
Go
155 lines
4.9 KiB
Go
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)
|
||
}
|
||
|
||
// run 行已终态与释放会话占用之间有个 settle 窗口(CI 慢机上会撞 409):
|
||
// 有界重试直到放行。
|
||
var code int
|
||
var out map[string]any
|
||
deadline2 := time.Now().Add(3 * time.Second)
|
||
for {
|
||
code, out = doJSON(t, h, "POST", "/api/conversations/"+convID+"/rewind", map[string]any{"messageId": userMsgID})
|
||
if code != http.StatusConflict {
|
||
break
|
||
}
|
||
if time.Now().After(deadline2) {
|
||
break
|
||
}
|
||
time.Sleep(15 * time.Millisecond)
|
||
}
|
||
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)
|
||
}
|
||
}
|