Add federation worker and canonical handoffs

This commit is contained in:
kami
2026-07-28 16:17:18 +04:00
parent 58793a5aa3
commit 2cecbc4015
22 changed files with 1429 additions and 108 deletions
+48 -16
View File
@@ -11,6 +11,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
@@ -36,6 +37,9 @@ func RenderTaskFile(t domain.Task) []byte {
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)
}
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")
return []byte(b.String())
}
@@ -99,21 +103,14 @@ type Meta struct {
RotationIndex int `json:"rotation_index"`
}
type Handoff struct {
Meta Meta `json:"meta"`
Anchor Anchor `json:"anchor"`
Goal string `json:"goal"`
DoneWhen []string `json:"done_when"`
Completed []Completed `json:"completed"`
Remaining []string `json:"remaining"`
Action string `json:"action"`
Command string `json:"command"`
Files []string `json:"files"`
Invariants []string `json:"invariants"`
DeadEnds []DeadEnd `json:"dead_ends"`
OpenQuestions []string `json:"open_questions"`
Build string `json:"build"`
Test string `json:"test"`
LastResult Result `json:"last_result"`
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"`
@@ -123,13 +120,32 @@ type Result struct {
var reasons = map[string]bool{"threshold": true, "milestone": true, "thrash": true, "manual": true}
const maxAuthoredLine = 200
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.Goal) == "" || len(h.DoneWhen) == 0 || strings.TrimSpace(h.Action) == "" || strings.TrimSpace(h.Command) == "" {
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 := validateAuthoredLine(h.Action); 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 == "" || len(d.SHA256) != 64 {
return errors.New("invalid dirty anchor")
@@ -139,6 +155,22 @@ func (h Handoff) Validate() error {
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 {
if strings.TrimSpace(s) == "" {
return errors.New("invalid handoff authored field: empty item")
}
if strings.Contains(s, "\n#") || len(s) > maxAuthoredLine {
return errors.New("invalid handoff authored field: prose smuggled into list")
}
return nil
}
+22 -1
View File
@@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"orchestra/internal/store"
@@ -27,7 +28,7 @@ func TestHandoffCASAndPickup(t *testing.T) {
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"}, Goal: "ship", DoneWhen: []string{"tests pass"}, Action: "test", Command: "go test ./...", LastResult: Result{AtSHA: head}}
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)
@@ -55,6 +56,26 @@ func TestDecodeRejectsUnknownKnowledgeFields(t *testing.T) {
}
}
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) {
+7 -3
View File
@@ -59,9 +59,13 @@ type Task struct {
Estimate *Estimate `json:"estimate,omitempty"`
State TaskState `json:"state"`
Lease *Lease `json:"lease,omitempty"`
Version int `json:"version"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
// HandoffRef survives the queued interval between TaskReleased and the
// next router-owned TaskLeased event; it is the only artifact the worker
// may use for local pickup validation.
HandoffRef string `json:"handoff_ref,omitempty"`
Version int `json:"version"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
}
type Event struct {
+183
View File
@@ -0,0 +1,183 @@
package federation
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"orchestra/internal/domain"
"strings"
)
// Client is the worker-side protocol client. It carries no task state: the
// homesrv event log remains authoritative and workers only persist their
// local execution session/checkouts.
type Client struct {
BaseURL string
WorkerID string
Token string
AdmitToken string
HTTP *http.Client
}
func (c Client) Register(ctx context.Context, w Worker) error {
b, err := json.Marshal(map[string]any{"id": w.ID, "address": w.Address, "capacity": w.Capacity, "token": c.Token})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/v1/federation/workers", bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.AdmitToken != "" {
req.Header.Set("Authorization", "Bearer "+c.AdmitToken)
}
h := c.HTTP
if h == nil {
h = http.DefaultClient
}
resp, err := h.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("federation register: %s: %s", resp.Status, strings.TrimSpace(string(msg)))
}
return nil
}
func (c Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
var r io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
r = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.BaseURL, "/")+path, r)
if err != nil {
return nil, err
}
req.Header.Set("X-Orchestra-Worker", c.WorkerID)
req.Header.Set("Authorization", "Bearer "+c.Token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
h := c.HTTP
if h == nil {
h = http.DefaultClient
}
resp, err := h.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode/100 != 2 {
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("federation: %s: %s", resp.Status, strings.TrimSpace(string(b)))
}
return resp, nil
}
func (c Client) Events(ctx context.Context, since uint64) ([]domain.Event, uint64, error) {
resp, err := c.request(ctx, http.MethodGet, "/v1/federation/events?since="+fmt.Sprint(since), nil)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
var out struct {
Cursor uint64 `json:"cursor"`
Events []domain.Event `json:"events"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, 0, err
}
return out.Events, out.Cursor, nil
}
// Tasks hydrates the worker's cache when its local state predates the
// coordinator's event-retention window. The coordinator remains authoritative
// for the task projection.
func (c Client) Tasks(ctx context.Context) ([]domain.Task, error) {
resp, err := c.request(ctx, http.MethodGet, "/v1/tasks", nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var tasks []domain.Task
if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil {
return nil, err
}
return tasks, nil
}
func (c Client) Ack(ctx context.Context, cursor uint64) error {
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/events/ack", map[string]uint64{"cursor": cursor})
if resp != nil {
resp.Body.Close()
}
return err
}
func (c Client) Heartbeat(ctx context.Context) error {
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/heartbeat", nil)
if resp != nil {
resp.Body.Close()
}
return err
}
func (c Client) Artifact(ctx context.Context, ref string) ([]byte, error) {
resp, err := c.request(ctx, http.MethodGet, "/v1/artifacts/"+url.PathEscape(ref), nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func (c Client) PutArtifact(ctx context.Context, b []byte) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/v1/artifacts", bytes.NewReader(b))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/octet-stream")
h := c.HTTP
if h == nil {
h = http.DefaultClient
}
resp, err := h.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
msg, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("artifact upload: %s: %s", resp.Status, strings.TrimSpace(string(msg)))
}
var out struct {
Ref string `json:"ref"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
return out.Ref, nil
}
func (c Client) Release(ctx context.Context, taskID, ref, anchor string) error {
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/handoff", map[string]string{"task_id": taskID, "handoff_ref": ref, "anchor_sha": anchor})
if resp != nil {
resp.Body.Close()
}
return err
}
func (c Client) Complete(ctx context.Context, taskID, reportRef string) error {
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/complete", map[string]string{"task_id": taskID, "handoff_ref": reportRef})
if resp != nil {
resp.Body.Close()
}
return err
}
+51
View File
@@ -0,0 +1,51 @@
package federation
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestClientRegistersPollsAndReadsArtifactAsWorker(t *testing.T) {
seen := map[string]bool{}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/federation/workers" {
seen["register"] = r.Header.Get("Authorization") == "Bearer admit"
w.WriteHeader(http.StatusCreated)
return
}
if r.Header.Get("X-Orchestra-Worker") != "h1" || r.Header.Get("Authorization") != "Bearer worker" {
t.Errorf("worker auth missing")
}
switch r.URL.Path {
case "/v1/federation/events":
seen["events"] = true
_, _ = w.Write([]byte(`{"cursor":3,"events":[{"seq":3,"id":"e","type":"TaskCreated","task_id":"t","version":1,"payload":{"source":"s","external_id":"x","project":"p"},"surface":"system"}]}`))
case "/v1/artifacts/abc":
seen["artifact"] = true
_, _ = w.Write([]byte(`{"meta":{"id":"x"}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
w.WriteHeader(404)
}
}))
defer s.Close()
c := Client{BaseURL: s.URL, WorkerID: "h1", Token: "worker", AdmitToken: "admit"}
if err := c.Register(context.Background(), Worker{ID: "h1"}); err != nil {
t.Fatal(err)
}
es, cur, err := c.Events(context.Background(), 0)
if err != nil || cur != 3 || len(es) != 1 || es[0].Type != "TaskCreated" {
t.Fatalf("events=%v cursor=%d err=%v", es, cur, err)
}
b, err := c.Artifact(context.Background(), "abc")
if err != nil || string(b) != "{\"meta\":{\"id\":\"x\"}}" {
t.Fatalf("artifact=%s err=%v", b, err)
}
for _, k := range []string{"register", "events", "artifact"} {
if !seen[k] {
t.Errorf("%s not seen", k)
}
}
}
+17
View File
@@ -42,6 +42,7 @@ func (r *Registry) init() {
r.cursors = map[string]uint64{}
}
}
// Register admits a worker. admitToken must match r.AdmitToken whenever one
// is configured. Re-registering an ID that's already claimed requires that
// worker's own current token, so a caller can't self-declare someone else's
@@ -118,6 +119,22 @@ func (r *Registry) Heartbeat(id string) error {
r.workers[id] = w
return nil
}
// Available refreshes TTL state and reports whether a registered worker owns
// this harness id. Router admission uses it so a reachable TCP bridge alone
// can never make an offline worker eligible for a lease.
func (r *Registry) Available(id string) bool {
r.mu.Lock()
defer r.mu.Unlock()
r.init()
w, ok := r.workers[id]
if !ok {
return false
}
w.Online = time.Since(w.LastSeen) <= r.TTL
r.workers[id] = w
return w.Online
}
func (r *Registry) Snapshot() []Worker {
r.mu.Lock()
defer r.mu.Unlock()
+177 -25
View File
@@ -75,6 +75,10 @@ type CLIAdapter struct {
// release. Nil disables Release (adapters built without one refuse
// loudly rather than skip validation).
CAS continuity.CAS
// Remote, when set by a federation worker, is pushed after the scratch
// commit and before the pane claim is released. Git is the cross-machine
// transport; a CAS handoff must never point at an unpushed anchor.
Remote string
}
// HandoffFile is the convention the agent writes its §6.1 handoff to before
@@ -83,8 +87,9 @@ type CLIAdapter struct {
// uploads the one the agent wrote (herdr does not write handoffs, §6.1).
const HandoffFile = ".orchestra-handoff.json"
// HandoffReportFile is the only handoff artifact an opaque harness authors.
// The worker which owns the checkout derives and seals the canonical JSON.
// HandoffReportFile holds the agent's small, labelled answer during release.
// It is not a report: the worker parses it, derives the protocol facts, and
// seals the resulting canonical JSON.
const HandoffReportFile = ".orchestra-handoff-report.md"
func (a CLIAdapter) CreateWorktree(ctx context.Context, repo, root, taskID string) (string, error) {
@@ -149,9 +154,16 @@ func (a CLIAdapter) Bootstrap(ctx context.Context, s Session, ref string) error
return a.prompt(ctx, s, fmt.Sprintf(bootstrapPrompt, ref), time.Minute)
}
const handoffPrompt = `Orchestra is about to rotate this task to a fresh session (context budget reached).
Before you stop, write a concise semantic handoff report to ` + HandoffReportFile + ` at the worktree root: what changed, validation evidence, remaining work, review findings, and dead ends/open questions.
Do not write protocol JSON, git anchors, or file hashes; Orchestra's checkout worker collects and validates those facts. Do not edit TASK.md. Once written, stop normally.`
const handoffPrompt = `Orchestra is about to rotate this task. Write ONLY the following labelled answers to ` + HandoffReportFile + `, then stop. Output nothing else.
NEXT: the single next action (one line).
WHY: why that is next (one line).
REMAINING: outstanding items, one line each. If none: NONE.
DEAD ENDS: approaches tried that failed — "tried X → failed because Y", one per line. If none: NONE.
OPEN Q: unresolved decisions, one line each. If none: NONE.
LEARNED: constraints discovered that are NOT in TASK.md, one line each. If none: NONE.
Do NOT include: what you completed (the diff shows it), the goal or done-criteria (TASK.md holds them), git SHAs/branches/paths, or a prose summary. No headings and no report. Do not edit TASK.md.`
// RequestHandoff prompts the agent to write HandoffFile before Release reads
// it. Optional capability: adapters without a live pane (tests, etc.) can
@@ -183,7 +195,7 @@ func (a CLIAdapter) RequestHandoffReason(ctx context.Context, s Session, reason
case "milestone":
sb.WriteString("A coherent unit of work looks complete (a successful commit). If the next step is independent of what you just did, this is a good point to hand off.\n")
}
fmt.Fprintf(&sb, "Before you stop, write a concise semantic handoff report to %s at the worktree root (reason: %q)", HandoffReportFile, reason)
fmt.Fprintf(&sb, "Before you stop, write the labelled handoff answers requested below to %s at the worktree root (reason: %q).\n\n%s", HandoffReportFile, reason, handoffPrompt[strings.Index(handoffPrompt, "NEXT:"):])
if len(deadEnds) > 0 {
sb.WriteString(" and a dead_ends entry for each of the following:\n")
for _, d := range deadEnds {
@@ -192,7 +204,7 @@ func (a CLIAdapter) RequestHandoffReason(ctx context.Context, s Session, reason
} else {
sb.WriteString(".\n")
}
sb.WriteString("Include what changed, validation evidence, remaining work, review findings, and any dead ends. Do not write protocol JSON, git anchors, or file hashes; Orchestra collects those. Do not edit TASK.md. Once written, stop normally.")
sb.WriteString("Do not add a prose summary, completed-work narration, or protocol JSON. Once written, stop normally.")
return a.prompt(ctx, s, sb.String(), time.Minute)
}
@@ -234,9 +246,9 @@ func (a CLIAdapter) NotifyConventionsChanged(ctx context.Context, s Session) err
return a.prompt(ctx, s, conventionsPrompt, time.Minute)
}
// Release reads the §6.1 handoff the agent wrote to HandoffFile at the
// worktree root, validates its schema and anchor against the worktree's real
// HEAD, uploads it to CAS, and only then releases herdr's claim on the pane
// Release reads the semantic report the agent wrote at the worktree root,
// derives and validates the canonical handoff from the worktree's real Git
// state, uploads it to CAS, and only then releases herdr's claim on the pane
// via the real pane.release_agent(pane_id, source, agent) method (confirmed
// live against herdr, AUDIT.md Phase 0 — the invented "pane.release" never
// existed and could never have returned a handoff_ref regardless, since
@@ -256,7 +268,7 @@ func (a CLIAdapter) Release(ctx context.Context, s Session) (string, error) {
if strings.TrimSpace(string(b)) == "" {
return "", fmt.Errorf("adapter: semantic handoff report is empty")
}
h, err := canonicalHandoff(s, string(b))
h, err := canonicalHandoff(s, string(b), a.lastObservedCommand(s))
if err != nil {
return "", err
}
@@ -270,6 +282,20 @@ func (a CLIAdapter) Release(ctx context.Context, s Session) (string, error) {
return "", fmt.Errorf("adapter: handoff dirty file changed since it was written: %s", d.Path)
}
}
// The semantic report is transferred in the CAS handoff, not in the
// scratch checkout. Keeping it in the scratch commit makes a successor
// mistake the predecessor's report for a newly requested handoff and can
// cause an immediate release/pickup loop.
dirty := h.Anchor.Dirty[:0]
for _, d := range h.Anchor.Dirty {
if filepath.Clean(d.Path) != HandoffReportFile {
dirty = append(dirty, d)
}
}
h.Anchor.Dirty = dirty
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("adapter: remove transferred semantic report: %w", err)
}
// Atomically commit whatever the handoff described as dirty onto a
// per-task scratch branch (§6.2 step 3) *before* uploading, so the
// successor's pickup validation collapses to a single HEAD compare
@@ -279,6 +305,11 @@ func (a CLIAdapter) Release(ctx context.Context, s Session) (string, error) {
if err := continuity.ScratchCommit(s.Worktree, branch, "orchestra: pre-release WIP snapshot ("+h.Meta.ID+")"); err != nil {
return "", fmt.Errorf("adapter: scratch commit: %w", err)
}
if a.Remote != "" {
if err := continuity.ScratchPush(s.Worktree, branch, a.Remote); err != nil {
return "", fmt.Errorf("adapter: push scratch branch: %w", err)
}
}
newSHA, err := HeadSHA(s.Worktree)
if err != nil {
return "", fmt.Errorf("adapter: read scratch HEAD: %w", err)
@@ -302,8 +333,13 @@ func (a CLIAdapter) Release(ctx context.Context, s Session) (string, error) {
}
// canonicalHandoff keeps Git-derived protocol facts on the worker that owns
// the checkout. The harness contributes only the semantic report (B17).
func canonicalHandoff(s Session, report string) (continuity.Handoff, error) {
// the checkout. Every authored field comes from the validated agent answer;
// it never fabricates task intent or a circular next action.
func canonicalHandoff(s Session, answer, command string) (continuity.Handoff, error) {
authored, err := parseHandoffAnswer(answer)
if err != nil {
return continuity.Handoff{}, fmt.Errorf("adapter: invalid handoff answer: %w", err)
}
sha, err := HeadSHA(s.Worktree)
if err != nil {
return continuity.Handoff{}, fmt.Errorf("adapter: read worktree HEAD: %w", err)
@@ -317,16 +353,131 @@ func canonicalHandoff(s Session, report string) (continuity.Handoff, error) {
return continuity.Handoff{}, err
}
return continuity.Handoff{
Meta: continuity.Meta{ID: handoffID(s), Reason: "threshold"},
Anchor: continuity.Anchor{GitSHA: sha, Branch: strings.TrimSpace(string(branchOut)), Dirty: dirty},
Goal: "Continue Orchestra task " + s.PaneID,
DoneWhen: []string{"Task completion is reported to Orchestra"},
Action: "Read the semantic handoff report and continue the task.",
Command: "cat " + HandoffReportFile,
Remaining: []string{report},
Meta: continuity.Meta{ID: handoffID(s), Reason: handoffReason(s)},
Anchor: continuity.Anchor{GitSHA: sha, Branch: strings.TrimSpace(string(branchOut)), Dirty: dirty},
Action: authored.Action,
Command: command,
Remaining: authored.Remaining,
DeadEnds: authored.DeadEnds,
OpenQuestions: authored.OpenQuestions,
Learned: authored.Learned,
}, nil
}
type handoffAnswer struct {
Action, Why string
Remaining []string
DeadEnds []continuity.DeadEnd
OpenQuestions, Learned []string
}
func parseHandoffAnswer(answer string) (handoffAnswer, error) {
var out handoffAnswer
sections := map[string][]string{}
var current string
for _, raw := range strings.Split(strings.ReplaceAll(answer, "\r\n", "\n"), "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
for _, name := range []string{"NEXT", "WHY", "REMAINING", "DEAD ENDS", "OPEN Q", "LEARNED"} {
prefix := name + ":"
if strings.HasPrefix(line, prefix) {
current = name
if value := strings.TrimSpace(strings.TrimPrefix(line, prefix)); value != "" {
sections[name] = append(sections[name], value)
}
goto parsed
}
}
if current == "" {
return out, fmt.Errorf("unexpected line %q", line)
}
sections[current] = append(sections[current], strings.TrimSpace(strings.TrimPrefix(line, "- ")))
parsed:
}
for _, name := range []string{"NEXT", "WHY", "REMAINING", "DEAD ENDS", "OPEN Q", "LEARNED"} {
if len(sections[name]) == 0 {
return out, fmt.Errorf("missing %s", name)
}
}
if len(sections["NEXT"]) != 1 || len(sections["WHY"]) != 1 {
return out, fmt.Errorf("NEXT and WHY each require one line")
}
out.Action = sections["NEXT"][0] + " — " + sections["WHY"][0]
for _, name := range []string{"REMAINING", "OPEN Q", "LEARNED"} {
values, err := answerList(sections[name])
if err != nil {
return out, fmt.Errorf("%s: %w", name, err)
}
switch name {
case "REMAINING":
out.Remaining = values
case "OPEN Q":
out.OpenQuestions = values
case "LEARNED":
out.Learned = values
}
}
deadEnds, err := answerList(sections["DEAD ENDS"])
if err != nil {
return out, fmt.Errorf("DEAD ENDS: %w", err)
}
for _, item := range deadEnds {
parts := strings.SplitN(item, "→", 2)
if len(parts) != 2 {
return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'")
}
if !strings.HasPrefix(parts[0], "tried ") || !strings.HasPrefix(strings.TrimSpace(parts[1]), "failed because ") {
return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'")
}
tried := strings.TrimSpace(strings.TrimPrefix(parts[0], "tried "))
why := strings.TrimSpace(strings.TrimPrefix(parts[1], "failed because "))
if tried == "" || why == "" {
return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'")
}
out.DeadEnds = append(out.DeadEnds, continuity.DeadEnd{Tried: tried, WhyFailed: why})
}
if err := (continuity.Handoff{Meta: continuity.Meta{ID: "answer", Reason: "manual"}, Anchor: continuity.Anchor{GitSHA: strings.Repeat("0", 40), Branch: "answer"}, Action: out.Action, Remaining: out.Remaining, DeadEnds: out.DeadEnds, OpenQuestions: out.OpenQuestions, Learned: out.Learned}).Validate(); err != nil {
return out, err
}
return out, nil
}
func answerList(lines []string) ([]string, error) {
if len(lines) == 1 && lines[0] == "NONE" {
return nil, nil
}
for _, line := range lines {
if line == "NONE" {
return nil, fmt.Errorf("NONE must be the only value")
}
}
return lines, nil
}
func (a CLIAdapter) lastObservedCommand(s Session) string {
calls, err := a.Activity(context.Background(), s)
if err != nil {
return ""
}
for i := len(calls) - 1; i >= 0; i-- {
if calls[i].Kind == "command" {
return calls[i].Key
}
}
return ""
}
func handoffReason(s Session) string {
switch s.HandoffReason {
case "threshold", "milestone", "thrash", "manual":
return s.HandoffReason
default:
return "threshold"
}
}
func handoffID(s Session) string {
id := s.AgentName
if id == "" {
@@ -368,11 +519,12 @@ func dirtyFiles(root string) ([]continuity.Dirty, error) {
return dirty, nil
}
func agentForSession(s Session, fallback string) string {
if s.AgentName != "" {
return s.AgentName
}
return fallback // compatibility with session records created before B16
func agentForSession(_ Session, fallback string) string {
// pane.release_agent identifies the harness binding, not herdr's
// machine-global terminal name. AgentName is only for prompt routing;
// passing it here is accepted by herdr but leaves the binding intact.
// Keep the configured harness for both new and pre-B16 session records.
return fallback
}
func (a CLIAdapter) Kill(ctx context.Context, s Session) error {
return a.Client.Call(ctx, "pane.close", map[string]any{"pane_id": s.PaneID}, nil)
+123 -41
View File
@@ -3,8 +3,6 @@ package herdr
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net"
"orchestra/internal/continuity"
@@ -61,15 +59,20 @@ func fakeHerdr(t *testing.T) *Client {
func validHandoff(anchorSHA string) continuity.Handoff {
return continuity.Handoff{
Meta: continuity.Meta{ID: "t1", Reason: "threshold", RotationIndex: 1},
Anchor: continuity.Anchor{GitSHA: anchorSHA, Branch: "main"},
Goal: "finish the thing",
DoneWhen: []string{"tests pass"},
Action: "continue",
Command: "go test ./...",
Meta: continuity.Meta{ID: "t1", Reason: "threshold", RotationIndex: 1},
Anchor: continuity.Anchor{GitSHA: anchorSHA, Branch: "main"},
Action: "run the focused tests",
Command: "go test ./...",
}
}
const validAnswer = `NEXT: run the focused tests
WHY: confirm the current implementation before changing it
REMAINING: NONE
DEAD ENDS: NONE
OPEN Q: NONE
LEARNED: NONE`
func TestReleaseUploadsHandoffAndReleasesAgent(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
@@ -81,11 +84,7 @@ func TestReleaseUploadsHandoffAndReleasesAgent(t *testing.T) {
t.Fatal(err)
}
b, err := continuity.Encode(validHandoff(head))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), b, 0644); err != nil {
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
t.Fatal(err)
}
@@ -105,8 +104,77 @@ func TestReleaseUploadsHandoffAndReleasesAgent(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got.Anchor.GitSHA == head {
t.Fatal("semantic report should be snapshotted before publication")
if got.Anchor.GitSHA != head {
t.Fatal("semantic report must not be included in the successor scratch anchor")
}
if _, err := os.Stat(filepath.Join(repo, HandoffReportFile)); !os.IsNotExist(err) {
t.Fatalf("transferred semantic report still present: %v", err)
}
}
func TestCanonicalHandoffCarriesCoordinatorReason(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "t@t")
runGit(t, repo, "config", "user.name", "t")
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
t.Fatal(err)
}
cas := &memCAS{}
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: cas}
ref, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo, HandoffReason: "thrash"})
if err != nil {
t.Fatal(err)
}
got, err := continuity.Load(ref, cas)
if err != nil {
t.Fatal(err)
}
if got.Meta.Reason != "thrash" {
t.Fatalf("canonical reason = %q, want thrash", got.Meta.Reason)
}
}
func TestReleaseUsesHarnessBindingNotDisplayName(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "t@t")
runGit(t, repo, "config", "user.name", "t")
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
t.Fatal(err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
request := make(chan Request, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
var req Request
if json.NewDecoder(bufio.NewReader(conn)).Decode(&req) == nil {
request <- req
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: json.RawMessage(`{}`)})
}
}()
a := CLIAdapter{Client: &Client{Path: ln.Addr().String(), dial: func() (net.Conn, error) { return net.Dial("tcp", ln.Addr().String()) }}, Harness: "opencode", CAS: &memCAS{}}
if _, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo, AgentName: "oc-task-specific"}); err != nil {
t.Fatal(err)
}
req := <-request
p, _ := json.Marshal(req.Params)
var got struct {
Agent string `json:"agent"`
}
_ = json.Unmarshal(p, &got)
if got.Agent != "opencode" {
t.Fatalf("release agent = %q, want harness binding opencode", got.Agent)
}
}
@@ -119,6 +187,43 @@ func TestReleaseRefusesWithoutHandoffFile(t *testing.T) {
}
}
func TestReleaseRefusesNarrativeOrCircularHandoffAnswer(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "t@t")
runGit(t, repo, "config", "user.name", "t")
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
bad := `NEXT: Continue the task from the handoff
WHY: the predecessor asked for it
REMAINING: what changed\n# a markdown report
DEAD ENDS: NONE
OPEN Q: NONE
LEARNED: NONE`
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(bad), 0o644); err != nil {
t.Fatal(err)
}
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: &memCAS{}}
if _, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo}); err == nil {
t.Fatal("expected invalid agent answer to refuse release")
}
}
func TestParseHandoffAnswerPreservesOnlyAuthoredFields(t *testing.T) {
a, err := parseHandoffAnswer(`NEXT: inspect the failing integration test
WHY: isolate the regression before changing production code
REMAINING: fix the assertion after identifying the cause
DEAD ENDS: tried rerunning the whole suite → failed because it obscures the relevant failure
OPEN Q: whether the remote worker has the updated fixture
LEARNED: the fixture requires a committed scratch branch
`)
if err != nil {
t.Fatal(err)
}
if len(a.Remaining) != 1 || len(a.DeadEnds) != 1 || len(a.OpenQuestions) != 1 || len(a.Learned) != 1 {
t.Fatalf("parsed answer lost authored fields: %#v", a)
}
}
func TestReleaseDoesNotTrustAgentSuppliedAnchor(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
@@ -126,11 +231,7 @@ func TestReleaseDoesNotTrustAgentSuppliedAnchor(t *testing.T) {
runGit(t, repo, "config", "user.name", "t")
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
b, err := continuity.Encode(validHandoff("deaddeaddeaddeaddeaddeaddeaddeaddeaddead"))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), b, 0644); err != nil {
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
t.Fatal(err)
}
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: &memCAS{}}
@@ -154,14 +255,7 @@ func TestReleaseScratchCommitsDirtyFilesBeforeUpload(t *testing.T) {
if err := os.WriteFile(wipPath, []byte("in progress"), 0644); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256([]byte("in progress"))
h := validHandoff(head)
h.Anchor.Dirty = []continuity.Dirty{{Path: "wip.txt", SHA256: hex.EncodeToString(sum[:])}}
b, err := continuity.Encode(h)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), b, 0644); err != nil {
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
t.Fatal(err)
}
@@ -203,22 +297,10 @@ func TestReleaseDoesNotTrustAgentSuppliedDirtyFile(t *testing.T) {
runGit(t, repo, "config", "user.email", "t@t")
runGit(t, repo, "config", "user.name", "t")
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
head, err := HeadSHA(repo)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, "wip.txt"), []byte("changed after handoff written"), 0644); err != nil {
t.Fatal(err)
}
stale := sha256.Sum256([]byte("original content"))
h := validHandoff(head)
h.Anchor.Dirty = []continuity.Dirty{{Path: "wip.txt", SHA256: hex.EncodeToString(stale[:])}}
b, err := continuity.Encode(h)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), b, 0644); err != nil {
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
t.Fatal(err)
}
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: &memCAS{}}
+13 -3
View File
@@ -160,6 +160,10 @@ type Session struct {
// its §6.1 handoff (HandoffFile) — avoids re-sending the same prompt
// every tick while Release keeps waiting for the file to appear.
HandoffRequested bool `json:"handoff_requested,omitempty"`
// HandoffReason is selected by the coordinator when it asks for the
// semantic report. The checkout worker, rather than the harness, copies
// it into the canonical handoff it seals at release time.
HandoffReason string `json:"handoff_reason,omitempty"`
// ConventionsHash is continuity.ConventionsHash of the project's shared
// *.md docs (AGENTS.md/CLAUDE.md/VOCAB.md) at the time this session was
// last notified of (or started with) their content — §6.3's staleness
@@ -455,10 +459,16 @@ func agentName(harness, taskID string) string {
id = "session"
}
name := prefix + "-" + id
if len(name) > 32 {
name = strings.TrimRight(name[:32], "-_")
if len(name) <= 32 {
return name
}
return name
// Keeping only the leading task-id characters made distinct long task
// IDs collide in herdr's machine-global name namespace. Reserve a stable
// digest suffix so truncation remains bounded *and* task-specific.
sum := sha256.Sum256([]byte(taskID))
const suffixLen = 8
keep := 32 - len(prefix) - 1 - 1 - suffixLen // prefix + "-" + stem + "-" + digest
return prefix + "-" + strings.TrimRight(id[:keep], "-_") + "-" + fmt.Sprintf("%x", sum[:])[:suffixLen]
}
// harnessStartArgs stays empty for Claude: --dangerously-skip-permissions
+11
View File
@@ -89,6 +89,17 @@ func TestAgentNameIsBoundedAndValid(t *testing.T) {
}
}
func TestAgentNameLongIDsDoNotCollide(t *testing.T) {
first := agentName("claude", "task-with-a-very-long-shared-prefix-aaaaaaaa")
second := agentName("claude", "task-with-a-very-long-shared-prefix-bbbbbbbb")
if first == second {
t.Fatalf("long task IDs collided: %q", first)
}
if len(first) > 32 || len(second) > 32 {
t.Fatalf("agent name exceeds limit: %q / %q", first, second)
}
}
func TestPromptDoesNotRetryAmbiguousDelivery(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
+6 -2
View File
@@ -147,8 +147,12 @@ func TestEndToEndIngestRouteLeaseRotateAndComplete(t *testing.T) {
t.Fatal(err)
}
got, _ = s.Task(task.ID)
if got.State != domain.StateLeased || got.Version != 4 {
t.Fatalf("pickup state=%s version=%d", got.State, got.Version)
// This fixture intentionally stores an opaque string rather than a typed
// handoff. Once TaskLeased carries a real handoff_ref, pickup correctly
// refuses it instead of silently continuing (the valid pickup contract is
// covered by the orchestrator continuity tests).
if got.State != domain.StateBlocked || got.Version != 5 {
t.Fatalf("invalid pickup state=%s version=%d", got.State, got.Version)
}
ref, err = s.PutArtifact([]byte("report"))
if err != nil {
+5
View File
@@ -249,6 +249,7 @@ func (c *Coordinator) requestReasonedHandoff(ctx context.Context, taskID string,
return
}
session.HandoffRequested = true
session.HandoffReason = reason
c.mu.Lock()
c.sessions[taskID] = session
_ = c.saveSessionsLocked()
@@ -652,6 +653,7 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffReportFile)); statErr != nil && !session.HandoffRequested {
if reqErr := requester.RequestHandoff(ctx, session); reqErr == nil {
session.HandoffRequested = true
session.HandoffReason = "threshold"
c.mu.Lock()
c.sessions[taskID] = session
_ = c.saveSessionsLocked()
@@ -688,6 +690,7 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
if !session.HandoffRequested {
if reqErr := requester.RequestHandoff(ctx, session); reqErr == nil {
session.HandoffRequested = true
session.HandoffReason = reason
c.mu.Lock()
c.sessions[taskID] = session
_ = c.saveSessionsLocked()
@@ -784,6 +787,7 @@ func (c *Coordinator) TurnDecision(ctx context.Context, taskID string) (string,
if !session.HandoffRequested {
if reqErr := requester.RequestHandoff(ctx, session); reqErr == nil {
session.HandoffRequested = true
session.HandoffReason = "threshold"
c.mu.Lock()
c.sessions[taskID] = session
_ = c.saveSessionsLocked()
@@ -811,6 +815,7 @@ func (c *Coordinator) TurnDecision(ctx context.Context, taskID string) (string,
if !session.HandoffRequested {
if reqErr := requester.RequestHandoff(ctx, session); reqErr == nil {
session.HandoffRequested = true
session.HandoffReason = "threshold"
c.mu.Lock()
c.sessions[taskID] = session
_ = c.saveSessionsLocked()
+3 -3
View File
@@ -279,7 +279,7 @@ func TestTurnDecision(t *testing.T) {
handoff := map[string]any{
"meta": map[string]any{"id": "h2", "reason": "manual", "rotation_index": 0},
"anchor": map[string]any{"git_sha": head, "branch": "orchestra/t1"},
"goal": "g", "done_when": []string{"x"}, "action": "prompt", "command": "go test ./...",
"action": "run the focused tests", "command": "go test ./...",
}
b, err := json.Marshal(handoff)
if err != nil {
@@ -334,7 +334,7 @@ func TestStartBlocksOnInvalidPickup(t *testing.T) {
badHandoff := map[string]any{
"meta": map[string]any{"id": "h1", "reason": "manual", "rotation_index": 0},
"anchor": map[string]any{"git_sha": strings0(40, 'a'), "branch": "orchestra/t1"},
"goal": "g", "done_when": []string{"x"}, "action": "prompt", "command": "go test ./...",
"action": "run the focused tests", "command": "go test ./...",
}
ref, err := s.PutArtifact(mustJSON(badHandoff))
if err != nil {
@@ -708,7 +708,7 @@ func TestActivityTriggersRequestReasonedHandoffWithoutReleasing(t *testing.T) {
handoff := map[string]any{
"meta": map[string]any{"id": "h3", "reason": "thrash", "rotation_index": 0},
"anchor": map[string]any{"git_sha": head, "branch": "orchestra/t1"},
"goal": "g", "done_when": []string{"x"}, "action": "prompt", "command": "go test ./...",
"action": "run the focused tests", "command": "go test ./...",
"dead_ends": []map[string]any{{"tried": "go test ./...", "why_failed": "failed 3 times"}},
}
b, err := json.Marshal(handoff)
+6 -1
View File
@@ -145,6 +145,7 @@ func (s *Store) apply(e domain.Event) error {
case "TaskReleased":
t.State = domain.StateQueued
t.Lease = nil
t.HandoffRef, _ = p["handoff_ref"].(string)
case "TaskCompleted":
t.State = domain.StateCompleted
t.Lease = nil
@@ -398,7 +399,11 @@ func (s *Store) Lease(id, harness string, ttl time.Duration) (domain.Event, erro
if t.State != domain.StateQueued {
return domain.Event{}, domain.ErrConflict
}
p, _ := json.Marshal(map[string]any{"harness_id": harness, "ttl": ttl.Seconds(), "until_ns": time.Now().Add(ttl).UnixNano(), "expected_version": t.Version})
payload := map[string]any{"harness_id": harness, "ttl": ttl.Seconds(), "until_ns": time.Now().Add(ttl).UnixNano(), "expected_version": t.Version}
if t.HandoffRef != "" {
payload["handoff_ref"] = t.HandoffRef
}
p, _ := json.Marshal(payload)
e := domain.Event{ID: domain.NewID(), Type: "TaskLeased", TaskID: id, Version: t.Version + 1, Payload: p, Surface: string(authz.System)}
return e, s.Append(e)
}
+34
View File
@@ -17,6 +17,40 @@ func created(id string) domain.Event {
return domain.Event{ID: id, Type: "TaskCreated", TaskID: "task-1", Version: 1, Payload: b, Surface: string(authz.System)}
}
func TestLeaseCarriesReleasedHandoffRef(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
b := []byte(`{"source":"s","external_id":"x","project":"p"}`)
if err := s.Append(domain.Event{ID: "create", Type: "TaskCreated", TaskID: "t", Version: 1, Payload: b, Surface: string(authz.System)}); err != nil {
t.Fatal(err)
}
if _, err := s.Lease("t", "h", time.Minute); err != nil {
t.Fatal(err)
}
ref, err := s.PutArtifact([]byte("handoff"))
if err != nil {
t.Fatal(err)
}
task, _ := s.Task("t")
p, _ := json.Marshal(map[string]string{"handoff_ref": ref, "anchor_sha": "0123456789012345678901234567890123456789"})
if err := s.Append(domain.Event{ID: "release", Type: "TaskReleased", TaskID: "t", Version: task.Version + 1, Payload: p, Surface: string(authz.System)}); err != nil {
t.Fatal(err)
}
e, err := s.Lease("t", "h", time.Minute)
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err := json.Unmarshal(e.Payload, &got); err != nil {
t.Fatal(err)
}
if got["handoff_ref"] != ref {
t.Fatalf("handoff_ref=%v want %s", got["handoff_ref"], ref)
}
}
func TestAppendReplayAndDeduplicate(t *testing.T) {
dir := t.TempDir()
s, err := Open(dir)