65230020b9
parseHandoffAnswer joins the agent's NEXT and WHY answers with " — ", and the prompt asks for a sentence each without naming any budget. Validate then held that join to one authored line's 200 characters. Two ordinary sentences do not fit, so every rotation failed. The failure was invisible twice over. The message said "prose smuggled into list", which named a branch the answer cannot reach: parseHandoffAnswer splits on newlines and trims, so no authored field ever contains "\n#". The only reachable cause was length, and the agent was never told what to shorten. Seen live on two tasks, and it left the release transaction stuck at "prepared" that pinned workpc-claude's only session slot (F30). Give Action the budget of both lines, name the length in the error, and put the limit in the prompt the agent actually reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011xsXyr5J1RACo71YeKG3Pu
208 lines
7.3 KiB
Go
208 lines
7.3 KiB
Go
package continuity
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"orchestra/internal/store"
|
|
)
|
|
|
|
func TestScratchCommitCapturesAllGitStatesExceptProtocolMarkers(t *testing.T) {
|
|
repo := t.TempDir()
|
|
run := func(args ...string) {
|
|
t.Helper()
|
|
if out, err := exec.Command("git", append([]string{"-C", repo}, args...)...).CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v: %v: %s", args, err, out)
|
|
}
|
|
}
|
|
run("init")
|
|
run("config", "user.email", "t@t")
|
|
run("config", "user.name", "t")
|
|
for _, name := range []string{"TASK.md", "deleted.txt", "renamed.txt", "staged.txt"} {
|
|
if err := os.WriteFile(filepath.Join(repo, name), []byte(name), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
run("add", "-A")
|
|
run("commit", "-m", "base")
|
|
if err := os.WriteFile(filepath.Join(repo, "staged.txt"), []byte("staged change"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
run("add", "staged.txt")
|
|
if err := os.Remove(filepath.Join(repo, "deleted.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
run("mv", "renamed.txt", "renamed-new.txt")
|
|
if err := os.WriteFile(filepath.Join(repo, "untracked.txt"), []byte("new"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(repo, ".orchestra-handoff-report.md"), []byte("protocol"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ScratchCommit(repo, "orchestra/scratch/test", "checkpoint"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"staged.txt", "renamed-new.txt", "untracked.txt"} {
|
|
if err := exec.Command("git", "-C", repo, "cat-file", "-e", "HEAD:"+want).Run(); err != nil {
|
|
t.Fatalf("checkpoint omitted %s: %v", want, err)
|
|
}
|
|
}
|
|
if err := exec.Command("git", "-C", repo, "cat-file", "-e", "HEAD:deleted.txt").Run(); err == nil {
|
|
t.Fatal("checkpoint retained deleted file")
|
|
}
|
|
if err := exec.Command("git", "-C", repo, "cat-file", "-e", "HEAD:.orchestra-handoff-report.md").Run(); err == nil {
|
|
t.Fatal("checkpoint committed protocol marker")
|
|
}
|
|
}
|
|
|
|
func TestHandoffCASAndPickup(t *testing.T) {
|
|
root := t.TempDir()
|
|
run := func(a ...string) {
|
|
c := exec.Command("git", append([]string{"-C", root}, a...)...)
|
|
c.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example")
|
|
if b, e := c.CombinedOutput(); e != nil {
|
|
t.Fatalf("git: %s %v", b, e)
|
|
}
|
|
}
|
|
os.WriteFile(filepath.Join(root, "TASK.md"), []byte("original"), 0644)
|
|
run("init")
|
|
run("add", "TASK.md")
|
|
run("commit", "-m", "init")
|
|
head := runOut(t, root, "rev-parse", "HEAD")
|
|
task := sha256.Sum256([]byte("original"))
|
|
s, _ := store.Open(t.TempDir())
|
|
h := Handoff{Meta: Meta{ID: "h1", Reason: "manual"}, Anchor: Anchor{GitSHA: head, Branch: "main"}, Action: "run the focused tests", Command: "go test ./..."}
|
|
ref, e := Save(h, s)
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
got, e := Load(ref, s)
|
|
if e != nil || got.Meta.ID != "h1" {
|
|
t.Fatalf("load: %v", e)
|
|
}
|
|
if e = ValidatePickup(root, got, hex.EncodeToString(task[:])); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
}
|
|
func runOut(t *testing.T, root string, a ...string) string {
|
|
b, e := exec.Command("git", append([]string{"-C", root}, a...)...).Output()
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
return string(b[:len(b)-1])
|
|
}
|
|
|
|
func TestDecodeRejectsUnknownKnowledgeFields(t *testing.T) {
|
|
_, e := Decode([]byte(`{"meta":{"id":"x","reason":"manual","rotation_index":0},"unknown":1}`))
|
|
if e == nil {
|
|
t.Fatal("expected strict schema error")
|
|
}
|
|
}
|
|
|
|
func TestHandoffRejectsFabricatedOrProseAuthoredFields(t *testing.T) {
|
|
base := Handoff{Meta: Meta{ID: "h", Reason: "manual"}, Anchor: Anchor{GitSHA: strings.Repeat("a", 40), Branch: "main"}, Action: "run the focused tests"}
|
|
if err := base.Validate(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
badAction := base
|
|
badAction.Action = "Continue the task from the handoff"
|
|
if err := badAction.Validate(); err == nil {
|
|
t.Fatal("expected circular action rejection")
|
|
}
|
|
badProse := base
|
|
badProse.Remaining = []string{"short\n# markdown heading"}
|
|
if err := badProse.Validate(); err == nil {
|
|
t.Fatal("expected prose-in-list rejection")
|
|
}
|
|
if _, err := Decode([]byte(`{"meta":{"id":"h","reason":"manual","rotation_index":0},"anchor":{"git_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","branch":"main"},"goal":"fabricated","action":"run tests"}`)); err == nil {
|
|
t.Fatal("expected goal rejection")
|
|
}
|
|
}
|
|
|
|
func TestScratchCommitProtectsTask(t *testing.T) {
|
|
root := t.TempDir()
|
|
run := func(a ...string) {
|
|
c := exec.Command("git", append([]string{"-C", root}, a...)...)
|
|
c.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example")
|
|
if b, e := c.CombinedOutput(); e != nil {
|
|
t.Fatalf("git: %s %v", b, e)
|
|
}
|
|
}
|
|
os.WriteFile(filepath.Join(root, "TASK.md"), []byte("fixed"), 0644)
|
|
run("init")
|
|
run("add", ".")
|
|
run("commit", "-m", "init")
|
|
os.WriteFile(filepath.Join(root, "wip.txt"), []byte("wip"), 0644)
|
|
if err := ScratchCommit(root, "scratch/task", "wip"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "TASK.md"), []byte("changed"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ScratchCommit(root, "scratch/other", "bad"); err == nil {
|
|
t.Fatal("expected immutable TASK.md rejection")
|
|
}
|
|
}
|
|
|
|
func TestVerifyTaskFileRejectsMutation(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "TASK.md"), []byte("task"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := sha256.Sum256([]byte("task"))
|
|
if err := VerifyTaskFile(root, hex.EncodeToString(sum[:])); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "TASK.md"), []byte("changed"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := VerifyTaskFile(root, hex.EncodeToString(sum[:])); err == nil {
|
|
t.Fatal("expected immutable task check to fail")
|
|
}
|
|
}
|
|
|
|
// TestActionCarriesTheBudgetOfBothLinesItIsMadeOf guards F31. Action is not one
|
|
// authored line: parseHandoffAnswer joins the agent's NEXT and WHY answers, and
|
|
// the prompt asks for a sentence each. Holding the join to a single line's
|
|
// budget rejected two ordinary sentences, which failed every rotation on
|
|
// workpc at "prose smuggled into list" and pinned the worker slot behind it.
|
|
func TestActionCarriesTheBudgetOfBothLinesItIsMadeOf(t *testing.T) {
|
|
next := strings.Repeat("a", maxAuthoredLine)
|
|
why := strings.Repeat("b", maxAuthoredLine)
|
|
h := Handoff{
|
|
Meta: Meta{ID: "h1", Reason: "threshold"},
|
|
Anchor: Anchor{GitSHA: strings.Repeat("0", 40), Branch: "main"},
|
|
Action: next + " — " + why,
|
|
}
|
|
if err := h.Validate(); err != nil {
|
|
t.Fatalf("two full-length answers rejected: %v", err)
|
|
}
|
|
h.Action = next + " — " + why + "c"
|
|
if err := h.Validate(); err == nil {
|
|
t.Fatal("an action past both budgets was accepted")
|
|
}
|
|
}
|
|
|
|
// An over-budget field must not be reported as smuggled prose. The agent
|
|
// cannot shorten what it is not told is too long, so it retried the same text.
|
|
func TestOverBudgetFieldNamesLengthNotProse(t *testing.T) {
|
|
h := Handoff{
|
|
Meta: Meta{ID: "h1", Reason: "threshold"},
|
|
Anchor: Anchor{GitSHA: strings.Repeat("0", 40), Branch: "main"},
|
|
Action: "do the next thing",
|
|
Remaining: []string{strings.Repeat("x", maxAuthoredLine+1)},
|
|
}
|
|
err := h.Validate()
|
|
if err == nil {
|
|
t.Fatal("over-budget item accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "limit") || strings.Contains(err.Error(), "smuggled") {
|
|
t.Fatalf("error names the wrong cause: %v", err)
|
|
}
|
|
}
|