318 lines
10 KiB
Go
318 lines
10 KiB
Go
package herdr
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"orchestra/internal/continuity"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
type memCAS struct{ m map[string][]byte }
|
|
|
|
func (c *memCAS) PutArtifact(b []byte) (string, error) {
|
|
if c.m == nil {
|
|
c.m = map[string][]byte{}
|
|
}
|
|
id := string(rune(len(c.m) + 'a'))
|
|
c.m[id] = b
|
|
return id, nil
|
|
}
|
|
func (c *memCAS) Artifact(ref string) ([]byte, error) { return c.m[ref], nil }
|
|
|
|
func runGit(t *testing.T, dir string, args ...string) {
|
|
t.Helper()
|
|
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v: %v: %s", args, err, out)
|
|
}
|
|
}
|
|
|
|
// fakeHerdr accepts one JSON-RPC connection and replies with an empty result
|
|
// to every request — enough to exercise CLIAdapter.Release's pane.release_agent
|
|
// call without a live herdr instance.
|
|
func fakeHerdr(t *testing.T) *Client {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { ln.Close() })
|
|
go func() {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
var req Request
|
|
if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&req); err != nil {
|
|
return
|
|
}
|
|
json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: json.RawMessage(`{}`)})
|
|
}()
|
|
return &Client{Path: ln.Addr().String(), dial: func() (net.Conn, error) { return net.Dial("tcp", ln.Addr().String()) }}
|
|
}
|
|
|
|
func validHandoff(anchorSHA string) continuity.Handoff {
|
|
return continuity.Handoff{
|
|
Meta: continuity.Meta{ID: "t1", Reason: "threshold", RotationIndex: 1},
|
|
Anchor: continuity.Anchor{GitSHA: anchorSHA, Branch: "main"},
|
|
Action: "run the focused tests",
|
|
Command: "go test ./...",
|
|
}
|
|
}
|
|
|
|
const validAnswer = `NEXT: run the focused tests
|
|
WHY: confirm the current implementation before changing it
|
|
REMAINING: NONE
|
|
DEAD ENDS: NONE
|
|
OPEN Q: NONE
|
|
LEARNED: NONE`
|
|
|
|
func TestReleaseUploadsHandoffAndReleasesAgent(t *testing.T) {
|
|
repo := t.TempDir()
|
|
runGit(t, repo, "init")
|
|
runGit(t, repo, "config", "user.email", "t@t")
|
|
runGit(t, repo, "config", "user.name", "t")
|
|
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
|
|
head, err := HeadSHA(repo)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cas := &memCAS{}
|
|
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: cas}
|
|
ref, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo})
|
|
if err != nil {
|
|
t.Fatalf("Release: %v", err)
|
|
}
|
|
if ref == "" {
|
|
t.Fatal("expected non-empty handoff ref")
|
|
}
|
|
if _, ok := cas.m[ref]; !ok {
|
|
t.Fatal("handoff was not uploaded to CAS")
|
|
}
|
|
got, err := continuity.Load(ref, cas)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Anchor.GitSHA != head {
|
|
t.Fatal("semantic report must not be included in the successor scratch anchor")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(repo, HandoffReportFile)); !os.IsNotExist(err) {
|
|
t.Fatalf("transferred semantic report still present: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCanonicalHandoffCarriesCoordinatorReason(t *testing.T) {
|
|
repo := t.TempDir()
|
|
runGit(t, repo, "init")
|
|
runGit(t, repo, "config", "user.email", "t@t")
|
|
runGit(t, repo, "config", "user.name", "t")
|
|
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
|
|
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cas := &memCAS{}
|
|
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: cas}
|
|
ref, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo, HandoffReason: "thrash"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := continuity.Load(ref, cas)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Meta.Reason != "thrash" {
|
|
t.Fatalf("canonical reason = %q, want thrash", got.Meta.Reason)
|
|
}
|
|
}
|
|
|
|
func TestReleaseUsesHarnessBindingNotDisplayName(t *testing.T) {
|
|
repo := t.TempDir()
|
|
runGit(t, repo, "init")
|
|
runGit(t, repo, "config", "user.email", "t@t")
|
|
runGit(t, repo, "config", "user.name", "t")
|
|
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
|
|
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
request := make(chan Request, 1)
|
|
go func() {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
var req Request
|
|
if json.NewDecoder(bufio.NewReader(conn)).Decode(&req) == nil {
|
|
request <- req
|
|
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: json.RawMessage(`{}`)})
|
|
}
|
|
}()
|
|
a := CLIAdapter{Client: &Client{Path: ln.Addr().String(), dial: func() (net.Conn, error) { return net.Dial("tcp", ln.Addr().String()) }}, Harness: "opencode", CAS: &memCAS{}}
|
|
if _, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo, AgentName: "oc-task-specific"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := <-request
|
|
p, _ := json.Marshal(req.Params)
|
|
var got struct {
|
|
Agent string `json:"agent"`
|
|
}
|
|
_ = json.Unmarshal(p, &got)
|
|
if got.Agent != "opencode" {
|
|
t.Fatalf("release agent = %q, want harness binding opencode", got.Agent)
|
|
}
|
|
}
|
|
|
|
func TestReleaseRefusesWithoutHandoffFile(t *testing.T) {
|
|
repo := t.TempDir()
|
|
runGit(t, repo, "init")
|
|
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: &memCAS{}}
|
|
if _, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo}); err == nil {
|
|
t.Fatal("expected error when no handoff file is present")
|
|
}
|
|
}
|
|
|
|
func TestReleaseRefusesNarrativeOrCircularHandoffAnswer(t *testing.T) {
|
|
repo := t.TempDir()
|
|
runGit(t, repo, "init")
|
|
runGit(t, repo, "config", "user.email", "t@t")
|
|
runGit(t, repo, "config", "user.name", "t")
|
|
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
|
|
bad := `NEXT: Continue the task from the handoff
|
|
WHY: the predecessor asked for it
|
|
REMAINING: what changed\n# a markdown report
|
|
DEAD ENDS: NONE
|
|
OPEN Q: NONE
|
|
LEARNED: NONE`
|
|
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(bad), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: &memCAS{}}
|
|
if _, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo}); err == nil {
|
|
t.Fatal("expected invalid agent answer to refuse release")
|
|
}
|
|
}
|
|
|
|
func TestParseHandoffAnswerPreservesOnlyAuthoredFields(t *testing.T) {
|
|
a, err := parseHandoffAnswer(`NEXT: inspect the failing integration test
|
|
WHY: isolate the regression before changing production code
|
|
REMAINING: fix the assertion after identifying the cause
|
|
DEAD ENDS: tried rerunning the whole suite → failed because it obscures the relevant failure
|
|
OPEN Q: whether the remote worker has the updated fixture
|
|
LEARNED: the fixture requires a committed scratch branch
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(a.Remaining) != 1 || len(a.DeadEnds) != 1 || len(a.OpenQuestions) != 1 || len(a.Learned) != 1 {
|
|
t.Fatalf("parsed answer lost authored fields: %#v", a)
|
|
}
|
|
}
|
|
|
|
func TestReleaseDoesNotTrustAgentSuppliedAnchor(t *testing.T) {
|
|
repo := t.TempDir()
|
|
runGit(t, repo, "init")
|
|
runGit(t, repo, "config", "user.email", "t@t")
|
|
runGit(t, repo, "config", "user.name", "t")
|
|
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
|
|
|
|
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: &memCAS{}}
|
|
if _, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo}); err != nil {
|
|
t.Fatalf("Release must derive the anchor itself: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReleaseScratchCommitsDirtyFilesBeforeUpload(t *testing.T) {
|
|
repo := t.TempDir()
|
|
runGit(t, repo, "init")
|
|
runGit(t, repo, "config", "user.email", "t@t")
|
|
runGit(t, repo, "config", "user.name", "t")
|
|
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
|
|
head, err := HeadSHA(repo)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wipPath := filepath.Join(repo, "wip.txt")
|
|
if err := os.WriteFile(wipPath, []byte("in progress"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cas := &memCAS{}
|
|
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: cas}
|
|
ref, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo})
|
|
if err != nil {
|
|
t.Fatalf("Release: %v", err)
|
|
}
|
|
got, err := continuity.Load(ref, cas)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Anchor.GitSHA == head {
|
|
t.Fatal("expected anchor to advance to the new scratch commit")
|
|
}
|
|
if len(got.Anchor.Dirty) != 0 {
|
|
t.Fatal("expected dirty entries to be cleared after scratch commit")
|
|
}
|
|
newHead, err := HeadSHA(repo)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Anchor.GitSHA != newHead {
|
|
t.Fatalf("stored anchor=%s does not match worktree HEAD=%s", got.Anchor.GitSHA, newHead)
|
|
}
|
|
branch, err := exec.Command("git", "-C", repo, "branch", "--show-current").Output()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Anchor.Branch != "orchestra/scratch/p1" || string(branch) != got.Anchor.Branch+"\n" {
|
|
t.Fatalf("expected worktree on scratch branch, got %q (handoff says %q)", branch, got.Anchor.Branch)
|
|
}
|
|
}
|
|
|
|
func TestReleaseDoesNotTrustAgentSuppliedDirtyFile(t *testing.T) {
|
|
repo := t.TempDir()
|
|
runGit(t, repo, "init")
|
|
runGit(t, repo, "config", "user.email", "t@t")
|
|
runGit(t, repo, "config", "user.name", "t")
|
|
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
|
|
if err := os.WriteFile(filepath.Join(repo, "wip.txt"), []byte("changed after handoff written"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: &memCAS{}}
|
|
if _, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo}); err != nil {
|
|
t.Fatalf("Release must derive dirty hashes itself: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReleaseRequiresCAS(t *testing.T) {
|
|
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude"}
|
|
if _, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: t.TempDir()}); err == nil {
|
|
t.Fatal("expected error when CAS is nil")
|
|
}
|
|
}
|