Files
orchestra/internal/continuity/continuity.go
T
kami cb7782db01 Stop the handoff carrying the write Orchestra asked for
Handoff.Command is the last command observed in the pane. The handoff prompt
tells the agent to write .orchestra-handoff-report.md and stop, so that write
is almost always the last command there. Validate then rejects it as circular:
"must not point to a handoff or report".

Every phase rotation therefore failed on Orchestra's own instruction. Live on
run 5, one stage past F36.

lastObservedCommand now skips commands the validator would call circular, so
the successor gets the last command that was real work. The rule stays in
continuity and is asked, not restated, which is how F36 happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xsXyr5J1RACo71YeKG3Pu
2026-08-28 01:45:11 +04:00

389 lines
14 KiB
Go

// Package continuity implements the validated, artifact-backed rotation contract.
package continuity
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"orchestra/internal/domain"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// RenderTaskFile produces the immutable §6.2 TASK.md content for a task.
// Deterministic in every field that comes from the task itself, so the same
// task always hashes to the same content.
func RenderTaskFile(t domain.Task) []byte {
var b strings.Builder
fmt.Fprintf(&b, "# Task %s\n\n", t.ID)
if t.Title != "" {
fmt.Fprintf(&b, "%s\n\n", t.Title)
}
fmt.Fprintf(&b, "- Project: %s\n", t.Project)
fmt.Fprintf(&b, "- Source: %s/%s\n", t.Source, t.ExternalID)
fmt.Fprintf(&b, "- Priority: %d\n", t.InherentPriority)
if len(t.Capability) > 0 {
fmt.Fprintf(&b, "- Capability: %s\n", strings.Join(t.Capability, ", "))
}
if t.Due != nil {
fmt.Fprintf(&b, "- Due: %s\n", t.Due.UTC().Format(time.RFC3339))
}
if t.Parent != "" {
fmt.Fprintf(&b, "- Parent: %s\n", t.Parent)
}
if strings.TrimSpace(t.Description) != "" {
fmt.Fprintf(&b, "\n## Instructions\n\n%s\n", t.Description)
}
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())
}
// TaskFileHash returns the sha256 of the TASK.md at the root of a worktree.
func TaskFileHash(root string) (string, error) {
b, err := os.ReadFile(filepath.Join(root, "TASK.md"))
if err != nil {
return "", err
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:]), nil
}
// ConventionsFiles are the §6.3 shared docs ("Shared *.md ... all agents
// contribute") whose staleness the orchestra is responsible for tracking —
// never the agent, which only sees its own cached copy.
var ConventionsFiles = []string{"AGENTS.md", "CLAUDE.md", "VOCAB.md"}
// ConventionsHash hashes the concatenation of whichever ConventionsFiles
// exist at root, in that fixed order, so the result changes iff any of their
// contents change (a file appearing/disappearing also changes it, since a
// length-prefixed marker precedes each file's bytes). A repo with none of
// these files hashes to a stable, comparable empty-set value rather than
// erroring — §6.3 is optional infrastructure, not every project uses it.
func ConventionsHash(root string) (string, error) {
h := sha256.New()
for _, name := range ConventionsFiles {
b, err := os.ReadFile(filepath.Join(root, name))
if err != nil {
fmt.Fprintf(h, "%s:0\n", name)
continue
}
fmt.Fprintf(h, "%s:%d\n", name, len(b))
h.Write(b)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
type Dirty struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
Deleted bool `json:"deleted,omitempty"`
}
type Completed struct {
What string `json:"what"`
Evidence string `json:"evidence"`
}
type DeadEnd struct {
Tried string `json:"tried"`
WhyFailed string `json:"why_failed"`
}
type Anchor struct {
GitSHA string `json:"git_sha"`
Branch string `json:"branch"`
Dirty []Dirty `json:"dirty,omitempty"`
}
type Meta struct {
ID string `json:"id"`
Parent string `json:"parent,omitempty"`
Reason string `json:"reason"`
RotationIndex int `json:"rotation_index"`
}
type Handoff struct {
Meta Meta `json:"meta"`
Anchor Anchor `json:"anchor"`
Remaining []string `json:"remaining"`
Action string `json:"action"`
Command string `json:"command"`
DeadEnds []DeadEnd `json:"dead_ends"`
OpenQuestions []string `json:"open_questions"`
Learned []string `json:"learned"`
}
type Result struct {
Command string `json:"command"`
ExitCode int `json:"exit_code"`
AtSHA string `json:"at_sha"`
}
// reconcile_failure is Orchestra's own trigger: human input could not be
// reconciled at repeated verified turn boundaries, so the session is handed to
// a successor rather than left running on intent that cannot be refreshed.
// phase_changed is a rotation Orchestra itself triggers when a phase request
// is accepted. The adapter has produced it since phase rotations landed
// (handoffReason), but this list was never extended, so every phase rotation
// built a handoff the validator then refused as "invalid handoff meta" (F36).
// The two halves of one contract have to name the same vocabulary.
var reasons = map[string]bool{"threshold": true, "milestone": true, "thrash": true, "manual": true, "reconcile_failure": true, "phase_changed": true}
const maxAuthoredLine = 200
// Action is not one authored line. parseHandoffAnswer joins the agent's NEXT
// and WHY answers with " — ", and the prompt asks for a sentence each without
// naming any budget. Two ordinary sentences cleared 200 characters and every
// rotation on workpc failed at "prose smuggled into list" (F31). Budget the
// joined field for the two lines it is actually made of.
const maxAuthoredAction = 2*maxAuthoredLine + len(" — ")
var circularAction = regexp.MustCompile(`(?i)handoff|report\.md|^continue the task`)
var circularCommand = regexp.MustCompile(`(?i)\.orchestra-handoff|handoff-report|report\.md`)
// IsCircularCommand reports whether Validate would reject this as a command
// that points at a handoff. Producers need the same answer before they build a
// handoff, so the rule lives in one place rather than being restated as a
// second regexp that can drift.
func IsCircularCommand(s string) bool { return circularCommand.MatchString(s) }
func (h Handoff) Validate() error {
if strings.TrimSpace(h.Meta.ID) == "" || !reasons[h.Meta.Reason] || h.Meta.RotationIndex < 0 {
return errors.New("invalid handoff meta")
}
if len(h.Anchor.GitSHA) != 40 || h.Anchor.Branch == "" || strings.TrimSpace(h.Action) == "" {
return errors.New("invalid handoff required fields")
}
if circularAction.MatchString(h.Action) {
return errors.New("invalid handoff action: must name concrete next work, not a handoff")
}
if err := validateAuthored(h.Action, maxAuthoredAction); err != nil {
return err
}
if circularCommand.MatchString(h.Command) {
return errors.New("invalid handoff command: must not point to a handoff or report")
}
for _, item := range append(append([]string{}, h.Remaining...), append(h.OpenQuestions, h.Learned...)...) {
if err := validateAuthoredLine(item); err != nil {
return err
}
}
for _, d := range h.Anchor.Dirty {
if filepath.IsAbs(d.Path) || d.Path == "" || (!d.Deleted && len(d.SHA256) != 64) {
return errors.New("invalid dirty anchor")
}
}
for _, d := range h.DeadEnds {
if strings.TrimSpace(d.Tried) == "" || strings.TrimSpace(d.WhyFailed) == "" {
return errors.New("invalid dead end")
}
if err := validateAuthoredLine(d.Tried); err != nil {
return err
}
if err := validateAuthoredLine(d.WhyFailed); err != nil {
return err
}
}
return nil
}
func validateAuthoredLine(s string) error {
return validateAuthored(s, maxAuthoredLine)
}
// The two rejections used to share one message, which named the cause the
// agent had not hit. An answer over budget was reported as smuggled prose, so
// the agent could not tell what to shorten and retried the same text.
func validateAuthored(s string, limit int) error {
if strings.TrimSpace(s) == "" {
return errors.New("invalid handoff authored field: empty item")
}
if strings.Contains(s, "\n#") {
return errors.New("invalid handoff authored field: prose smuggled into list")
}
if len(s) > limit {
return fmt.Errorf("invalid handoff authored field: %d characters, limit %d", len(s), limit)
}
return nil
}
func Encode(h Handoff) ([]byte, error) {
if err := h.Validate(); err != nil {
return nil, err
}
return json.Marshal(h)
}
func Decode(b []byte) (Handoff, error) {
var h Handoff
d := json.NewDecoder(strings.NewReader(string(b)))
d.DisallowUnknownFields()
if err := d.Decode(&h); err != nil {
return h, err
}
return h, h.Validate()
}
func ValidatePickup(root string, h Handoff, taskFileSHA string) error {
if err := h.Validate(); err != nil {
return err
}
out, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
if err != nil || strings.TrimSpace(string(out)) != h.Anchor.GitSHA {
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
}
sum := sha256.Sum256(b)
if hex.EncodeToString(sum[:]) != d.SHA256 {
return fmt.Errorf("handoff dirty file changed: %s", d.Path)
}
}
if taskFileSHA != "" {
b, e := os.ReadFile(filepath.Join(root, "TASK.md"))
if e != nil {
return e
}
sum := sha256.Sum256(b)
if hex.EncodeToString(sum[:]) != taskFileSHA {
return errors.New("TASK.md changed")
}
}
return nil
}
// VerifyTaskFile ensures the worktree contains the original, immutable task.
func VerifyTaskFile(root, taskFileSHA string) error {
if taskFileSHA == "" {
return errors.New("TASK.md hash required")
}
b, err := os.ReadFile(filepath.Join(root, "TASK.md"))
if err != nil {
return err
}
sum := sha256.Sum256(b)
if hex.EncodeToString(sum[:]) != taskFileSHA {
return errors.New("TASK.md changed")
}
return nil
}
type CAS interface {
PutArtifact([]byte) (string, error)
Artifact(string) ([]byte, error)
}
func Save(h Handoff, cas CAS) (string, error) {
b, e := Encode(h)
if e != nil {
return "", e
}
return cas.PutArtifact(b)
}
func Load(ref string, cas CAS) (Handoff, error) {
b, e := cas.Artifact(ref)
if e != nil {
return Handoff{}, e
}
return Decode(b)
}
// 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")
}
if strings.TrimSpace(message) == "" {
return errors.New("scratch commit message required")
}
status, err := exec.Command("git", "-C", root, "status", "--porcelain", "--", "TASK.md").Output()
if err != nil {
return err
}
if len(status) != 0 {
return errors.New("TASK.md is immutable")
}
// Reuse the branch across rotations of the same task rather than failing
// on "branch already exists" — a task can rotate more than once.
if err := exec.Command("git", "-C", root, "switch", branch).Run(); err != nil {
if err := exec.Command("git", "-C", root, "switch", "-c", branch).Run(); err != nil {
return err
}
}
// 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
}
// 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()
}
func ScratchPush(root, branch, remote string) error {
if branch == "" || remote == "" {
return errors.New("scratch branch and remote required")
}
return exec.Command("git", "-C", root, "push", remote, branch).Run()
}
func ScratchPull(root, branch, remote string) error {
if branch == "" || remote == "" {
return errors.New("scratch branch and remote required")
}
if err := exec.Command("git", "-C", root, "fetch", remote, branch).Run(); err != nil {
return err
}
return exec.Command("git", "-C", root, "merge", "--ff-only", "FETCH_HEAD").Run()
}
// ScratchSync pushes/pulls a scratch branch. Pull uses fast-forward-only to
// avoid silently merging independent WIP histories.
func ScratchSync(root, branch, remote string, push bool) error {
if branch == "" || strings.ContainsAny(branch, " \t\n") || remote == "" {
return errors.New("invalid scratch sync")
}
args := []string{"-C", root, "push", remote, branch}
if !push {
args = []string{"-C", root, "fetch", remote, branch}
}
if err := exec.Command("git", args...).Run(); err != nil {
return err
}
if !push {
return exec.Command("git", "-C", root, "merge", "--ff-only", "FETCH_HEAD").Run()
}
return nil
}