Files
orchestra/internal/herdr/herdr.go
T
kami 3fe3aee5b7 fix(herdr): close Phase 4 item 2 — actually ask the agent for a handoff
Release already validated and uploaded a §6.1 handoff, but nothing ever
told the agent the .orchestra-handoff.json convention existed, so the
file it waited on never got written. rotate() now prompts the agent
once via a new optional herdr.HandoffRequester capability
(CLIAdapter.RequestHandoff) when the file is missing, and defers
Release until it appears, mirroring the .orchestra-report.md/B3 ask
pattern rather than inventing a handoff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
2026-07-27 22:10:22 +04:00

257 lines
7.1 KiB
Go

// Package herdr contains the small, protocol-oriented execution seam used by
// the orchestration layer. It deliberately does not shell out to a harness.
package herdr
import (
"bufio"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
var ErrProtocol = errors.New("herdr protocol error")
type Request struct {
ID string `json:"id"`
Method string `json:"method"`
// Herdr's JSON-RPC decoder requires params to be present, including for
// parameterless calls such as ping. Encode nil as an explicit JSON null.
Params any `json:"params"`
}
type Response struct {
ID string `json:"id"`
Result json.RawMessage `json:"result"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
type Client struct {
Path string
Timeout time.Duration
dial func() (net.Conn, error)
mu sync.Mutex
next uint64
panes map[string]string
}
type WorktreeInfo struct {
Path string `json:"path"`
}
type worktreeResponse struct {
Path string `json:"path"`
Worktree WorktreeInfo `json:"worktree"`
RootPane struct {
PaneID string `json:"pane_id"`
Agent string `json:"agent"`
} `json:"root_pane"`
Workspace struct {
RootPane struct {
PaneID string `json:"pane_id"`
} `json:"root_pane"`
} `json:"workspace"`
}
func New(path string) *Client {
return &Client{Path: path, Timeout: 10 * time.Second, panes: map[string]string{}}
}
func (c *Client) conn() (net.Conn, error) {
if c.dial != nil {
return c.dial()
}
network := "unix"
if strings.Contains(c.Path, "://") || (strings.Contains(c.Path, ":") && !strings.HasPrefix(c.Path, "/")) {
network = "tcp"
}
return net.DialTimeout(network, c.Path, c.Timeout)
}
func (c *Client) Call(ctx context.Context, method string, params any, out any) error {
c.mu.Lock()
defer c.mu.Unlock()
c.next++
id := fmt.Sprint(c.next)
cn, err := c.conn()
if err != nil {
return err
}
defer cn.Close()
if d, ok := ctx.Deadline(); ok {
_ = cn.SetDeadline(d)
} else if c.Timeout > 0 {
_ = cn.SetDeadline(time.Now().Add(c.Timeout))
}
if params == nil {
params = map[string]any{}
}
if err = json.NewEncoder(cn).Encode(Request{ID: id, Method: method, Params: params}); err != nil {
return err
}
var r Response
if err = json.NewDecoder(bufio.NewReader(cn)).Decode(&r); err != nil {
return err
}
if r.Error != nil {
return fmt.Errorf("%w: %s", ErrProtocol, r.Error.Message)
}
if out != nil && len(r.Result) > 0 {
return json.Unmarshal(r.Result, out)
}
return nil
}
type PingResult struct {
Protocol json.RawMessage `json:"protocol"`
Version json.RawMessage `json:"version"`
}
func (c *Client) Ping(ctx context.Context) (PingResult, error) {
var p PingResult
err := c.Call(ctx, "ping", nil, &p)
return p, err
}
func (c *Client) CheckProtocol(ctx context.Context, want string) error {
p, e := c.Ping(ctx)
if e != nil {
return e
}
if want != "" {
var text string
if err := json.Unmarshal(p.Protocol, &text); err != nil {
text = string(p.Protocol)
}
if text != want {
return fmt.Errorf("%w: want %s, got %s", ErrProtocol, want, text)
}
}
return nil
}
type Session struct {
PaneID string `json:"pane_id"`
Worktree string `json:"worktree"`
Harness string `json:"harness"`
HerdrID string `json:"herdr_id,omitempty"`
// SessionFile is the filesystem path to the harness's own session/
// transcript state (a Claude Code transcript, a Codex rollout, ...).
// ClaudeUsage/CodexUsage/OpenCodeUsage read *this*, never the herdr pane
// id — occupancy is a property of the harness's session state, not of
// the pane multiplexing it. Left empty until resolved (see
// CLIAdapter.Occupancy), since the file may not exist yet immediately
// after lease.
SessionFile string `json:"session_file,omitempty"`
// TaskFileSHA is the sha256 of the worktree's TASK.md at the time this
// session's lease was created — the immutable-spec hash continuity's
// pickup validation compares against on the next rotation (§6.2).
TaskFileSHA string `json:"task_file_sha,omitempty"`
// HandoffRequested is set once rotate() has prompted the agent to write
// 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"`
}
func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error {
p := map[string]any{"target": pane, "text": text}
if wait > 0 {
p["wait"] = map[string]any{"until": []string{"idle"}, "timeout_ms": wait.Milliseconds()}
}
return c.Call(ctx, "agent.prompt", p, nil)
}
func (c *Client) Worktree(ctx context.Context, cwd, path, branch string) (string, error) {
var r worktreeResponse
// Protocol 17 requires exactly one of path or branch. Use the explicit
// path so the worker owns the checkout location; herdr manages the branch
// associated with that worktree.
p := map[string]any{"cwd": cwd, "path": path}
e := c.Call(ctx, "worktree.create", p, &r)
if e != nil && strings.Contains(strings.ToLower(e.Error()), "already exists") {
e = c.Call(ctx, "worktree.open", map[string]any{"cwd": cwd, "path": path}, &r)
}
if e != nil {
return "", e
}
if r.RootPane.PaneID != "" {
c.mu.Lock()
c.panes[path] = r.RootPane.PaneID
c.mu.Unlock()
}
if r.Path != "" {
return r.Path, nil
}
return r.Worktree.Path, nil
}
func (c *Client) StartAgent(ctx context.Context, cwd, path, branch, harness, taskID string) (Session, error) {
c.mu.Lock()
paneID := c.panes[path]
c.mu.Unlock()
if paneID == "" {
return Session{}, fmt.Errorf("herdr: no pane recorded for worktree %s", path)
}
var s Session
if err := c.Call(ctx, "agent.start", map[string]any{
"pane_id": paneID,
"kind": harness,
"name": harness,
"args": []string{},
}, &s); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already") {
return Session{}, err
}
}
s.PaneID = paneID
s.Worktree = path
s.Harness = harness
return s, nil
}
// HeadSHA returns the current commit of a worktree. The rotation path uses
// this to populate TaskReleased.anchor_sha without trusting the adapter's
// opaque handoff-ref return value.
func HeadSHA(root string) (string, error) {
out, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
if err != nil {
return "", err
}
sha := string(out)
if len(sha) > 0 && sha[len(sha)-1] == '\n' {
sha = sha[:len(sha)-1]
}
if len(sha) != 40 {
return "", fmt.Errorf("herdr: unexpected HEAD output %q", sha)
}
return sha, nil
}
// AnchorValid checks the split-then-close safety condition without trusting a
// predecessor's prose. dirty maps contain path -> expected SHA-256.
func AnchorValid(root, sha string, dirty map[string]string) error {
out, e := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
if e != nil {
return e
}
if string(out) != sha+"\n" {
return fmt.Errorf("anchor: HEAD mismatch")
}
for p, want := range dirty {
b, e := os.ReadFile(filepath.Join(root, p))
if e != nil {
return e
}
h := sha256.Sum256(b)
if fmt.Sprintf("%x", h) != want {
return fmt.Errorf("anchor: %s changed", p)
}
}
return nil
}