package herdr import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "orchestra/internal/continuity" "os" "path/filepath" "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) } 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" 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) { 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 } if err := a.Client.Prompt(ctx, s.PaneID, fmt.Sprintf("Begin Orchestra task %s. Inspect the repository, understand the task context, and proceed with the requested work.", task), time.Minute); err != nil { return Session{}, err } return s, nil } // 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.Client.Prompt(ctx, s.PaneID, fmt.Sprintf(bootstrapPrompt, ref), time.Minute) } // handoffPrompt is Phase 4 item 2's missing half (AUDIT.md): Release already // validates and uploads a §6.1 handoff the agent wrote, but nothing ever told // the agent that convention exists. rotate() sends this once occupancy crosses // the hard threshold at a turn boundary, mirroring the .orchestra-report.md // convention B3 established for completion — the plane still never invents a // handoff, it only asks the agent to produce one, then validates it in Release. const handoffPrompt = `Orchestra is about to rotate this task to a fresh session (context budget reached). Before you stop, write a §6.1 handoff to ` + HandoffFile + ` at the worktree root, a JSON object with at least: {"meta":{"id":""},"anchor":{"git_sha":"","branch":"","dirty":[{"path":"","sha256":""}, ...for any uncommitted files]},"knowledge":{...whatever structured context the next agent needs...}} Do not edit TASK.md. Do not fabricate the git_sha or dirty file hashes — read them for real. 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.Client.Prompt(ctx, s.PaneID, handoffPrompt, 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, HandoffFile) b, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("adapter: handoff not written yet (%s): %w", path, err) } h, err := continuity.Decode(b) if err != nil { return "", fmt.Errorf("adapter: invalid handoff: %w", err) } sha, err := HeadSHA(s.Worktree) if err != nil { return "", fmt.Errorf("adapter: read worktree HEAD: %w", err) } if h.Anchor.GitSHA != sha { return "", fmt.Errorf("adapter: handoff anchor %s does not match worktree HEAD %s", h.Anchor.GitSHA, 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": a.Harness, }, nil); err != nil { return "", fmt.Errorf("adapter: pane.release_agent: %w", err) } return ref, nil } 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} }