Promote root output.md only when the runner succeeded, protected parents were untouched, and the local target is trusted. Restore errors abort instead of being ignored. Co-authored-by: Cursor <cursoragent@cursor.com>
195 lines
6.7 KiB
Go
195 lines
6.7 KiB
Go
package squad_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"chat-agent-system/internal/squad"
|
|
)
|
|
|
|
func TestLocalNeedSnapshotName_DoesNotOccupyTarget(t *testing.T) {
|
|
if got := squad.LocalNeedSnapshotName("output.md", "output.md"); got != squad.RepairSourceOutputName {
|
|
t.Fatalf("root output.md must snapshot as source_output.md, got %q", got)
|
|
}
|
|
if got := squad.LocalNeedSnapshotName("tasks/03_review/output.md", "output.md"); got != squad.RepairSourceOutputName {
|
|
t.Fatalf("nested output.md must not occupy target, got %q", got)
|
|
}
|
|
if got := squad.LocalNeedSnapshotName("blackboard.json", "output.md"); got != "blackboard.json" {
|
|
t.Fatalf("other needs keep basename, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSnapshotInputsAndContainedTaskHaveNoParentRefs(t *testing.T) {
|
|
ws := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(ws, "output.md"), []byte("BAD DRAFT"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(ws, "template.md"), []byte("# tmpl\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(ws, "blackboard.json"), []byte(`{"k":1}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sub := filepath.Join(ws, "tasks", "repair_1")
|
|
if err := squad.SnapshotInputsIntoSubdir(ws, sub, []string{"output.md"}, "output.md"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src, err := os.ReadFile(filepath.Join(sub, squad.RepairSourceOutputName))
|
|
if err != nil || string(src) != "BAD DRAFT" {
|
|
t.Fatalf("source snapshot: %s %v", src, err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(sub, "output.md")); err == nil {
|
|
t.Fatal("must not copy draft onto the target slot")
|
|
}
|
|
info, err := os.Lstat(filepath.Join(sub, "template.md"))
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
t.Fatalf("template.md must be a regular snapshot, not a symlink: %v %v", info, err)
|
|
}
|
|
|
|
step := squad.StepSpec{
|
|
ID: "step_repair_1",
|
|
Role: "writer",
|
|
Title: "fix",
|
|
Mode: "execute",
|
|
Needs: []string{"output.md"},
|
|
}
|
|
task := squad.BuildContainedTaskMarkdown(step, "output.md")
|
|
if squad.TaskMarkdownEscapesParent(task) {
|
|
t.Fatalf("contained TASK.md still has parent refs:\n%s", task)
|
|
}
|
|
if !strings.Contains(task, squad.RepairSourceOutputName) {
|
|
t.Fatalf("TASK.md must point at local source snapshot:\n%s", task)
|
|
}
|
|
}
|
|
|
|
func TestTrustedLocalDeliverableRejectsSymlinkAndEmpty(t *testing.T) {
|
|
ws := t.TempDir()
|
|
sub := filepath.Join(ws, "tasks", "repair_1")
|
|
if err := os.MkdirAll(sub, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(ws, "output.md"), []byte("PARENT"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(filepath.Join("..", "..", "output.md"), filepath.Join(sub, "output.md")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := squad.TrustedLocalDeliverable(sub, "output.md"); ok {
|
|
t.Fatal("symlink to parent must not count as a trusted local target")
|
|
}
|
|
_ = os.Remove(filepath.Join(sub, "output.md"))
|
|
if err := os.WriteFile(filepath.Join(sub, "output.md"), []byte(" \n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := squad.TrustedLocalDeliverable(sub, "output.md"); ok {
|
|
t.Fatal("whitespace-only local target must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestDecideRepairPromote(t *testing.T) {
|
|
if ok, reason := squad.DecideRepairPromote(nil, nil, true); !ok || reason != squad.RepairPromoteOK {
|
|
t.Fatalf("clean success must promote, got %v %s", ok, reason)
|
|
}
|
|
if ok, reason := squad.DecideRepairPromote(assertErr{}, nil, true); ok || reason != squad.RepairDiscardRunError {
|
|
t.Fatalf("runErr must void promote, got %v %s", ok, reason)
|
|
}
|
|
if ok, reason := squad.DecideRepairPromote(nil, []string{"output.md"}, true); ok || reason != squad.RepairDiscardContainment {
|
|
t.Fatalf("mutation must void promote, got %v %s", ok, reason)
|
|
}
|
|
if ok, reason := squad.DecideRepairPromote(nil, nil, false); ok || reason != squad.RepairDiscardNoLocal {
|
|
t.Fatalf("missing local must void promote, got %v %s", ok, reason)
|
|
}
|
|
}
|
|
|
|
type assertErr struct{}
|
|
|
|
func (assertErr) Error() string { return "backend timeout" }
|
|
|
|
func TestParentInputsMutated_CreateAndDelete(t *testing.T) {
|
|
ws := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(ws, "output.md"), []byte("OUT"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snap := squad.SnapshotWorkspaceInputs(ws)
|
|
if snap.BlackboardOK {
|
|
t.Fatal("blackboard must start missing")
|
|
}
|
|
if err := os.WriteFile(filepath.Join(ws, "blackboard.json"), []byte(`{"created":true}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Remove(filepath.Join(ws, "output.md")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
changed := squad.ParentInputsMutated(ws, snap)
|
|
want := map[string]bool{"output.md": true, "blackboard.json": true}
|
|
if len(changed) != 2 {
|
|
t.Fatalf("create+delete must both count, got %v", changed)
|
|
}
|
|
for _, name := range changed {
|
|
if !want[name] {
|
|
t.Fatalf("unexpected mutation %s in %v", name, changed)
|
|
}
|
|
}
|
|
if err := squad.RestoreWorkspaceInputs(ws, snap); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(ws, "output.md"))
|
|
if err != nil || string(got) != "OUT" {
|
|
t.Fatalf("deleted output.md must be restored, got %q %v", got, err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(ws, "blackboard.json")); err == nil {
|
|
t.Fatal("created blackboard.json must be removed")
|
|
}
|
|
}
|
|
|
|
func TestRestoreWorkspaceInputs_PropagatesWriteError(t *testing.T) {
|
|
ws := t.TempDir()
|
|
snap := squad.WorkspaceInputSnapshot{OutputOK: true, Output: []byte("x")}
|
|
blocked := filepath.Join(ws, "output.md")
|
|
if err := os.MkdirAll(filepath.Join(blocked, "nested"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := squad.RestoreWorkspaceInputs(ws, snap)
|
|
if err == nil {
|
|
t.Fatal("restore must fail when output.md is a non-empty directory")
|
|
}
|
|
if !strings.Contains(err.Error(), "restore") {
|
|
t.Fatalf("error must name restore, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParentSnapshotRestore(t *testing.T) {
|
|
ws := t.TempDir()
|
|
orig := []byte("ORIGINAL ROOT")
|
|
if err := os.WriteFile(filepath.Join(ws, "output.md"), orig, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(ws, "template.md"), []byte("T0"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snap := squad.SnapshotWorkspaceInputs(ws)
|
|
if err := os.WriteFile(filepath.Join(ws, "output.md"), []byte("MUTATED BETTER"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(ws, "template.md"), []byte("T1"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
changed := squad.ParentInputsMutated(ws, snap)
|
|
if len(changed) != 2 {
|
|
t.Fatalf("expected output+template mutated, got %v", changed)
|
|
}
|
|
if err := squad.RestoreWorkspaceInputs(ws, snap); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := os.ReadFile(filepath.Join(ws, "output.md"))
|
|
if string(got) != string(orig) {
|
|
t.Fatalf("root output.md not restored, got %q", got)
|
|
}
|
|
gotT, _ := os.ReadFile(filepath.Join(ws, "template.md"))
|
|
if string(gotT) != "T0" {
|
|
t.Fatalf("template.md not restored, got %q", gotT)
|
|
}
|
|
}
|