Close the checkpoint gaps: pinned eligibility, symlink guard, partial restore, retention (N3 WP3)
Why: rollback eligibility was implicit (any struct with a mark field),
restore followed symlinks out of the root, a single failing file
aborted the rewind leaving unreported partial state, and the log grew
unboundedly with an O(N) read per record.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestCheckpointedToolSetIsPinned (eligibility is exactly
{write_file, edit_file, multi_edit}), TestRewindSkipsSymlinkTarget
(link and outside target untouched, classified Skipped),
TestRewindPartialFailureContinues (Failed reported, rest restored),
TestCompactKeepsRecentAnchors (original seqs kept, monotonic after),
plus pinned edges for empty ordered, identical-rewrite=Already, and
per-record roots; all pre-existing rewind tests pass unchanged.
Impact: Result gains Failed (additive); Rewind no longer aborts on a
single file; symlinks classify as external changes; Compact bounds the
log; Seq is computed from a cached counter instead of a full read.
Refs: Agent-System-Documents#43 (N3 plan WP3)
This commit is contained in:
@@ -5,6 +5,7 @@ package checkpoint
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -21,16 +22,20 @@ type Record struct {
|
||||
After []byte `json:"after,omitempty"`
|
||||
}
|
||||
|
||||
// Result 是一次回滚的分类。Skipped 是当前内容既不是写入后也不是写入前的文件。
|
||||
// Result 是一次回滚的分类。Skipped 是当前内容既不是写入后也不是写入前的文件
|
||||
// (含目标是符号链接的情况——恢复会跟出根外,按外部改动处理)。
|
||||
// Failed 是恢复动作本身失败的文件(Rewind 不因单文件失败中止,继续其余)。
|
||||
type Result struct {
|
||||
Restored []string
|
||||
Skipped []string
|
||||
Failed []string
|
||||
}
|
||||
|
||||
// Log 是追加写的检查点文件。
|
||||
type Log struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
path string
|
||||
mu sync.Mutex
|
||||
lastSeq int // Open 时一次读出的最大 Seq;Record 递增(不再全读)
|
||||
}
|
||||
|
||||
func Open(path string) (*Log, error) {
|
||||
@@ -42,7 +47,12 @@ func Open(path string) (*Log, error) {
|
||||
return nil, err
|
||||
}
|
||||
_ = f.Close()
|
||||
return &Log{path: path}, nil
|
||||
l := &Log{path: path}
|
||||
// 初始化序号(一次读,之后递增)。
|
||||
if all, err := l.read(); err == nil && len(all) > 0 {
|
||||
l.lastSeq = all[len(all)-1].Seq
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *Log) Path() string { return l.path }
|
||||
@@ -54,12 +64,9 @@ func (l *Log) Record(anchor, root, rel string, before []byte, existed bool, afte
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
all, err := l.read()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l.lastSeq++
|
||||
rec := Record{
|
||||
Seq: len(all) + 1, Anchor: anchor, Root: root, Path: filepath.ToSlash(rel),
|
||||
Seq: l.lastSeq, Anchor: anchor, Root: root, Path: filepath.ToSlash(rel),
|
||||
Existed: existed, Before: append([]byte(nil), before...), After: append([]byte(nil), after...),
|
||||
}
|
||||
raw, err := json.Marshal(rec)
|
||||
@@ -75,6 +82,56 @@ func (l *Log) Record(anchor, root, rel string, before []byte, existed bool, afte
|
||||
return err
|
||||
}
|
||||
|
||||
// Compact 只保留最近 keepAnchors 个 anchor 的记录(按 Seq 序判定新旧,
|
||||
// 保留原 Seq 不重排,序号单调性不破)。keepAnchors ≤ 0 不动。
|
||||
// 重写是原子的(临时文件 + rename)。
|
||||
func (l *Log) Compact(keepAnchors int) error {
|
||||
if l == nil || keepAnchors <= 0 {
|
||||
return nil
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
all, err := l.read()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var anchors []string
|
||||
seen := map[string]bool{}
|
||||
for _, rec := range all {
|
||||
if !seen[rec.Anchor] {
|
||||
seen[rec.Anchor] = true
|
||||
anchors = append(anchors, rec.Anchor)
|
||||
}
|
||||
}
|
||||
if len(anchors) <= keepAnchors {
|
||||
return nil
|
||||
}
|
||||
keep := map[string]bool{}
|
||||
for _, a := range anchors[len(anchors)-keepAnchors:] {
|
||||
keep[a] = true
|
||||
}
|
||||
tmp := l.path + ".compact.tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc := json.NewEncoder(f)
|
||||
for _, rec := range all {
|
||||
if keep[rec.Anchor] {
|
||||
if err := enc.Encode(rec); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, l.path)
|
||||
}
|
||||
|
||||
// Plan 列出从 anchor 起(含)会被回滚的文件,不改磁盘。
|
||||
// ordered 是用户消息 id 的时间顺序。
|
||||
func (l *Log) Plan(anchor string, ordered []string) (Result, error) {
|
||||
@@ -103,12 +160,22 @@ func (l *Log) Rewind(anchor string, ordered []string) (Result, error) {
|
||||
return Result{}, err
|
||||
}
|
||||
plan := classify(all, anchor, ordered)
|
||||
// 单文件失败不中止:符号链接目标归入 Skipped(外部改动语义),
|
||||
// 其余恢复错误记入 Failed 并继续(部分恢复对调用方可读)。
|
||||
restored := plan.Restored[:0]
|
||||
for _, rel := range plan.Restored {
|
||||
group := groupFor(selected(all, anchor, ordered), rel)
|
||||
if err := restore(group); err != nil {
|
||||
return Result{}, err
|
||||
if errors.Is(err, errSymlinkTarget) {
|
||||
plan.Skipped = append(plan.Skipped, rel)
|
||||
continue
|
||||
}
|
||||
plan.Failed = append(plan.Failed, rel)
|
||||
continue
|
||||
}
|
||||
restored = append(restored, rel)
|
||||
}
|
||||
plan.Restored = restored
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
@@ -226,12 +293,19 @@ func fileState(group []Record) int {
|
||||
return stateSkip
|
||||
}
|
||||
|
||||
var errSymlinkTarget = errors.New("checkpoint: restore target is a symlink")
|
||||
|
||||
func restore(group []Record) error {
|
||||
if len(group) == 0 {
|
||||
return nil
|
||||
}
|
||||
first := group[0]
|
||||
abs := filepath.Join(first.Root, filepath.FromSlash(first.Path))
|
||||
// 符号链接防护:恢复会跟出根外(与 fs_write_file 的防护同语义)。
|
||||
// 按外部改动处理——由调用方归入 Skipped,不写不删。
|
||||
if info, err := os.Lstat(abs); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return errSymlinkTarget
|
||||
}
|
||||
if !first.Existed {
|
||||
return os.Remove(abs)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package checkpoint
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- N3 WP3:检查点收口 ----
|
||||
|
||||
// 符号链接目标:恢复会跟出根外——归入 Skipped,链接与目标都不动。
|
||||
func TestRewindSkipsSymlinkTarget(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
real := filepath.Join(root, "real.txt")
|
||||
if err := os.WriteFile(real, []byte("被跟踪写入后"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := log.Record("m1", root, "real.txt", []byte("写入前"), true, []byte("被跟踪写入后")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(t.TempDir(), "outside.txt")
|
||||
if err := os.WriteFile(outside, []byte("根外内容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(real); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, real); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := log.Rewind("m1", []string{"m1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Skipped) != 1 || res.Skipped[0] != "real.txt" {
|
||||
t.Fatalf("符号链接必须进 Skipped:%+v", res)
|
||||
}
|
||||
if len(res.Restored) != 0 || len(res.Failed) != 0 {
|
||||
t.Fatalf("符号链接不得恢复也不得算失败:%+v", res)
|
||||
}
|
||||
body, _ := os.ReadFile(outside)
|
||||
if string(body) != "根外内容" {
|
||||
t.Fatalf("根外目标被写:%q", body)
|
||||
}
|
||||
if info, _ := os.Lstat(real); info == nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatal("符号链接本身不得被改动")
|
||||
}
|
||||
}
|
||||
|
||||
// 部分恢复:一个文件恢复动作失败(所在目录只读,Remove 失败),
|
||||
// 另一个照常恢复,失败进 Result.Failed,不中止。
|
||||
func TestRewindPartialFailureContinues(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dirA, dirB := filepath.Join(root, "a"), filepath.Join(root, "b")
|
||||
for _, d := range []string{dirA, dirB} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 两个都是「被跟踪创建」的文件(existed=false,当前内容==写入后 → stateRestore)。
|
||||
if err := os.WriteFile(filepath.Join(dirA, "keep.txt"), []byte("A后"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dirB, "fail.txt"), []byte("B后"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := log.Record("m1", root, "a/keep.txt", nil, false, []byte("A后")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := log.Record("m1", root, "b/fail.txt", nil, false, []byte("B后")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// dirB 只读:os.Remove 在其中失败。
|
||||
if err := os.Chmod(dirB, 0o500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.Chmod(dirB, 0o755) }()
|
||||
res, err := log.Rewind("m1", []string{"m1"})
|
||||
if err != nil {
|
||||
t.Fatalf("单文件失败不得中止:%v", err)
|
||||
}
|
||||
if len(res.Failed) != 1 || res.Failed[0] != "b/fail.txt" {
|
||||
t.Fatalf("失败文件应进 Failed:%+v", res)
|
||||
}
|
||||
if len(res.Restored) != 1 || res.Restored[0] != "a/keep.txt" {
|
||||
t.Fatalf("其余文件照常恢复:%+v", res)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dirA, "keep.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("keep.txt 应被删除")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dirB, "fail.txt")); err != nil {
|
||||
t.Fatal("fail.txt 应保留")
|
||||
}
|
||||
}
|
||||
|
||||
// Compact:只留最近 N 个 anchor 的记录,原 Seq 保留,后续 Record 序号单调。
|
||||
func TestCompactKeepsRecentAnchors(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct{ anchor, rel string }{
|
||||
{"m1", "a"}, {"m2", "b"}, {"m2", "c"}, {"m3", "d"},
|
||||
} {
|
||||
if err := log.Record(tc.anchor, root, tc.rel, []byte("x"), true, []byte("y")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := log.Compact(2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, err := log.read()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("Compact(2) 应留 3 条(m2×2 + m3),得到 %d", len(all))
|
||||
}
|
||||
for _, rec := range all {
|
||||
if rec.Anchor == "m1" {
|
||||
t.Fatalf("m1 应被裁掉:%+v", all)
|
||||
}
|
||||
}
|
||||
// Seq 保留原值且后续递增单调。
|
||||
maxSeq := 0
|
||||
for _, rec := range all {
|
||||
if rec.Seq > maxSeq {
|
||||
maxSeq = rec.Seq
|
||||
}
|
||||
}
|
||||
if err := log.Record("m4", root, "e", []byte("x"), true, []byte("y")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, _ = log.read()
|
||||
if all[len(all)-1].Seq <= maxSeq {
|
||||
t.Fatalf("Compact 后序号必须单调:%d → %d", maxSeq, all[len(all)-1].Seq)
|
||||
}
|
||||
}
|
||||
|
||||
// 空 ordered 边界:只回滚 anchor 自己的记录(既有语义钉住)。
|
||||
func TestRewindEmptyOrderedKeepsAnchorOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("A后"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "b.txt"), []byte("B后"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := log.Record("m1", root, "a.txt", []byte("A前"), true, []byte("A后")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := log.Record("m2", root, "b.txt", []byte("B前"), true, []byte("B后")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := log.Rewind("m2", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Restored) != 1 || res.Restored[0] != "b.txt" {
|
||||
t.Fatalf("空 ordered 只回滚 anchor 自己:%+v", res)
|
||||
}
|
||||
if body, _ := os.ReadFile(filepath.Join(root, "a.txt")); string(body) != "A后" {
|
||||
t.Fatalf("m1 的文件不得被动:%q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 「外部同字节改写」分类为 Already(内容等价即视为同步,钉住语义)。
|
||||
func TestIdenticalExternalRewriteIsAlready(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("相同内容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := log.Record("m1", root, "a.txt", []byte("相同内容"), true, []byte("相同内容")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 外部用相同字节重写一遍(mtime 变、内容同)。
|
||||
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("相同内容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := log.Plan("m1", []string{"m1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Restored) != 0 || len(res.Skipped) != 0 {
|
||||
t.Fatalf("同字节改写应为 Already(不进任何分类):%+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// root 移动:记录按各自 Root 恢复,不窜根。
|
||||
func TestRewindUsesPerRecordRoot(t *testing.T) {
|
||||
root1, root2 := t.TempDir(), t.TempDir()
|
||||
log, err := Open(filepath.Join(root1, "cp.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root2, "a.txt"), []byte("root2 的内容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := log.Record("m1", root2, "a.txt", []byte("写入前"), true, []byte("root2 的内容")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := log.Rewind("m1", []string{"m1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Restored) != 1 {
|
||||
t.Fatalf("应按记录内 Root 恢复:%+v", res)
|
||||
}
|
||||
if body, _ := os.ReadFile(filepath.Join(root2, "a.txt")); string(body) != "写入前" {
|
||||
t.Fatalf("root2 未恢复:%q", body)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root1, "a.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("不得在 root1 造出同名文件")
|
||||
}
|
||||
}
|
||||
@@ -40,3 +40,24 @@ func TestWriteAndEditRecordCheckpoints(t *testing.T) {
|
||||
t.Fatalf("shell file %q %v", side, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N3 WP3:回滚资格集合钉住(验收② 的机器证据)----
|
||||
|
||||
// 资格集合恰为 {write_file, edit_file, multi_edit}:三者在集且生产记录,
|
||||
// 其余内置工具(read/grep/glob/list_dir)永不在集。
|
||||
func TestCheckpointedToolSetIsPinned(t *testing.T) {
|
||||
want := map[string]bool{"write_file": true, "edit_file": true, "multi_edit": true}
|
||||
if len(CheckpointedTools) != len(want) {
|
||||
t.Fatalf("资格集合漂移:%v", CheckpointedTools)
|
||||
}
|
||||
for _, n := range CheckpointedTools {
|
||||
if !want[n] || !Checkpointed(n) {
|
||||
t.Fatalf("资格集合漂移:%v", CheckpointedTools)
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"read_file", "grep", "glob", "list_dir", "run_python", "run_shell"} {
|
||||
if Checkpointed(n) {
|
||||
t.Fatalf("%s 不得在回滚资格集内", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +366,21 @@ func WorkspaceToolsTracked(root string, files *filestate.Tracker) *Registry {
|
||||
return WorkspaceToolsCheckpointed(root, files, nil, nil)
|
||||
}
|
||||
|
||||
// CheckpointedTools 是会写检查点的工具名集合——回滚资格的唯一声明处
|
||||
// (回滚只撤销这三类写入;shell/python/其他工具永不在集)。集合由
|
||||
// WorkspaceToolsCheckpointed 的构造使用,并有测试钉住,防静默漂移。
|
||||
var CheckpointedTools = []string{"write_file", "edit_file", "multi_edit"}
|
||||
|
||||
// Checkpointed 报告一个工具名是否有回滚资格(供宿主/测试查询)。
|
||||
func Checkpointed(name string) bool {
|
||||
for _, n := range CheckpointedTools {
|
||||
if n == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WorkspaceToolsCheckpointed 在写、改文件前把改前内容交给检查点。log 为空则不记录。
|
||||
func WorkspaceToolsCheckpointed(root string, files *filestate.Tracker, log *checkpoint.Log, anchor func() string) *Registry {
|
||||
mark := writeMark(log, root, anchor)
|
||||
|
||||
Reference in New Issue
Block a user