From d68f5efc58e13b968c1569483bc4959c6af9ab78 Mon Sep 17 00:00:00 2001
From: xgopilot
Date: Fri, 8 May 2026 07:34:39 +0000
Subject: [PATCH] fix: resolve mainline conflicts for checkpoint diff
Generated with [codeagent](https://github.com/qbox/codeagent)
Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com>
---
README.md | 25 +-
internal/checkpoint/per_edit_snapshot.go | 510 +++++++++++++++---
internal/checkpoint/per_edit_snapshot_test.go | 32 +-
internal/cli/gateway_runtime_bridge.go | 2 +-
internal/cli/gateway_runtime_bridge_test.go | 19 +
internal/gateway/bootstrap.go | 6 +-
internal/gateway/contracts.go | 6 +-
internal/gateway/protocol/jsonrpc.go | 6 +-
internal/runtime/checkpoint_flow_test.go | 219 ++++++++
internal/runtime/checkpoint_restore.go | 33 +-
internal/runtime/run.go | 21 +-
11 files changed, 772 insertions(+), 107 deletions(-)
diff --git a/README.md b/README.md
index f774116c..2a32173c 100644
--- a/README.md
+++ b/README.md
@@ -8,20 +8,21 @@
-
-
+
+
-
+
-
+
+
文档
·
@@ -56,6 +57,7 @@ NeoCode 是一个运行在本地开发环境中的 AI Coding Agent。
- MCP 接入:通过 MCP stdio server 扩展外部工具能力。
- Gateway 模式:通过本地 JSON-RPC / SSE / WebSocket 接口连接桌面端、脚本和第三方客户端。
- Feishu Adapter:支持 Webhook 与 SDK 长连接接入,并用单张状态卡片持续回传 run 状态。
+- Local Runner:`neocode runner` 在本机执行工具,通过 WebSocket 主动连接云端 Gateway,无需开放入站端口。
---
@@ -183,6 +185,21 @@ neocode use --model
neocode use openai --model gpt-4.1
```
+#### Local Runner
+
+在本机启动执行守护进程,主动连接云端 Gateway 接收工具执行请求。
+
+```bash
+# 启动 runner(默认连接 127.0.0.1:8080)
+neocode runner
+
+# 指定远程 Gateway 地址和 token
+neocode runner --gateway-address "your-gateway.com:8080" --token-file ~/.neocode/auth.json
+
+# 指定 Runner 名称与工作目录
+neocode runner --runner-name "我的本机" --workdir /path/to/project
+```
+
### 6. Shell 诊断代理
用于进入代理 shell、初始化 shell integration、手动触发诊断和控制自动诊断模式。
diff --git a/internal/checkpoint/per_edit_snapshot.go b/internal/checkpoint/per_edit_snapshot.go
index a76b6f82..9efa78d2 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 的内容。
@@ -193,13 +194,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 {
@@ -219,8 +221,12 @@ 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) {
return s.finalizeCheckpoint(checkpointID, false)
}
@@ -235,16 +241,40 @@ func (s *PerEditSnapshotStore) finalizeCheckpoint(checkpointID string, captureEx
if checkpointID == "" {
return false, fmt.Errorf("per-edit: empty checkpointID")
}
- s.pendingMu.Lock()
- if len(s.pending) == 0 {
- s.pendingMu.Unlock()
+
+ // 收集版本号(持锁)后释放,再逐文件读 meta 构建快照。
+ s.indexMu.Lock()
+ if len(s.pathToVersions) == 0 {
+ s.indexMu.Unlock()
return false, nil
}
- snapshot := make(map[string]int, len(s.pending))
- for k, v := range s.pending {
- snapshot[k] = v
+ 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
+ }
}
- s.pendingMu.Unlock()
var exactSnapshot map[string]int
if captureExactState {
@@ -267,6 +297,35 @@ func (s *PerEditSnapshotStore) finalizeCheckpoint(checkpointID string, captureEx
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()
+ return false, nil
+ }
+ snapshot := make(map[string]int, len(s.pending))
+ for k, v := range s.pending {
+ snapshot[k] = v
+ }
+ s.pendingMu.Unlock()
+
+ meta := CheckpointMeta{
+ CheckpointID: checkpointID,
+ CreatedAt: time.Now().UTC(),
+ FileVersions: snapshot,
+ }
+ if err := s.writeCheckpointMeta(meta); err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
// captureExactStateSnapshot 为当前 pending 里的每个文件追加一个“checkpoint 结束态”精确版本。
func (s *PerEditSnapshotStore) captureExactStateSnapshot(baseVersions map[string]int) (map[string]int, error) {
s.indexMu.Lock()
@@ -304,59 +363,119 @@ 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 {
+ 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
+ }
+ for h := range guardCP.FileVersions {
+ hashSet[h] = struct{}{}
+ }
+ }
+ // 无论有无 guard,都必须合并全量 pathToVersions。
+ // guard 是 pending-only 的,不包含此前创建的、本 turn 未触碰的新文件;
+ // 不合并则这些文件在 restore 后仍会残留。
+ for h := range s.pathToVersions {
+ hashSet[h] = struct{}{}
+ }
+
+ for hash := range hashSet {
if err := ctx.Err(); err != nil {
return err
}
- nextVersion := s.findNextVersionLocked(hash, vA)
- if nextVersion == 0 {
+
+ // 目标状态:target checkpoint 时刻文件应如何。
+ toContent, toIsDir, toExists, toMode, toDisplay, err := s.contentAtCheckpointLocked(hash, targetCP.FileVersions, false)
+ if err != nil {
+ return err
+ }
+
+ // 当前状态:guard checkpoint 时刻(或磁盘现状)。
+ var fromContent []byte
+ var fromIsDir, fromExists bool
+ var fromMode os.FileMode
+ var fromDisplay string
+ if hasGuard {
+ 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
+ }
+
+ display := toDisplay
+ if display == "" {
+ display = fromDisplay
+ }
+ if display == "" {
continue
}
- nextMeta, err := s.readVersionMeta(hash, nextVersion)
- if err != nil {
- return fmt.Errorf("per-edit: read meta v%d: %w", nextVersion, err)
+
+ if toExists == fromExists && toIsDir == fromIsDir && bytes.Equal(toContent, fromContent) && toMode == fromMode {
+ continue
}
- target := s.resolveDisplayPathLocked(hash, nextMeta.DisplayPath)
- if target == "" {
- return fmt.Errorf("per-edit: missing display path for hash %s", hash)
+
+ // 类型不一致时,先移除旧节点(文件变目录 或 目录变文件)
+ 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 !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)
+
+ 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
}
- if nextMeta.IsDir {
- if err := os.MkdirAll(target, nextMeta.Mode); err != nil {
- return fmt.Errorf("per-edit: restore mkdir %s: %w", target, err)
+
+ if toIsDir {
+ 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
}
- content, err := s.readVersionBin(hash, nextVersion)
- if err != nil {
- return fmt.Errorf("per-edit: read bin v%d: %w", nextVersion, err)
- }
- 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, toMode); err != nil {
+ return fmt.Errorf("per-edit: restore write %s: %w", display, err)
}
}
return nil
@@ -444,11 +563,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
}
@@ -479,6 +598,208 @@ 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 作为首次触碰前的基线
+// - after: 取最大版本号,对其应用 v_next 语义(若 v_next 存在则以 v_next.bin
+// 作为 run 结束时的文件状态;否则退化到 workdir)。
+//
+// 限制:当 run 的最后一次写入是版本链末端且无后续 capture 时,after-side 会退化到
+// 当前 workdir。若 run 结束后用户手动修改了该文件,这些修改会混入 diff。
+// 此时若文件 mtime 晚于 run 最后一个 checkpoint 的创建时间,该文件会被跳过并记录警告。
+//
+// checkpointIDs 应为 PerEditCheckpointIDFromRef 提取后的值(不含 "peredit:" 前缀)。
+//
+// prevFileVersions 为上一个 run 最后一个 checkpoint 的 FileVersions 快照。
+// 版本号未变的文件(同一 hash 在相邻 run 中版本号相同)会被跳过,
+// 因为这些文件在本 run 中未产生新 capture,内容没有变化。传 nil 表示不过滤。
+func (s *PerEditSnapshotStore) RunAggregateDiff(ctx context.Context, checkpointIDs []string, prevFileVersions map[string]int) (string, []FileChangeEntry, error) {
+ type versionRange struct {
+ minV int
+ 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 {
+ vr.minV = v
+ }
+ if v > vr.maxV {
+ vr.maxV = v
+ }
+ versionByHash[hash] = vr
+ } else {
+ versionByHash[hash] = versionRange{minV: v, maxV: v}
+ }
+ }
+ }
+
+ // 过滤历史文件:版本号与上一个 run 结束时相同 = 本 run 未产生新 capture = 跳过。
+ if prevFileVersions != nil {
+ for hash, vr := range versionByHash {
+ if vr.minV == vr.maxV {
+ if prevV, ok := prevFileVersions[hash]; ok && prevV == vr.minV {
+ delete(versionByHash, hash)
+ }
+ }
+ }
+ }
+ 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
+ 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)
+ }
+ }
+ 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
+ }
+ if beforeIsDir && 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 {
@@ -687,11 +1008,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
}
@@ -871,6 +1192,16 @@ func (s *PerEditSnapshotStore) writeCheckpointMeta(meta CheckpointMeta) error {
return writeFileAtomic(s.checkpointMetaPath(meta.CheckpointID), data, 0o644)
}
+// GetCheckpointFileVersions 读取指定 checkpoint 的 FileVersions 映射,
+// 供调用方用于版本号比较(如 RunAggregateDiff 的跨 run 过滤)。
+func (s *PerEditSnapshotStore) GetCheckpointFileVersions(checkpointID string) (map[string]int, error) {
+ meta, err := s.readCheckpointMeta(checkpointID)
+ if err != nil {
+ return nil, err
+ }
+ return meta.FileVersions, nil
+}
+
func (s *PerEditSnapshotStore) readCheckpointMeta(checkpointID string) (CheckpointMeta, error) {
var meta CheckpointMeta
data, err := os.ReadFile(s.checkpointMetaPath(checkpointID))
@@ -968,41 +1299,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
}
// contentAtExactVersionLocked 读取指定 hash/version 保存的精确内容,调用方必须持有 indexMu。
@@ -1094,6 +1439,35 @@ func readWorkdirContent(absPath string) ([]byte, bool, bool) {
return data, false, true
}
+// contentAfterLastVersionLocked 返回文件在 run 结束时的状态:
+// 以 v_last 为版本号,通过 v_next 语义找到 run 后的首次工具触碰前快照;
+// 若无后续触碰则退回 readWorkdirContent。indexMu 必须被持有。
+// 返回值最后一个是 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 {
+ c, isDir, exists := readWorkdirContent(display)
+ return c, isDir, exists, true
+ }
+ nextMeta, err := s.readVersionMeta(hash, nextV)
+ if err != nil {
+ c, isDir, exists := readWorkdirContent(display)
+ return c, isDir, exists, true
+ }
+ if !nextMeta.Existed {
+ return nil, false, false, false
+ }
+ if nextMeta.IsDir {
+ return nil, true, true, false
+ }
+ content, err := s.readVersionBin(hash, nextV)
+ if err != nil {
+ c, isDir, exists := readWorkdirContent(display)
+ return c, isDir, exists, true
+ }
+ return content, false, true, false
+}
+
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 a3822038..56092aaa 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) {
diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go
index 90dd8314..46455254 100644
--- a/internal/cli/gateway_runtime_bridge.go
+++ b/internal/cli/gateway_runtime_bridge.go
@@ -1722,8 +1722,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),
- RunID: strings.TrimSpace(input.RunID),
Scope: strings.TrimSpace(input.Scope),
+ RunID: strings.TrimSpace(input.RunID),
})
if err != nil {
return gateway.CheckpointDiffResult{}, err
diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go
index dc80f838..94da5148 100644
--- a/internal/cli/gateway_runtime_bridge_test.go
+++ b/internal/cli/gateway_runtime_bridge_test.go
@@ -2759,3 +2759,22 @@ func TestGatewayRuntimePortBridgeDeleteMCPServerSuccess(t *testing.T) {
t.Fatalf("servers = %+v, want [srv-2]", cfgMgr.cfg.Tools.MCP.Servers)
}
}
+
+func TestDefaultBuildGatewayRuntimePortListSessionsWithoutExplicitWorkdir(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("USERPROFILE", home)
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
+
+ port, cleanup, err := defaultBuildGatewayRuntimePort(context.Background(), "")
+ if err != nil {
+ t.Fatalf("defaultBuildGatewayRuntimePort() error = %v", err)
+ }
+ if cleanup != nil {
+ defer func() { _ = cleanup() }()
+ }
+
+ if _, err := port.ListSessions(context.Background()); err != nil {
+ t.Fatalf("ListSessions() with empty cli workdir should succeed, got %v", err)
+ }
+}
diff --git a/internal/gateway/bootstrap.go b/internal/gateway/bootstrap.go
index 67b930e5..941feca3 100644
--- a/internal/gateway/bootstrap.go
+++ b/internal/gateway/bootstrap.go
@@ -2105,15 +2105,15 @@ func decodeCheckpointDiffPayload(payload any) CheckpointDiffInput {
SubjectID: strings.TrimSpace(typed.SubjectID),
SessionID: strings.TrimSpace(typed.SessionID),
CheckpointID: strings.TrimSpace(typed.CheckpointID),
- RunID: strings.TrimSpace(typed.RunID),
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"),
- RunID: readStringValue(typed, "run_id"),
Scope: readStringValue(typed, "scope"),
+ RunID: readStringValue(typed, "run_id"),
}
default:
raw, marshalErr := json.Marshal(payload)
@@ -2130,8 +2130,8 @@ func decodeCheckpointDiffPayload(payload any) CheckpointDiffInput {
return CheckpointDiffInput{
SessionID: strings.TrimSpace(decoded.SessionID),
CheckpointID: strings.TrimSpace(decoded.CheckpointID),
- RunID: strings.TrimSpace(decoded.RunID),
Scope: strings.TrimSpace(decoded.Scope),
+ RunID: strings.TrimSpace(decoded.RunID),
}
}
}
diff --git a/internal/gateway/contracts.go b/internal/gateway/contracts.go
index f2ef11de..fe13798a 100644
--- a/internal/gateway/contracts.go
+++ b/internal/gateway/contracts.go
@@ -326,10 +326,10 @@ type CheckpointDiffInput struct {
SessionID string `json:"session_id"`
// CheckpointID 是可选的 checkpoint 标识,为空则查最新代码检查点。
CheckpointID string `json:"checkpoint_id,omitempty"`
- // RunID 是 run 范围 diff 的运行标识。
- RunID string `json:"run_id,omitempty"`
- // Scope 控制 diff 范围;为空保持相邻 checkpoint,run 表示本次请求净变更。
+ // 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 d9829aa2..f30ec886 100644
--- a/internal/gateway/protocol/jsonrpc.go
+++ b/internal/gateway/protocol/jsonrpc.go
@@ -303,8 +303,8 @@ type UndoRestoreParams struct {
type CheckpointDiffParams struct {
SessionID string `json:"session_id"`
CheckpointID string `json:"checkpoint_id,omitempty"`
- RunID string `json:"run_id,omitempty"`
- Scope string `json:"scope,omitempty"`
+ Scope string `json:"scope,omitempty"` // 可选,"run" 表示 run 级聚合 diff
+ RunID string `json:"run_id,omitempty"` // scope=run 时必需
}
// ResolvePermissionParams 表示 gateway.resolvePermission 参数。
@@ -936,8 +936,8 @@ 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.RunID = strings.TrimSpace(p.RunID)
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)
}
diff --git a/internal/runtime/checkpoint_flow_test.go b/internal/runtime/checkpoint_flow_test.go
index 213778ca..7dfa5d00 100644
--- a/internal/runtime/checkpoint_flow_test.go
+++ b/internal/runtime/checkpoint_flow_test.go
@@ -940,3 +940,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-2",
+ SessionID: "session-1",
+ RunID: "run-target",
+ CreatedAt: now.Add(time.Second),
+ CodeCheckpointRef: checkpoint.RefForPerEditCheckpoint("cp-2"),
+ },
+ {
+ CheckpointID: "cp-1",
+ SessionID: "session-1",
+ RunID: "run-target",
+ CreatedAt: now,
+ CodeCheckpointRef: checkpoint.RefForPerEditCheckpoint("cp-1"),
+ },
+ },
+ }
+ 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)
+ }
+ // 当前 run-scope diff 默认返回目标 checkpoint(未显式指定时为最新 checkpoint)。
+ if result.CheckpointID != "cp-2" {
+ t.Fatalf("CheckpointID = %q, want cp-2", 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 checkpoint found for run") {
+ t.Fatalf("error = %v, want 'no code checkpoint found for run'", 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 b6e1a432..fdd7207b 100644
--- a/internal/runtime/checkpoint_restore.go
+++ b/internal/runtime/checkpoint_restore.go
@@ -58,14 +58,26 @@ 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)
}
if guardWritten {
s.perEditStore.Reset()
}
- guardRecord, guardErr := s.createGuardCheckpoint(ctx, sessionID, record.RunID, guardID, guardWritten)
+ var fallbackRef string
+ if !guardWritten && s.checkpointStore != nil {
+ records, listErr := s.checkpointStore.ListCheckpoints(ctx, sessionID, checkpoint.ListCheckpointOpts{Limit: 5})
+ if listErr == nil {
+ for _, r := range records {
+ if r.Reason == agentsession.CheckpointReasonEndOfTurn && checkpoint.IsPerEditRef(r.CodeCheckpointRef) {
+ fallbackRef = r.CodeCheckpointRef
+ break
+ }
+ }
+ }
+ }
+ guardRecord, guardErr := s.createGuardCheckpoint(ctx, sessionID, record.RunID, guardID, guardWritten, fallbackRef)
if guardErr != nil {
if guardWritten {
_ = s.perEditStore.DeleteCheckpoint(guardID)
@@ -85,7 +97,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)
}
}
@@ -197,8 +213,9 @@ func (s *Service) UndoRestoreCheckpoint(ctx context.Context, sessionID string) (
}
// createGuardCheckpoint 创建 pre_restore_guard 类型的 checkpoint。
-// guardWritten=true 时 guardID 对应的 per-edit cp_.json 已写入,CodeCheckpointRef 指向它;否则仅记 session 状态。
-func (s *Service) createGuardCheckpoint(ctx context.Context, sessionID, runID, guardID string, guardWritten bool) (agentsession.CheckpointRecord, error) {
+// guardWritten=true 时 guardID 对应的 per-edit cp_.json 已写入,CodeCheckpointRef 指向它;
+// guardWritten=false 时若 fallbackRef 非空,则用它作为 CodeCheckpointRef 以保证 undo 可走代码恢复路径。
+func (s *Service) createGuardCheckpoint(ctx context.Context, sessionID, runID, guardID string, guardWritten bool, fallbackRef string) (agentsession.CheckpointRecord, error) {
session, err := s.sessionStore.LoadSession(ctx, sessionID)
if err != nil {
return agentsession.CheckpointRecord{}, fmt.Errorf("checkpoint: load session for guard: %w", err)
@@ -217,6 +234,8 @@ func (s *Service) createGuardCheckpoint(ctx context.Context, sessionID, runID, g
var ref string
if guardWritten {
ref = checkpoint.RefForPerEditCheckpoint(guardID)
+ } else if fallbackRef != "" {
+ ref = fallbackRef
}
now := time.Now()
@@ -282,8 +301,8 @@ func (s *Service) updateRuntimeSessionAfterRestore(sessionID string, head agents
type CheckpointDiffInput struct {
SessionID string `json:"session_id"`
CheckpointID string `json:"checkpoint_id,omitempty"` // 可选,为空则查最新代码检查点
- RunID string `json:"run_id,omitempty"`
- Scope string `json:"scope,omitempty"`
+ Scope string `json:"scope,omitempty"` // 可选,"run" 表示 run 级聚合 diff
+ RunID string `json:"run_id,omitempty"` // scope=run 时指定目标 run
}
// CheckpointDiffResult 描述两个相邻代码检查点之间的差异。
diff --git a/internal/runtime/run.go b/internal/runtime/run.go
index 6518aeee..2294faf4 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,24 @@ 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{})
+ if listErr == nil {
+ var perEditIDs []string
+ for _, r := range records {
+ if strings.TrimSpace(r.RunID) != statePtr.runID {
+ continue
+ }
+ 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{