3fe3aee5b7
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
348 lines
14 KiB
Go
348 lines
14 KiB
Go
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":"<any stable string for this handoff>"},"anchor":{"git_sha":"<current HEAD via git rev-parse HEAD>","branch":"<current branch>","dirty":[{"path":"<repo-relative path>","sha256":"<sha256 of its current contents>"}, ...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}
|
|
}
|