c85fb81663
Closes the last two S11 triggers. internal/herdr/activity.go normalizes tool/function calls per harness (ClaudeActivity verified against the existing transcript format, CodexActivity best-effort/unverified, OpenCodeActivity refuses — no confirmed per-tool-call source exists) and implements the three thrash rules plus a narrow milestone check (successful git commit as the last call). CLIAdapter.RequestHandoffReason asks the agent to write a handoff with meta.reason set, same "ask, don't invent" pattern as the existing handoff/ report requests. rotate() and TurnDecision generalize the manual-bypass shortcut to manual/milestone/thrash and request (never directly release) on a detected trigger. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
418 lines
17 KiB
Go
418 lines
17 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)
|
|
}
|
|
|
|
// 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 §6.1 handoff to %s at the worktree root with meta.reason=%q", HandoffFile, 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("Use the same anchor convention as any other handoff: the real git_sha via 'git rev-parse HEAD', the real branch, and a real sha256 of any uncommitted files — never fabricated. Do not edit TASK.md. Once written, stop normally.")
|
|
return a.Client.Prompt(ctx, s.PaneID, 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.Client.Prompt(ctx, s.PaneID, 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, 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}
|
|
}
|