fix(continuity): wire scratch-commit-before-release and rewrite bootstrap prompt (Phase 4 items 3, 5, 6)
Release now re-verifies every handoff Anchor.Dirty file hash (previously unchecked after the top-level anchor SHA compare), snapshots dirty state onto a per-task scratch branch before uploading, and rewrites the anchor to the new commit so successor pickup collapses to a single HEAD compare. ScratchCommit made idempotent for repeated rotations of the same task. Bootstrap's prompt now points the agent at the scratch-branch commit history instead of vague "read the handoff" prose, and does not claim a GET /v1/artifacts/<ref> endpoint that doesn't exist. MarkdownChanges had zero callers and zero tests; deleted per AUDIT.md's explicit deletion option rather than half-wiring an undesigned feature. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
package herdr
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"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"},
|
||||
Goal: "finish the thing",
|
||||
DoneWhen: []string{"tests pass"},
|
||||
Action: "continue",
|
||||
Command: "go test ./...",
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
b, err := continuity.Encode(validHandoff(head))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffFile), b, 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.Fatalf("stored handoff anchor=%s want=%s", got.Anchor.GitSHA, head)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestReleaseRefusesOnAnchorMismatch(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")
|
||||
|
||||
b, err := continuity.Encode(validHandoff("deaddeaddeaddeaddeaddeaddeaddeaddeaddead"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffFile), b, 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.Fatal("expected error on anchor mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
sum := sha256.Sum256([]byte("in progress"))
|
||||
h := validHandoff(head)
|
||||
h.Anchor.Dirty = []continuity.Dirty{{Path: "wip.txt", SHA256: hex.EncodeToString(sum[:])}}
|
||||
b, err := continuity.Encode(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffFile), b, 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/t1" || string(branch) != got.Anchor.Branch+"\n" {
|
||||
t.Fatalf("expected worktree on scratch branch, got %q (handoff says %q)", branch, got.Anchor.Branch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseRefusesOnStaleDirtyFile(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, "wip.txt"), []byte("changed after handoff written"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := sha256.Sum256([]byte("original content"))
|
||||
h := validHandoff(head)
|
||||
h.Anchor.Dirty = []continuity.Dirty{{Path: "wip.txt", SHA256: hex.EncodeToString(stale[:])}}
|
||||
b, err := continuity.Encode(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffFile), b, 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.Fatal("expected refusal when a dirty file no longer matches the handoff's recorded hash")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user