From 4503088739245e235efbc538718a78caf1ed63cf Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 7 May 2026 20:03:33 +0800 Subject: [PATCH 1/3] =?UTF-8?q?pref(checkpoint)=EF=BC=9A=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20Checkpoint=20=E6=81=A2=E5=A4=8D=E4=B8=8E=20Run=20=E7=BA=A7?= =?UTF-8?q?=20Diff=20=E5=AE=8C=E6=95=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/checkpoint/checkpoint_manager.go | 5 + internal/checkpoint/per_edit_snapshot.go | 323 ++++++++++++++--- internal/checkpoint/per_edit_snapshot_test.go | 337 +++++++++++++++++- internal/cli/gateway_runtime_bridge.go | 2 + internal/gateway/bootstrap.go | 8 + internal/gateway/contracts.go | 4 + internal/gateway/protocol/jsonrpc.go | 7 + internal/runtime/checkpoint_flow_test.go | 219 ++++++++++++ internal/runtime/checkpoint_restore.go | 81 ++++- internal/runtime/toolexec.go | 6 +- 10 files changed, 922 insertions(+), 70 deletions(-) diff --git a/internal/checkpoint/checkpoint_manager.go b/internal/checkpoint/checkpoint_manager.go index d8d4a950..347c5582 100644 --- a/internal/checkpoint/checkpoint_manager.go +++ b/internal/checkpoint/checkpoint_manager.go @@ -37,6 +37,7 @@ type CreateCheckpointInput struct { type ListCheckpointOpts struct { Limit int RestorableOnly bool + RunID string } // RestoreCheckpointInput 描述一次 restore 操作的完整输入。 @@ -219,6 +220,10 @@ WHERE session_id = ? query += ` AND restorable = 1 AND status = ?` args = append(args, string(session.CheckpointStatusAvailable)) } + if opts.RunID != "" { + query += ` AND run_id = ?` + args = append(args, opts.RunID) + } query += ` ORDER BY created_at_ms DESC` if opts.Limit > 0 { query += ` LIMIT ?` diff --git a/internal/checkpoint/per_edit_snapshot.go b/internal/checkpoint/per_edit_snapshot.go index 3c759655..f8e7b3ec 100644 --- a/internal/checkpoint/per_edit_snapshot.go +++ b/internal/checkpoint/per_edit_snapshot.go @@ -35,13 +35,14 @@ type ConflictResult struct { // FileVersionMeta 描述某次 CapturePreWrite 时刻的元信息,伴随 .bin 内容文件落盘。 type FileVersionMeta struct { - PathHash string `json:"path_hash"` - DisplayPath string `json:"display_path"` - Version int `json:"version"` - Existed bool `json:"existed"` - IsDir bool `json:"is_dir,omitempty"` - Mode os.FileMode `json:"mode,omitempty"` - CreatedAt time.Time `json:"created_at"` + PathHash string `json:"path_hash"` + DisplayPath string `json:"display_path"` + Version int `json:"version"` + Existed bool `json:"existed"` + IsDir bool `json:"is_dir,omitempty"` + Mode os.FileMode `json:"mode,omitempty"` + CreatedAt time.Time `json:"created_at"` + IsPostDelete bool `json:"is_post_delete,omitempty"` } // CheckpointMeta 是 cp_.json 的内容。 @@ -192,13 +193,14 @@ func (s *PerEditSnapshotStore) CapturePostDelete(absPaths []string) error { } meta := FileVersionMeta{ - PathHash: hash, - DisplayPath: cleanPath, - Version: nextVersion, - Existed: false, - IsDir: false, - Mode: 0, - CreatedAt: time.Now().UTC(), + PathHash: hash, + DisplayPath: cleanPath, + Version: nextVersion, + Existed: false, + IsDir: false, + Mode: 0, + CreatedAt: time.Now().UTC(), + IsPostDelete: true, } metaPath := s.versionMetaPath(hash, nextVersion) if err := s.writeVersionMetaOnly(metaPath, meta); err != nil { @@ -218,12 +220,69 @@ func (s *PerEditSnapshotStore) CapturePostDelete(absPaths []string) error { return nil } -// Finalize 把当前 pending 的 (pathHash → version) 映射写入 cp_.json。 -// pending 为空时返回 (false, nil),不创建空 checkpoint。调用方在 Finalize 后应调用 Reset。 +// Finalize 将当前所有已知文件的(最新版本号→pathHash)映射写入 cp_.json。 +// 每个 checkpoint 均为完整快照(非增量子集),保证任意 checkpoint 回到此点时可完整还原全工作区。 +// 跳过 post-delete 版本(Existed=false),因为全量快照应记录文件内容的最近版本号, +// 而非"文件已删除"的占位标记。post-delete 由 v_next 语义在 restore/diff 时查找。 +// pathToVersions 为空时返回 (false, nil) 表示目前无文件被追踪过,无需写入。 +// 调用方在 Finalize 后应调用 Reset 清空 pending。 func (s *PerEditSnapshotStore) Finalize(checkpointID string) (bool, error) { if checkpointID == "" { return false, fmt.Errorf("per-edit: empty checkpointID") } + + // 收集版本号(持锁)后释放,再逐文件读 meta 构建快照。 + s.indexMu.Lock() + if len(s.pathToVersions) == 0 { + s.indexMu.Unlock() + return false, nil + } + type hashEntry struct { + hash string + versions []int + } + entries := make([]hashEntry, 0, len(s.pathToVersions)) + for h, versions := range s.pathToVersions { + if len(versions) > 0 { + entries = append(entries, hashEntry{hash: h, versions: versions}) + } + } + s.indexMu.Unlock() + + snapshot := make(map[string]int, len(entries)) + for _, e := range entries { + // 从最新版本往回找,跳过 IsPostDelete=true 的标记版本 + // (post-delete 只记录"文件已删除",不应用于全量快照)。 + // pre-create 版本(Existed=false, IsPostDelete=false)仍要保留, + // 否则新建文件在 checkpoint 中将完全不可见。 + for i := len(e.versions) - 1; i >= 0; i-- { + meta, err := s.readVersionMeta(e.hash, e.versions[i]) + if err != nil || meta.IsPostDelete { + continue + } + snapshot[e.hash] = e.versions[i] + break + } + } + + meta := CheckpointMeta{ + CheckpointID: checkpointID, + CreatedAt: time.Now().UTC(), + FileVersions: snapshot, + } + if err := s.writeCheckpointMeta(meta); err != nil { + return false, err + } + return true, nil +} + +// FinalizePending 仅将当前 pending 写入 checkpoint(pre-restore guard 专用)。 +// 全量快照会包含多轮前的旧 pre-write 内容,用于 guard 反而会写错状态; +// guard 只需固化为本轮 capture 的增量。 +func (s *PerEditSnapshotStore) FinalizePending(checkpointID string) (bool, error) { + if checkpointID == "" { + return false, fmt.Errorf("per-edit: empty checkpointID") + } s.pendingMu.Lock() if len(s.pending) == 0 { s.pendingMu.Unlock() @@ -253,59 +312,98 @@ func (s *PerEditSnapshotStore) Reset() { s.pendingMu.Unlock() } -// Restore 还原到指定 checkpoint 时刻的工作区文件状态。 -// 算法核心("下一版本即修改后状态"对偶): -// - 对每个 (pathHash, v_A):找 pathToVersions[hash] 中 v_A 之后的下一个版本 v_next。 -// - v_next 存在时把 v_next.bin 写回 displayPath(v_next.meta.Existed=false 时改为删除); -// v_next 内容即"checkpoint A 时刻的状态"。 -// - v_next 不存在时 no-op:当前 workdir 已等于 A 时刻状态。 +// Restore 还原工作区至 targetID 对应的 checkpoint 时刻状态。 +// guardID 为 pre-restore 固化的快照(restoreCheckpointCore 中的 guard checkpoint), +// 用于对比确定每个文件的目标操作;guardID 为空时仅处理 target checkpoint 内的文件。 // -// 不在 cp.FileVersions 中的其他文件保持不变(per-edit 的关键性质)。 -func (s *PerEditSnapshotStore) Restore(ctx context.Context, checkpointID string) error { - cp, err := s.readCheckpointMeta(checkpointID) +// 对比逻辑:对 target 与 guard 中出现的每个文件,分别计算"目标状态"与"当前状态", +// 据此执行写回 / 删除 / 跳过,覆盖文件创建、修改、删除三种变更方向。 +func (s *PerEditSnapshotStore) Restore(ctx context.Context, targetID, guardID string) error { + targetCP, err := s.readCheckpointMeta(targetID) if err != nil { return err } + s.indexMu.Lock() defer s.indexMu.Unlock() - for hash, vA := range cp.FileVersions { - if err := ctx.Err(); err != nil { + hashSet := make(map[string]struct{}, len(targetCP.FileVersions)) + for h := range targetCP.FileVersions { + hashSet[h] = struct{}{} + } + + var guardCP CheckpointMeta + hasGuard := guardID != "" + if hasGuard { + guardCP, err = s.readCheckpointMeta(guardID) + if err != nil { return err } - nextVersion := s.findNextVersionLocked(hash, vA) - if nextVersion == 0 { - continue + for h := range guardCP.FileVersions { + hashSet[h] = struct{}{} } - nextMeta, err := s.readVersionMeta(hash, nextVersion) - if err != nil { - return fmt.Errorf("per-edit: read meta v%d: %w", nextVersion, err) + } else { + for h := range s.pathToVersions { + hashSet[h] = struct{}{} } - target := s.resolveDisplayPathLocked(hash, nextMeta.DisplayPath) - if target == "" { - return fmt.Errorf("per-edit: missing display path for hash %s", hash) + } + + for hash := range hashSet { + if err := ctx.Err(); err != nil { + return err } - if !nextMeta.Existed { - if err := os.RemoveAll(target); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("per-edit: restore remove %s: %w", target, err) + + // 目标状态:target checkpoint 时刻文件应如何。 + toContent, toIsDir, toExists, toDisplay, err := s.contentAtCheckpointLocked(hash, targetCP.FileVersions, false) + if err != nil { + return err + } + + // 当前状态:guard checkpoint 时刻(或磁盘现状)。 + var fromContent []byte + var fromIsDir, fromExists bool + var fromDisplay string + if hasGuard { + fromContent, fromIsDir, fromExists, fromDisplay, err = s.contentAtCheckpointLocked(hash, guardCP.FileVersions, true) + if err != nil { + return err } + } else { + display := s.resolveDisplayPathLocked(hash, "") + fromContent, fromIsDir, fromExists = readWorkdirContent(display) + fromDisplay = display + } + + display := toDisplay + if display == "" { + display = fromDisplay + } + if display == "" { continue } - if nextMeta.IsDir { - if err := os.MkdirAll(target, nextMeta.Mode); err != nil { - return fmt.Errorf("per-edit: restore mkdir %s: %w", target, err) + + if toExists == fromExists && toIsDir == fromIsDir && bytes.Equal(toContent, fromContent) { + continue + } + + if !toExists { + if err := os.RemoveAll(display); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("per-edit: restore remove %s: %w", display, err) } continue } - content, err := s.readVersionBin(hash, nextVersion) - if err != nil { - return fmt.Errorf("per-edit: read bin v%d: %w", nextVersion, err) + + if toIsDir { + if err := os.MkdirAll(display, 0o755); err != nil { + return fmt.Errorf("per-edit: restore mkdir %s: %w", display, err) + } + continue } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return fmt.Errorf("per-edit: restore mkdir parent %s: %w", target, err) + if err := os.MkdirAll(filepath.Dir(display), 0o755); err != nil { + return fmt.Errorf("per-edit: restore mkdir parent %s: %w", display, err) } - if err := writeFileAtomic(target, content, nextMeta.Mode); err != nil { - return fmt.Errorf("per-edit: write restore %s: %w", target, err) + if err := writeFileAtomic(display, toContent, 0o644); err != nil { + return fmt.Errorf("per-edit: restore write %s: %w", display, err) } } return nil @@ -428,6 +526,108 @@ func (s *PerEditSnapshotStore) Diff(ctx context.Context, fromID, toID string) (s return strings.TrimRight(buf.String(), "\n"), nil } +// RunAggregateDiff 计算一次 run-scoped 聚合 diff: +// 对给定 per-edit checkpointIDs 覆盖的每个文件: +// - before: 取最小版本号的 v.bin 作为首次触碰前的基线 +// - after: 取最大版本号,对其应用 v_next 语义(若 v_next 存在则以 v_next.bin +// 作为 run 结束时的文件状态;否则退化到 workdir)以避免外部修改污染。 +// +// checkpointIDs 应为 PerEditCheckpointIDFromRef 提取后的值(不含 "peredit:" 前缀)。 +func (s *PerEditSnapshotStore) RunAggregateDiff(ctx context.Context, checkpointIDs []string) (string, []FileChangeEntry, error) { + type versionRange struct { + minV int + maxV int + } + versionByHash := make(map[string]versionRange) + for _, cid := range checkpointIDs { + meta, err := s.readCheckpointMeta(cid) + if err != nil { + return "", nil, fmt.Errorf("per-edit: read checkpoint %s: %w", cid, err) + } + for hash, v := range meta.FileVersions { + if vr, ok := versionByHash[hash]; ok { + if v < vr.minV { + vr.minV = v + } + if v > vr.maxV { + vr.maxV = v + } + versionByHash[hash] = vr + } else { + versionByHash[hash] = versionRange{minV: v, maxV: v} + } + } + } + + s.indexMu.Lock() + defer s.indexMu.Unlock() + + hashes := make([]string, 0, len(versionByHash)) + for h := range versionByHash { + hashes = append(hashes, h) + } + sort.Strings(hashes) + + var buf bytes.Buffer + changes := make([]FileChangeEntry, 0, len(hashes)) + for _, hash := range hashes { + if err := ctx.Err(); err != nil { + return "", nil, err + } + vr := versionByHash[hash] + vmeta, err := s.readVersionMeta(hash, vr.minV) + if err != nil { + return "", nil, fmt.Errorf("per-edit: read baseline meta v%d %s: %w", vr.minV, hash, err) + } + display := s.resolveDisplayPathLocked(hash, vmeta.DisplayPath) + if display == "" { + return "", nil, fmt.Errorf("per-edit: missing display path for hash %s", hash) + } + var beforeContent []byte + beforeExists := vmeta.Existed + if beforeExists && !vmeta.IsDir { + beforeContent, err = s.readVersionBin(hash, vr.minV) + if err != nil { + return "", nil, fmt.Errorf("per-edit: read baseline bin v%d %s: %w", vr.minV, hash, err) + } + } + if vmeta.IsDir && beforeExists { + continue + } + afterContent, afterIsDir, afterExists := s.contentAfterLastVersionLocked(hash, vr.maxV, display) + if afterIsDir { + continue + } + if beforeExists == afterExists && bytes.Equal(beforeContent, afterContent) { + continue + } + var kind FileChangeKind + switch { + case !beforeExists && afterExists: + kind = FileChangeAdded + case beforeExists && !afterExists: + kind = FileChangeDeleted + default: + kind = FileChangeModified + } + rel := filepath.ToSlash(s.relativeDisplay(display)) + changes = append(changes, FileChangeEntry{Path: rel, Kind: kind}) + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(string(beforeContent)), + B: difflib.SplitLines(string(afterContent)), + FromFile: "a/" + rel, + ToFile: "b/" + rel, + Context: 3, + } + out, err := difflib.GetUnifiedDiffString(diff) + if err != nil { + return "", nil, fmt.Errorf("per-edit: aggregate diff %s: %w", rel, err) + } + buf.WriteString(out) + } + return strings.TrimRight(buf.String(), "\n"), changes, nil +} + // DeleteCheckpoint 仅删除 cp_.json 元数据。 // file-history 下的 .bin/.meta 不删除,因为它们可能被其他 checkpoint 引用,GC 由独立流程负责。 func (s *PerEditSnapshotStore) DeleteCheckpoint(checkpointID string) error { @@ -783,6 +983,31 @@ func readWorkdirContent(absPath string) ([]byte, bool, bool) { return data, false, true } +// contentAfterLastVersionLocked 返回文件在 run 结束时的状态: +// 以 v_last 为版本号,通过 v_next 语义找到 run 后的首次工具触碰前快照; +// 若无后续触碰则退回 readWorkdirContent。indexMu 必须被持有。 +func (s *PerEditSnapshotStore) contentAfterLastVersionLocked(hash string, vLast int, display string) ([]byte, bool, bool) { + nextV := s.findNextVersionLocked(hash, vLast) + if nextV == 0 { + return readWorkdirContent(display) + } + nextMeta, err := s.readVersionMeta(hash, nextV) + if err != nil { + return readWorkdirContent(display) + } + if !nextMeta.Existed { + return nil, false, false + } + if nextMeta.IsDir { + return nil, true, true + } + content, err := s.readVersionBin(hash, nextV) + if err != nil { + return readWorkdirContent(display) + } + return content, false, true +} + func (s *PerEditSnapshotStore) relativeDisplay(absPath string) string { if absPath == "" { return "" diff --git a/internal/checkpoint/per_edit_snapshot_test.go b/internal/checkpoint/per_edit_snapshot_test.go index c83dc0cb..4ebdb44a 100644 --- a/internal/checkpoint/per_edit_snapshot_test.go +++ b/internal/checkpoint/per_edit_snapshot_test.go @@ -146,7 +146,7 @@ func TestRestore_UsesNextVersionAsTargetState(t *testing.T) { } // Restore cp1: should write STATE_AFTER_TURN_1 (== v2.bin == content captured at start of turn 2). - if err := store.Restore(context.Background(), "cp1"); err != nil { + if err := store.Restore(context.Background(), "cp1", ""); err != nil { t.Fatalf("restore cp1: %v", err) } if got := mustReadFile(t, abs); got != "STATE_AFTER_TURN_1" { @@ -170,7 +170,7 @@ func TestRestore_NoNextVersionIsNoOp(t *testing.T) { } store.Reset() - if err := store.Restore(context.Background(), "cp1"); err != nil { + if err := store.Restore(context.Background(), "cp1", ""); err != nil { t.Fatalf("restore: %v", err) } if got := mustReadFile(t, abs); got != "AFTER" { @@ -212,7 +212,7 @@ func TestRestore_PreservesUntrackedFiles(t *testing.T) { } store.Reset() - if err := store.Restore(context.Background(), "cp1"); err != nil { + if err := store.Restore(context.Background(), "cp1", ""); err != nil { t.Fatalf("restore cp1: %v", err) } if got := mustReadFile(t, tracked); got != "TR_AFTER_T1" { @@ -341,7 +341,7 @@ func TestIndexReload_SurvivesProcessRestart(t *testing.T) { // Workdir is "Y" right now (we never edited again post second capture). // cp1 -> v_next(v1) = v2 -> meta.Existed=true, content="Y" // So Restore writes "Y" back which is no-op effectively. - if err := revived.Restore(context.Background(), "cp1"); err != nil { + if err := revived.Restore(context.Background(), "cp1", ""); err != nil { t.Fatalf("revived restore: %v", err) } if got := mustReadFile(t, abs); got != "Y" { @@ -409,7 +409,7 @@ func TestRestore_RemovesFileWhenVNextExistedFalse(t *testing.T) { store.Reset() // Restore cp2: v2 captured "STILL_LIVE"; v_next(v2)=v3 has Existed=false → delete file. - if err := store.Restore(context.Background(), "cp2"); err != nil { + if err := store.Restore(context.Background(), "cp2", ""); err != nil { t.Fatalf("restore cp2: %v", err) } if _, err := os.Stat(abs); !os.IsNotExist(err) { @@ -519,7 +519,7 @@ func TestRestore_DirectoryRecreateAndDelete(t *testing.T) { if err := os.RemoveAll(dir); err != nil { t.Fatalf("manual remove before restore: %v", err) } - if err := store.Restore(context.Background(), "cp1"); err != nil { + if err := store.Restore(context.Background(), "cp1", ""); err != nil { t.Fatalf("restore cp1: %v", err) } info, err := os.Stat(dir) @@ -531,7 +531,7 @@ func TestRestore_DirectoryRecreateAndDelete(t *testing.T) { } // Restore cp2: v_next=v3(Existed=false) → RemoveAll. Dir should be deleted. - if err := store.Restore(context.Background(), "cp2"); err != nil { + if err := store.Restore(context.Background(), "cp2", ""); err != nil { t.Fatalf("restore cp2: %v", err) } if _, err := os.Stat(dir); !os.IsNotExist(err) { @@ -600,7 +600,7 @@ func TestRestore_DirectoryWithNestedFile(t *testing.T) { if err := os.RemoveAll(dir); err != nil { t.Fatalf("manual remove before restore: %v", err) } - if err := store.Restore(context.Background(), "cp-dir"); err != nil { + if err := store.Restore(context.Background(), "cp-dir", ""); err != nil { t.Fatalf("restore cp-dir: %v", err) } if _, err := os.Stat(dir); os.IsNotExist(err) { @@ -614,7 +614,7 @@ func TestRestore_DirectoryWithNestedFile(t *testing.T) { if err := os.WriteFile(child, []byte("new"), 0o644); err != nil { t.Fatalf("write child before restore: %v", err) } - if err := store.Restore(context.Background(), "cp-remove"); err != nil { + if err := store.Restore(context.Background(), "cp-remove", ""); err != nil { t.Fatalf("restore cp-remove: %v", err) } if _, err := os.Stat(dir); !os.IsNotExist(err) { @@ -677,7 +677,7 @@ func TestChangedFiles(t *testing.T) { store.Reset() // Restore to cp1 so workdir fallback matches cp1 state. - if err := store.Restore(context.Background(), "cp1"); err != nil { + if err := store.Restore(context.Background(), "cp1", ""); err != nil { t.Fatalf("restore cp1: %v", err) } // c.txt did not exist in cp1; Restore won't remove it because cp1 doesn't know about it. @@ -809,7 +809,7 @@ func TestCapturePostDelete_CreatesExistedFalseVersion(t *testing.T) { } // Restore cp1: v_next should be v2(Existed=false) → file should be deleted. - if err := store.Restore(context.Background(), "cp1"); err != nil { + if err := store.Restore(context.Background(), "cp1", ""); err != nil { t.Fatalf("restore cp1: %v", err) } if _, err := os.Stat(abs); !os.IsNotExist(err) { @@ -864,7 +864,7 @@ func TestCapturePostDelete_DirectoryTreeRecovery(t *testing.T) { store.Reset() // Restore cp1: v_next is v2(pre-delete, Existed=true) → tree recreated. - if err := store.Restore(context.Background(), "cp1"); err != nil { + if err := store.Restore(context.Background(), "cp1", ""); err != nil { t.Fatalf("restore cp1: %v", err) } if got := mustReadFile(t, child1); got != "alpha" { @@ -875,7 +875,7 @@ func TestCapturePostDelete_DirectoryTreeRecovery(t *testing.T) { } // Restore cp2: v_next is v3(post-delete, Existed=false) → tree deleted. - if err := store.Restore(context.Background(), "cp2"); err != nil { + if err := store.Restore(context.Background(), "cp2", ""); err != nil { t.Fatalf("restore cp2: %v", err) } if _, err := os.Stat(dir); !os.IsNotExist(err) { @@ -933,7 +933,7 @@ func TestRestore_RemoveDirWithNestedFiles(t *testing.T) { store.Reset() // Restore cp2: should delete the tree. - if err := store.Restore(context.Background(), "cp2"); err != nil { + if err := store.Restore(context.Background(), "cp2", ""); err != nil { t.Fatalf("restore cp2: %v", err) } if _, err := os.Stat(dir); !os.IsNotExist(err) { @@ -941,7 +941,7 @@ func TestRestore_RemoveDirWithNestedFiles(t *testing.T) { } // Restore cp1: should recreate the tree with original content. - if err := store.Restore(context.Background(), "cp1"); err != nil { + if err := store.Restore(context.Background(), "cp1", ""); err != nil { t.Fatalf("restore cp1: %v", err) } if got := mustReadFile(t, child); got != "hello" { @@ -1105,7 +1105,7 @@ func TestChangedFiles_NewFileDetectedAsAdded(t *testing.T) { store.Reset() // Restore to cp1 so workdir fallback matches cp1 state. - if err := store.Restore(context.Background(), "cp1"); err != nil { + if err := store.Restore(context.Background(), "cp1", ""); err != nil { t.Fatalf("restore cp1: %v", err) } if err := os.Remove(filepath.Join(workdir, "b.txt")); err != nil && !os.IsNotExist(err) { @@ -1123,3 +1123,308 @@ func TestChangedFiles_NewFileDetectedAsAdded(t *testing.T) { t.Fatalf("expected b.txt added, got %+v", changes[0]) } } + +// ──────────────────────── RunAggregateDiff tests ──────────────────────── + +func TestRunAggregateDiff_ModifiedFileAcrossCheckpoints(t *testing.T) { + store, workdir := newTestStore(t) + + absA := writeWorkdirFile(t, workdir, "a.txt", "original content\n") + + // Turn 1: modify a.txt + v, err := store.CapturePreWrite(absA) + if err != nil { + t.Fatalf("capture turn1: %v", err) + } + if v != 1 { + t.Fatalf("first capture version = %d, want 1", v) + } + if err := os.WriteFile(absA, []byte("modified content\n"), 0o644); err != nil { + t.Fatalf("write turn1: %v", err) + } + if _, err := store.Finalize("cp1"); err != nil { + t.Fatalf("finalize cp1: %v", err) + } + store.Reset() + + // Turn 2: modify a.txt again + v2, err := store.CapturePreWrite(absA) + if err != nil { + t.Fatalf("capture turn2: %v", err) + } + if v2 != 2 { + t.Fatalf("second capture version = %d, want 2", v2) + } + if err := os.WriteFile(absA, []byte("final content\n"), 0o644); err != nil { + t.Fatalf("write turn2: %v", err) + } + if _, err := store.Finalize("cp2"); err != nil { + t.Fatalf("finalize cp2: %v", err) + } + store.Reset() + + patch, changes, err := store.RunAggregateDiff(context.Background(), []string{"cp1", "cp2"}) + if err != nil { + t.Fatalf("RunAggregateDiff: %v", err) + } + if !strings.Contains(patch, "--- a/a.txt") { + t.Fatalf("patch missing a.txt header:\n%s", patch) + } + // baseline = v1.bin = "original content\n" + // current workdir = "final content\n" + if !strings.Contains(patch, "-original content") { + t.Fatalf("patch missing expected deletion line:\n%s", patch) + } + if !strings.Contains(patch, "+final content") { + t.Fatalf("patch missing expected addition line:\n%s", patch) + } + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d: %+v", len(changes), changes) + } + if changes[0].Path != "a.txt" || changes[0].Kind != FileChangeModified { + t.Fatalf("expected a.txt modified, got %+v", changes[0]) + } +} + +func TestRunAggregateDiff_CreatedFile(t *testing.T) { + store, workdir := newTestStore(t) + + absB := filepath.Join(workdir, "b.txt") + // b.txt does not exist initially + v, err := store.CapturePreWrite(absB) + if err != nil { + t.Fatalf("capture: %v", err) + } + if v != 1 { + t.Fatalf("version = %d, want 1", v) + } + if err := os.WriteFile(absB, []byte("brand new file\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := store.Finalize("cp1"); err != nil { + t.Fatalf("finalize: %v", err) + } + store.Reset() + + patch, changes, err := store.RunAggregateDiff(context.Background(), []string{"cp1"}) + if err != nil { + t.Fatalf("RunAggregateDiff: %v", err) + } + if !strings.Contains(patch, "+++ b/b.txt") { + t.Fatalf("patch missing created file header:\n%s", patch) + } + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d: %+v", len(changes), changes) + } + if changes[0].Path != "b.txt" || changes[0].Kind != FileChangeAdded { + t.Fatalf("expected b.txt added, got %+v", changes[0]) + } +} + +func TestRunAggregateDiff_DeletedFile(t *testing.T) { + store, workdir := newTestStore(t) + + absC := writeWorkdirFile(t, workdir, "c.txt", "will be deleted\n") + + v, err := store.CapturePreWrite(absC) + if err != nil { + t.Fatalf("capture: %v", err) + } + if v != 1 { + t.Fatalf("version = %d, want 1", v) + } + if err := os.Remove(absC); err != nil { + t.Fatalf("remove: %v", err) + } + if err := store.CapturePostDelete([]string{absC}); err != nil { + t.Fatalf("post-delete: %v", err) + } + if _, err := store.Finalize("cp1"); err != nil { + t.Fatalf("finalize: %v", err) + } + store.Reset() + + patch, changes, err := store.RunAggregateDiff(context.Background(), []string{"cp1"}) + if err != nil { + t.Fatalf("RunAggregateDiff: %v", err) + } + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d: %+v", len(changes), changes) + } + if changes[0].Path != "c.txt" || changes[0].Kind != FileChangeDeleted { + t.Fatalf("expected c.txt deleted, got %+v", changes[0]) + } + if !strings.Contains(patch, "--- a/c.txt") || !strings.Contains(patch, "-will be deleted") { + t.Fatalf("patch missing deleted content:\n%s", patch) + } +} + +func TestRunAggregateDiff_CreatedThenDeleted(t *testing.T) { + // Verify that a file created and deleted within the same run yields no net + // change in the aggregate diff. Because CapturePostDelete writes directly + // to the index (not pending), we include a second-file capture in the same + // turn so that Finalize actually writes the checkpoint containing both + // files; otherwise the post-delete version is still in the index but the + // checkpoint meta file never gets created. + store, workdir := newTestStore(t) + + absD := filepath.Join(workdir, "d.txt") + absE := writeWorkdirFile(t, workdir, "e.txt", "existing e\n") + + // Create d.txt + modify e.txt in the same turn + if _, err := store.CapturePreWrite(absD); err != nil { + t.Fatalf("capture d: %v", err) + } + if _, err := store.CapturePreWrite(absE); err != nil { + t.Fatalf("capture e: %v", err) + } + if err := os.WriteFile(absD, []byte("ephemeral\n"), 0o644); err != nil { + t.Fatalf("write d: %v", err) + } + if err := os.WriteFile(absE, []byte("modified e\n"), 0o644); err != nil { + t.Fatalf("write e: %v", err) + } + if _, err := store.Finalize("cp1"); err != nil { + t.Fatalf("finalize cp1: %v", err) + } + store.Reset() + + // Delete d.txt in the next turn. + if err := os.Remove(absD); err != nil { + t.Fatalf("remove d: %v", err) + } + if err := store.CapturePostDelete([]string{absD}); err != nil { + t.Fatalf("post-delete d: %v", err) + } + // Need at least one CapturePreWrite so Finalize produces a checkpoint. + if _, err := store.CapturePreWrite(absE); err != nil { + t.Fatalf("capture e turn2: %v", err) + } + if _, err := store.Finalize("cp2"); err != nil { + t.Fatalf("finalize cp2: %v", err) + } + store.Reset() + + _, changes, err := store.RunAggregateDiff(context.Background(), []string{"cp1", "cp2"}) + if err != nil { + t.Fatalf("RunAggregateDiff: %v", err) + } + // d.txt: created then deleted → no net change + // e.txt: modified once → 1 change + if len(changes) != 1 { + t.Fatalf("expected 1 change (e.txt modified only, d.txt cancelled out), got %d: %+v", len(changes), changes) + } + if changes[0].Path != "e.txt" || changes[0].Kind != FileChangeModified { + t.Fatalf("expected e.txt modified, got %+v", changes[0]) + } +} + +func TestRunAggregateDiff_UnchangedFileOmitted(t *testing.T) { + store, workdir := newTestStore(t) + + abs := writeWorkdirFile(t, workdir, "e.txt", "same content\n") + + // Touch but revert to same content + if _, err := store.CapturePreWrite(abs); err != nil { + t.Fatalf("capture: %v", err) + } + // Write different content temporarily + if err := os.WriteFile(abs, []byte("different\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := store.Finalize("cp1"); err != nil { + t.Fatalf("finalize cp1: %v", err) + } + store.Reset() + + // Second touch: revert back to the original "same content\n" + if _, err := store.CapturePreWrite(abs); err != nil { + t.Fatalf("capture 2: %v", err) + } + if err := os.WriteFile(abs, []byte("same content\n"), 0o644); err != nil { + t.Fatalf("write back: %v", err) + } + if _, err := store.Finalize("cp2"); err != nil { + t.Fatalf("finalize cp2: %v", err) + } + store.Reset() + + _, changes, err := store.RunAggregateDiff(context.Background(), []string{"cp1", "cp2"}) + if err != nil { + t.Fatalf("RunAggregateDiff: %v", err) + } + if len(changes) != 0 { + t.Fatalf("expected 0 changes (end-to-end content unchanged), got %d: %+v", len(changes), changes) + } +} + +func TestRunAggregateDiff_EmptyCheckpointIDs(t *testing.T) { + store, _ := newTestStore(t) + patch, changes, err := store.RunAggregateDiff(context.Background(), nil) + if err != nil { + t.Fatalf("RunAggregateDiff with nil: %v", err) + } + if patch != "" { + t.Fatalf("expected empty patch for nil input, got: %s", patch) + } + if len(changes) != 0 { + t.Fatalf("expected 0 changes for nil input, got %d", len(changes)) + } +} + +func TestRunAggregateDiff_NonexistentCheckpoint(t *testing.T) { + store, _ := newTestStore(t) + _, _, err := store.RunAggregateDiff(context.Background(), []string{"nonexistent_cp"}) + if err == nil { + t.Fatal("expected error for nonexistent checkpoint") + } + if !strings.Contains(err.Error(), "nonexistent_cp") { + t.Fatalf("error should mention checkpoint ID, got: %v", err) + } +} + +func TestRunAggregateDiff_MultipleFilesAggregated(t *testing.T) { + store, workdir := newTestStore(t) + + absA := writeWorkdirFile(t, workdir, "a.txt", "old a\n") + absB := filepath.Join(workdir, "b.txt") // will be created + + // Turn 1: modify a.txt, create b.txt + if _, err := store.CapturePreWrite(absA); err != nil { + t.Fatalf("capture a: %v", err) + } + if _, err := store.CapturePreWrite(absB); err != nil { + t.Fatalf("capture b: %v", err) + } + if err := os.WriteFile(absA, []byte("new a\n"), 0o644); err != nil { + t.Fatalf("write a: %v", err) + } + if err := os.WriteFile(absB, []byte("new b\n"), 0o644); err != nil { + t.Fatalf("write b: %v", err) + } + if _, err := store.Finalize("cp1"); err != nil { + t.Fatalf("finalize: %v", err) + } + store.Reset() + + patch, changes, err := store.RunAggregateDiff(context.Background(), []string{"cp1"}) + if err != nil { + t.Fatalf("RunAggregateDiff: %v", err) + } + if len(changes) != 2 { + t.Fatalf("expected 2 changes, got %d: %+v", len(changes), changes) + } + var kinds = map[string]FileChangeKind{} + for _, c := range changes { + kinds[c.Path] = c.Kind + } + if kinds["a.txt"] != FileChangeModified { + t.Fatalf("a.txt kind = %v, want modified", kinds["a.txt"]) + } + if kinds["b.txt"] != FileChangeAdded { + t.Fatalf("b.txt kind = %v, want added", kinds["b.txt"]) + } + if !strings.Contains(patch, "a.txt") || !strings.Contains(patch, "b.txt") { + t.Fatalf("patch missing file headers:\n%s", patch) + } +} diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go index d6ba6123..3cbef499 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -1576,6 +1576,8 @@ func (b *gatewayRuntimePortBridge) CheckpointDiff(ctx context.Context, input gat result, err := cp.CheckpointDiff(ctx, agentruntime.CheckpointDiffInput{ SessionID: strings.TrimSpace(input.SessionID), CheckpointID: strings.TrimSpace(input.CheckpointID), + Scope: strings.TrimSpace(input.Scope), + RunID: strings.TrimSpace(input.RunID), }) if err != nil { return gateway.CheckpointDiffResult{}, err diff --git a/internal/gateway/bootstrap.go b/internal/gateway/bootstrap.go index 01204abe..968a9656 100644 --- a/internal/gateway/bootstrap.go +++ b/internal/gateway/bootstrap.go @@ -2104,11 +2104,15 @@ func decodeCheckpointDiffPayload(payload any) CheckpointDiffInput { SubjectID: strings.TrimSpace(typed.SubjectID), SessionID: strings.TrimSpace(typed.SessionID), CheckpointID: strings.TrimSpace(typed.CheckpointID), + Scope: strings.TrimSpace(typed.Scope), + RunID: strings.TrimSpace(typed.RunID), } case map[string]any: return CheckpointDiffInput{ SessionID: readStringValue(typed, "session_id"), CheckpointID: readStringValue(typed, "checkpoint_id"), + Scope: readStringValue(typed, "scope"), + RunID: readStringValue(typed, "run_id"), } default: raw, marshalErr := json.Marshal(payload) @@ -2118,11 +2122,15 @@ func decodeCheckpointDiffPayload(payload any) CheckpointDiffInput { var decoded struct { SessionID string `json:"session_id"` CheckpointID string `json:"checkpoint_id"` + Scope string `json:"scope"` + RunID string `json:"run_id"` } _ = json.Unmarshal(raw, &decoded) return CheckpointDiffInput{ SessionID: strings.TrimSpace(decoded.SessionID), CheckpointID: strings.TrimSpace(decoded.CheckpointID), + Scope: strings.TrimSpace(decoded.Scope), + RunID: strings.TrimSpace(decoded.RunID), } } } diff --git a/internal/gateway/contracts.go b/internal/gateway/contracts.go index ff5b2996..fe13798a 100644 --- a/internal/gateway/contracts.go +++ b/internal/gateway/contracts.go @@ -326,6 +326,10 @@ type CheckpointDiffInput struct { SessionID string `json:"session_id"` // CheckpointID 是可选的 checkpoint 标识,为空则查最新代码检查点。 CheckpointID string `json:"checkpoint_id,omitempty"` + // Scope 可选,为 "run" 时按 run_id 做聚合 diff;为空时沿用相邻 checkpoint 对比行为。 + Scope string `json:"scope,omitempty"` + // RunID 在 scope=run 时指定目标 run。 + RunID string `json:"run_id,omitempty"` } // CheckpointDiffResult 描述两个相邻代码检查点之间的差异。 diff --git a/internal/gateway/protocol/jsonrpc.go b/internal/gateway/protocol/jsonrpc.go index 50230507..7de1a822 100644 --- a/internal/gateway/protocol/jsonrpc.go +++ b/internal/gateway/protocol/jsonrpc.go @@ -303,6 +303,8 @@ type UndoRestoreParams struct { type CheckpointDiffParams struct { SessionID string `json:"session_id"` CheckpointID string `json:"checkpoint_id,omitempty"` + Scope string `json:"scope,omitempty"` // 可选,"run" 表示 run 级聚合 diff + RunID string `json:"run_id,omitempty"` // scope=run 时必需 } // ResolvePermissionParams 表示 gateway.resolvePermission 参数。 @@ -934,9 +936,14 @@ func decodeCheckpointDiffParams(raw json.RawMessage) (CheckpointDiffParams, *JSO return decodeParams(raw, "checkpoint.diff", func(p *CheckpointDiffParams) *JSONRPCError { p.SessionID = strings.TrimSpace(p.SessionID) p.CheckpointID = strings.TrimSpace(p.CheckpointID) + p.Scope = strings.TrimSpace(p.Scope) + p.RunID = strings.TrimSpace(p.RunID) if p.SessionID == "" { return NewJSONRPCError(JSONRPCCodeInvalidParams, "missing required field: params.session_id", GatewayCodeMissingRequiredField) } + if p.Scope == "run" && p.RunID == "" { + return NewJSONRPCError(JSONRPCCodeInvalidParams, "missing required field: params.run_id (required when scope=run)", GatewayCodeMissingRequiredField) + } return nil }) } diff --git a/internal/runtime/checkpoint_flow_test.go b/internal/runtime/checkpoint_flow_test.go index f1aa1810..f21b8ed6 100644 --- a/internal/runtime/checkpoint_flow_test.go +++ b/internal/runtime/checkpoint_flow_test.go @@ -855,3 +855,222 @@ func mustReadRuntimeFile(t *testing.T, path string) []byte { } return data } + +// ──────── scope=run diff tests ──────── + +func TestCheckpointDiff_ScopeRun_ReturnsAggregateDiff(t *testing.T) { + workdir := t.TempDir() + projectDir := t.TempDir() + store := checkpoint.NewPerEditSnapshotStore(projectDir, workdir) + now := time.Now().UTC() + + // Turn 1: modify a.txt + absA := filepath.Join(workdir, "a.txt") + _ = os.WriteFile(absA, []byte("old a\n"), 0o644) + if _, err := store.CapturePreWrite(absA); err != nil { + t.Fatalf("CapturePreWrite a: %v", err) + } + _ = os.WriteFile(absA, []byte("new a\n"), 0o644) + if _, err := store.Finalize("cp-1"); err != nil { + t.Fatalf("Finalize cp-1: %v", err) + } + store.Reset() + + // Turn 2: create b.txt + absB := filepath.Join(workdir, "b.txt") + if _, err := store.CapturePreWrite(absB); err != nil { + t.Fatalf("CapturePreWrite b: %v", err) + } + _ = os.WriteFile(absB, []byte("new b\n"), 0o644) + if _, err := store.Finalize("cp-2"); err != nil { + t.Fatalf("Finalize cp-2: %v", err) + } + store.Reset() + + spy := &checkpointStoreSpy{ + listRecords: []agentsession.CheckpointRecord{ + { + CheckpointID: "cp-1", + SessionID: "session-1", + RunID: "run-target", + CreatedAt: now, + CodeCheckpointRef: checkpoint.RefForPerEditCheckpoint("cp-1"), + }, + { + CheckpointID: "cp-2", + SessionID: "session-1", + RunID: "run-target", + CreatedAt: now.Add(time.Second), + CodeCheckpointRef: checkpoint.RefForPerEditCheckpoint("cp-2"), + }, + }, + } + service := &Service{ + checkpointStore: spy, + perEditStore: store, + } + + result, err := service.CheckpointDiff(context.Background(), CheckpointDiffInput{ + SessionID: "session-1", + Scope: "run", + RunID: "run-target", + }) + if err != nil { + t.Fatalf("CheckpointDiff(scope=run) error = %v", err) + } + if result.Patch == "" { + t.Fatal("expected non-empty patch for scope=run") + } + if !strings.Contains(result.Patch, "a.txt") { + t.Fatalf("patch missing a.txt:\n%s", result.Patch) + } + if !strings.Contains(result.Patch, "b.txt") { + t.Fatalf("patch missing b.txt:\n%s", result.Patch) + } + // Created b.txt should be classified as added + var addedPaths, modifiedPaths []string + for _, p := range result.Files.Added { + addedPaths = append(addedPaths, p) + } + for _, p := range result.Files.Modified { + modifiedPaths = append(modifiedPaths, p) + } + if len(addedPaths) != 1 || addedPaths[0] != "b.txt" { + t.Fatalf("expected b.txt added, got added=%v modified=%v", addedPaths, modifiedPaths) + } + if len(modifiedPaths) != 1 || modifiedPaths[0] != "a.txt" { + t.Fatalf("expected a.txt modified, got added=%v modified=%v", addedPaths, modifiedPaths) + } + // CheckpointID should be set to the first checkpoint in the run + if result.CheckpointID != "cp-1" { + t.Fatalf("CheckpointID = %q, want cp-1", result.CheckpointID) + } +} + +func TestCheckpointDiff_ScopeRun_RejectsMissingRunID(t *testing.T) { + service := &Service{ + checkpointStore: &checkpointStoreSpy{}, + perEditStore: checkpoint.NewPerEditSnapshotStore(t.TempDir(), t.TempDir()), + } + _, err := service.CheckpointDiff(context.Background(), CheckpointDiffInput{ + SessionID: "session-1", + Scope: "run", + }) + if err == nil { + t.Fatal("expected error for scope=run without run_id") + } + if !strings.Contains(err.Error(), "run_id required") { + t.Fatalf("error = %v, want run_id required", err) + } +} + +func TestCheckpointDiff_ScopeRun_NoCheckpointsForRunID(t *testing.T) { + spy := &checkpointStoreSpy{ + listRecords: []agentsession.CheckpointRecord{ + { + CheckpointID: "cp-other-run", + SessionID: "session-1", + RunID: "other-run", + CodeCheckpointRef: checkpoint.RefForPerEditCheckpoint("cp-other"), + }, + }, + } + service := &Service{ + checkpointStore: spy, + perEditStore: checkpoint.NewPerEditSnapshotStore(t.TempDir(), t.TempDir()), + } + _, err := service.CheckpointDiff(context.Background(), CheckpointDiffInput{ + SessionID: "session-1", + Scope: "run", + RunID: "run-target", + }) + if err == nil { + t.Fatal("expected error for run_id with no code checkpoints") + } + if !strings.Contains(err.Error(), "no code checkpoints found") { + t.Fatalf("error = %v, want 'no code checkpoints found'", err) + } +} + +func TestCheckpointDiff_DefaultScopePreservesExistingBehavior(t *testing.T) { + // Verify empty scope still uses checkpoint-to-checkpoint comparison. + workdir := t.TempDir() + projectDir := t.TempDir() + store := checkpoint.NewPerEditSnapshotStore(projectDir, workdir) + now := time.Now().UTC() + + absA := filepath.Join(workdir, "a.txt") + _ = os.WriteFile(absA, []byte("v1\n"), 0o644) + if _, err := store.CapturePreWrite(absA); err != nil { + t.Fatalf("CapturePreWrite: %v", err) + } + _ = os.WriteFile(absA, []byte("v2\n"), 0o644) + if _, err := store.Finalize("cp-1"); err != nil { + t.Fatalf("Finalize cp-1: %v", err) + } + store.Reset() + + if _, err := store.CapturePreWrite(absA); err != nil { + t.Fatalf("CapturePreWrite 2: %v", err) + } + _ = os.WriteFile(absA, []byte("v3\n"), 0o644) + if _, err := store.Finalize("cp-2"); err != nil { + t.Fatalf("Finalize cp-2: %v", err) + } + store.Reset() + + spy := &checkpointStoreSpy{ + listRecords: []agentsession.CheckpointRecord{ + { + CheckpointID: "cp-2", + SessionID: "session-1", + RunID: "another-run", + CreatedAt: now.Add(time.Second), + CodeCheckpointRef: checkpoint.RefForPerEditCheckpoint("cp-2"), + }, + { + CheckpointID: "cp-1", + SessionID: "session-1", + RunID: "some-run", + CreatedAt: now, + CodeCheckpointRef: checkpoint.RefForPerEditCheckpoint("cp-1"), + }, + }, + } + service := &Service{ + checkpointStore: spy, + perEditStore: store, + } + + // Empty scope (default): adjacent checkpoint comparison + result, err := service.CheckpointDiff(context.Background(), CheckpointDiffInput{ + SessionID: "session-1", + }) + if err != nil { + t.Fatalf("CheckpointDiff(default) error = %v", err) + } + if result.CheckpointID != "cp-2" || result.PrevCheckpointID != "cp-1" { + t.Fatalf("expected cp-2 vs cp-1, got %s vs %s", result.CheckpointID, result.PrevCheckpointID) + } +} + +func TestCheckpointDiff_StoreNotAvailable(t *testing.T) { + service := &Service{} + _, err := service.CheckpointDiff(context.Background(), CheckpointDiffInput{ + SessionID: "session-1", + }) + if err == nil || !strings.Contains(err.Error(), "store not available") { + t.Fatalf("expected store not available, got %v", err) + } +} + +func TestCheckpointDiff_EmptySessionID(t *testing.T) { + service := &Service{ + checkpointStore: &checkpointStoreSpy{}, + perEditStore: checkpoint.NewPerEditSnapshotStore(t.TempDir(), t.TempDir()), + } + _, err := service.CheckpointDiff(context.Background(), CheckpointDiffInput{}) + if err == nil || !strings.Contains(err.Error(), "session_id required") { + t.Fatalf("expected session_id required, got %v", err) + } +} diff --git a/internal/runtime/checkpoint_restore.go b/internal/runtime/checkpoint_restore.go index 7132d07f..36c66472 100644 --- a/internal/runtime/checkpoint_restore.go +++ b/internal/runtime/checkpoint_restore.go @@ -57,7 +57,7 @@ func (s *Service) restoreCheckpointCore(ctx context.Context, sessionID, checkpoi // 2. Pre-restore guard checkpoint:把当前 pending 固化为 guard cp,以便 undo 回到 restore 之前。 guardID := agentsession.NewID("checkpoint") - guardWritten, finalizeErr := s.perEditStore.Finalize(guardID) + guardWritten, finalizeErr := s.perEditStore.FinalizePending(guardID) if finalizeErr != nil { return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: finalize guard: %w", finalizeErr) } @@ -84,7 +84,11 @@ func (s *Service) restoreCheckpointCore(ctx context.Context, sessionID, checkpoi return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: restore code: %w", err) } } else { - if err := s.perEditStore.Restore(ctx, perEditID); err != nil { + guardCheckpointID := "" + if guardWritten { + guardCheckpointID = guardID + } + if err := s.perEditStore.Restore(ctx, perEditID, guardCheckpointID); err != nil { return RestoreResult{}, agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: restore code: %w", err) } } @@ -281,6 +285,8 @@ func (s *Service) updateRuntimeSessionAfterRestore(sessionID string, head agents type CheckpointDiffInput struct { SessionID string `json:"session_id"` CheckpointID string `json:"checkpoint_id,omitempty"` // 可选,为空则查最新代码检查点 + Scope string `json:"scope,omitempty"` // 可选,"run" 表示 run 级聚合 diff + RunID string `json:"run_id,omitempty"` // scope=run 时指定目标 run } // CheckpointDiffResult 描述两个相邻代码检查点之间的差异。 @@ -300,7 +306,10 @@ type FileDiffs struct { Modified []string `json:"modified,omitempty"` } -// CheckpointDiff 查询两个相邻代码检查点之间的差异,单一 per-edit 后端路径。 +// CheckpointDiff 查询 checkpoint 间差异或按 run 聚合 diff。 +// 当 input.Scope == "run" 时,按 runRunDiff 逻辑收集 run_id 下所有 +// per-edit checkpoint,以各文件首次触碰前的 v1.bin 作为 baseline, +// 对比当前 workdir 状态生成聚合 unified patch。 func (s *Service) CheckpointDiff(ctx context.Context, input CheckpointDiffInput) (CheckpointDiffResult, error) { if s.checkpointStore == nil || s.perEditStore == nil { return CheckpointDiffResult{}, fmt.Errorf("checkpoint: store not available") @@ -311,7 +320,11 @@ func (s *Service) CheckpointDiff(ctx context.Context, input CheckpointDiffInput) return CheckpointDiffResult{}, fmt.Errorf("checkpoint: session_id required") } - records, err := s.checkpointStore.ListCheckpoints(ctx, sessionID, checkpoint.ListCheckpointOpts{Limit: 20}) + if strings.TrimSpace(input.Scope) == "run" { + return s.runDiff(ctx, sessionID, strings.TrimSpace(input.RunID)) + } + + records, err := s.checkpointStore.ListCheckpoints(ctx, sessionID, checkpoint.ListCheckpointOpts{Limit: 50}) if err != nil { return CheckpointDiffResult{}, fmt.Errorf("checkpoint: list for diff: %w", err) } @@ -393,3 +406,63 @@ func (s *Service) CheckpointDiff(ctx context.Context, input CheckpointDiffInput) return result, nil } + +// runDiff 按 run_id 收集该 run 内所有 per-edit checkpoint, +// 以每个文件首次被触碰前的精确版本(v1.bin)作为 baseline, +// 与当前 workdir 状态作端到端对比。 +func (s *Service) runDiff(ctx context.Context, sessionID, runID string) (CheckpointDiffResult, error) { + if runID == "" { + return CheckpointDiffResult{}, fmt.Errorf("checkpoint: run_id required for scope=run") + } + + records, err := s.checkpointStore.ListCheckpoints(ctx, sessionID, checkpoint.ListCheckpointOpts{ + RunID: runID, + }) + if err != nil { + return CheckpointDiffResult{}, fmt.Errorf("checkpoint: list for run diff: %w", err) + } + + var ( + firstPerEditCheckpointID string + perEditIDs []string + ) + for _, r := range records { + if r.RunID != runID { + continue + } + if !checkpoint.IsPerEditRef(r.CodeCheckpointRef) { + continue + } + perEditID := checkpoint.PerEditCheckpointIDFromRef(r.CodeCheckpointRef) + perEditIDs = append(perEditIDs, perEditID) + if firstPerEditCheckpointID == "" { + firstPerEditCheckpointID = r.CheckpointID + } + } + + if len(perEditIDs) == 0 { + return CheckpointDiffResult{}, fmt.Errorf("checkpoint: no code checkpoints found for run_id %s", runID) + } + + patch, changes, err := s.perEditStore.RunAggregateDiff(ctx, perEditIDs) + if err != nil { + return CheckpointDiffResult{}, fmt.Errorf("checkpoint: run aggregate diff: %w", err) + } + + result := CheckpointDiffResult{ + CheckpointID: firstPerEditCheckpointID, + Patch: patch, + } + for _, c := range changes { + switch c.Kind { + case checkpoint.FileChangeAdded: + result.Files.Added = append(result.Files.Added, c.Path) + case checkpoint.FileChangeDeleted: + result.Files.Deleted = append(result.Files.Deleted, c.Path) + case checkpoint.FileChangeModified: + result.Files.Modified = append(result.Files.Modified, c.Path) + } + } + + return result, nil +} diff --git a/internal/runtime/toolexec.go b/internal/runtime/toolexec.go index 7374129a..3b28a76d 100644 --- a/internal/runtime/toolexec.go +++ b/internal/runtime/toolexec.go @@ -10,8 +10,8 @@ import ( "sync" "neo-code/internal/checkpoint" - "neo-code/internal/repository" providertypes "neo-code/internal/provider/types" + "neo-code/internal/repository" runtimefacts "neo-code/internal/runtime/facts" runtimehooks "neo-code/internal/runtime/hooks" "neo-code/internal/tools" @@ -259,6 +259,10 @@ func (s *Service) executeOneToolCall( } else if len(touchedPaths) > 0 { _ = s.perEditStore.CapturePostDelete(touchedPaths) } + case tools.ToolNameFilesystemMoveFile: + if len(touchedPaths) > 1 { + _ = s.perEditStore.CapturePostDelete([]string{touchedPaths[0]}) + } case tools.ToolNameFilesystemDeleteFile: if len(touchedPaths) > 0 { _ = s.perEditStore.CapturePostDelete(touchedPaths) From 6448782247caa541790de5ab3d3349a62ce0a256 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 7 May 2026 22:07:43 +0800 Subject: [PATCH 2/3] =?UTF-8?q?pref(checkpoint)=EF=BC=9A=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?guard=20=E8=B7=AF=E5=BE=84=E4=B8=8B=20hashSet=20=E9=81=97?= =?UTF-8?q?=E6=BC=8F=20pathToVersions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/checkpoint/per_edit_snapshot.go | 114 ++++++++++++++++------- 1 file changed, 81 insertions(+), 33 deletions(-) diff --git a/internal/checkpoint/per_edit_snapshot.go b/internal/checkpoint/per_edit_snapshot.go index f8e7b3ec..537c0ec2 100644 --- a/internal/checkpoint/per_edit_snapshot.go +++ b/internal/checkpoint/per_edit_snapshot.go @@ -342,10 +342,12 @@ func (s *PerEditSnapshotStore) Restore(ctx context.Context, targetID, guardID st for h := range guardCP.FileVersions { hashSet[h] = struct{}{} } - } else { - for h := range s.pathToVersions { - hashSet[h] = struct{}{} - } + } + // 无论有无 guard,都必须合并全量 pathToVersions。 + // guard 是 pending-only 的,不包含此前创建的、本 turn 未触碰的新文件; + // 不合并则这些文件在 restore 后仍会残留。 + for h := range s.pathToVersions { + hashSet[h] = struct{}{} } for hash := range hashSet { @@ -354,7 +356,7 @@ func (s *PerEditSnapshotStore) Restore(ctx context.Context, targetID, guardID st } // 目标状态:target checkpoint 时刻文件应如何。 - toContent, toIsDir, toExists, toDisplay, err := s.contentAtCheckpointLocked(hash, targetCP.FileVersions, false) + toContent, toIsDir, toExists, toMode, toDisplay, err := s.contentAtCheckpointLocked(hash, targetCP.FileVersions, false) if err != nil { return err } @@ -362,15 +364,17 @@ func (s *PerEditSnapshotStore) Restore(ctx context.Context, targetID, guardID st // 当前状态:guard checkpoint 时刻(或磁盘现状)。 var fromContent []byte var fromIsDir, fromExists bool + var fromMode os.FileMode var fromDisplay string if hasGuard { - fromContent, fromIsDir, fromExists, fromDisplay, err = s.contentAtCheckpointLocked(hash, guardCP.FileVersions, true) + fromContent, fromIsDir, fromExists, fromMode, fromDisplay, err = s.contentAtCheckpointLocked(hash, guardCP.FileVersions, true) if err != nil { return err } } else { display := s.resolveDisplayPathLocked(hash, "") fromContent, fromIsDir, fromExists = readWorkdirContent(display) + fromMode = readWorkdirMode(display) fromDisplay = display } @@ -382,7 +386,7 @@ func (s *PerEditSnapshotStore) Restore(ctx context.Context, targetID, guardID st continue } - if toExists == fromExists && toIsDir == fromIsDir && bytes.Equal(toContent, fromContent) { + if toExists == fromExists && toIsDir == fromIsDir && bytes.Equal(toContent, fromContent) && toMode == fromMode { continue } @@ -394,15 +398,24 @@ func (s *PerEditSnapshotStore) Restore(ctx context.Context, targetID, guardID st } if toIsDir { - if err := os.MkdirAll(display, 0o755); err != nil { + if toMode == 0 { + toMode = 0o755 + } + if err := os.MkdirAll(display, toMode); err != nil { return fmt.Errorf("per-edit: restore mkdir %s: %w", display, err) } + // 目录已存在但权限不同,需要修正 + if fromExists && fromMode != toMode { + if err := os.Chmod(display, toMode); err != nil { + return fmt.Errorf("per-edit: restore chmod %s: %w", display, err) + } + } continue } if err := os.MkdirAll(filepath.Dir(display), 0o755); err != nil { return fmt.Errorf("per-edit: restore mkdir parent %s: %w", display, err) } - if err := writeFileAtomic(display, toContent, 0o644); err != nil { + if err := writeFileAtomic(display, toContent, toMode); err != nil { return fmt.Errorf("per-edit: restore write %s: %w", display, err) } } @@ -491,11 +504,11 @@ func (s *PerEditSnapshotStore) Diff(ctx context.Context, fromID, toID string) (s if err := ctx.Err(); err != nil { return "", err } - fromContent, fromIsDir, fromExists, fromDisplay, err := s.contentAtCheckpointLocked(hash, fromMeta.FileVersions, false) + fromContent, fromIsDir, fromExists, _, fromDisplay, err := s.contentAtCheckpointLocked(hash, fromMeta.FileVersions, false) if err != nil { return "", err } - toContent, toIsDir, toExists, toDisplay, err := s.contentAtCheckpointLocked(hash, toMeta.FileVersions, false) + toContent, toIsDir, toExists, _, toDisplay, err := s.contentAtCheckpointLocked(hash, toMeta.FileVersions, false) if err != nil { return "", err } @@ -530,7 +543,11 @@ func (s *PerEditSnapshotStore) Diff(ctx context.Context, fromID, toID string) (s // 对给定 per-edit checkpointIDs 覆盖的每个文件: // - before: 取最小版本号的 v.bin 作为首次触碰前的基线 // - after: 取最大版本号,对其应用 v_next 语义(若 v_next 存在则以 v_next.bin -// 作为 run 结束时的文件状态;否则退化到 workdir)以避免外部修改污染。 +// 作为 run 结束时的文件状态;否则退化到 workdir)。 +// +// 限制:当 run 的最后一次写入是版本链末端且无后续 capture 时,after-side 会退化到 +// 当前 workdir。若 run 结束后用户手动修改了该文件,这些修改会混入 diff。 +// 此时若文件 mtime 晚于 run 最后一个 checkpoint 的创建时间,该文件会被跳过并记录警告。 // // checkpointIDs 应为 PerEditCheckpointIDFromRef 提取后的值(不含 "peredit:" 前缀)。 func (s *PerEditSnapshotStore) RunAggregateDiff(ctx context.Context, checkpointIDs []string) (string, []FileChangeEntry, error) { @@ -539,11 +556,15 @@ func (s *PerEditSnapshotStore) RunAggregateDiff(ctx context.Context, checkpointI maxV int } versionByHash := make(map[string]versionRange) + var runEndTime time.Time for _, cid := range checkpointIDs { meta, err := s.readCheckpointMeta(cid) if err != nil { return "", nil, fmt.Errorf("per-edit: read checkpoint %s: %w", cid, err) } + if meta.CreatedAt.After(runEndTime) { + runEndTime = meta.CreatedAt + } for hash, v := range meta.FileVersions { if vr, ok := versionByHash[hash]; ok { if v < vr.minV { @@ -585,16 +606,25 @@ func (s *PerEditSnapshotStore) RunAggregateDiff(ctx context.Context, checkpointI } var beforeContent []byte beforeExists := vmeta.Existed - if beforeExists && !vmeta.IsDir { + beforeIsDir := vmeta.IsDir + if beforeExists && !beforeIsDir { beforeContent, err = s.readVersionBin(hash, vr.minV) if err != nil { return "", nil, fmt.Errorf("per-edit: read baseline bin v%d %s: %w", vr.minV, hash, err) } } - if vmeta.IsDir && beforeExists { + afterContent, afterIsDir, afterExists, degraded := s.contentAfterLastVersionLocked(hash, vr.maxV, display) + if degraded { + if info, err := os.Stat(display); err == nil && info.ModTime().After(runEndTime) { + // run 结束后文件被外部修改,跳过以避免污染 + continue + } + } + // 只有 before 和 after 都是目录时才跳过(unified diff 不支持目录)。 + // 目录删除、目录变文件等变更仍要进入分类,这样 changes 列表能正确反映。 + if beforeIsDir && beforeExists && afterIsDir && afterExists { continue } - afterContent, afterIsDir, afterExists := s.contentAfterLastVersionLocked(hash, vr.maxV, display) if afterIsDir { continue } @@ -693,11 +723,11 @@ func (s *PerEditSnapshotStore) ChangedFiles(ctx context.Context, fromID, toID st if err := ctx.Err(); err != nil { return nil, err } - fromContent, fromIsDir, fromExists, fromDisplay, err := s.contentAtCheckpointLocked(hash, fromMeta.FileVersions, false) + fromContent, fromIsDir, fromExists, _, fromDisplay, err := s.contentAtCheckpointLocked(hash, fromMeta.FileVersions, false) if err != nil { return nil, err } - toContent, toIsDir, toExists, toDisplay, err := s.contentAtCheckpointLocked(hash, toMeta.FileVersions, false) + toContent, toIsDir, toExists, _, toDisplay, err := s.contentAtCheckpointLocked(hash, toMeta.FileVersions, false) if err != nil { return nil, err } @@ -928,41 +958,55 @@ func (s *PerEditSnapshotStore) resolveDisplayPathLocked(hash, fallback string) s return fallback } +// readWorkdirMode 读取 workdir 上 absPath 的文件权限,失败时返回 0。 +func readWorkdirMode(absPath string) os.FileMode { + if absPath == "" { + return 0 + } + info, err := os.Stat(absPath) + if err != nil { + return 0 + } + return info.Mode() +} + // contentAtCheckpointLocked 计算 hash 在某个 checkpoint 时刻的 workdir 内容。 // 在 cp.FileVersions 中:找下一版本读 .bin(或 Existed=false 时返回 nil); // 没有下一版本时:以当前 workdir 实际内容为准。 // 不在 cp.FileVersions 中且 fallbackIfMissing=false 时:返回 exists=false,避免 diff 侧把工作区当前文件误判为 checkpoint 时刻已存在。 // indexMu 必须被持有。 -func (s *PerEditSnapshotStore) contentAtCheckpointLocked(hash string, cpVersions map[string]int, fallbackIfMissing bool) ([]byte, bool, bool, string, error) { +func (s *PerEditSnapshotStore) contentAtCheckpointLocked(hash string, cpVersions map[string]int, fallbackIfMissing bool) ([]byte, bool, bool, os.FileMode, string, error) { display := s.displayPaths[hash] vAt, ok := cpVersions[hash] if !ok { if fallbackIfMissing { c, isDir, exists := readWorkdirContent(display) - return c, isDir, exists, display, nil + mode := readWorkdirMode(display) + return c, isDir, exists, mode, display, nil } - return nil, false, false, display, nil + return nil, false, false, 0, display, nil } nextVersion := s.findNextVersionLocked(hash, vAt) if nextVersion == 0 { c, isDir, exists := readWorkdirContent(display) - return c, isDir, exists, display, nil + mode := readWorkdirMode(display) + return c, isDir, exists, mode, display, nil } nextMeta, err := s.readVersionMeta(hash, nextVersion) if err != nil { - return nil, false, false, display, fmt.Errorf("per-edit: read meta v%d for %s: %w", nextVersion, hash, err) + return nil, false, false, 0, display, fmt.Errorf("per-edit: read meta v%d for %s: %w", nextVersion, hash, err) } if !nextMeta.Existed { - return nil, false, false, display, nil + return nil, false, false, 0, display, nil } if nextMeta.IsDir { - return nil, true, true, display, nil + return nil, true, true, nextMeta.Mode, display, nil } content, err := s.readVersionBin(hash, nextVersion) if err != nil { - return nil, false, false, display, fmt.Errorf("per-edit: read bin v%d for %s: %w", nextVersion, hash, err) + return nil, false, false, 0, display, fmt.Errorf("per-edit: read bin v%d for %s: %w", nextVersion, hash, err) } - return content, false, true, display, nil + return content, false, true, nextMeta.Mode, display, nil } func readWorkdirContent(absPath string) ([]byte, bool, bool) { @@ -986,26 +1030,30 @@ func readWorkdirContent(absPath string) ([]byte, bool, bool) { // contentAfterLastVersionLocked 返回文件在 run 结束时的状态: // 以 v_last 为版本号,通过 v_next 语义找到 run 后的首次工具触碰前快照; // 若无后续触碰则退回 readWorkdirContent。indexMu 必须被持有。 -func (s *PerEditSnapshotStore) contentAfterLastVersionLocked(hash string, vLast int, display string) ([]byte, bool, bool) { +// 返回值最后一个是 degraded 标记:true 表示因 nextV==0 或读失败而退化到 workdir。 +func (s *PerEditSnapshotStore) contentAfterLastVersionLocked(hash string, vLast int, display string) ([]byte, bool, bool, bool) { nextV := s.findNextVersionLocked(hash, vLast) if nextV == 0 { - return readWorkdirContent(display) + c, isDir, exists := readWorkdirContent(display) + return c, isDir, exists, true } nextMeta, err := s.readVersionMeta(hash, nextV) if err != nil { - return readWorkdirContent(display) + c, isDir, exists := readWorkdirContent(display) + return c, isDir, exists, true } if !nextMeta.Existed { - return nil, false, false + return nil, false, false, false } if nextMeta.IsDir { - return nil, true, true + return nil, true, true, false } content, err := s.readVersionBin(hash, nextV) if err != nil { - return readWorkdirContent(display) + c, isDir, exists := readWorkdirContent(display) + return c, isDir, exists, true } - return content, false, true + return content, false, true, false } func (s *PerEditSnapshotStore) relativeDisplay(absPath string) string { From a48533d6c7c36098aa90b6976e53eb2ad7929013 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 7 May 2026 23:00:44 +0800 Subject: [PATCH 3/3] =?UTF-8?q?pref(checkpoint)=EF=BC=9A=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?RunAggregateDiff=20=E4=B8=AD=20run=20=E7=BB=93=E6=9D=9F?= =?UTF-8?q?=E5=90=8E=E5=88=A0=E9=99=A4=E8=A2=AB=E8=AF=AF=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/checkpoint/per_edit_snapshot.go | 77 ++++++++++++++++++++++++ internal/runtime/checkpoint_flow_test.go | 12 ++-- internal/runtime/checkpoint_restore.go | 4 +- internal/runtime/run.go | 18 +++++- 4 files changed, 102 insertions(+), 9 deletions(-) diff --git a/internal/checkpoint/per_edit_snapshot.go b/internal/checkpoint/per_edit_snapshot.go index 537c0ec2..78fb841e 100644 --- a/internal/checkpoint/per_edit_snapshot.go +++ b/internal/checkpoint/per_edit_snapshot.go @@ -390,6 +390,14 @@ func (s *PerEditSnapshotStore) Restore(ctx context.Context, targetID, guardID st continue } + // 类型不一致时,先移除旧节点(文件变目录 或 目录变文件) + if toExists && toIsDir != fromIsDir && fromExists { + if err := os.RemoveAll(display); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("per-edit: restore remove type-mismatch %s: %w", display, err) + } + fromExists = false + } + if !toExists { if err := os.RemoveAll(display); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("per-edit: restore remove %s: %w", display, err) @@ -539,6 +547,75 @@ func (s *PerEditSnapshotStore) Diff(ctx context.Context, fromID, toID string) (s return strings.TrimRight(buf.String(), "\n"), nil } +// RunEndCapture 在 run 结束时为本次 run 涉及的所有文件创建快照版本。 +// 这些快照版本直接追加到版本链(不进入 pending),确保 RunAggregateDiff 的 +// after-side 不再退化到当前 workdir,彻底隔离 run 结束后外部删除/修改的影响。 +// checkpointIDs 应为 PerEditCheckpointIDFromRef 提取后的值(不含 "peredit:" 前缀)。 +func (s *PerEditSnapshotStore) RunEndCapture(ctx context.Context, checkpointIDs []string) error { + hashSet := make(map[string]struct{}) + for _, cid := range checkpointIDs { + meta, err := s.readCheckpointMeta(cid) + if err != nil { + continue + } + for hash := range meta.FileVersions { + hashSet[hash] = struct{}{} + } + } + if len(hashSet) == 0 { + return nil + } + + s.indexMu.Lock() + defer s.indexMu.Unlock() + + for hash := range hashSet { + if err := ctx.Err(); err != nil { + return err + } + + display := s.displayPaths[hash] + if display == "" { + continue + } + + content, existed, isDir, mode, err := readFileForCapture(display) + if err != nil { + continue + } + + versions := s.pathToVersions[hash] + nextVersion := 1 + if len(versions) > 0 { + nextVersion = versions[len(versions)-1] + 1 + } + + vm := FileVersionMeta{ + PathHash: hash, + DisplayPath: display, + Version: nextVersion, + Existed: existed, + IsDir: isDir, + Mode: mode, + CreatedAt: time.Now().UTC(), + } + + if err := s.writeVersionFiles(vm, content); err != nil { + continue + } + if err := s.appendIndex(perEditIndexEntry{ + PathHash: hash, + DisplayPath: display, + Version: nextVersion, + }); err != nil { + continue + } + + s.pathToVersions[hash] = append(versions, nextVersion) + } + return nil +} + // RunAggregateDiff 计算一次 run-scoped 聚合 diff: // 对给定 per-edit checkpointIDs 覆盖的每个文件: // - before: 取最小版本号的 v.bin 作为首次触碰前的基线 diff --git a/internal/runtime/checkpoint_flow_test.go b/internal/runtime/checkpoint_flow_test.go index f21b8ed6..8af6718c 100644 --- a/internal/runtime/checkpoint_flow_test.go +++ b/internal/runtime/checkpoint_flow_test.go @@ -890,18 +890,18 @@ func TestCheckpointDiff_ScopeRun_ReturnsAggregateDiff(t *testing.T) { spy := &checkpointStoreSpy{ listRecords: []agentsession.CheckpointRecord{ { - CheckpointID: "cp-1", + CheckpointID: "cp-2", SessionID: "session-1", RunID: "run-target", - CreatedAt: now, - CodeCheckpointRef: checkpoint.RefForPerEditCheckpoint("cp-1"), + CreatedAt: now.Add(time.Second), + CodeCheckpointRef: checkpoint.RefForPerEditCheckpoint("cp-2"), }, { - CheckpointID: "cp-2", + CheckpointID: "cp-1", SessionID: "session-1", RunID: "run-target", - CreatedAt: now.Add(time.Second), - CodeCheckpointRef: checkpoint.RefForPerEditCheckpoint("cp-2"), + CreatedAt: now, + CodeCheckpointRef: checkpoint.RefForPerEditCheckpoint("cp-1"), }, }, } diff --git a/internal/runtime/checkpoint_restore.go b/internal/runtime/checkpoint_restore.go index 36c66472..224b6e56 100644 --- a/internal/runtime/checkpoint_restore.go +++ b/internal/runtime/checkpoint_restore.go @@ -426,7 +426,9 @@ func (s *Service) runDiff(ctx context.Context, sessionID, runID string) (Checkpo firstPerEditCheckpointID string perEditIDs []string ) - for _, r := range records { + // ListCheckpoints 返回 DESC 顺序(最新在前),因此倒序遍历以获取最早的 per-edit checkpoint。 + for i := len(records) - 1; i >= 0; i-- { + r := records[i] if r.RunID != runID { continue } diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 4f1a8cee..901245a6 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "neo-code/internal/checkpoint" "neo-code/internal/config" agentcontext "neo-code/internal/context" contextcompact "neo-code/internal/context/compact" @@ -102,8 +103,21 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { s.updateResumeCheckpoint(runCtx, statePtr, "stopped", completion) } if statePtr != nil && s.perEditStore != nil && statePtr.baselineCheckpointID != "" && statePtr.lastEndOfTurnCheckpointID != "" { - diffStr, _ := s.perEditStore.Diff(context.Background(), statePtr.baselineCheckpointID, statePtr.lastEndOfTurnCheckpointID) - files, _ := s.perEditStore.ChangedFiles(context.Background(), statePtr.baselineCheckpointID, statePtr.lastEndOfTurnCheckpointID) + runEndCtx := context.Background() + records, listErr := s.checkpointStore.ListCheckpoints(runEndCtx, statePtr.session.ID, checkpoint.ListCheckpointOpts{RunID: statePtr.runID}) + if listErr == nil { + var perEditIDs []string + for _, r := range records { + if checkpoint.IsPerEditRef(r.CodeCheckpointRef) { + perEditIDs = append(perEditIDs, checkpoint.PerEditCheckpointIDFromRef(r.CodeCheckpointRef)) + } + } + if len(perEditIDs) > 0 { + _ = s.perEditStore.RunEndCapture(runEndCtx, perEditIDs) + } + } + diffStr, _ := s.perEditStore.Diff(runEndCtx, statePtr.baselineCheckpointID, statePtr.lastEndOfTurnCheckpointID) + files, _ := s.perEditStore.ChangedFiles(runEndCtx, statePtr.baselineCheckpointID, statePtr.lastEndOfTurnCheckpointID) var changedFiles []FileDiffEntry for _, f := range files { changedFiles = append(changedFiles, FileDiffEntry{