Files
orchestra/internal/continuity/continuity.go
T
kami 65230020b9 Budget the handoff action for the two lines it is made of
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
2026-08-28 01:00:04 +04:00

378 lines
13 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.
var reasons = map[string]bool{"threshold": true, "milestone": true, "thrash": true, "manual": true, "reconcile_failure": 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`)
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
}