527 lines
17 KiB
Go
527 lines
17 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"
|
|
"regexp"
|
|
"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
|
|
agents map[string]string // pane ID -> machine-global agent name
|
|
}
|
|
|
|
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{}, agents: 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"`
|
|
AgentName string `json:"agent_name,omitempty"`
|
|
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"`
|
|
// SessionID is the harness-native identity when its usage is stored in a
|
|
// database rather than a transcript. OpenCode's SQLite session ID is kept
|
|
// here so rotation never guesses "the newest session" after a restart.
|
|
SessionID string `json:"session_id,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"`
|
|
// 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
|
|
// tracking lives at the orchestra layer, never trusted from the agent's
|
|
// cached view.
|
|
ConventionsHash string `json:"conventions_hash,omitempty"`
|
|
}
|
|
|
|
// bootDeadline bounds the retry loops below. Freshly created panes/agents
|
|
// have been observed (live, 2026-07-28) to reject the very next call for a
|
|
// range of different transient reasons as herdr finishes bringing them up —
|
|
// "not an available shell", "not an active named agent", "target ... not
|
|
// found" — a new wording each time a prior one got fixed. There is no other
|
|
// legitimate reason a call against state orchestra itself just created would
|
|
// fail immediately, so these loops retry any error rather than pattern-match
|
|
// an open-ended and apparently still-growing set of herdr wordings, bounded
|
|
// by wall-clock time rather than attempt count so a slow-booting pane still
|
|
// gets the same real budget as a fast-failing one.
|
|
const (
|
|
bootRetryWindow = 15 * time.Second
|
|
bootRetryDelay = 500 * time.Millisecond
|
|
|
|
// agentAttachWindow/agentAttachPoll bound StartAgent's post-success
|
|
// confirmation poll (B13, found live 2026-07-28): agent.start can return
|
|
// no error while never actually starting an agent, observed on two of
|
|
// three back-to-back leases — the pane's agent_status stayed "unknown"
|
|
// after 2+ minutes of polling, with no error surfaced anywhere. A
|
|
// legitimate attach has been observed taking "well over a minute", so
|
|
// this window is deliberately longer than bootRetryWindow.
|
|
claudeTrustObserveWindow = 15 * time.Second
|
|
claudeTrustClearWindow = 15 * time.Second
|
|
claudeTrustPoll = 500 * time.Millisecond
|
|
)
|
|
|
|
var (
|
|
agentAttachWindow = 90 * time.Second
|
|
agentAttachPoll = 2 * time.Second
|
|
)
|
|
|
|
type paneStatus struct {
|
|
Agent string `json:"agent"`
|
|
AgentStatus string `json:"agent_status"`
|
|
}
|
|
|
|
// paneGetResponse mirrors herdr's pane.get result envelope. The pane fields
|
|
// are not returned at the top level: the result is {"type":"pane_info",
|
|
// "pane": {...}}. Keeping that wrapper explicit prevents a successful
|
|
// agent.start from being falsely reported as unattached.
|
|
type paneGetResponse struct {
|
|
Pane paneStatus `json:"pane"`
|
|
}
|
|
|
|
type paneReadResponse struct {
|
|
Read struct {
|
|
Text string `json:"text"`
|
|
} `json:"read"`
|
|
}
|
|
|
|
// paneAgentAttached reports whether herdr's own view of the pane shows a
|
|
// real agent bound to it, rather than trusting agent.start's return value.
|
|
func (c *Client) paneAgentAttached(ctx context.Context, paneID string) (bool, error) {
|
|
var p paneGetResponse
|
|
if err := c.Call(ctx, "pane.get", map[string]any{"pane_id": paneID}, &p); err != nil {
|
|
return false, err
|
|
}
|
|
return p.Pane.Agent != "" && p.Pane.AgentStatus != "" && p.Pane.AgentStatus != "unknown", nil
|
|
}
|
|
|
|
func (c *Client) paneText(ctx context.Context, paneID string) (string, error) {
|
|
var p paneReadResponse
|
|
if err := c.Call(ctx, "pane.read", map[string]any{"pane_id": paneID, "source": "recent"}, &p); err != nil {
|
|
return "", err
|
|
}
|
|
return p.Read.Text, nil
|
|
}
|
|
|
|
func claudeWorkspaceTrustPrompt(text string) bool {
|
|
return strings.Contains(text, "Accessing workspace:") && strings.Contains(text, "Yes, I trust this folder")
|
|
}
|
|
|
|
// confirmClaudeWorkspaceTrust accepts only Claude Code's exact workspace
|
|
// trust prompt. Orchestra creates its worktrees from registered project
|
|
// repositories, so leaving this interactive would make every new worktree
|
|
// permanently unattended-ineligible. It deliberately does not accept any
|
|
// other Claude confirmation (in particular bypass-permissions mode).
|
|
func (c *Client) confirmClaudeWorkspaceTrust(ctx context.Context, paneID string) error {
|
|
deadline := time.Now().Add(claudeTrustObserveWindow)
|
|
accepted := false
|
|
for {
|
|
text, err := c.paneText(ctx, paneID)
|
|
if err == nil && claudeWorkspaceTrustPrompt(text) {
|
|
if !accepted {
|
|
if err := c.Call(ctx, "pane.send_text", map[string]any{"pane_id": paneID, "text": "1\n"}, nil); err != nil {
|
|
return fmt.Errorf("herdr: accept Claude workspace trust for pane %s: %w", paneID, err)
|
|
}
|
|
accepted = true
|
|
deadline = time.Now().Add(claudeTrustClearWindow)
|
|
}
|
|
} else if accepted {
|
|
return nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
if accepted {
|
|
return fmt.Errorf("herdr: Claude workspace trust prompt did not clear for pane %s", paneID)
|
|
}
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(claudeTrustPoll):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error {
|
|
var status paneGetResponse
|
|
if err := c.Call(ctx, "pane.get", map[string]any{"pane_id": pane}, &status); err != nil {
|
|
return fmt.Errorf("herdr: inspect pane before prompt: %w", err)
|
|
}
|
|
if strings.EqualFold(status.Pane.AgentStatus, "blocked") {
|
|
return fmt.Errorf("herdr: refusing prompt to blocked pane %s", pane)
|
|
}
|
|
transcript, err := c.paneText(ctx, pane)
|
|
if err != nil {
|
|
return fmt.Errorf("herdr: inspect pane text before prompt: %w", err)
|
|
}
|
|
if permissionPrompt(transcript) {
|
|
return fmt.Errorf("herdr: refusing prompt while pane %s shows a permission dialog", pane)
|
|
}
|
|
c.mu.Lock()
|
|
target := c.agents[pane]
|
|
c.mu.Unlock()
|
|
if target == "" {
|
|
// Session records created before unique agent names were introduced
|
|
// used the pane as target; retain that compatibility path.
|
|
target = pane
|
|
}
|
|
p := map[string]any{"target": target, "text": text}
|
|
if wait > 0 {
|
|
p["wait"] = map[string]any{"until": []string{"idle"}, "timeout_ms": wait.Milliseconds()}
|
|
}
|
|
callCtx := ctx
|
|
var cancel context.CancelFunc
|
|
if wait > 0 {
|
|
if d, ok := ctx.Deadline(); !ok || time.Until(d) < wait+5*time.Second {
|
|
callCtx, cancel = context.WithTimeout(ctx, wait+5*time.Second)
|
|
defer cancel()
|
|
}
|
|
}
|
|
// A transport failure after writing is ambiguous: herdr may already have
|
|
// delivered the UI-changing request, so never resend it. A JSON-RPC
|
|
// protocol error, however, is herdr's explicit rejection before it acted
|
|
// (notably its short post-start readiness window); that is safe to retry
|
|
// for the bounded boot window.
|
|
deadline := time.Now().Add(bootRetryWindow)
|
|
for {
|
|
err := c.Call(callCtx, "agent.prompt", p, nil)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !errors.Is(err, ErrProtocol) || time.Now().After(deadline) {
|
|
return fmt.Errorf("herdr: prompt delivery is uncertain; not retrying: %w", err)
|
|
}
|
|
select {
|
|
case <-callCtx.Done():
|
|
return fmt.Errorf("herdr: prompt delivery is uncertain; not retrying: %w", callCtx.Err())
|
|
case <-time.After(bootRetryDelay):
|
|
}
|
|
}
|
|
}
|
|
|
|
// BindAgent restores the pane-to-agent routing from a persisted Session.
|
|
func (c *Client) BindAgent(pane, agent string) {
|
|
if pane == "" || agent == "" {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.agents == nil {
|
|
c.agents = map[string]string{}
|
|
}
|
|
c.agents[pane] = agent
|
|
}
|
|
|
|
func permissionPrompt(text string) bool {
|
|
text = strings.ToLower(text)
|
|
return strings.Contains(text, "permission required") || strings.Contains(text, "approval required") || strings.Contains(text, "waiting for approval")
|
|
}
|
|
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
|
|
deadline := time.Now().Add(bootRetryWindow)
|
|
var err error
|
|
for {
|
|
s = Session{}
|
|
err = c.Call(ctx, "agent.start", map[string]any{
|
|
"pane_id": paneID,
|
|
"kind": harness,
|
|
"name": agentName(harness, taskID),
|
|
"args": harnessStartArgs(harness),
|
|
}, &s)
|
|
if err == nil {
|
|
break
|
|
}
|
|
if strings.Contains(strings.ToLower(err.Error()), "already") {
|
|
err = nil
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return Session{}, err
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return Session{}, ctx.Err()
|
|
case <-time.After(bootRetryDelay):
|
|
}
|
|
}
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
// B13: agent.start's success does not mean an agent actually attached.
|
|
// Confirm via pane.get before declaring the lease started, so a silent
|
|
// no-op surfaces as an observable error (TaskBlocked) instead of leaving
|
|
// the task leased against a pane that will never produce a session.
|
|
attachDeadline := time.Now().Add(agentAttachWindow)
|
|
for {
|
|
ok, statusErr := c.paneAgentAttached(ctx, paneID)
|
|
if statusErr == nil && ok {
|
|
break
|
|
}
|
|
if time.Now().After(attachDeadline) {
|
|
if statusErr != nil {
|
|
return Session{}, fmt.Errorf("herdr: agent.start reported success for pane %s but confirming attach failed: %w", paneID, statusErr)
|
|
}
|
|
return Session{}, fmt.Errorf("herdr: agent.start reported success for pane %s but no agent attached within %s", paneID, agentAttachWindow)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return Session{}, ctx.Err()
|
|
case <-time.After(agentAttachPoll):
|
|
}
|
|
}
|
|
if strings.EqualFold(harness, "claude") {
|
|
if err := c.confirmClaudeWorkspaceTrust(ctx, paneID); err != nil {
|
|
return Session{}, err
|
|
}
|
|
}
|
|
s.PaneID = paneID
|
|
s.Worktree = path
|
|
s.Harness = harness
|
|
s.AgentName = agentName(harness, taskID)
|
|
c.BindAgent(paneID, s.AgentName)
|
|
return s, nil
|
|
}
|
|
|
|
var invalidAgentName = regexp.MustCompile(`[^a-z0-9_-]+`)
|
|
|
|
func agentName(harness, taskID string) string {
|
|
prefix := map[string]string{"opencode": "oc", "claude": "cl", "codex": "cx"}[strings.ToLower(harness)]
|
|
if prefix == "" {
|
|
prefix = "agent"
|
|
}
|
|
id := strings.Trim(invalidAgentName.ReplaceAllString(strings.ToLower(taskID), "-"), "-_")
|
|
if id == "" {
|
|
id = "session"
|
|
}
|
|
name := prefix + "-" + id
|
|
if len(name) <= 32 {
|
|
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
|
|
// introduces a separate first-run disclaimer. StartAgent instead acknowledges
|
|
// only the registered-worktree trust prompt after Claude is running.
|
|
func harnessStartArgs(harness string) []string {
|
|
return []string{}
|
|
}
|
|
|
|
// 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
|
|
}
|