fix: make worker handoff rotation durable
This commit is contained in:
@@ -40,7 +40,17 @@ func RenderTaskFile(t domain.Task) []byte {
|
||||
if strings.TrimSpace(t.Description) != "" {
|
||||
fmt.Fprintf(&b, "\n## Instructions\n\n%s\n", t.Description)
|
||||
}
|
||||
b.WriteString("\nThis file is immutable for the lifetime of the task (§6.2) — its hash is\ncarried in every handoff and re-verified on every pickup. Do not edit it.\n")
|
||||
if len(t.Acceptance) > 0 {
|
||||
b.WriteString("\n## Acceptance criteria\n")
|
||||
for _, criterion := range t.Acceptance {
|
||||
fmt.Fprintf(&b, "\n- %s", criterion)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if t.QualityGate != "" {
|
||||
fmt.Fprintf(&b, "\n## Quality gate\n\n%s\n", t.QualityGate)
|
||||
}
|
||||
b.WriteString("\n## Completion\n\nRun the configured quality gate. When the task is ready for the worker to verify and deliver, create `.orchestra/done`. Do not write a prose completion report.\n\nThis file is immutable for the lifetime of the task (§6.2) — its hash is\ncarried in every handoff and re-verified on every pickup. Do not edit it.\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
@@ -80,8 +90,9 @@ func ConventionsHash(root string) (string, error) {
|
||||
}
|
||||
|
||||
type Dirty struct {
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
}
|
||||
type Completed struct {
|
||||
What string `json:"what"`
|
||||
@@ -147,7 +158,7 @@ func (h Handoff) Validate() error {
|
||||
}
|
||||
}
|
||||
for _, d := range h.Anchor.Dirty {
|
||||
if filepath.IsAbs(d.Path) || d.Path == "" || len(d.SHA256) != 64 {
|
||||
if filepath.IsAbs(d.Path) || d.Path == "" || (!d.Deleted && len(d.SHA256) != 64) {
|
||||
return errors.New("invalid dirty anchor")
|
||||
}
|
||||
}
|
||||
@@ -199,6 +210,12 @@ func ValidatePickup(root string, h Handoff, taskFileSHA string) error {
|
||||
return errors.New("handoff anchor HEAD mismatch")
|
||||
}
|
||||
for _, d := range h.Anchor.Dirty {
|
||||
if d.Deleted {
|
||||
if _, e := os.Stat(filepath.Join(root, d.Path)); !errors.Is(e, os.ErrNotExist) {
|
||||
return fmt.Errorf("handoff deleted file restored: %s", d.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
b, e := os.ReadFile(filepath.Join(root, d.Path))
|
||||
if e != nil {
|
||||
return e
|
||||
@@ -257,7 +274,12 @@ func Load(ref string, cas CAS) (Handoff, error) {
|
||||
return Decode(b)
|
||||
}
|
||||
|
||||
// ScratchCommit records WIP atomically on a dedicated branch before rotation.
|
||||
// ScratchCommit records every piece of repository work except Orchestra's
|
||||
// ephemeral protocol markers. In particular, git add -A is intentional: it
|
||||
// includes already-staged changes, deletions, renames, and untracked files.
|
||||
// TASK.md is checked before touching the index; it is an immutable input, not
|
||||
// deliverable work. The report/done markers remain local so a successor never
|
||||
// mistakes an old protocol signal for a new one.
|
||||
func ScratchCommit(root, branch, message string) error {
|
||||
if branch == "" || strings.ContainsAny(branch, " \t\n") {
|
||||
return errors.New("invalid scratch branch")
|
||||
@@ -279,14 +301,20 @@ func ScratchCommit(root, branch, message string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := exec.Command("git", "-C", root, "add", "-A").Run(); err != nil {
|
||||
// A harness may have staged a protocol marker itself. Remove it from the
|
||||
// index before staging the real checkpoint; this does not alter its working
|
||||
// tree contents and makes the exclusion apply to staged state too.
|
||||
for _, marker := range []string{".orchestra", ".orchestra-handoff.json", ".orchestra-handoff-report.md"} {
|
||||
if err := exec.Command("git", "-C", root, "reset", "-q", "HEAD", "--", marker).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := exec.Command("git", "-C", root, "add", "-A", "--", ".", ":(exclude)TASK.md", ":(exclude).orchestra", ":(exclude).orchestra-handoff.json", ":(exclude).orchestra-handoff-report.md").Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
full, err := exec.Command("git", "-C", root, "status", "--porcelain").Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(full) == 0 {
|
||||
// Only staged non-protocol work is committed. Remaining marker files are
|
||||
// expected and must not suppress a clean committed-anchor checkpoint.
|
||||
if exec.Command("git", "-C", root, "diff", "--cached", "--quiet").Run() == nil {
|
||||
return nil // nothing to snapshot; branch already reflects the worktree
|
||||
}
|
||||
return exec.Command("git", "-C", root, "commit", "-m", message).Run()
|
||||
|
||||
@@ -12,6 +12,54 @@ import (
|
||||
"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) {
|
||||
|
||||
Reference in New Issue
Block a user