package execenv import ( "context" "errors" "fmt" "io" "log/slog" "os" "os/exec" "path/filepath" "strings" "sync" "testing" "time" ) func worktreeTestLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // testRepoTemplate is the repository newTestRepo hands out copies of. It is // built once per test binary: copying it spawns no git process, where // building each repo afresh spawned five, a sizeable share of a single-turn // test. TestMain removes it. var testRepoTemplate struct { once sync.Once dir string err error } // newTestRepo creates a git repo with one commit and returns its path. The // repo carries its own identity config so tests don't depend on the machine's // global git setup. func newTestRepo(t *testing.T) string { t.Helper() requireGit(t) testRepoTemplate.once.Do(func() { testRepoTemplate.dir, testRepoTemplate.err = buildTestRepoTemplate() }) if testRepoTemplate.err != nil { t.Fatalf("build the template repo: %v", testRepoTemplate.err) } dir := t.TempDir() // macOS hands out /var/folders temp dirs that are symlinks to /private/var. // resolveGitRoot canonicalises, so the test must compare against the // canonical form too. if resolved, err := filepath.EvalSymlinks(dir); err == nil { dir = resolved } if err := os.CopyFS(dir, os.DirFS(testRepoTemplate.dir)); err != nil { t.Fatalf("copy the template repo: %v", err) } return dir } func buildTestRepoTemplate() (string, error) { dir, err := os.MkdirTemp("", "multica-test-repo") if err != nil { return "", err } for name, content := range map[string]string{"tracked.txt": "original\n", "keep.txt": "keep\n"} { if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { return dir, err } } for _, args := range [][]string{ {"init", "-b", "main"}, {"config", "user.name", "Test User"}, {"config", "user.email", "test@test.com"}, // git commit forks a DETACHED `git maintenance run --auto` and // returns without waiting for it. That background process creates // and removes lock files under .git/ after this function is done, // so newTestRepo's os.CopyFS can list .git/objects/maintenance.lock // and then fail to open it — failing whichever test happened to copy // the template at that moment, in a package that has nothing to do // with git maintenance. Every copy inherits this config, which also // keeps the git commands tests run on their own copy from spawning a // background process that races the assertions against it. {"config", "gc.auto", "0"}, {"config", "maintenance.auto", "false"}, {"add", "."}, {"commit", "-m", "initial"}, } { if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil { return dir, fmt.Errorf("git %v: %s: %w", args, out, err) } } return dir, nil } // requireGit skips the whole test when git is missing from the environment. // This is the ONLY place a skip is allowed: once a test is past it, every git // command is part of the assertion surface, so a failure there is a real // regression and must fail loudly. An earlier version skipped on any git error, // which would have turned "the delivered branch is gone" into a green run. func requireGit(t *testing.T) { t.Helper() if _, err := exec.LookPath("git"); err != nil { t.Skipf("git is not available: %v", err) } } func gitRun(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v failed: %s: %v", args, out, err) } return strings.TrimSpace(string(out)) } func gitTry(t *testing.T, dir string, args ...string) (string, error) { t.Helper() cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) out, err := cmd.CombinedOutput() return strings.TrimSpace(string(out)), err } func readFile(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } return string(b) } // testBranchOwner is the conversation every multi-turn test in this package // speaks for. Tests that need a DIFFERENT conversation vary one field. var testBranchOwner = branchOwner{ WorkspaceID: "11112222-3333-4444-5555-000000000001", AgentID: "11112222-3333-4444-5555-000000000002", ConversationID: "11112222-3333-4444-5555-000000000003", } func prepareForTest(t *testing.T, localPath string) *LocalWorktree { t.Helper() wt, err := PrepareLocalWorktree(LocalWorktreeParams{ LocalPath: localPath, EnvRoot: t.TempDir(), AgentName: "J", TaskID: "11112222-3333-4444-5555-666677778888", }, worktreeTestLogger()) if err != nil { t.Fatalf("PrepareLocalWorktree: %v", err) } return wt } // Nothing may write inside a repo this package hands to a test while the test // is reading it, and git's auto maintenance is the one writer tests do not // spawn themselves: it runs detached, outliving the command that started it. // In the template that made os.CopyFS race a lock file git was removing; in a // copy it would race the test's own assertions. Asserting on a copy covers // both, since the copy inherits the template's config. func TestTestRepoDisablesGitAutoMaintenance(t *testing.T) { t.Parallel() repo := newTestRepo(t) for key, want := range map[string]string{"gc.auto": "0", "maintenance.auto": "false"} { // An unset key exits 1, which is the regression itself — read it // without letting that exit code stand in for the comparison. got, _ := gitTry(t, repo, "config", "--get", key) if got != want { t.Errorf("%s = %q, want %q", key, got, want) } } } // The agent must see the user's uncommitted work, not a clean HEAD checkout. // This is the property that makes worktree mode usable rather than confusing: // otherwise the agent silently reviews code the user hasn't got open. // // Capturing that state must not disturb the user's own working tree either — // they may have a build running against it — so the index, the files, and the // stash list are left alone. func TestPrepareLocalWorktreeReplaysUncommittedWork(t *testing.T) { t.Parallel() repo := newTestRepo(t) writeFile(t, filepath.Join(repo, "tracked.txt"), "edited by user\n") writeFile(t, filepath.Join(repo, "brand-new.txt"), "untracked\n") writeFile(t, filepath.Join(repo, "nested/deep.txt"), "nested untracked\n") statusBefore := gitRun(t, repo, "status", "--porcelain") wt := prepareForTest(t, repo) if got := readFile(t, filepath.Join(wt.Path, "tracked.txt")); got != "edited by user\n" { t.Errorf("tracked edit not replayed into worktree: got %q", got) } if got := readFile(t, filepath.Join(wt.Path, "brand-new.txt")); got != "untracked\n" { t.Errorf("untracked file not copied: got %q", got) } if got := readFile(t, filepath.Join(wt.Path, "nested/deep.txt")); got != "nested untracked\n" { t.Errorf("nested untracked file not copied: got %q", got) } if !wt.DirtyBaseCaptured { t.Error("DirtyBaseCaptured = false, want true") } // A task with no conversation behind it — no issue, no chat session — has // nothing to continue and keeps the task-scoped branch. if want := "agent/j/" + taskKey("11112222-3333-4444-5555-666677778888"); wt.Branch != want { t.Errorf("branch = %q, want %q", wt.Branch, want) } if wt.Continued { t.Error("a task with no conversation reports Continued = true") } if got := readFile(t, filepath.Join(repo, "tracked.txt")); got != "edited by user\n" { t.Errorf("user's file changed: got %q", got) } if got := gitRun(t, repo, "status", "--porcelain"); got != statusBefore { t.Errorf("user's git status changed:\nbefore %q\nafter %q", statusBefore, got) } if got := gitRun(t, repo, "stash", "list"); got != "" { t.Errorf("stash list should stay empty, got %q", got) } } // The user's own uncommitted work must not be mistaken for the agent's output. // It is committed as a baseline at prepare time, so a task that changes nothing // still counts as a no-op and leaves no branch — the user's WIP is already safe // in their own working tree, and a branch duplicating it is pure noise. func TestFinalizeDropsBranchWhenOnlyBaseWasDirty(t *testing.T) { t.Parallel() repo := newTestRepo(t) writeFile(t, filepath.Join(repo, "tracked.txt"), "edited by user\n") writeFile(t, filepath.Join(repo, "scratch.txt"), "untracked scratch\n") wt := prepareForTest(t, repo) // The baseline must be a commit of its own, not left as pending changes. if wt.BaseCommit == "" { t.Fatal("no baseline commit recorded for a dirty base") } if dirty, err := worktreeIsDirty(wt.Path); err != nil || dirty { t.Errorf("worktree still dirty after baseline commit (dirty=%v, err=%v)", dirty, err) } outcome := finalizeOK(t, wt) if outcome.Branch != "" { t.Errorf("Branch = %q, want empty: the agent changed nothing", outcome.Branch) } if outcome.AutoCommitted { t.Error("AutoCommitted = true, but there was nothing of the agent's to commit") } } // The deliverable is a branch. Whatever the agent leaves uncommitted must be // committed onto it before the worktree is removed, or `git worktree remove // --force` would delete the work with no way back. // // With a dirty base, the delivered branch also separates the two authorships: // the user's WIP is the baseline commit, the agent's work sits on top. That is // what makes `git diff ..` a readable review of the agent // alone. func TestFinalizeCommitsLeftoversAboveTheUserBaseline(t *testing.T) { t.Parallel() repo := newTestRepo(t) writeFile(t, filepath.Join(repo, "tracked.txt"), "edited by user\n") wt := prepareForTest(t, repo) writeFile(t, filepath.Join(wt.Path, "agent-output.txt"), "work product\n") outcome := finalizeOK(t, wt) if !outcome.AutoCommitted { t.Error("AutoCommitted = false, want true") } if outcome.Branch != wt.Branch { t.Fatalf("Branch = %q, want %q", outcome.Branch, wt.Branch) } if _, err := os.Stat(wt.Path); !os.IsNotExist(err) { t.Errorf("worktree directory still present after Finalize: %v", err) } if list := gitRun(t, repo, "worktree", "list"); strings.Contains(list, wt.Path) { t.Errorf("worktree still registered in user's repo:\n%s", list) } // The user's own checkout must be untouched by any of it. if _, err := os.Stat(filepath.Join(repo, "agent-output.txt")); !os.IsNotExist(err) { t.Error("agent output leaked into the user's working tree") } // The branch survives in the user's repo, and the agent's commit alone // sits above the baseline. if got := gitRun(t, repo, "show", outcome.Branch+":agent-output.txt"); got != "work product" { t.Errorf("branch does not carry agent output, got %q", got) } agentFiles := gitRun(t, repo, "diff", "--name-only", wt.BaseCommit, outcome.Branch) if agentFiles != "agent-output.txt" { t.Errorf("agent diff = %q, want just agent-output.txt", agentFiles) } // And the user's uncommitted edit still reached the branch. if got := gitRun(t, repo, "show", outcome.Branch+":tracked.txt"); got != "edited by user" { t.Errorf("branch lost the user's uncommitted edit, got %q", got) } } // A resource may point at a subdirectory of a repo. The worktree covers the // whole repo, but the agent has to land at the same depth the user chose. func TestPrepareLocalWorktreeSubdirectory(t *testing.T) { t.Parallel() repo := newTestRepo(t) writeFile(t, filepath.Join(repo, "services/api/main.go"), "package main\n") gitRun(t, repo, "add", ".") gitRun(t, repo, "commit", "-m", "add service") sub := filepath.Join(repo, "services/api") wt := prepareForTest(t, sub) if wt.GitRoot != repo { t.Errorf("GitRoot = %q, want repo root %q", wt.GitRoot, repo) } want := filepath.Join(wt.Path, "services", "api") if wt.WorkDir != want { t.Errorf("WorkDir = %q, want %q", wt.WorkDir, want) } if got := readFile(t, filepath.Join(wt.WorkDir, "main.go")); got != "package main\n" { t.Errorf("subdirectory content missing from worktree: %q", got) } } // Worktree mode is opt-in per resource, so a non-git directory means the user // configured something that cannot work. Fail with an actionable message rather // than silently running in-place, which would leave them wondering why their // tasks still queue one at a time. func TestPrepareLocalWorktreeRejectsNonGitDirectory(t *testing.T) { t.Parallel() _, err := PrepareLocalWorktree(LocalWorktreeParams{ LocalPath: t.TempDir(), EnvRoot: t.TempDir(), TaskID: "task-1", }, worktreeTestLogger()) if err == nil { t.Fatal("expected an error for a non-git directory") } if !strings.Contains(err.Error(), "not a git repository") || !strings.Contains(err.Error(), "in_place") { t.Errorf("error should name the problem and the fix, got: %v", err) } } // A repo with no commits has nothing to branch from. The message has to say so, // because "git worktree add failed" alone sends the user hunting in the wrong // place. func TestPrepareLocalWorktreeRejectsRepoWithoutCommits(t *testing.T) { t.Parallel() dir := t.TempDir() gitRun(t, dir, "init", "-b", "main") _, err := PrepareLocalWorktree(LocalWorktreeParams{ LocalPath: dir, EnvRoot: t.TempDir(), TaskID: "task-1", }, worktreeTestLogger()) if err == nil { t.Fatal("expected an error for a repo with no commits") } if !strings.Contains(err.Error(), "no commit") { t.Errorf("error should explain the missing commit, got: %v", err) } } // Concurrency is the entire point of the mode: two tasks on one directory must // both get a working checkout, with distinct branches, without corrupting git's // admin files. func TestPrepareLocalWorktreeConcurrentTasks(t *testing.T) { t.Parallel() repo := newTestRepo(t) const tasks = 4 var ( wg sync.WaitGroup mu sync.Mutex results []*LocalWorktree errs []error ) for i := range tasks { wg.Add(1) go func(i int) { defer wg.Done() wt, err := PrepareLocalWorktree(LocalWorktreeParams{ LocalPath: repo, EnvRoot: t.TempDir(), AgentName: "J", TaskID: strings.Repeat(string(rune('a'+i)), 8), }, worktreeTestLogger()) mu.Lock() defer mu.Unlock() if err != nil { errs = append(errs, err) return } results = append(results, wt) }(i) } wg.Wait() for _, err := range errs { t.Errorf("concurrent prepare failed: %v", err) } if len(results) != tasks { t.Fatalf("got %d worktrees, want %d", len(results), tasks) } branches := map[string]bool{} for _, wt := range results { if branches[wt.Branch] { t.Errorf("duplicate branch %q across concurrent tasks", wt.Branch) } branches[wt.Branch] = true if got := readFile(t, filepath.Join(wt.Path, "tracked.txt")); got != "original\n" { t.Errorf("worktree %s has wrong content: %q", wt.Path, got) } } for _, wt := range results { finalizeOK(t, wt) } if list := gitRun(t, repo, "worktree", "list"); strings.Count(list, "\n") != 0 { t.Errorf("worktrees left registered after finalize:\n%s", list) } } // End-to-end shape of a worktree-mode task: Prepare builds the env and writes // its sidecars into the worktree, the daemon's cleanup pass removes them, and // Finalize delivers a branch carrying only the agent's real work. The // sidecar-free branch is the user-visible contract — a diff full of // .agent_context/ scaffolding would make the mode unusable for review. func TestWorktreeModeDeliversBranchWithoutSidecars(t *testing.T) { t.Parallel() repo := newTestRepo(t) // Start dirty, so the branch gets a baseline commit as well as the agent's // own — a sidecar could otherwise hide in either one. writeFile(t, filepath.Join(repo, "tracked.txt"), "edited by user\n") originalHead := gitRun(t, repo, "rev-parse", "HEAD") envRoot := filepath.Join(t.TempDir(), "env") env, err := Prepare(PrepareParams{ WorkspacesRoot: filepath.Dir(envRoot), WorkspaceID: "ws-1", TaskID: "11112222-3333-4444-5555-666677778888", AgentName: "J", Provider: "claude", LocalWorktree: &LocalWorktreeParams{LocalPath: repo}, Task: TaskContextForEnv{IssueID: "issue-1", AgentName: "J"}, }, worktreeTestLogger()) if err != nil { t.Fatalf("Prepare: %v", err) } if env.LocalWorktree == nil { t.Fatal("Prepare did not build a worktree") } // The env root is ordinary daemon-owned scratch in this mode, so the GC // exemption meant for a user's own directory must not apply. if env.LocalDirectory { t.Error("LocalDirectory = true in worktree mode; the GC would exempt this env root forever") } if env.WorkDir != env.LocalWorktree.Path { t.Errorf("WorkDir = %q, want the worktree root %q", env.WorkDir, env.LocalWorktree.Path) } if _, err := os.Stat(filepath.Join(env.WorkDir, TaskContextMarkerRelPath)); err != nil { t.Fatalf("precondition: Prepare should have written sidecars into the worktree: %v", err) } // The agent's actual work. writeFile(t, filepath.Join(env.WorkDir, "real-change.txt"), "actual work\n") // What the daemon runs before Finalize. if err := CleanupRuntimeConfig(env.WorkDir, "claude"); err != nil { t.Fatalf("CleanupRuntimeConfig: %v", err) } if err := CleanupSidecars(env.RootDir); err != nil { t.Fatalf("CleanupSidecars: %v", err) } outcome := finalizeOK(t, env.LocalWorktree) if outcome.Branch == "" { t.Fatal("no branch delivered for a task that changed a file") } // Every file the branch touches, across the baseline and agent commits. files := gitRun(t, repo, "diff", "--name-only", originalHead, outcome.Branch) if !strings.Contains(files, "real-change.txt") { t.Errorf("branch is missing the agent's work:\n%s", files) } for _, sidecar := range []string{".agent_context", ".multica", "CLAUDE.md"} { if strings.Contains(files, sidecar) { t.Errorf("sidecar %q leaked into the delivered branch:\n%s", sidecar, files) } } // The user's own checkout keeps exactly the edit it started with, and // nothing the agent or the runtime produced. if got := gitRun(t, repo, "status", "--porcelain"); got != "M tracked.txt" { t.Errorf("user's working tree changed, want only their own edit:\n%s", got) } } // A crashed daemon leaves a registration pointing at an env root that GC later // deletes. The next task on the same repo must clean that up rather than // accumulating dead entries in the user's `git worktree list` forever. func TestPrepareLocalWorktreePrunesStaleRegistrations(t *testing.T) { t.Parallel() repo := newTestRepo(t) orphanEnv, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatalf("resolve orphan env root: %v", err) } orphan := filepath.Join(orphanEnv, "dead-task") gitRun(t, repo, "worktree", "add", "--quiet", "--detach", orphan) // Simulate the crash: the directory disappears with GC, the registration // survives in the user's repo. if err := os.RemoveAll(orphan); err != nil { t.Fatalf("remove orphan worktree dir: %v", err) } if list := gitRun(t, repo, "worktree", "list"); !strings.Contains(list, orphan) { t.Fatalf("precondition failed: orphan not registered:\n%s", list) } prepareForTest(t, repo) if list := gitRun(t, repo, "worktree", "list"); strings.Contains(list, orphan) { t.Errorf("stale registration not pruned:\n%s", list) } } // finalizeOK finalizes a worktree and fails the test if the work could not be // persisted. Tests that exercise the failure path call Finalize directly. func finalizeOK(t *testing.T, wt *LocalWorktree) LocalWorktreeOutcome { t.Helper() outcome, err := wt.Finalize(worktreeTestLogger()) if err != nil { t.Fatalf("Finalize: %v", err) } return outcome } // The one operation in this flow that can destroy work is `git worktree remove // --force`. If the agent's changes could not be committed, removing the // worktree would delete the only copy — so Finalize must keep it and say so. // commit.gpgSign with no usable key is the realistic trigger: --no-verify does // not disable signing. func TestFinalizeKeepsWorktreeWhenCommitFails(t *testing.T) { t.Parallel() repo := newTestRepo(t) wt := prepareForTest(t, repo) writeFile(t, filepath.Join(wt.Path, "agent-output.txt"), "irreplaceable work\n") // Force every commit in this worktree to fail the way a signing setup the // daemon can't satisfy would. gitRun(t, wt.Path, "config", "commit.gpgSign", "true") gitRun(t, wt.Path, "config", "gpg.program", filepath.Join(repo, "definitely-not-a-real-gpg")) outcome, err := wt.Finalize(worktreeTestLogger()) if err == nil { t.Fatal("Finalize returned nil error after the commit failed") } if outcome.PreservedPath != wt.Path { t.Errorf("PreservedPath = %q, want %q", outcome.PreservedPath, wt.Path) } // The whole point: the work is still on disk. if got := readFile(t, filepath.Join(wt.Path, "agent-output.txt")); got != "irreplaceable work\n" { t.Errorf("agent work was destroyed despite the commit failure, got %q", got) } // And it stays discoverable through git rather than only a log line. if list := gitRun(t, repo, "worktree", "list"); !strings.Contains(list, wt.Path) { t.Errorf("preserved worktree is no longer registered, so the user cannot find it:\n%s", list) } if !strings.Contains(err.Error(), wt.Path) { t.Errorf("error should name the preserved path, got: %v", err) } } // An in_place task on the same directory leaves .agent_context/ and .multica/ // in the user's tree while it runs. A concurrent worktree snapshot sees them as // untracked files; copying them would hand this task another issue's brief and // commit it to the branch. func TestPrepareLocalWorktreeSkipsMulticaSidecars(t *testing.T) { t.Parallel() repo := newTestRepo(t) writeFile(t, filepath.Join(repo, ".agent_context", "issue_context.md"), "OTHER issue's brief\n") writeFile(t, filepath.Join(repo, ".multica", "project", "resources.json"), "{}\n") // An in_place resource may point at a SUBDIRECTORY of this repo, in which // case its sidecars sit below that subdirectory, not at the repo root. writeFile(t, filepath.Join(repo, "services", "api", ".agent_context", "issue_context.md"), "yet another issue's brief\n") writeFile(t, filepath.Join(repo, "services", "api", ".multica", "task.json"), "{}\n") writeFile(t, filepath.Join(repo, "real-untracked.txt"), "user's own file\n") writeFile(t, filepath.Join(repo, "services", "api", "notes.txt"), "user's nested file\n") wt := prepareForTest(t, repo) if _, err := os.Stat(filepath.Join(wt.Path, ".agent_context")); !os.IsNotExist(err) { t.Error("another task's .agent_context was copied into this worktree") } if _, err := os.Stat(filepath.Join(wt.Path, ".multica")); !os.IsNotExist(err) { t.Error("another task's .multica was copied into this worktree") } if _, err := os.Stat(filepath.Join(wt.Path, "services", "api", ".agent_context")); !os.IsNotExist(err) { t.Error("a subdirectory task's .agent_context was copied into this worktree") } if _, err := os.Stat(filepath.Join(wt.Path, "services", "api", ".multica")); !os.IsNotExist(err) { t.Error("a subdirectory task's .multica was copied into this worktree") } if got := readFile(t, filepath.Join(wt.Path, "real-untracked.txt")); got != "user's own file\n" { t.Errorf("the user's own untracked file was not replayed: %q", got) } if got := readFile(t, filepath.Join(wt.Path, "services", "api", "notes.txt")); got != "user's nested file\n" { t.Errorf("the user's nested untracked file was not replayed: %q", got) } } // Silently starting from HEAD when the user's uncommitted state cannot be // replayed would have the agent review code the user never wrote and report on // it confidently. Refusing to start is the recoverable outcome. func TestPrepareLocalWorktreeFailsWhenUntrackedReplayIsTruncated(t *testing.T) { t.Parallel() repo := newTestRepo(t) // One file over the copy budget is enough to prove the bound fails closed // rather than under-copying; writing 2001 files would only be slower. big := strings.Repeat("x", 1024) for i := range maxUntrackedFiles + 1 { writeFile(t, filepath.Join(repo, "untracked", "f"+string(rune('a'+i%26))+strings.Repeat("z", i/26)+".txt"), big) } _, err := PrepareLocalWorktree(LocalWorktreeParams{ LocalPath: repo, EnvRoot: t.TempDir(), AgentName: "J", TaskID: "task-truncated", }, worktreeTestLogger()) if err == nil { t.Fatal("expected prepare to fail rather than replay a truncated tree") } if !strings.Contains(err.Error(), "in_place") { t.Errorf("error should offer a way out, got: %v", err) } // A failed prepare must not leave a half-built worktree registered. if list := gitRun(t, repo, "worktree", "list"); strings.Count(list, "\n") != 0 { t.Errorf("aborted prepare left a worktree registered:\n%s", list) } if branches := gitRun(t, repo, "branch", "--list", "agent/*"); branches != "" { t.Errorf("aborted prepare left a branch behind: %q", branches) } } // Worktree tasks get a fresh env root per task ID, exactly like in_place ones // (shouldReusePriorWorkdir refuses any local assignment). So they need the same // per-issue Codex session store: without it, each comment turn on an issue // starts a new empty CODEX_HOME, the prior rollout is invisible, and the agent // silently loses the conversation it was having with the user. func TestPrepareWorktreeModeUsesPerIssueCodexSessionStore(t *testing.T) { repo := newTestRepo(t) workspacesRoot := t.TempDir() codexHome := t.TempDir() t.Setenv("CODEX_HOME", codexHome) prepareTurn := func(taskID string) *Environment { t.Helper() env, err := Prepare(PrepareParams{ WorkspacesRoot: workspacesRoot, WorkspaceID: "ws-1", TaskID: taskID, AgentName: "J", Provider: "codex", LocalWorktree: &LocalWorktreeParams{LocalPath: repo}, Task: TaskContextForEnv{IssueID: "issue-1", AgentID: "agent-1", AgentName: "J"}, }, worktreeTestLogger()) if err != nil { t.Fatalf("Prepare(%s): %v", taskID, err) } return env } // Two turns on the same issue, different task IDs — the shape of a // follow-up comment. first := prepareTurn("aaaa1111-2222-3333-4444-5555666677aa") second := prepareTurn("bbbb1111-2222-3333-4444-5555666677bb") sessionsOf := func(env *Environment) string { t.Helper() target, err := filepath.EvalSymlinks(filepath.Join(env.CodexHome, "sessions")) if err != nil { t.Fatalf("resolve sessions dir: %v", err) } return target } firstSessions := sessionsOf(first) secondSessions := sessionsOf(second) if firstSessions != secondSessions { t.Errorf("each turn got its own sessions dir, so Codex cannot resume:\n first %s\n second %s", firstSessions, secondSessions) } // And it must be the stable per-issue store, not a task-local directory. if strings.Contains(firstSessions, taskKey("aaaa1111-2222-3333-4444-5555666677aa")) { t.Errorf("sessions dir is task-scoped (%s); it will not survive the next turn", firstSessions) } } // The daemon runs its sidecar cleanup before Finalize commits. If that cleanup // fails, committing anyway would deliver a branch whose content is Multica's // own runtime files — the exact leak this mode promises to prevent. The abort // must therefore stop the commit AND keep the worktree, since the agent's work // is still in it. func TestFinalizeAbortRefusesToCommitAndKeepsWorktree(t *testing.T) { t.Parallel() repo := newTestRepo(t) wt := prepareForTest(t, repo) writeFile(t, filepath.Join(wt.Path, "agent-output.txt"), "real work\n") writeFile(t, filepath.Join(wt.Path, ".agent_context", "issue_context.md"), "runtime brief\n") wt.AbortWithReason(errors.New("cleanup failed")) // The first reason is the one closest to the root cause, so later aborts // must not overwrite it. wt.AbortWithReason(errors.New("a later failure")) wt.AbortWithReason(nil) outcome, err := wt.Finalize(worktreeTestLogger()) if err == nil { t.Fatal("Finalize returned nil error after an abort") } if !strings.Contains(err.Error(), "cleanup failed") || strings.Contains(err.Error(), "a later failure") { t.Errorf("want only the first abort reason in the error, got: %v", err) } if outcome.Branch != "" { t.Errorf("Branch = %q, want empty: nothing may be delivered after an abort", outcome.Branch) } if outcome.AutoCommitted { t.Error("AutoCommitted = true; the abort must prevent the commit") } if outcome.PreservedPath != wt.Path { t.Errorf("PreservedPath = %q, want %q", outcome.PreservedPath, wt.Path) } // Nothing committed: the branch must still be sitting on its base. tip := gitRun(t, repo, "rev-parse", wt.Branch) if tip != wt.BaseCommit { t.Errorf("branch moved to %s; the sidecar-carrying commit was delivered anyway", tip) } // And the work is still recoverable on disk. if got := readFile(t, filepath.Join(wt.Path, "agent-output.txt")); got != "real work\n" { t.Errorf("agent work was destroyed: %q", got) } if list := gitRun(t, repo, "worktree", "list"); !strings.Contains(list, wt.Path) { t.Errorf("preserved worktree is no longer registered:\n%s", list) } } // An untracked symlink is content the user can see. Replaying it faithfully is // ambiguous (link vs target, targets outside the repo), so the snapshot must // fail rather than hand the agent a tree with a file quietly missing. func TestPrepareLocalWorktreeFailsOnUntrackedSymlink(t *testing.T) { t.Parallel() repo := newTestRepo(t) if err := os.Symlink(filepath.Join(repo, "tracked.txt"), filepath.Join(repo, "shortcut.txt")); err != nil { t.Skipf("symlinks unavailable: %v", err) } _, err := PrepareLocalWorktree(LocalWorktreeParams{ LocalPath: repo, EnvRoot: t.TempDir(), AgentName: "J", TaskID: "task-symlink", }, worktreeTestLogger()) if err == nil { t.Fatal("expected prepare to fail rather than silently drop the symlink") } if !strings.Contains(err.Error(), "untracked") { t.Errorf("error should name the untracked replay, got: %v", err) } if list := gitRun(t, repo, "worktree", "list"); strings.Count(list, "\n") != 0 { t.Errorf("aborted prepare left a worktree registered:\n%s", list) } } // Discard is the abandon path for a Prepare that fails after the worktree // exists. Without it every such failure leaves a registration in the user's // repo and a branch no task ever ran in. func TestDiscardRemovesWorktreeAndBranch(t *testing.T) { t.Parallel() repo := newTestRepo(t) wt := prepareForTest(t, repo) wt.Discard(worktreeTestLogger()) if _, err := os.Stat(wt.Path); !os.IsNotExist(err) { t.Errorf("worktree directory survived Discard: %v", err) } if list := gitRun(t, repo, "worktree", "list"); strings.Contains(list, wt.Path) { t.Errorf("worktree still registered after Discard:\n%s", list) } if out, err := gitTry(t, repo, "rev-parse", "--verify", wt.Branch); err == nil { t.Errorf("branch survived Discard, resolves to %s", out) } } // prepareTurn runs one turn of a conversation: a fresh task id and env root on // the same conversation key, which is what a follow-up comment on an issue // produces. func prepareTurn(t *testing.T, localPath, conversationKey, taskID string) *LocalWorktree { t.Helper() wt, err := prepareTurnAs(t, localPath, conversationKey, taskID, testBranchOwner) if err != nil { t.Fatalf("PrepareLocalWorktree(%s): %v", taskID, err) } return wt } // prepareTurnAs gives every turn an env root in t.TempDir, so a turn a test // leaves unfinalized is cleaned up with it. func prepareTurnAs(t *testing.T, localPath, conversationKey, taskID string, owner branchOwner) (*LocalWorktree, error) { return PrepareLocalWorktree(LocalWorktreeParams{ LocalPath: localPath, EnvRoot: t.TempDir(), AgentName: "J", TaskID: taskID, ConversationKey: conversationKey, WorkspaceID: owner.WorkspaceID, AgentID: owner.AgentID, ConversationID: owner.ConversationID, }, worktreeTestLogger()) } const ( turnOneTask = "11112222-3333-4444-5555-aaaaaaaaaaaa" turnTwoTask = "11112222-3333-4444-5555-bbbbbbbbbbbb" turnThreeTask = "11112222-3333-4444-5555-cccccccccccc" ) // The bug this fixes: every comment on one issue produced a new branch forked // from HEAD, so the second turn stood in a tree that did not contain the first // turn's work and nothing said so (MUL-6881). func TestPrepareLocalWorktreeContinuesTheConversationBranch(t *testing.T) { t.Parallel() repo := newTestRepo(t) first := prepareTurn(t, repo, "MUL-6881", turnOneTask) if first.Branch != "agent/j/mul-6881" { t.Fatalf("first turn branch = %q, want agent/j/mul-6881", first.Branch) } if first.Continued { t.Error("first turn reports Continued = true; there was nothing to continue") } writeFile(t, filepath.Join(first.WorkDir, "turn-one.txt"), "work from turn one\n") firstOutcome := finalizeOK(t, first) if firstOutcome.Branch != "agent/j/mul-6881" { t.Fatalf("first outcome branch = %q", firstOutcome.Branch) } firstTip := gitRun(t, repo, "rev-parse", "agent/j/mul-6881") second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if second.Branch != "agent/j/mul-6881" { t.Fatalf("second turn branch = %q, want the conversation's branch", second.Branch) } if !second.Continued { t.Error("second turn reports Continued = false, want true") } if second.BaseCommit != firstTip { t.Errorf("second turn base = %s, want the first turn's tip %s", second.BaseCommit, firstTip) } if got := readFile(t, filepath.Join(second.WorkDir, "turn-one.txt")); got != "work from turn one\n" { t.Errorf("turn one's work is missing from turn two's tree: %q", got) } writeFile(t, filepath.Join(second.WorkDir, "turn-two.txt"), "work from turn two\n") finalizeOK(t, second) third := prepareTurn(t, repo, "MUL-6881", turnThreeTask) for _, name := range []string{"turn-one.txt", "turn-two.txt"} { if _, err := os.Stat(filepath.Join(third.WorkDir, name)); err != nil { t.Errorf("turn three is missing %s: %v", name, err) } } // Turn three only reads. It must leave the conversation's branch alone: the // branch holds every turn before it, and the read-only cleanup would take // those with it. if outcome := finalizeOK(t, third); outcome.Branch != "agent/j/mul-6881" { t.Errorf("read-only turn reported branch %q, want the conversation's branch", outcome.Branch) } for file, want := range map[string]string{"turn-one.txt": "work from turn one", "turn-two.txt": "work from turn two"} { if got := gitRun(t, repo, "show", "agent/j/mul-6881:"+file); got != want { t.Errorf("%s on the branch after a read-only turn = %q, want %q", file, got, want) } } // One branch for the whole conversation, not one per comment. branches := gitRun(t, repo, "branch", "--list", "agent/j/*") if strings.Count(branches, "agent/j/") != 1 { t.Errorf("conversation produced more than one branch:\n%s", branches) } } // A clean local HEAD advancing during a turn is committed work, not a new // user edit to replay onto the conversation branch (#8544). func TestPrepareLocalWorktreeDoesNotReplayCleanHEADAdvance(t *testing.T) { for _, dirty := range []bool{false, true} { t.Run(fmt.Sprintf("new_local_edit_%t", dirty), func(t *testing.T) { repo := newTestRepo(t) first := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "turn-one.txt"), "agent work\n") writeFile(t, filepath.Join(repo, "upstream.txt"), "committed upstream work\n") gitRun(t, repo, "add", "upstream.txt") gitRun(t, repo, "commit", "-m", "advance local HEAD") finalizeOK(t, first) firstTip := gitRun(t, repo, "rev-parse", first.Branch) if dirty { writeFile(t, filepath.Join(repo, "local-edit.txt"), "new uncommitted work\n") } second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if !second.Continued || second.Branch != first.Branch { t.Fatalf("expected to continue %s, got %s (continued=%v)", first.Branch, second.Branch, second.Continued) } if _, err := os.Stat(filepath.Join(second.WorkDir, "upstream.txt")); !os.IsNotExist(err) { t.Fatalf("committed local HEAD advance was replayed: %v", err) } if got := readFile(t, filepath.Join(second.WorkDir, "turn-one.txt")); got != "agent work\n" { t.Fatalf("agent work changed: %q", got) } if dirty { if got := readFile(t, filepath.Join(second.WorkDir, "local-edit.txt")); got != "new uncommitted work\n" { t.Fatalf("new local edit missing: %q", got) } } else if second.BaseCommit != firstTip { t.Fatalf("clean resume authored a commit: %s != %s", second.BaseCommit, firstTip) } finalizeOK(t, second) secondTip := gitRun(t, repo, "rev-parse", second.Branch) third := prepareTurn(t, repo, "MUL-6881", turnThreeTask) if third.BaseCommit != secondTip { t.Fatalf("unchanged third turn replayed local state again: %s != %s", third.BaseCommit, secondTip) } finalizeOK(t, third) }) } } func TestPrepareLocalWorktreeExcludesHEADAdvanceWithExistingUserEdits(t *testing.T) { for _, kind := range []string{"untracked", "tracked", "deleted"} { for _, newEdits := range []bool{false, true} { t.Run(fmt.Sprintf("%s/new_edits_%t", kind, newEdits), func(t *testing.T) { repo := newTestRepo(t) switch kind { case "untracked": writeFile(t, filepath.Join(repo, "draft.txt"), "user draft\n") case "tracked": writeFile(t, filepath.Join(repo, "tracked.txt"), "user WIP\n") case "deleted": if err := os.Remove(filepath.Join(repo, "tracked.txt")); err != nil { t.Fatal(err) } } writeFile(t, filepath.Join(repo, "notes.txt"), "old user notes\n") first := prepareTurn(t, repo, "MUL-7488", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "task.txt"), "agent work\n") if kind == "untracked" { writeFile(t, filepath.Join(first.WorkDir, "draft.txt"), "draft improved by the agent\n") } else if kind == "tracked" { writeFile(t, filepath.Join(first.WorkDir, "tracked.txt"), "WIP improved by the agent\n") } finalizeOK(t, first) firstTip := gitRun(t, repo, "rev-parse", first.Branch) // Advancing HEAD commits only upstream work, leaving all WIP alone. writeFile(t, filepath.Join(repo, "upstream.txt"), "committed upstream work\n") gitRun(t, repo, "add", "upstream.txt") gitRun(t, repo, "commit", "-m", "advance local HEAD") if newEdits { writeFile(t, filepath.Join(repo, "keep.txt"), "new tracked user edit\n") writeFile(t, filepath.Join(repo, "scratch.txt"), "new untracked user edit\n") if err := os.Remove(filepath.Join(repo, "notes.txt")); err != nil { t.Fatal(err) } } sourceStatus := gitRun(t, repo, "status", "--porcelain") sourceIndex := gitRun(t, repo, "ls-files", "--stage") second := prepareTurn(t, repo, "MUL-7488", turnTwoTask) if !second.Continued || second.Branch != first.Branch || len(second.ReplayConflicts) != 0 { t.Fatalf("unexpected continuation: %s, continued=%v, conflicts=%v", second.Branch, second.Continued, second.ReplayConflicts) } if _, err := os.Stat(filepath.Join(second.WorkDir, "upstream.txt")); !os.IsNotExist(err) { t.Fatalf("committed HEAD advance was replayed: %v", err) } if kind == "untracked" { if got := readFile(t, filepath.Join(second.WorkDir, "draft.txt")); got != "draft improved by the agent\n" { t.Fatalf("agent's draft was overwritten: %q", got) } } else if kind == "tracked" { if got := readFile(t, filepath.Join(second.WorkDir, "tracked.txt")); got != "WIP improved by the agent\n" { t.Fatalf("agent's tracked edit was overwritten: %q", got) } } else if _, err := os.Stat(filepath.Join(second.WorkDir, "tracked.txt")); !os.IsNotExist(err) { t.Fatalf("user's deletion was lost: %v", err) } if newEdits { for name, want := range map[string]string{"keep.txt": "new tracked user edit\n", "scratch.txt": "new untracked user edit\n"} { if got := readFile(t, filepath.Join(second.WorkDir, name)); got != want { t.Fatalf("new user edit missing in %s: %q", name, got) } } if _, err := os.Stat(filepath.Join(second.WorkDir, "notes.txt")); !os.IsNotExist(err) { t.Fatalf("new untracked deletion was lost: %v", err) } } else if second.BaseCommit != firstTip { t.Fatalf("unchanged WIP authored a replay commit: %s != %s", second.BaseCommit, firstTip) } if got := gitRun(t, repo, "status", "--porcelain"); got != sourceStatus { t.Fatalf("source status changed: %q != %q", got, sourceStatus) } if got := gitRun(t, repo, "ls-files", "--stage"); got != sourceIndex { t.Fatal("source index changed during prepare") } finalizeOK(t, second) secondTip := gitRun(t, repo, "rev-parse", second.Branch) third := prepareTurn(t, repo, "MUL-7488", turnThreeTask) if third.BaseCommit != secondTip || len(third.ReplayConflicts) != 0 { t.Fatalf("unchanged third turn replayed state again: %s, conflicts=%v", third.BaseCommit, third.ReplayConflicts) } finalizeOK(t, third) }) } } } func TestPrepareLocalWorktreeNormalizesEditsAcrossChangesInTheSameFile(t *testing.T) { repo := newTestRepo(t) base := "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\n" writeFile(t, filepath.Join(repo, "tracked.txt"), base) gitRun(t, repo, "add", "tracked.txt") gitRun(t, repo, "commit", "-m", "multi-line base") writeFile(t, filepath.Join(repo, "tracked.txt"), strings.Replace(base, "one", "user one", 1)) first := prepareTurn(t, repo, "MUL-7488", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "tracked.txt"), strings.Replace(base, "one", "agent one", 1)) finalizeOK(t, first) firstTip := gitRun(t, repo, "rev-parse", first.Branch) // Pulling a change to another line and restoring the same user WIP is not // a new uncommitted edit; neither line should be replayed into the task. upstream := strings.Replace(base, "eight", "upstream eight", 1) writeFile(t, filepath.Join(repo, "tracked.txt"), upstream) gitRun(t, repo, "add", "tracked.txt") gitRun(t, repo, "commit", "-m", "upstream edit to another line") writeFile(t, filepath.Join(repo, "tracked.txt"), strings.Replace(upstream, "one", "user one", 1)) second := prepareTurn(t, repo, "MUL-7488", turnTwoTask) if second.BaseCommit != firstTip || len(second.ReplayConflicts) != 0 { t.Fatalf("unchanged WIP caused replay: %s, conflicts=%v", second.BaseCommit, second.ReplayConflicts) } if got := readFile(t, filepath.Join(second.WorkDir, "tracked.txt")); got != strings.Replace(base, "one", "agent one", 1) { t.Fatalf("committed source edit entered the task or agent edit was lost: %q", got) } finalizeOK(t, second) } func TestPrepareLocalWorktreeExcludesHEADAdvanceAfterUncommittedConflictResolution(t *testing.T) { repo := newTestRepo(t) writeFile(t, filepath.Join(repo, "tracked.txt"), "user A\n") first := prepareTurn(t, repo, "MUL-7488", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "tracked.txt"), "agent B\n") finalizeOK(t, first) writeFile(t, filepath.Join(repo, "tracked.txt"), "user C\n") for _, taskID := range []string{turnTwoTask, turnThreeTask} { pending := prepareTurn(t, repo, "MUL-7488", taskID) if len(pending.ReplayConflicts) != 1 { t.Fatalf("expected a genuine user conflict, got %v", pending.ReplayConflicts) } gitRun(t, pending.Path, "reset", "--hard", pending.BaseCommit) finalizeOK(t, pending) } record := gitRun(t, repo, "rev-parse", userStateRef(first.Branch)) if parents := strings.Fields(gitRun(t, repo, "rev-list", "--parents", "-n", "1", record+"^1")); len(parents) != 3 { t.Fatalf("expected a nested two-parent record, got %v", parents) } writeFile(t, filepath.Join(repo, "upstream.txt"), "committed upstream work\n") gitRun(t, repo, "add", "upstream.txt") gitRun(t, repo, "commit", "-m", "advance local HEAD") resumed := prepareTurn(t, repo, "MUL-7488", "11112222-3333-4444-5555-dddddddddddd") if len(resumed.ReplayConflicts) != 1 || resumed.ReplayConflicts[0] != "tracked.txt" { t.Fatalf("pending user conflict was lost: %v", resumed.ReplayConflicts) } if _, err := os.Stat(filepath.Join(resumed.WorkDir, "upstream.txt")); !os.IsNotExist(err) { t.Fatalf("HEAD advance entered the task through a nested record: %v", err) } writeFile(t, filepath.Join(resumed.WorkDir, "tracked.txt"), "agent B and user C\n") gitRun(t, resumed.Path, "add", "tracked.txt") finalizeOK(t, resumed) resolvedTip := gitRun(t, repo, "rev-parse", resumed.Branch) next := prepareTurn(t, repo, "MUL-7488", "11112222-3333-4444-5555-eeeeeeeeeeee") if next.BaseCommit != resolvedTip || len(next.ReplayConflicts) != 0 { t.Fatalf("resolved user edit was replayed: %s, conflicts=%v", next.BaseCommit, next.ReplayConflicts) } finalizeOK(t, next) } func TestPrepareLocalWorktreeContinuesAfterUserCommitsConflictingDraft(t *testing.T) { for _, kind := range []string{"untracked", "tracked"} { for _, newEdits := range []bool{false, true} { t.Run(fmt.Sprintf("%s/new_edits_%t", kind, newEdits), func(t *testing.T) { repo := newTestRepo(t) name := "draft.txt" if kind == "tracked" { name = "tracked.txt" } writeFile(t, filepath.Join(repo, name), "previous user draft\n") first := prepareTurn(t, repo, "MUL-7488", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, name), "draft improved by the agent\n") writeFile(t, filepath.Join(first.WorkDir, "task.txt"), "agent work\n") finalizeOK(t, first) record := gitRun(t, repo, "rev-parse", userStateRef(first.Branch)) tip := gitRun(t, repo, "rev-parse", first.Branch) // The finished, committed draft conflicts with the old snapshot. // Its committed changes must not enter the task or block resumes. writeFile(t, filepath.Join(repo, name), "finished draft committed by the user\n") gitRun(t, repo, "add", name) gitRun(t, repo, "commit", "-m", "finish and commit the draft") if newEdits { writeFile(t, filepath.Join(repo, "scratch.txt"), "current uncommitted user edit\n") } sourceStatus := gitRun(t, repo, "status", "--porcelain") sourceIndex := gitRun(t, repo, "ls-files", "--stage") for _, taskID := range []string{turnTwoTask, turnThreeTask} { resumed, err := prepareTurnAs(t, repo, "MUL-7488", taskID, testBranchOwner) if err != nil { t.Errorf("resume %s failed after the draft was committed: %v", taskID, err) continue } if !resumed.Continued || resumed.Branch != first.Branch || len(resumed.ReplayConflicts) != 0 { t.Fatalf("unexpected continuation: %s, continued=%v, conflicts=%v", resumed.Branch, resumed.Continued, resumed.ReplayConflicts) } if (!newEdits || taskID == turnThreeTask) && resumed.BaseCommit != tip { t.Fatalf("unchanged user WIP authored a commit on resume %s", taskID) } if got := readFile(t, filepath.Join(resumed.WorkDir, name)); got != "draft improved by the agent\n" { t.Fatalf("committed draft overwrote the agent's work: %q", got) } if got := readFile(t, filepath.Join(resumed.WorkDir, "task.txt")); got != "agent work\n" { t.Fatalf("earlier agent work was lost: %q", got) } if newEdits { if got := readFile(t, filepath.Join(resumed.WorkDir, "scratch.txt")); got != "current uncommitted user edit\n" { t.Fatalf("current user edit was lost: %q", got) } } finalizeOK(t, resumed) tip = gitRun(t, repo, "rev-parse", first.Branch) if got := gitRun(t, repo, "rev-parse", userStateRef(first.Branch)); got == record { t.Fatal("successful resume did not advance the captured user state") } if got := gitRun(t, repo, "status", "--porcelain"); got != sourceStatus { t.Fatalf("resume changed the user's checkout status: %q", got) } if got := gitRun(t, repo, "ls-files", "--stage"); got != sourceIndex { t.Fatal("resume changed the user's index") } if got := readFile(t, filepath.Join(repo, name)); got != "finished draft committed by the user\n" { t.Fatalf("resume changed the user's committed draft: %q", got) } } }) } } } func TestPrepareLocalWorktreeHandsCurrentEditsToAgentAfterHEADNormalizationConflict(t *testing.T) { repo := newTestRepo(t) writeFile(t, filepath.Join(repo, "tracked.txt"), "previous user draft\n") first := prepareTurn(t, repo, "MUL-7488", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "tracked.txt"), "draft improved by the agent\n") finalizeOK(t, first) writeFile(t, filepath.Join(repo, "tracked.txt"), "finished draft committed by the user\n") gitRun(t, repo, "add", "tracked.txt") gitRun(t, repo, "commit", "-m", "finish and commit the draft") writeFile(t, filepath.Join(repo, "tracked.txt"), "current uncommitted user edit\n") second := prepareTurn(t, repo, "MUL-7488", turnTwoTask) if len(second.ReplayConflicts) != 1 || second.ReplayConflicts[0] != "tracked.txt" { t.Fatalf("current user edit was not offered as a conflict: %v", second.ReplayConflicts) } conflict := readFile(t, filepath.Join(second.WorkDir, "tracked.txt")) for _, want := range []string{"draft improved by the agent", "current uncommitted user edit", "<<<<<<<"} { if !strings.Contains(conflict, want) { t.Fatalf("conflicted file is missing %q: %s", want, conflict) } } writeFile(t, filepath.Join(second.WorkDir, "tracked.txt"), "agent draft and current user edit\n") gitRun(t, second.Path, "add", "tracked.txt") finalizeOK(t, second) resolvedTip := gitRun(t, repo, "rev-parse", second.Branch) third := prepareTurn(t, repo, "MUL-7488", turnThreeTask) if third.BaseCommit != resolvedTip || len(third.ReplayConflicts) != 0 { t.Fatalf("resolved edit was replayed again: %s, conflicts=%v", third.BaseCommit, third.ReplayConflicts) } if got := readFile(t, filepath.Join(third.WorkDir, "tracked.txt")); got != "agent draft and current user edit\n" { t.Fatalf("resolved draft was lost: %q", got) } finalizeOK(t, third) } func TestPrepareLocalWorktreePreservesOtherDraftsWhenNormalizationConflicts(t *testing.T) { for _, committedChange := range []string{"finish", "delete", "directory"} { for _, newEdit := range []bool{false, true} { t.Run(fmt.Sprintf("%s/new_edit_%t", committedChange, newEdit), func(t *testing.T) { repo := newTestRepo(t) // A tracked draft allows both content and modify/delete conflicts. writeFile(t, filepath.Join(repo, "notes.txt"), "original notes\n") gitRun(t, repo, "add", "notes.txt") gitRun(t, repo, "commit", "-m", "initial notes") writeFile(t, filepath.Join(repo, "notes.txt"), "old user notes\n") writeFile(t, filepath.Join(repo, "scratch.txt"), "old user scratch\n") first := prepareTurn(t, repo, "MUL-7488", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "scratch.txt"), "scratch improved by the agent\n") finalizeOK(t, first) tip := gitRun(t, repo, "rev-parse", first.Branch) switch committedChange { case "finish": writeFile(t, filepath.Join(repo, "notes.txt"), "finished committed notes\n") case "delete", "directory": if err := os.Remove(filepath.Join(repo, "notes.txt")); err != nil { t.Fatal(err) } if committedChange == "directory" { writeFile(t, filepath.Join(repo, "notes.txt", "finished.txt"), "finished committed notes\n") } } gitRun(t, repo, "add", "-A", "--", "notes.txt") gitRun(t, repo, "commit", "-m", "commit the notes change") if newEdit { writeFile(t, filepath.Join(repo, "keep.txt"), "new tracked user edit\n") } sourceHead := gitRun(t, repo, "rev-parse", "HEAD") sourceStatus := gitRun(t, repo, "status", "--porcelain") sourceIndex := gitRun(t, repo, "ls-files", "--stage") for _, taskID := range []string{turnTwoTask, turnThreeTask} { resumed := prepareTurn(t, repo, "MUL-7488", taskID) if len(resumed.ReplayConflicts) != 0 { t.Fatalf("an unchanged draft was replayed as a conflict: %v", resumed.ReplayConflicts) } if (!newEdit || taskID == turnThreeTask) && resumed.BaseCommit != tip { t.Fatal("unchanged drafts caused an extra replay commit") } if got := readFile(t, filepath.Join(resumed.WorkDir, "scratch.txt")); got != "scratch improved by the agent\n" { t.Fatalf("unchanged user scratch overwrote the agent's work: %q", got) } if got := readFile(t, filepath.Join(resumed.WorkDir, "notes.txt")); got != "old user notes\n" { t.Fatalf("committed notes change entered the task branch: %q", got) } if newEdit { if got := readFile(t, filepath.Join(resumed.WorkDir, "keep.txt")); got != "new tracked user edit\n" { t.Fatalf("new user edit was lost: %q", got) } } finalizeOK(t, resumed) tip = gitRun(t, repo, "rev-parse", first.Branch) if got := gitRun(t, repo, "rev-parse", "HEAD"); got != sourceHead { t.Fatal("normalization moved the user's HEAD") } if got := gitRun(t, repo, "status", "--porcelain"); got != sourceStatus { t.Fatalf("normalization changed source status: %q", got) } if got := gitRun(t, repo, "ls-files", "--stage"); got != sourceIndex { t.Fatal("normalization changed the user's index") } } }) } } } func TestPrepareLocalWorktreeNormalizesConflictPathsLiterally(t *testing.T) { repo := newTestRepo(t) // Brackets are valid filenames on every platform but are also Git glob // syntax. Restoring the first path must not reset the second draft's WIP. conflicting := "notes [1].txt" unchanged := "notes 1.txt" writeFile(t, filepath.Join(repo, conflicting), "old user notes\n") writeFile(t, filepath.Join(repo, unchanged), "old user scratch\n") first := prepareTurn(t, repo, "MUL-7488", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, unchanged), "scratch improved by the agent\n") finalizeOK(t, first) tip := gitRun(t, repo, "rev-parse", first.Branch) writeFile(t, filepath.Join(repo, conflicting), "finished committed notes\n") gitRun(t, repo, "add", "--", ":(literal)"+conflicting) gitRun(t, repo, "commit", "-m", "finish notes with a literal name") second := prepareTurn(t, repo, "MUL-7488", turnTwoTask) if len(second.ReplayConflicts) != 0 || second.BaseCommit != tip { t.Fatalf("unrelated matching filename was replayed: %v, base=%s", second.ReplayConflicts, second.BaseCommit) } if got := readFile(t, filepath.Join(second.WorkDir, unchanged)); got != "scratch improved by the agent\n" { t.Fatalf("unrelated matching draft was overwritten: %q", got) } if got := readFile(t, filepath.Join(second.WorkDir, conflicting)); got != "old user notes\n" { t.Fatalf("committed notes were replayed: %q", got) } finalizeOK(t, second) } // The user's uncommitted work reaches the branch once, as turn one's baseline, // and every later turn replays only what they changed since. Replaying their // whole tree again would ask git to merge their copy of a file against the // agent's edits to it — a conflict raised precisely when the agent did what it // was asked to do — or, for a file that started untracked, silently copy their // older version over the agent's, every turn. // // A file that started untracked is committed to the branch at the turn that // first sees it. The user's later edits to their own copy, deletions included, // still have to reach the next turn — the snapshot has to cover untracked // content for that question to be answerable at all. func TestPrepareLocalWorktreeReplaysOnlyTheUserEditsSinceTheLastTurn(t *testing.T) { t.Parallel() repo := newTestRepo(t) writeFile(t, filepath.Join(repo, "tracked.txt"), "user work in progress\n") writeFile(t, filepath.Join(repo, "draft.txt"), "user draft\n") writeFile(t, filepath.Join(repo, "scratch.txt"), "v1\n") writeFile(t, filepath.Join(repo, "notes.txt"), "notes v1\n") first := prepareTurn(t, repo, "MUL-6881", turnOneTask) if got := readFile(t, filepath.Join(first.WorkDir, "tracked.txt")); got != "user work in progress\n" { t.Fatalf("turn one did not see the user's uncommitted work: %q", got) } // The agent edits the very line the user was working on, and a file the // user never committed. writeFile(t, filepath.Join(first.WorkDir, "tracked.txt"), "user work in progress, finished by the agent\n") writeFile(t, filepath.Join(first.WorkDir, "draft.txt"), "draft, rewritten by the agent\n") finalizeOK(t, first) // Between the turns the user edits a committed file and an untracked one, // deletes another untracked one, and leaves the files the agent edited // exactly as they were. writeFile(t, filepath.Join(repo, "keep.txt"), "user edited this between turns\n") writeFile(t, filepath.Join(repo, "scratch.txt"), "v2\n") if err := os.Remove(filepath.Join(repo, "notes.txt")); err != nil { t.Fatalf("remove notes.txt: %v", err) } second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if len(second.ReplayConflicts) != 0 { t.Fatalf("unexpected conflict: %v", second.ReplayConflicts) } if got := readFile(t, filepath.Join(second.WorkDir, "tracked.txt")); got != "user work in progress, finished by the agent\n" { t.Errorf("the agent's edit was reverted by replaying the user's older copy: %q", got) } if got := readFile(t, filepath.Join(second.WorkDir, "draft.txt")); got != "draft, rewritten by the agent\n" { t.Errorf("the agent's edit to a once-untracked file was reverted: %q", got) } if got := readFile(t, filepath.Join(second.WorkDir, "keep.txt")); got != "user edited this between turns\n" { t.Errorf("the user's edit between turns did not reach the worktree: %q", got) } if got := readFile(t, filepath.Join(second.WorkDir, "scratch.txt")); got != "v2\n" { t.Errorf("scratch.txt = %q, want the user's newer version", got) } if _, err := os.Stat(filepath.Join(second.WorkDir, "notes.txt")); !os.IsNotExist(err) { t.Errorf("notes.txt survived the user deleting it: %v", err) } // The user's own directory is never written to, on any turn. if got := readFile(t, filepath.Join(repo, "tracked.txt")); got != "user work in progress\n" { t.Errorf("the user's directory was modified: %q", got) } } // A real conflict — the user rewrites lines the agent also rewrote — belongs to // the agent, not to the daemon. The turn starts on the conflicted tree with // both versions in it, because the only alternative that does not lose the // user's newer edit is having something read both sides and decide. // // That turn cannot be delivered while the merge is open: committing would turn // conflict markers into the branch's content, and recording the snapshot would // tell every later turn the user's edit had landed. So a conflict the agent // never resolves stays pending, and the next turn offers it again. func TestPrepareLocalWorktreeHandsConflictingUserEditsToTheAgent(t *testing.T) { t.Parallel() repo := newTestRepo(t) writeFile(t, filepath.Join(repo, "tracked.txt"), "user work in progress\n") first := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "tracked.txt"), "rewritten by the agent\n") finalizeOK(t, first) recordedAfterFirst := gitRun(t, repo, "rev-parse", userStateRef("agent/j/mul-6881")) // The user rewrites the same line their own way. writeFile(t, filepath.Join(repo, "tracked.txt"), "rewritten by the user instead\n") second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if len(second.ReplayConflicts) != 1 || second.ReplayConflicts[0] != "tracked.txt" { t.Fatalf("ReplayConflicts = %v, want [tracked.txt]", second.ReplayConflicts) } got := readFile(t, filepath.Join(second.WorkDir, "tracked.txt")) for _, want := range []string{"rewritten by the agent", "rewritten by the user instead", "<<<<<<<"} { if !strings.Contains(got, want) { t.Errorf("conflicted file does not contain %q:\n%s", want, got) } } if unmerged, err := unmergedPaths(second.Path); err != nil || len(unmerged) != 1 { t.Errorf("unmergedPaths = %v, %v; want one unresolved path", unmerged, err) } // A cherry-pick the agent never started must not be left for it to conclude. gitDir := gitRun(t, second.Path, "rev-parse", "--git-dir") if _, err := os.Stat(filepath.Join(gitDir, "CHERRY_PICK_HEAD")); err == nil { t.Error("worktree left mid-cherry-pick; the agent would have to know it was one") } // The agent walks away without resolving anything. outcome, err := second.Finalize(worktreeTestLogger()) if err == nil { t.Fatal("Finalize delivered a branch while the merge was still open") } if !strings.Contains(err.Error(), "tracked.txt") { t.Errorf("error does not name the unresolved file: %v", err) } if outcome.Branch != "" { t.Errorf("outcome named branch %q for a turn that committed nothing", outcome.Branch) } if outcome.PreservedPath != second.Path { t.Errorf("PreservedPath = %q, want the worktree at %q", outcome.PreservedPath, second.Path) } if _, statErr := os.Stat(second.Path); statErr != nil { t.Errorf("worktree removed despite an unresolved merge: %v", statErr) } if got := gitRun(t, repo, "rev-parse", userStateRef("agent/j/mul-6881")); got != recordedAfterFirst { t.Error("the snapshot advanced past an edit the branch never took") } if got := gitRun(t, repo, "show", "agent/j/mul-6881:tracked.txt"); got != "rewritten by the agent" { t.Errorf("branch content = %q, want the previous turn's version uncommitted over", got) } _ = removeLocalWorktreeDir(repo, second.Path, worktreeTestLogger()) third := prepareTurn(t, repo, "MUL-6881", turnThreeTask) if len(third.ReplayConflicts) == 0 { t.Fatal("the user's edit was forgotten after one unresolved turn") } if got := readFile(t, filepath.Join(third.WorkDir, "tracked.txt")); !strings.Contains(got, "rewritten by the user instead") { t.Errorf("turn three tracked.txt = %q, want the user's edit still on offer", got) } } // The A/B/C round trip: the user's conflicting edit survives until the agent // resolves it, and is not replayed again afterwards. func TestConflictResolvedByTheAgentIsDeliveredAndNotReplayedAgain(t *testing.T) { t.Parallel() repo := newTestRepo(t) writeFile(t, filepath.Join(repo, "tracked.txt"), "A\n") first := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "tracked.txt"), "B\n") finalizeOK(t, first) writeFile(t, filepath.Join(repo, "tracked.txt"), "C\n") second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if len(second.ReplayConflicts) == 0 { t.Fatal("turn two saw no conflict between the agent's B and the user's C") } // The agent resolves it the way the prompt asks: read both sides, keep both. writeFile(t, filepath.Join(second.WorkDir, "tracked.txt"), "B and C\n") gitRun(t, second.Path, "add", "tracked.txt") outcome := finalizeOK(t, second) if outcome.Branch != "agent/j/mul-6881" { t.Fatalf("resolved turn delivered %q", outcome.Branch) } if got := gitRun(t, repo, "show", "agent/j/mul-6881:tracked.txt"); got != "B and C" { t.Errorf("branch content = %q, want the agent's resolution", got) } // Turn three: the user has not touched anything since, so there is nothing // left to replay — and in particular C must not come back as a conflict. third := prepareTurn(t, repo, "MUL-6881", turnThreeTask) if len(third.ReplayConflicts) != 0 { t.Errorf("turn three replayed the resolved edit again: %v", third.ReplayConflicts) } if got := readFile(t, filepath.Join(third.WorkDir, "tracked.txt")); got != "B and C\n" { t.Errorf("turn three tracked.txt = %q, want the resolution", got) } } // Discard runs when preparation fails after the worktree exists. It may only // drop a branch this prepare created. func TestDiscardKeepsTheContinuedBranch(t *testing.T) { t.Parallel() repo := newTestRepo(t) first := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "turn-one.txt"), "work from turn one\n") finalizeOK(t, first) second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) second.Discard(worktreeTestLogger()) if _, err := gitTry(t, repo, "rev-parse", "--verify", "agent/j/mul-6881"); err != nil { t.Fatal("discarding a follow-up turn deleted the conversation's branch") } if _, err := os.Stat(second.Path); !os.IsNotExist(err) { t.Errorf("worktree still on disk after Discard: %v", err) } } // git allows one worktree per branch. A sibling task on the same conversation // still has to run, and it should start from the conversation's latest work // rather than from HEAD. // // It works from the first turn on because every branch this mode creates gets a // baseline commit of its own, so there is a checkpoint to record before the // first turn has finished — see commitBaseline. func TestPrepareLocalWorktreeForksWhenTheConversationBranchIsBusy(t *testing.T) { t.Parallel() repo := newTestRepo(t) first := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "turn-one.txt"), "work from turn one\n") // Commit inside the worktree so the sibling has something to inherit while // the branch is still checked out here. gitRun(t, first.Path, "add", "-A") gitRun(t, first.Path, "commit", "-m", "turn one") tip := gitRun(t, repo, "rev-parse", "agent/j/mul-6881") sibling := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if sibling.Branch == first.Branch { t.Fatalf("sibling took the branch already checked out at %s", first.Path) } if want := "agent/j/mul-6881-" + taskKey(turnTwoTask); sibling.Branch != want { t.Errorf("sibling branch = %q, want %q", sibling.Branch, want) } if sibling.BaseCommit != tip { t.Errorf("sibling base = %s, want the conversation's tip %s", sibling.BaseCommit, tip) } if _, err := os.Stat(filepath.Join(sibling.WorkDir, "turn-one.txt")); err != nil { t.Errorf("sibling does not carry the conversation's work: %v", err) } finalizeOK(t, sibling) finalizeOK(t, first) } // Once the user merges the branch, its tip carries nothing HEAD does not. // Continuing from it would leave the next turn behind the user's own commits. func TestPrepareLocalWorktreeRestartsAMergedConversationBranch(t *testing.T) { t.Parallel() repo := newTestRepo(t) first := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "turn-one.txt"), "work from turn one\n") finalizeOK(t, first) gitRun(t, repo, "merge", "--no-edit", "agent/j/mul-6881") writeFile(t, filepath.Join(repo, "user-commit.txt"), "the user kept working\n") gitRun(t, repo, "add", "-A") gitRun(t, repo, "commit", "-m", "user work after the merge") head := gitRun(t, repo, "rev-parse", "HEAD") second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if second.Continued { t.Error("continued a branch the user had already merged") } // Restarted at HEAD: the base is this turn's own baseline commit, sitting // directly on the user's newest commit rather than behind it. if _, err := gitTry(t, repo, "merge-base", "--is-ancestor", head, second.BaseCommit); err != nil { t.Errorf("second turn base %s does not build on the user's HEAD %s", second.BaseCommit, head) } if parent := gitRun(t, repo, "rev-parse", second.BaseCommit+"^"); parent != head { t.Errorf("second turn base is parented at %s, want the user's HEAD %s", parent, head) } if _, err := os.Stat(filepath.Join(second.WorkDir, "user-commit.txt")); err != nil { t.Errorf("the user's commits after the merge are missing: %v", err) } } func TestLocalWorktreeConversation(t *testing.T) { t.Parallel() issueID := "01a056ac-0eda-797d-8ac2-b7d7a3935ae7" chatID := "01a056ad-5b37-762a-a15f-390717f4dae1" tests := []struct { name string params PrepareParams wantKey string wantID string }{ {"issue identifier names the branch, issue id owns it", PrepareParams{IssueIdentifier: "MUL-6881", Task: TaskContextForEnv{IssueID: issueID}}, "MUL-6881", issueID}, {"issue without an identifier", PrepareParams{Task: TaskContextForEnv{IssueID: issueID}}, taskKey(issueID), issueID}, {"chat session", PrepareParams{Task: TaskContextForEnv{ChatSessionID: chatID}}, "chat-" + taskKey(chatID), chatID}, {"neither", PrepareParams{}, "", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { key, id := localWorktreeConversation(tt.params) if key != tt.wantKey || id != tt.wantID { t.Errorf("localWorktreeConversation() = (%q, %q), want (%q, %q)", key, id, tt.wantKey, tt.wantID) } }) } } // A branch is continued because it is provably this conversation's, never // because the name matches. `agent/j/mul-6881` is a name the user can type // themselves, and appending to their branch — or reading their work as the // previous turn's — is the failure this guards. func TestPrepareLocalWorktreeRefusesToAdoptABranchItDoesNotOwn(t *testing.T) { t.Parallel() repo := newTestRepo(t) // The user made this branch themselves, with content of their own. gitRun(t, repo, "branch", "agent/j/mul-6881") gitRun(t, repo, "worktree", "add", "--quiet", filepath.Join(t.TempDir(), "theirs"), "agent/j/mul-6881") userBranchDir := "" for _, line := range strings.Split(gitRun(t, repo, "worktree", "list", "--porcelain"), "\n") { if strings.HasPrefix(line, "worktree ") && strings.HasSuffix(line, "theirs") { userBranchDir = strings.TrimPrefix(line, "worktree ") } } if userBranchDir == "" { t.Fatal("could not locate the user's own worktree") } writeFile(t, filepath.Join(userBranchDir, "unrelated.txt"), "the user's own work\n") gitRun(t, userBranchDir, "add", "-A") gitRun(t, userBranchDir, "commit", "-m", "the user's own commit") gitRun(t, repo, "worktree", "remove", "--force", userBranchDir) userTip := gitRun(t, repo, "rev-parse", "agent/j/mul-6881") first := prepareTurn(t, repo, "MUL-6881", turnOneTask) if first.Continued { t.Error("continued a branch with no proof it belongs to this conversation") } if first.Branch == "agent/j/mul-6881" { t.Fatal("took over the user's own branch") } if want := "agent/j/mul-6881-" + testBranchOwner.fingerprint(); first.Branch != want { t.Errorf("branch = %q, want %q", first.Branch, want) } if _, err := os.Stat(filepath.Join(first.WorkDir, "unrelated.txt")); err == nil { t.Error("the user's unrelated work leaked into this task's tree") } writeFile(t, filepath.Join(first.WorkDir, "agent.txt"), "turn one\n") finalizeOK(t, first) // The user's branch is untouched, and the conversation's own fallback // branch is continued by the next turn. if got := gitRun(t, repo, "rev-parse", "agent/j/mul-6881"); got != userTip { t.Error("the user's branch was moved") } second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if !second.Continued || second.Branch != first.Branch { t.Errorf("second turn: branch = %q, continued = %v; want %q continued", second.Branch, second.Continued, first.Branch) } if _, err := os.Stat(filepath.Join(second.WorkDir, "agent.txt")); err != nil { t.Errorf("the fallback branch did not carry turn one's work: %v", err) } } // Two workspaces can mint the same issue identifier, and two agents can share a // display name. Neither may end up on one branch. func TestPrepareLocalWorktreeSeparatesIdenticallyNamedConversations(t *testing.T) { t.Parallel() repo := newTestRepo(t) mine := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(mine.WorkDir, "mine.txt"), "my work\n") finalizeOK(t, mine) other := testBranchOwner other.WorkspaceID = "99998888-3333-4444-5555-000000000001" theirs, err := prepareTurnAs(t, repo, "MUL-6881", turnTwoTask, other) if err != nil { t.Fatalf("PrepareLocalWorktree for the other workspace: %v", err) } if theirs.Branch == mine.Branch { t.Fatalf("another workspace's issue MUL-6881 landed on %q", mine.Branch) } if theirs.Continued { t.Error("continued a branch owned by a different workspace") } if _, err := os.Stat(filepath.Join(theirs.WorkDir, "mine.txt")); err == nil { t.Error("another workspace's work is visible in this task's tree") } } // The branch is the user's to delete. Its snapshot must not outlive it: the ref // pins their whole working tree as of some past turn against `git gc`, and // nothing else would ever come back for it. func TestPrepareLocalWorktreePrunesSnapshotsOfDeletedBranches(t *testing.T) { t.Parallel() repo := newTestRepo(t) writeFile(t, filepath.Join(repo, "tracked.txt"), "user work\n") first := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "agent.txt"), "turn one\n") finalizeOK(t, first) ref := userStateRef("agent/j/mul-6881") if _, err := gitTry(t, repo, "rev-parse", "--verify", ref); err != nil { t.Fatal("turn one recorded no snapshot") } // The user deletes the branch, the ordinary end of a piece of work once they // have taken what they wanted from it. Any later task on this repo is the // one that has to notice. gitRun(t, repo, "branch", "-D", "agent/j/mul-6881") elsewhere, err := prepareTurnAs(t, repo, "MUL-7000", turnTwoTask, branchOwner{ WorkspaceID: testBranchOwner.WorkspaceID, AgentID: testBranchOwner.AgentID, ConversationID: "11112222-3333-4444-5555-000000000009", }) if err != nil { t.Fatalf("PrepareLocalWorktree on another conversation: %v", err) } if _, err := gitTry(t, repo, "rev-parse", "--verify", ref); err == nil { t.Error("the snapshot of a deleted branch was left behind") } writeFile(t, filepath.Join(elsewhere.WorkDir, "other.txt"), "other work\n") finalizeOK(t, elsewhere) // A new conversation branch of the old name starts clean rather than // inheriting a snapshot the branch no longer carries. third := prepareTurn(t, repo, "MUL-6881", turnThreeTask) if third.Continued { t.Error("continued a branch the user had deleted") } writeFile(t, filepath.Join(third.WorkDir, "again.txt"), "turn three\n") finalizeOK(t, third) if _, err := gitTry(t, repo, "rev-parse", "--verify", ref); err != nil { t.Error("the recreated branch recorded no snapshot") } } // The snapshot is the user's directory, not the daemon's view of it: a sidecar // left in their tree by a concurrent in_place task must never reach the branch. func TestCaptureUserSnapshotExcludesMulticaSidecars(t *testing.T) { t.Parallel() repo := newTestRepo(t) writeFile(t, filepath.Join(repo, ".agent_context", "brief.md"), "another task's brief\n") writeFile(t, filepath.Join(repo, "sub", ".multica", "state.json"), "{}\n") writeFile(t, filepath.Join(repo, "real.txt"), "the user's file\n") head := gitRun(t, repo, "rev-parse", "HEAD") snapshot, err := captureUserSnapshot(repo, t.TempDir(), head, worktreeTestLogger()) if err != nil { t.Fatalf("captureUserSnapshot: %v", err) } listed := gitRun(t, repo, "ls-tree", "-r", "--name-only", snapshot) for _, unwanted := range []string{".agent_context/brief.md", "sub/.multica/state.json"} { if strings.Contains(listed, unwanted) { t.Errorf("snapshot carries the sidecar %s:\n%s", unwanted, listed) } } if !strings.Contains(listed, "real.txt") { t.Errorf("snapshot dropped the user's own file:\n%s", listed) } } // Git must rehash a same-size edit when its timestamp equals the index's. // A private index with a newer timestamp would incorrectly make it look clean. func TestCaptureUserSnapshotPreservesRacyIndexTimestamp(t *testing.T) { repo := newTestRepo(t) gitRun(t, repo, "config", "core.trustctime", "false") gitRun(t, repo, "config", "core.checkStat", "minimal") path := filepath.Join(repo, "tracked.txt") stamp := time.Now().Add(-time.Minute).Truncate(time.Second) if err := os.Chtimes(path, stamp, stamp); err != nil { t.Fatal(err) } gitRun(t, repo, "update-index", "--refresh") indexPath := filepath.Join(repo, ".git", "index") if err := os.Chtimes(indexPath, stamp, stamp); err != nil { t.Fatal(err) } indexBefore, err := os.ReadFile(indexPath) if err != nil { t.Fatal(err) } writeFile(t, path, "modified\n") // Same byte length as "original\n". if err := os.Chtimes(path, stamp, stamp); err != nil { t.Fatal(err) } head := gitRun(t, repo, "rev-parse", "HEAD") snapshot, err := captureUserSnapshot(repo, t.TempDir(), head, worktreeTestLogger()) if err != nil { t.Fatal(err) } if got := gitRun(t, repo, "show", snapshot+":tracked.txt"); got != "modified" { t.Fatalf("same-size uncommitted edit was omitted from snapshot: %q", got) } indexAfter, err := os.ReadFile(indexPath) if err != nil { t.Fatal(err) } info, err := os.Stat(indexPath) if err != nil { t.Fatal(err) } if string(indexBefore) != string(indexAfter) || !info.ModTime().Equal(stamp) { t.Fatal("snapshot changed the user's live index") } } // The record is what proves a branch is this conversation's: an owner AND the // tip it was written at. func TestBranchRecordRoundTrips(t *testing.T) { t.Parallel() repo := newTestRepo(t) head := gitRun(t, repo, "rev-parse", "HEAD") writeFile(t, filepath.Join(repo, "tracked.txt"), "user work\n") snapshot, err := captureUserSnapshot(repo, t.TempDir(), head, worktreeTestLogger()) if err != nil { t.Fatalf("captureUserSnapshot: %v", err) } gitRun(t, repo, "branch", "agent/j/mul-6881") recorded, err := writeBranchRecord(repo, "agent/j/mul-6881", snapshot, head, testBranchOwner) if err != nil { t.Fatalf("writeBranchRecord: %v", err) } record, err := readBranchRecord(repo, recorded) if err != nil { t.Fatalf("readBranchRecord: %v", err) } if record.owner != testBranchOwner { t.Errorf("owner = %+v, want %+v", record.owner, testBranchOwner) } if record.checkpoint != head { t.Errorf("checkpoint = %s, want the branch tip %s", record.checkpoint, head) } // The tree is the user's directory, which is what the next turn replays from. if got := gitRun(t, repo, "show", record.state+":tracked.txt"); got != "user work" { t.Errorf("record tree tracked.txt = %q, want the user's edit", got) } // A commit this code did not write proves nothing. plain, err := readBranchRecord(repo, head) if err != nil { t.Fatalf("readBranchRecord(HEAD): %v", err) } if plain.owner != (branchOwner{}) || plain.checkpoint != "" { t.Errorf("a plain commit reported %+v", plain) } } // Ownership is not a property of the NAME. A branch Multica delivered, that the // user then deleted and recreated for something of their own, keeps matching // the recorded owner — and there is no prepare in between for the orphan sweep // to notice the gap. Only the recorded checkpoint distinguishes them. func TestPrepareLocalWorktreeRefusesABranchDeletedAndRecreatedUnderTheSameName(t *testing.T) { t.Parallel() repo := newTestRepo(t) first := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "agent.txt"), "turn one\n") finalizeOK(t, first) if first.Branch != "agent/j/mul-6881" { t.Fatalf("first turn branch = %q", first.Branch) } // The user deletes the delivered branch and makes their own of that name, // with unrelated work on it. No task runs in between. gitRun(t, repo, "branch", "-D", "agent/j/mul-6881") gitRun(t, repo, "branch", "agent/j/mul-6881") theirs := t.TempDir() gitRun(t, repo, "worktree", "add", "--quiet", filepath.Join(theirs, "wt"), "agent/j/mul-6881") writeFile(t, filepath.Join(theirs, "wt", "unrelated.txt"), "the user's own work\n") gitRun(t, filepath.Join(theirs, "wt"), "add", "-A") gitRun(t, filepath.Join(theirs, "wt"), "commit", "-m", "the user's own commit") gitRun(t, repo, "worktree", "remove", "--force", filepath.Join(theirs, "wt")) userTip := gitRun(t, repo, "rev-parse", "agent/j/mul-6881") second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if second.Continued { t.Error("continued a branch that no longer contains the commit it was recorded at") } if second.Branch == "agent/j/mul-6881" { t.Fatal("took over the branch the user recreated") } if _, err := os.Stat(filepath.Join(second.WorkDir, "unrelated.txt")); err == nil { t.Error("the user's unrelated work leaked into this task's tree") } finalizeOK(t, second) if got := gitRun(t, repo, "rev-parse", "agent/j/mul-6881"); got != userTip { t.Error("the user's branch was moved") } } // Same proof, other shape: the branch still exists but was force-moved onto // history that never carried this conversation's work — a force-push, a reset, // a script. The checkpoint is the commit the turn DELIVERED, not whatever the // branch ref says afterwards, so the moved branch is refused rather than // silently continued. func TestPrepareLocalWorktreeRefusesABranchForceMovedOffItsRecord(t *testing.T) { t.Parallel() repo := newTestRepo(t) first := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "agent.txt"), "turn one\n") finalizeOK(t, first) delivered := gitRun(t, repo, "rev-parse", "agent/j/mul-6881") ref, err := readUserStateRef(repo, "agent/j/mul-6881") if err != nil { t.Fatalf("readUserStateRef: %v", err) } record, err := readBranchRecord(repo, ref) if err != nil { t.Fatalf("readBranchRecord: %v", err) } if record.checkpoint != delivered { t.Fatalf("checkpoint = %s, want the delivered tip %s", record.checkpoint, delivered) } // The user builds their own history and points the branch at it. gitRun(t, repo, "checkout", "--quiet", "-b", "theirs") writeFile(t, filepath.Join(repo, "unrelated.txt"), "the user's own work\n") gitRun(t, repo, "add", "-A") gitRun(t, repo, "commit", "-m", "the user's own commit") gitRun(t, repo, "checkout", "--quiet", "main") gitRun(t, repo, "branch", "-f", "agent/j/mul-6881", "theirs") second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if second.Continued { t.Error("continued a branch force-moved off the commit it was recorded at") } if second.Branch == "agent/j/mul-6881" { t.Fatal("took over the force-moved branch") } if _, err := os.Stat(filepath.Join(second.WorkDir, "unrelated.txt")); err == nil { t.Error("the user's unrelated work leaked into this task's tree") } // The deterministic form of the same race: a record written while the ref // already points somewhere else must still pin what was delivered, not // whatever the ref says at the moment of writing. written, err := writeBranchRecord(repo, "agent/j/mul-6881", record.state, delivered, testBranchOwner) if err != nil { t.Fatalf("writeBranchRecord: %v", err) } rewritten, err := readBranchRecord(repo, written) if err != nil { t.Fatalf("readBranchRecord: %v", err) } if rewritten.checkpoint != delivered { t.Errorf("checkpoint = %s, want the commit passed in (%s), not the live ref", rewritten.checkpoint, delivered) } } // The record IS part of delivering the branch. If it cannot be written, the // branch is not continuable and the turn has not delivered what it claims — so // the task fails and the worktree is kept, rather than reporting success and // leaving a stale record behind to authorise the next turn. func TestFinalizeFailsWhenTheBranchRecordCannotBeWritten(t *testing.T) { t.Parallel() repo := newTestRepo(t) wt := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(wt.WorkDir, "agent.txt"), "turn one\n") // Hold the ref's lock so only the final update-ref fails, the way a crashed // git or a concurrent writer would leave it. lock := filepath.Join(repo, ".git", filepath.FromSlash(userStateRef("agent/j/mul-6881"))+".lock") if err := os.MkdirAll(filepath.Dir(lock), 0o755); err != nil { t.Fatalf("prepare ref lock dir: %v", err) } if err := os.WriteFile(lock, nil, 0o644); err != nil { t.Fatalf("hold ref lock: %v", err) } outcome, err := wt.Finalize(worktreeTestLogger()) if err == nil { t.Fatal("Finalize reported success without recording the branch") } if outcome.PreservedPath != wt.Path { t.Errorf("PreservedPath = %q, want the worktree at %q", outcome.PreservedPath, wt.Path) } if _, statErr := os.Stat(wt.Path); statErr != nil { t.Errorf("worktree removed even though the branch could not be recorded: %v", statErr) } // The agent's work is committed to the branch regardless — that commit // happens before the record, and losing it would be the worse failure. if got := gitRun(t, repo, "show", "agent/j/mul-6881:agent.txt"); got != "turn one" { t.Errorf("branch content = %q, want the agent's work committed", got) } // And with no record, a later turn must not adopt the branch: the hole this // closes is the user deleting it and recreating one of their own at the // commit the prepare-time record used to point at. if err := os.Remove(lock); err != nil { t.Fatalf("release ref lock: %v", err) } _ = removeLocalWorktreeDir(repo, wt.Path, worktreeTestLogger()) gitRun(t, repo, "branch", "-D", "agent/j/mul-6881") gitRun(t, repo, "branch", "agent/j/mul-6881") worktree := filepath.Join(t.TempDir(), "theirs") gitRun(t, repo, "worktree", "add", "--quiet", worktree, "agent/j/mul-6881") writeFile(t, filepath.Join(worktree, "unrelated.txt"), "the user's own work\n") gitRun(t, worktree, "add", "-A") gitRun(t, worktree, "commit", "-m", "the user's own commit") gitRun(t, repo, "worktree", "remove", "--force", worktree) second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if second.Continued || second.Branch == "agent/j/mul-6881" { t.Errorf("second turn adopted the user's branch: branch = %q, continued = %v", second.Branch, second.Continued) } if _, err := os.Stat(filepath.Join(second.WorkDir, "unrelated.txt")); err == nil { t.Error("the user's unrelated work leaked into this task's tree") } } // The run itself can destroy the proof: an agent that resets its worktree back // to the user's own HEAD leaves the branch sitting on a plain user commit. // Recording that as the checkpoint would make a branch the user later recreates // there look like this conversation's, so the turn refuses to record it and // keeps the worktree instead. func TestFinalizeRefusesToRecordADeliveryThatResetPastItsBaseline(t *testing.T) { t.Parallel() repo := newTestRepo(t) writeFile(t, filepath.Join(repo, "tracked.txt"), "user work in progress\n") head := gitRun(t, repo, "rev-parse", "HEAD") wt := prepareTurn(t, repo, "MUL-6881", turnOneTask) if wt.BaseCommit == head { t.Fatal("prepare left the branch on the user's own HEAD, with no commit of its own") } writeFile(t, filepath.Join(wt.WorkDir, "agent.txt"), "turn one\n") // The agent throws its own history away and lands back on the user's HEAD. gitRun(t, wt.Path, "reset", "--hard", head) outcome, err := wt.Finalize(worktreeTestLogger()) if err == nil { t.Fatal("Finalize recorded a delivery that no longer contains the branch's own commit") } if !strings.Contains(err.Error(), "no longer contains") { t.Errorf("error does not explain what is missing: %v", err) } if outcome.Branch != "" { t.Errorf("outcome named branch %q for a delivery it refused to record", outcome.Branch) } if outcome.PreservedPath != wt.Path { t.Errorf("PreservedPath = %q, want the worktree at %q", outcome.PreservedPath, wt.Path) } if _, statErr := os.Stat(wt.Path); statErr != nil { t.Errorf("worktree removed despite refusing the delivery: %v", statErr) } // And the hole this closes: the user deletes the branch, recreates one of // their own at that same HEAD, and the next turn must not adopt it. _ = removeLocalWorktreeDir(repo, wt.Path, worktreeTestLogger()) gitRun(t, repo, "branch", "-D", "agent/j/mul-6881") gitRun(t, repo, "branch", "agent/j/mul-6881") theirs := filepath.Join(t.TempDir(), "theirs") gitRun(t, repo, "worktree", "add", "--quiet", theirs, "agent/j/mul-6881") writeFile(t, filepath.Join(theirs, "unrelated.txt"), "the user's own work\n") gitRun(t, theirs, "add", "-A") gitRun(t, theirs, "commit", "-m", "the user's own commit") gitRun(t, repo, "worktree", "remove", "--force", theirs) second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if second.Continued || second.Branch == "agent/j/mul-6881" { t.Errorf("second turn adopted the user's branch: branch = %q, continued = %v", second.Branch, second.Continued) } if _, err := os.Stat(filepath.Join(second.WorkDir, "unrelated.txt")); err == nil { t.Error("the user's unrelated work leaked into this task's tree") } } // The same guard from the other side: whatever the worktree delivered has to BE // the task's branch. A run that ended somewhere else — a detached checkout, a // different branch — delivered a commit this record has no business describing, // and the branch it names would not carry it. func TestFinalizeRefusesToRecordADeliveryFromOffTheBranch(t *testing.T) { t.Parallel() repo := newTestRepo(t) head := gitRun(t, repo, "rev-parse", "HEAD") wt := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(wt.WorkDir, "agent.txt"), "turn one\n") gitRun(t, wt.Path, "add", "-A") gitRun(t, wt.Path, "commit", "-m", "turn one") delivered := gitRun(t, repo, "rev-parse", "agent/j/mul-6881") // The run wanders off its own branch before it ends. gitRun(t, wt.Path, "checkout", "--quiet", "--detach", head) outcome, err := wt.Finalize(worktreeTestLogger()) if err == nil { t.Fatal("Finalize recorded a delivery that is not the branch's tip") } if !strings.Contains(err.Error(), "did not deliver onto its own branch") { t.Errorf("error does not explain the mismatch: %v", err) } if outcome.PreservedPath != wt.Path { t.Errorf("PreservedPath = %q, want the worktree at %q", outcome.PreservedPath, wt.Path) } // The branch keeps what it had; nothing was recorded against the stray tip. if got := gitRun(t, repo, "rev-parse", "agent/j/mul-6881"); got != delivered { t.Errorf("branch moved to %s, want %s", got, delivered) } ref, err := readUserStateRef(repo, "agent/j/mul-6881") if err != nil { t.Fatalf("readUserStateRef: %v", err) } record, err := readBranchRecord(repo, ref) if err != nil { t.Fatalf("readBranchRecord: %v", err) } if record.checkpoint == head { t.Error("the stray HEAD was recorded as this branch's checkpoint") } _ = removeLocalWorktreeDir(repo, wt.Path, worktreeTestLogger()) } // A branch created by this prepare always gets a commit of its own, even when // the user's directory was clean and there was nothing to replay. Without it // the branch would sit exactly where the user's HEAD does, and nothing would // tell it apart from a branch they create there themselves. func TestPrepareLocalWorktreeAlwaysGivesANewBranchACommitOfItsOwn(t *testing.T) { t.Parallel() repo := newTestRepo(t) head := gitRun(t, repo, "rev-parse", "HEAD") wt := prepareTurn(t, repo, "MUL-6881", turnOneTask) if wt.BaseCommit == head { t.Fatal("a clean tree left the branch on the user's HEAD") } if parent := gitRun(t, repo, "rev-parse", wt.BaseCommit+"^"); parent != head { t.Errorf("baseline is parented at %s, want the user's HEAD %s", parent, head) } // It changes nothing: the point is its existence, not its content. if diff := gitRun(t, repo, "diff", "--stat", head, wt.BaseCommit); diff != "" { t.Errorf("the baseline of a clean tree is not empty:\n%s", diff) } // And a turn that produces nothing still leaves no branch behind. outcome := finalizeOK(t, wt) if outcome.Branch != "" { t.Errorf("read-only turn reported branch %q", outcome.Branch) } if outcome.AutoCommitted { t.Error("AutoCommitted = true for a turn that changed nothing") } if _, err := gitTry(t, repo, "rev-parse", "--verify", "agent/j/mul-6881"); err == nil { t.Error("a turn that changed nothing left its branch behind") } } // A follow-up turn brings the user's newest edits in as its own baseline // commit, and that commit — not the checkpoint the previous turn left — is what // the delivery has to keep. A run that resets past it has not delivered those // edits, and recording them as delivered is how they disappear from every later // turn without anyone seeing it. func TestFinalizeRefusesWhenAFollowUpResetsPastTheUserEditsItReplayed(t *testing.T) { t.Parallel() repo := newTestRepo(t) first := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "agent.txt"), "turn one\n") finalizeOK(t, first) firstTip := gitRun(t, repo, "rev-parse", "agent/j/mul-6881") // Between the turns the user edits a file of their own. writeFile(t, filepath.Join(repo, "tracked.txt"), "the user's newest edit\n") second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if !second.Continued { t.Fatal("second turn did not continue the conversation's branch") } if got := readFile(t, filepath.Join(second.WorkDir, "tracked.txt")); got != "the user's newest edit\n" { t.Fatalf("second turn did not replay the user's edit: %q", got) } // The agent throws the turn away, landing back on what turn one delivered. gitRun(t, second.Path, "reset", "--hard", firstTip) if _, err := second.Finalize(worktreeTestLogger()); err == nil { t.Fatal("Finalize recorded the user's edits as delivered after they were reset away") } if _, statErr := os.Stat(second.Path); statErr != nil { t.Errorf("worktree removed despite refusing the delivery: %v", statErr) } _ = removeLocalWorktreeDir(repo, second.Path, worktreeTestLogger()) // The property that matters: the third turn still sees the user's edit, // whichever branch it ends up on. third := prepareTurn(t, repo, "MUL-6881", turnThreeTask) if got := readFile(t, filepath.Join(third.WorkDir, "tracked.txt")); got != "the user's newest edit\n" { t.Errorf("third turn tracked.txt = %q, want the user's edit still present", got) } } // The same rule where the merge conflicted: the branch only carries this turn's // snapshot once something is committed after the agent resolves. A resolution // that leaves no commit is indistinguishable from throwing the merge away, so // the record must not claim the edits landed, and they come back next turn. // // The user committing on a delivered branch is supported, so a later turn can // start well past the checkpoint the previous one recorded. What tells that // turn whether its own merge landed is where IT started, not that older // checkpoint: measuring against the checkpoint let a resolution that committed // nothing count as delivered, and the user's local edit then went missing from // the turn after. func TestConflictAfterAUserCommitOnTheBranchStillOffersTheEditAgain(t *testing.T) { t.Parallel() repo := newTestRepo(t) writeFile(t, filepath.Join(repo, "tracked.txt"), "user work in progress\n") first := prepareTurn(t, repo, "MUL-6881", turnOneTask) writeFile(t, filepath.Join(first.WorkDir, "tracked.txt"), "rewritten by the agent\n") finalizeOK(t, first) recordedAfterFirst := gitRun(t, repo, "rev-parse", userStateRef("agent/j/mul-6881")) // The user reviews the branch and commits on top of it — the documented, // supported case. The branch tip now sits past the recorded checkpoint. worktree := filepath.Join(t.TempDir(), "review") gitRun(t, repo, "worktree", "add", "--quiet", worktree, "agent/j/mul-6881") writeFile(t, filepath.Join(worktree, "review.txt"), "the user's own commit on the branch\n") gitRun(t, worktree, "add", "-A") gitRun(t, worktree, "commit", "-m", "the user's tweak on the agent's branch") gitRun(t, repo, "worktree", "remove", "--force", worktree) movedTip := gitRun(t, repo, "rev-parse", "agent/j/mul-6881") // And in their own directory they rewrite the same line the agent did, so // the next turn starts mid-merge. writeFile(t, filepath.Join(repo, "tracked.txt"), "rewritten by the user instead\n") second := prepareTurn(t, repo, "MUL-6881", turnTwoTask) if !second.Continued { t.Fatal("second turn did not continue the branch the user had committed on") } if second.BaseCommit != movedTip { t.Fatalf("second turn base = %s, want the branch tip the user left %s", second.BaseCommit, movedTip) } if len(second.ReplayConflicts) == 0 { t.Fatal("second turn saw no conflict") } // The agent resolves in favour of what the branch already had: nothing to // commit, so the branch stays exactly where this turn found it. writeFile(t, filepath.Join(second.WorkDir, "tracked.txt"), "rewritten by the agent\n") gitRun(t, second.Path, "add", "tracked.txt") if outcome := finalizeOK(t, second); outcome.Branch != "agent/j/mul-6881" { t.Fatalf("resolved turn delivered %q", outcome.Branch) } if got := gitRun(t, repo, "rev-parse", "agent/j/mul-6881"); got != movedTip { t.Fatalf("branch moved to %s, want %s — the turn committed nothing", got, movedTip) } if got := gitRun(t, repo, "rev-parse", userStateRef("agent/j/mul-6881")); got == recordedAfterFirst { t.Error("the turn recorded nothing at all; it should have re-recorded the state the branch carries") } // The edit was never committed anywhere, so the third turn has to offer it // again rather than treat it as delivered. third := prepareTurn(t, repo, "MUL-6881", turnThreeTask) if len(third.ReplayConflicts) == 0 { t.Error("the user's local edit was recorded as delivered even though no commit carried it") } } // Production never prepares in the daemon's own process: PrepareIsolated runs // Prepare in a helper and the Environment comes back as JSON. Every guarantee // Finalize makes depends on state this struct keeps unexported, and ordinary // marshalling drops those fields without a word — the daemon then finalized a // worktree it believed it owned nothing of. This test runs two turns across // that boundary, which is where the in-process tests above cannot look. // // It also goes through Prepare, the entry point the daemon calls, with a full // issue claim, so the plumbing from the claim is covered too: the issue // identifier names the branch, and the workspace, agent and issue become the // identity the branch is recorded under. func TestIsolatedPrepareCarriesTheStateFinalizeNeeds(t *testing.T) { t.Parallel() repo := newTestRepo(t) writeFile(t, filepath.Join(repo, "tracked.txt"), "user work in progress\n") workspacesRoot := t.TempDir() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() turn := func(taskID string) *Environment { t.Helper() env, err := PrepareIsolated(ctx, preparationHelperTestCommand(), PrepareParams{ WorkspacesRoot: workspacesRoot, WorkspaceID: testBranchOwner.WorkspaceID, TaskID: taskID, IssueIdentifier: "MUL-6881", Provider: "claude", AgentName: "J", Task: TaskContextForEnv{ IssueID: testBranchOwner.ConversationID, AgentID: testBranchOwner.AgentID, }, LocalWorktree: &LocalWorktreeParams{LocalPath: repo}, }, worktreeTestLogger()) if err != nil { t.Fatalf("PrepareIsolated(%s): %v", taskID, err) } return env } first := turn(turnOneTask) wt := first.LocalWorktree if wt.Branch != "agent/j/mul-6881" { t.Fatalf("branch = %q", wt.Branch) } // The proofs Finalize makes all hang off state that crosses as JSON — // createdBranch among them, without which a read-only turn keeps its branch. if !wt.tracksState || wt.userState == "" || wt.owner != testBranchOwner || !wt.createdBranch { t.Fatalf("state lost crossing the helper boundary: tracksState=%v userState=%q owner=%+v createdBranch=%v", wt.tracksState, wt.userState, wt.owner, wt.createdBranch) } writeFile(t, filepath.Join(wt.WorkDir, "agent.txt"), "work from turn one\n") finalizeOK(t, wt) // Finalize's record is the observable proof it had the state: the checkpoint // must be the tip it delivered, not the baseline Prepare recorded. delivered := gitRun(t, repo, "rev-parse", "agent/j/mul-6881") ref, err := readUserStateRef(repo, "agent/j/mul-6881") if err != nil { t.Fatalf("readUserStateRef: %v", err) } record, err := readBranchRecord(repo, ref) if err != nil { t.Fatalf("readBranchRecord: %v", err) } if record.checkpoint != delivered { t.Errorf("checkpoint = %s, want the delivered tip %s", record.checkpoint, delivered) } if record.owner != testBranchOwner { t.Errorf("record owner = %+v, want %+v", record.owner, testBranchOwner) } // And the continuation the whole feature is for still holds across it. second := turn(turnTwoTask) if !second.LocalWorktree.Continued || second.LocalWorktree.Branch != "agent/j/mul-6881" { t.Fatalf("turn two: branch = %q continued = %v", second.LocalWorktree.Branch, second.LocalWorktree.Continued) } if _, err := os.Stat(filepath.Join(second.WorkDir, "agent.txt")); err != nil { t.Errorf("turn two does not carry turn one's work: %v", err) } } // A read-only turn drops its branch after the daemon removes its own sidecars. // This path is intentionally separate from the continuation case above: it // proves the JSON helper boundary preserves createdBranch through cleanup and // that Finalize deletes the ref when no user change remains. func TestIsolatedPrepareKeepsTheReadOnlyBranchDrop(t *testing.T) { repo := newTestRepo(t) workspacesRoot := t.TempDir() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() env, err := PrepareIsolated(ctx, preparationHelperTestCommand(), PrepareParams{ WorkspacesRoot: workspacesRoot, WorkspaceID: testBranchOwner.WorkspaceID, TaskID: turnOneTask, IssueIdentifier: "MUL-6881", Provider: "claude", AgentName: "J", Task: TaskContextForEnv{ IssueID: testBranchOwner.ConversationID, AgentID: testBranchOwner.AgentID, }, LocalWorktree: &LocalWorktreeParams{LocalPath: repo}, }, worktreeTestLogger()) if err != nil { t.Fatalf("PrepareIsolated: %v", err) } if err := CleanupRuntimeConfig(env.WorkDir, "claude"); err != nil { t.Fatalf("CleanupRuntimeConfig: %v", err) } if err := CleanupSidecars(env.RootDir); err != nil { t.Fatalf("CleanupSidecars: %v", err) } outcome := finalizeOK(t, env.LocalWorktree) if outcome.Branch != "" { t.Errorf("read-only turn reported branch %q", outcome.Branch) } if _, err := gitTry(t, repo, "rev-parse", "--verify", "agent/j/mul-6881"); err == nil { t.Error("a turn that changed nothing left its branch behind") } }