4b320809bd
Delivery merges the task branch, and that branch carries the "orchestra: TASK.md" commit. Master therefore ends up holding the previous task's TASK.md, and the next worktree branches from it. writeTaskFile returned early on os.Stat, so it left that inherited file in place. The worker then hashed the current task and every immutability check failed with "TASK.md changed" against a hash for a task nobody was running. Releases failed, rotation never relaunched, phase requests were never read, and the task died on retry_limit without leaving research. Presence is not identity. Compare content, and rewrite when it differs. Found live in run 7, the first task to start after run 6's pull request merged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
175 lines
5.7 KiB
Go
175 lines
5.7 KiB
Go
package orchestrator_test
|
|
|
|
import (
|
|
"context"
|
|
"orchestra/internal/continuity"
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/orchestrator"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func initRepo(t *testing.T, dir string) {
|
|
t.Helper()
|
|
run := func(args ...string) {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
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)
|
|
}
|
|
}
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
run("init")
|
|
if err := os.WriteFile(filepath.Join(dir, "README"), []byte("x"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
run("add", "README")
|
|
run("commit", "-m", "init")
|
|
}
|
|
|
|
func TestPerProjectGitWorktreesResolvesByProject(t *testing.T) {
|
|
base := t.TempDir()
|
|
repoA := filepath.Join(base, "repo-a")
|
|
repoB := filepath.Join(base, "repo-b")
|
|
initRepo(t, repoA)
|
|
initRepo(t, repoB)
|
|
|
|
w := orchestrator.PerProjectGitWorktrees{
|
|
Projects: map[string]orchestrator.ProjectRepo{
|
|
"proj-a": {Repo: repoA, WorktreeRoot: filepath.Join(base, "wt-a")},
|
|
},
|
|
Default: orchestrator.GitWorktrees{Root: filepath.Join(base, "wt-default"), Repo: repoB},
|
|
}
|
|
|
|
pathA, err := w.Create(context.Background(), domain.Task{ID: "t1", Project: "proj-a"})
|
|
if err != nil {
|
|
t.Fatalf("create for proj-a: %v", err)
|
|
}
|
|
if filepath.Dir(pathA) != filepath.Join(base, "wt-a") {
|
|
t.Fatalf("expected proj-a worktree under wt-a, got %s", pathA)
|
|
}
|
|
|
|
pathDefault, err := w.Create(context.Background(), domain.Task{ID: "t2", Project: "unconfigured-project"})
|
|
if err != nil {
|
|
t.Fatalf("create for unconfigured project: %v", err)
|
|
}
|
|
if filepath.Dir(pathDefault) != filepath.Join(base, "wt-default") {
|
|
t.Fatalf("expected unconfigured project to use default worktree root, got %s", pathDefault)
|
|
}
|
|
}
|
|
|
|
// TestGitWorktreesCommitsTaskFile guards AUDIT.md's B6: nothing wrote a
|
|
// TASK.md into a worktree in the first place, so §6.2 pickup validation had
|
|
// nothing to check. GitWorktrees.Create must now write and commit an
|
|
// immutable TASK.md whose on-disk hash matches continuity.RenderTaskFile.
|
|
func TestGitWorktreesCommitsTaskFile(t *testing.T) {
|
|
base := t.TempDir()
|
|
repo := filepath.Join(base, "repo")
|
|
initRepo(t, repo)
|
|
|
|
w := orchestrator.GitWorktrees{Root: filepath.Join(base, "wt"), Repo: repo}
|
|
task := domain.Task{ID: "t1", Project: "p", Source: "jsonl", ExternalID: "1", Title: "do the thing", Acceptance: []string{"tests pass"}, QualityGate: "go test ./..."}
|
|
|
|
path, err := w.Create(context.Background(), task)
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
want := continuity.RenderTaskFile(task)
|
|
got, err := os.ReadFile(filepath.Join(path, "TASK.md"))
|
|
if err != nil {
|
|
t.Fatalf("read TASK.md: %v", err)
|
|
}
|
|
if string(got) != string(want) {
|
|
t.Fatalf("TASK.md content mismatch:\ngot: %s\nwant: %s", got, want)
|
|
}
|
|
if !strings.Contains(string(got), "## Acceptance criteria") || !strings.Contains(string(got), ".orchestra/done") {
|
|
t.Fatalf("TASK.md is missing deterministic completion contract: %s", got)
|
|
}
|
|
|
|
status, err := exec.Command("git", "-C", path, "status", "--porcelain", "--", "TASK.md").Output()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(status) != 0 {
|
|
t.Fatalf("TASK.md not committed, status: %s", status)
|
|
}
|
|
|
|
sha, err := continuity.TaskFileHash(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := continuity.VerifyTaskFile(path, sha); err != nil {
|
|
t.Fatalf("VerifyTaskFile: %v", err)
|
|
}
|
|
|
|
// Re-creating (path already exists) must not touch the committed file.
|
|
path2, err := w.Create(context.Background(), task)
|
|
if err != nil {
|
|
t.Fatalf("recreate: %v", err)
|
|
}
|
|
if path2 != path {
|
|
t.Fatalf("recreate returned different path: %s vs %s", path2, path)
|
|
}
|
|
got2, err := os.ReadFile(filepath.Join(path, "TASK.md"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got2) != string(want) {
|
|
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)
|
|
}
|
|
}
|