Files
Agent-System-Backend/internal/runmgr/rewind.go
T
weichen f862892544
ci / vet-and-test (pull_request) Successful in 42s
Take the checkpoint closure: retention on rewind, HTTP tests, eligibility pin (N3 WP3)
Why: the library now pins eligibility, guards symlinks, reports partial
restores, and bounds the log (Agent-Native#33); the product wires
retention after successful rewinds and needed the HTTP-layer and
eligibility evidence.
Evidence: go test -count=1 ./... green; TestRewindFilesHTTPHappy and
TestRewindFilesHTTPBusy409 (HTTP happy + conflict),
TestBackendCheckpointEligibilityIsWriteFileOnly (the product surface
checkpoints exactly {fs_write_file}).
Impact: checkpoints.jsonl is compacted to the latest 200 anchors after
each successful rewind.
Refs: Agent-Native#33, Agent-Squad#37, N3 plan WP3
2026-10-07 15:59:14 +00:00

143 lines
3.9 KiB
Go

package runmgr
import (
"fmt"
"log"
"path/filepath"
"strings"
"agent-native/runtime/checkpoint"
core "agent-system-core"
)
func (m *Manager) checkpointFile(owner, convID string) (string, error) {
if m == nil || m.workspaceRoot == "" || owner == "" || convID == "" {
return "", fmt.Errorf("未配置工作区,不能记录文件检查点")
}
return filepath.Join(m.workspaceRoot, owner, convID, ".agentx", "checkpoints.jsonl"), nil
}
// OpenCheckpoints 打开这个会话的文件检查点日志。
func (m *Manager) OpenCheckpoints(owner, convID string) (*checkpoint.Log, error) {
path, err := m.checkpointFile(owner, convID)
if err != nil {
return nil, err
}
return checkpoint.Open(path)
}
func (c *runCtx) OpenCheckpoints() (*checkpoint.Log, error) {
if c == nil || c.mgr == nil {
return nil, fmt.Errorf("未配置工作区,不能记录文件检查点")
}
return c.mgr.OpenCheckpoints(c.run.Owner, c.run.ConversationID)
}
func (c *runCtx) CheckpointAnchor() string {
if c == nil {
return ""
}
return c.userMessage.ID
}
// NoteFileCheckpoint 记录一次工作区写入的改前内容。
func (c *runCtx) NoteFileCheckpoint(rel string, before []byte, existed bool, after []byte) error {
log, err := c.OpenCheckpoints()
if err != nil {
return err
}
root := c.ConversationSandbox()
return log.Record(c.CheckpointAnchor(), root, rel, before, existed, after)
}
func (t *toolCtx) NoteFileCheckpoint(rel string, before []byte, existed bool, after []byte) error {
if t == nil || t.rc == nil {
return nil
}
return t.rc.NoteFileCheckpoint(rel, before, existed, after)
}
// PlanRewind 列出从这条用户消息起会被回滚的文件,不改磁盘。
func (m *Manager) PlanRewind(owner, convID, messageID string) (checkpoint.Result, error) {
ordered, err := m.userMessageIDs(convID, messageID)
if err != nil {
return checkpoint.Result{}, err
}
log, err := m.OpenCheckpoints(owner, convID)
if err != nil {
return checkpoint.Result{}, err
}
return log.Plan(messageID, ordered)
}
// RewindFiles 把文件退回到这条用户消息之前,并追加一条系统说明。
func (m *Manager) RewindFiles(owner, convID, messageID string) (checkpoint.Result, error) {
ordered, err := m.userMessageIDs(convID, messageID)
if err != nil {
return checkpoint.Result{}, err
}
cpLog, err := m.OpenCheckpoints(owner, convID)
if err != nil {
return checkpoint.Result{}, err
}
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,
Parts: []core.Part{{Type: core.PartText, Text: note}},
}
if err := m.st.AppendMessage(&msg); err != nil {
return res, err
}
return res, nil
}
func (m *Manager) userMessageIDs(convID, messageID string) ([]string, error) {
msgs, err := m.st.ListMessages(convID)
if err != nil {
return nil, err
}
var ordered []string
found := false
for _, msg := range msgs {
if msg.Role != core.RoleUser {
continue
}
ordered = append(ordered, msg.ID)
if msg.ID == messageID {
found = true
}
}
if !found {
return nil, fmt.Errorf("找不到用户消息 %s", messageID)
}
return ordered, nil
}
func rewindNote(res checkpoint.Result) string {
var b strings.Builder
b.WriteString("已把文件回滚到这条消息之前。")
if len(res.Restored) == 0 && len(res.Skipped) == 0 {
b.WriteString("没有需要回滚的文件。")
return b.String()
}
if len(res.Restored) > 0 {
b.WriteString("已恢复:")
b.WriteString(strings.Join(res.Restored, "、"))
b.WriteString("。")
}
if len(res.Skipped) > 0 {
b.WriteString("已跳过:")
b.WriteString(strings.Join(res.Skipped, "、"))
b.WriteString("(文件在追踪之外被改过)。")
}
return b.String()
}