Files
Agent-Native/runtime/checkpoint/n3_test.go
T
weichen e8f4e097a7
ci / vet-and-test (pull_request) Failing after 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
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)
2026-10-07 15:47:24 +00:00

233 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 造出同名文件")
}
}