diff --git a/README.md b/README.md index f774116c..2a32173c 100644 --- a/README.md +++ b/README.md @@ -8,20 +8,21 @@ Go Version - - CI Status + + Codecov Coverage - License + License MIT Docs - + Platform

+

文档 · @@ -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{