Files
orchestra/internal/herdr/adapter.go
T
2026-07-28 13:20:28 +04:00

522 lines
20 KiB
Go

package herdr
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"orchestra/internal/continuity"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
// sha256sum returns the sha256 of a file, or nil if it can't be read — the
// caller compares against a known-good hex digest, so a nil/short mismatch
// naturally fails that comparison rather than needing its own error path.
func sha256sum(path string) []byte {
b, err := os.ReadFile(path)
if err != nil {
return nil
}
sum := sha256.Sum256(b)
return sum[:]
}
type Adapter interface {
Lease(context.Context, string, string) (Session, error)
Bootstrap(context.Context, Session, string) error
Release(context.Context, Session) (string, error)
Kill(context.Context, Session) error
Occupancy(Session) (float64, error)
}
// PromptLeaser accepts the complete task-specific launch instruction. It is
// optional so non-interactive adapters and existing tests retain the small
// Lease seam, while remote worktrees are never left with only an opaque ID.
type PromptLeaser interface {
LeasePrompt(context.Context, string, string, string) (Session, error)
}
type WorktreeCreator interface {
CreateWorktree(context.Context, string, string, string) (string, error)
}
// TurnBoundary is optional so older herdr deployments remain usable. A true
// result means the current harness turn has ended and handoff is safe.
type TurnBoundary interface {
AtTurnBoundary(context.Context, Session) (bool, error)
}
type PaneExit interface {
PaneExited(context.Context, Session) (bool, error)
}
// AgentStatus is a live, non-lifecycle status reported by herdr. Consumers
// must not infer task completion or release from it.
type AgentStatus interface {
AgentStatus(context.Context, Session) (string, error)
}
type AgentBlocker interface {
AgentBlocker(context.Context, Session) (string, error)
}
type PaneCapture interface {
PaneCapture(context.Context, Session, string) (string, error)
}
type CLIAdapter struct {
Client *Client
Harness string
Window int64
Usage func(string) (Usage, error)
// CAS is where the agent-authored §6.1 handoff artifact is uploaded on
// release. Nil disables Release (adapters built without one refuse
// loudly rather than skip validation).
CAS continuity.CAS
}
// HandoffFile is the convention the agent writes its §6.1 handoff to before
// stopping, mirroring the .orchestra-report.md convention B3 established for
// completion: the plane never invents a handoff, it only validates and
// 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.
const HandoffReportFile = ".orchestra-handoff-report.md"
func (a CLIAdapter) CreateWorktree(ctx context.Context, repo, root, taskID string) (string, error) {
path, err := a.Client.Worktree(ctx, repo, filepath.Join(root, taskID), "orchestra/"+taskID)
if err != nil {
return "", err
}
if path == "" {
return "", fmt.Errorf("adapter: herdr returned empty worktree path")
}
return path, nil
}
func (a CLIAdapter) Lease(ctx context.Context, task, worktree string) (Session, error) {
return a.LeasePrompt(ctx, task, worktree, defaultTaskPrompt(task))
}
func defaultTaskPrompt(task string) string {
return fmt.Sprintf("Begin Orchestra task %s. Inspect the repository, understand the task context, and proceed with the requested work.", task)
}
// LeasePrompt starts a harness and sends an immutable copy of the task's
// actionable instruction. This is required for a herdr-hosted remote
// worktree: homesrv cannot safely write/read that machine's TASK.md.
func (a CLIAdapter) LeasePrompt(ctx context.Context, task, worktree, prompt string) (Session, error) {
if a.Client == nil {
return Session{}, fmt.Errorf("adapter: client required")
}
s, err := a.Client.StartAgent(ctx, worktree, worktree, "orchestra/"+task, a.Harness, task)
if err != nil {
return Session{}, err
}
// The initial instruction is an asynchronous launch message. Waiting for
// idle here turns a normal long-running first turn into a false lease
// failure (and TaskBlocked) even though herdr accepted the prompt.
if err := a.Client.Prompt(ctx, s.PaneID, prompt, 0); err != nil {
// The request may have reached herdr even when its response was lost.
// Preserve the live session so Coordinator can reconcile completion.
return s, err
}
return s, nil
}
func (a CLIAdapter) prompt(ctx context.Context, s Session, text string, wait time.Duration) error {
a.Client.BindAgent(s.PaneID, s.AgentName)
return a.Client.Prompt(ctx, s.PaneID, text, wait)
}
// bootstrapPrompt implements the §6.2 pickup procedure: the plane has already
// run ValidatePickup before this is ever sent (Coordinator.Start blocks the
// task and never bootstraps on failure), so this prompt does not ask the
// agent to re-derive trust in the handoff — it orients the agent inside a
// checkout the plane has already certified, and tells it what NOT to touch.
const bootstrapPrompt = `You are picking up an in-progress Orchestra task (handoff ref %s).
This worktree's anchor and TASK.md have already been verified by the plane before you were started — you do not need to re-derive trust in them.
1. Re-read TASK.md at the worktree root. It is immutable; never edit it.
2. Run 'git log --stat -5' and 'git branch --show-current' in this worktree — the prior agent's uncommitted work was snapshotted onto a scratch branch with a descriptive commit message before rotation; that commit is the record of what it did and what's left.
3. Do not repeat work already recorded as done or as a dead end in that commit history.
4. Continue the task from there.`
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.`
// RequestHandoff prompts the agent to write HandoffFile before Release reads
// it. Optional capability: adapters without a live pane (tests, etc.) can
// omit it and rotate() falls back to waiting on the file appearing on its own.
type HandoffRequester interface {
RequestHandoff(context.Context, Session) error
}
func (a CLIAdapter) RequestHandoff(ctx context.Context, s Session) error {
return a.prompt(ctx, s, handoffPrompt, time.Minute)
}
// ReasonedHandoffRequester is RequestHandoff's counterpart for the two
// orchestrator-detected triggers (S11: milestone, thrash) rather than the
// occupancy-driven ones. It exists separately from HandoffRequester because
// these prompts need to say *why* — naming the detected dead ends for thrash,
// or the recognized completion point for milestone — instead of the generic
// "context budget reached" framing handoffPrompt uses.
type ReasonedHandoffRequester interface {
RequestHandoffReason(ctx context.Context, s Session, reason string, deadEnds []continuity.DeadEnd) error
}
func (a CLIAdapter) RequestHandoffReason(ctx context.Context, s Session, reason string, deadEnds []continuity.DeadEnd) error {
var sb strings.Builder
fmt.Fprintf(&sb, "Orchestra has detected a %q rotation trigger for this task.\n", reason)
switch reason {
case "thrash":
sb.WriteString("Signs of thrashing were detected (repeated failing test runs, repeated edits to the same file, or the same tool call repeated back to back). Stop the current approach rather than trying it again.\n")
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)
if len(deadEnds) > 0 {
sb.WriteString(" and a dead_ends entry for each of the following:\n")
for _, d := range deadEnds {
fmt.Fprintf(&sb, "- tried: %q, why_failed: %q\n", d.Tried, d.WhyFailed)
}
} 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.")
return a.prompt(ctx, s, sb.String(), time.Minute)
}
// Activity resolves the harness's tool-call history the same way Occupancy
// resolves its session file (Session.SessionFile if set, otherwise a fresh
// per-harness lookup), then dispatches to the harness-specific parser.
func (a CLIAdapter) Activity(ctx context.Context, s Session) ([]ToolCall, error) {
path := s.SessionFile
if path == "" {
resolved, err := a.resolveSessionFile(s)
if err != nil {
return nil, fmt.Errorf("adapter: resolve session file: %w", err)
}
path = resolved
}
switch a.Harness {
case "claude":
return ClaudeActivity(path)
case "codex":
return CodexActivity(path)
default:
return OpenCodeActivity(path)
}
}
// conventionsPrompt is §6.3's "orchestra injects a notice to agents whose
// current task is adjacent" — staleness is tracked here, not by trusting the
// agent's cached view of the shared docs.
const conventionsPrompt = `Notice from Orchestra: the shared project conventions (AGENTS.md / CLAUDE.md / VOCAB.md) have been updated since you started this task. Re-read them now before continuing, in case something you're relying on has changed.`
// ConventionsNotifier is the optional capability rotate()'s convention-drift
// check uses; adapters without a live pane (tests, non-interactive harnesses)
// can omit it.
type ConventionsNotifier interface {
NotifyConventionsChanged(context.Context, Session) error
}
func (a CLIAdapter) NotifyConventionsChanged(ctx context.Context, s Session) error {
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
// 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
// herdr does not write handoffs, the agent does). A missing or invalid
// handoff is refused rather than guessed at: the caller (Coordinator.rotate)
// leaves the lease intact and retries next tick, giving the agent time to
// finish writing it.
func (a CLIAdapter) Release(ctx context.Context, s Session) (string, error) {
if a.CAS == nil {
return "", fmt.Errorf("adapter: CAS store required to upload handoff")
}
path := filepath.Join(s.Worktree, HandoffReportFile)
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("adapter: semantic handoff report not written yet (%s): %w", path, err)
}
if strings.TrimSpace(string(b)) == "" {
return "", fmt.Errorf("adapter: semantic handoff report is empty")
}
h, err := canonicalHandoff(s, string(b))
if err != nil {
return "", err
}
sha, err := HeadSHA(s.Worktree)
if err != nil {
return "", fmt.Errorf("adapter: read worktree HEAD: %w", err)
}
_ = sha
for _, d := range h.Anchor.Dirty {
if hex.EncodeToString(sha256sum(filepath.Join(s.Worktree, d.Path))) != d.SHA256 {
return "", fmt.Errorf("adapter: handoff dirty file changed since it was written: %s", d.Path)
}
}
// 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
// instead of re-hashing every dirty file individually.
if len(h.Anchor.Dirty) > 0 {
branch := "orchestra/scratch/" + h.Meta.ID
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)
}
newSHA, err := HeadSHA(s.Worktree)
if err != nil {
return "", fmt.Errorf("adapter: read scratch HEAD: %w", err)
}
h.Anchor.GitSHA = newSHA
h.Anchor.Branch = branch
h.Anchor.Dirty = nil
}
ref, err := continuity.Save(h, a.CAS)
if err != nil {
return "", fmt.Errorf("adapter: upload handoff: %w", err)
}
if err := a.Client.Call(ctx, "pane.release_agent", map[string]any{
"pane_id": s.PaneID,
"source": "herdr:" + a.Harness,
"agent": agentForSession(s, a.Harness),
}, nil); err != nil {
return "", fmt.Errorf("adapter: pane.release_agent: %w", err)
}
return ref, nil
}
// 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) {
sha, err := HeadSHA(s.Worktree)
if err != nil {
return continuity.Handoff{}, fmt.Errorf("adapter: read worktree HEAD: %w", err)
}
branchOut, err := exec.Command("git", "-C", s.Worktree, "branch", "--show-current").Output()
if err != nil {
return continuity.Handoff{}, fmt.Errorf("adapter: read worktree branch: %w", err)
}
dirty, err := dirtyFiles(s.Worktree)
if err != nil {
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},
}, nil
}
func handoffID(s Session) string {
id := s.AgentName
if id == "" {
id = s.PaneID
}
id = strings.Trim(invalidAgentName.ReplaceAllString(strings.ToLower(id), "-"), "-_")
if id == "" {
return "session"
}
return id
}
func dirtyFiles(root string) ([]continuity.Dirty, error) {
paths := map[string]bool{}
for _, args := range [][]string{{"diff", "--name-only", "-z"}, {"ls-files", "--others", "--exclude-standard", "-z"}} {
out, err := exec.Command("git", append([]string{"-C", root}, args...)...).Output()
if err != nil {
return nil, err
}
for _, path := range strings.Split(string(out), "\x00") {
if path != "" && path != HandoffFile {
paths[path] = true
}
}
}
keys := make([]string, 0, len(paths))
for path := range paths {
keys = append(keys, path)
}
sort.Strings(keys)
dirty := make([]continuity.Dirty, 0, len(keys))
for _, path := range keys {
sum := sha256sum(filepath.Join(root, path))
if len(sum) == 0 {
return nil, fmt.Errorf("adapter: hash dirty file %s", path)
}
dirty = append(dirty, continuity.Dirty{Path: path, SHA256: hex.EncodeToString(sum)})
}
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 (a CLIAdapter) Kill(ctx context.Context, s Session) error {
return a.Client.Call(ctx, "pane.close", map[string]any{"pane_id": s.PaneID}, nil)
}
func (a CLIAdapter) AtTurnBoundary(ctx context.Context, s Session) (bool, error) {
status, err := a.AgentStatus(ctx, s)
if err != nil {
return false, err
}
return !IsBusy(status), nil
}
func (a CLIAdapter) PaneExited(ctx context.Context, s Session) (bool, error) {
status, err := a.AgentStatus(ctx, s)
if err != nil {
return false, err
}
return strings.EqualFold(status, "exited") || strings.EqualFold(status, "dead"), nil
}
func (a CLIAdapter) AgentStatus(ctx context.Context, s Session) (string, error) {
// Current herdr protocol exposes agent state through agent.get; older
// Orchestra code used pane.status, which is not a valid protocol method.
var r map[string]any
if err := a.Client.Call(ctx, "agent.get", map[string]any{"target": s.PaneID}, &r); err != nil {
return "", err
}
return statusFromAgentResult(r), nil
}
func (a CLIAdapter) AgentBlocker(ctx context.Context, s Session) (string, error) {
var r struct {
Read struct {
Text string `json:"text"`
} `json:"read"`
}
if err := a.Client.Call(ctx, "pane.read", map[string]any{"pane_id": s.PaneID, "source": "recent"}, &r); err != nil {
return "", err
}
text := strings.TrimSpace(r.Read.Text)
lines := strings.Split(text, "\n")
for i, raw := range lines {
line := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "┃"))
if !strings.EqualFold(line, "Permission required") && !strings.EqualFold(line, "Approval required") && !strings.HasPrefix(strings.ToLower(line), "waiting for") {
continue
}
for _, next := range lines[i+1:] {
command := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(next), "┃"))
if strings.HasPrefix(command, "$ ") {
return strings.ToLower(line) + ": shell command `" + strings.TrimSpace(strings.TrimPrefix(command, "$ ")) + "`", nil
}
}
return strings.ToLower(line), nil
}
return "", nil
}
func (a CLIAdapter) PaneCapture(ctx context.Context, s Session, source string) (string, error) {
if source == "" {
source = "recent"
}
var r struct {
Read struct {
Text string `json:"text"`
} `json:"read"`
}
if err := a.Client.Call(ctx, "pane.read", map[string]any{"pane_id": s.PaneID, "source": source}, &r); err != nil {
return "", err
}
return r.Read.Text, nil
}
func statusFromAgentResult(v any) string {
if m, ok := v.(map[string]any); ok {
for _, key := range []string{"status", "agent_status", "state"} {
if s, ok := m[key].(string); ok && s != "" {
return s
}
}
for _, child := range m {
if s := statusFromAgentResult(child); s != "" {
return s
}
}
}
if a, ok := v.([]any); ok {
for _, child := range a {
if s := statusFromAgentResult(child); s != "" {
return s
}
}
}
return ""
}
var _ = json.RawMessage{}
// Occupancy reads the harness's own session state — never the herdr pane id,
// which ClaudeUsage/CodexUsage/OpenCodeUsage cannot open (spec §5.2.1: "the
// whole rotation system rests on this number"). A session file that cannot
// be resolved or read is a hard error, not a silently-empty Usage{}, so
// callers (Coordinator.rotate, refreshSessionHealth) surface it instead of
// mistaking "we don't know" for "occupancy is zero".
func (a CLIAdapter) Occupancy(s Session) (float64, error) {
if a.Usage == nil {
return 0, fmt.Errorf("adapter: usage reader required")
}
path := s.SessionFile
if path == "" {
resolved, err := a.resolveSessionFile(s)
if err != nil {
return 0, fmt.Errorf("adapter: resolve session file: %w", err)
}
path = resolved
}
u, e := a.Usage(path)
if e != nil {
return 0, fmt.Errorf("adapter: read usage from %s: %w", path, e)
}
return Fraction(u, a.Window), nil
}
func (a CLIAdapter) resolveSessionFile(s Session) (string, error) {
switch a.Harness {
case "claude":
return ClaudeSessionFile(s.Worktree)
case "codex":
_, path, err := CodexActiveUsage("")
return path, err
default:
// opencode's session-file resolution needs the running session id,
// which is only available via the SSE/status API (OpenCodeStatus),
// not derivable from the worktree alone. Per AUDIT.md Phase 1, wiring
// this needs verification against a live opencode instance before it
// can drive rotation — refuse loudly rather than guess a path.
return "", fmt.Errorf("adapter: harness %q has no session-file resolver; verify against a live session first (AUDIT.md Phase 1)", a.Harness)
}
}
var Claude = func(c *Client, w int64, cas continuity.CAS) CLIAdapter {
return CLIAdapter{Client: c, Harness: "claude", Window: w, Usage: ClaudeUsage, CAS: cas}
}
var Codex = func(c *Client, w int64, cas continuity.CAS) CLIAdapter {
return CLIAdapter{Client: c, Harness: "codex", Window: w, Usage: CodexUsage, CAS: cas}
}
var OpenCode = func(c *Client, w int64, cas continuity.CAS) CLIAdapter {
return CLIAdapter{Client: c, Harness: "opencode", Window: w, Usage: OpenCodeUsage, CAS: cas}
}