diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 1b87679..5396091 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -4,6 +4,7 @@ package orchestrator import ( + "bytes" "context" "encoding/json" "errors" @@ -88,10 +89,18 @@ func (w GitWorktrees) Create(ctx context.Context, t domain.Task) (string, error) // clean, and so its hash survives independent of any later scratch commits. func writeTaskFile(ctx context.Context, worktree string, t domain.Task) error { path := filepath.Join(worktree, "TASK.md") - if _, err := os.Stat(path); err == nil { + want := continuity.RenderTaskFile(t) + // Presence is not identity. Delivery merges the task branch, and the + // branch carries this commit, so a later task's worktree branches from a + // master that already holds the *previous* task's TASK.md. Returning early + // on os.Stat left that file in place, and every immutability check then + // failed with "TASK.md changed" against a hash for a task nobody was + // running. Found live: run 7 died on retry_limit without ever leaving + // research. + if got, err := os.ReadFile(path); err == nil && bytes.Equal(got, want) { return nil } - if err := os.WriteFile(path, continuity.RenderTaskFile(t), 0644); err != nil { + if err := os.WriteFile(path, want, 0644); err != nil { return err } for _, args := range [][]string{{"add", "TASK.md"}, {"commit", "-m", "orchestra: TASK.md"}} { diff --git a/internal/orchestrator/worktrees_test.go b/internal/orchestrator/worktrees_test.go index 3344628..ebc33b6 100644 --- a/internal/orchestrator/worktrees_test.go +++ b/internal/orchestrator/worktrees_test.go @@ -125,3 +125,50 @@ func TestGitWorktreesCommitsTaskFile(t *testing.T) { t.Fatalf("TASK.md changed on recreate") } } + +// A delivered task branch carries its own TASK.md, so master ends up holding +// the previous task's file. The next worktree must not inherit it: run 7 died +// on retry_limit because every release failed "TASK.md changed" against a hash +// for a task nobody was running. +func TestWorktreeReplacesAnInheritedTaskFile(t *testing.T) { + base := t.TempDir() + repo := filepath.Join(base, "repo") + initRepo(t, repo) + + previous := domain.Task{ID: "previous-task", Project: "p", Title: "the merged one"} + if err := os.WriteFile(filepath.Join(repo, "TASK.md"), continuity.RenderTaskFile(previous), 0644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"add", "TASK.md"}, {"commit", "-m", "merged task branch"}} { + cmd := exec.Command("git", args...) + cmd.Dir = repo + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + + current := domain.Task{ID: "current-task", Project: "p", Title: "the one being run"} + w := orchestrator.GitWorktrees{Root: filepath.Join(base, "wt"), Repo: repo} + path, err := w.Create(context.Background(), current) + if err != nil { + t.Fatalf("create: %v", err) + } + got, err := os.ReadFile(filepath.Join(path, "TASK.md")) + if err != nil { + t.Fatal(err) + } + if string(got) != string(continuity.RenderTaskFile(current)) { + t.Fatalf("worktree inherited a stale TASK.md:\n%s", got) + } + // Committed, not left dirty: the immutability check reads git status. + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = path + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git status: %v: %s", err, out) + } + if strings.Contains(string(out), "TASK.md") { + t.Fatalf("TASK.md left uncommitted: %s", out) + } +}