cb7782db01
Handoff.Command is the last command observed in the pane. The handoff prompt tells the agent to write .orchestra-handoff-report.md and stop, so that write is almost always the last command there. Validate then rejects it as circular: "must not point to a handoff or report". Every phase rotation therefore failed on Orchestra's own instruction. Live on run 5, one stage past F36. lastObservedCommand now skips commands the validator would call circular, so the successor gets the last command that was real work. The rule stays in continuity and is asked, not restated, which is how F36 happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011xsXyr5J1RACo71YeKG3Pu
820 lines
30 KiB
Go
820 lines
30 KiB
Go
package herdr
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"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)
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// ApprovalResponder executes an explicitly displayed permission decision.
|
|
// Implementations must re-read the pane before sending input so callers can
|
|
// bind a decision to the exact capture they rendered.
|
|
type ApprovalResponder interface {
|
|
RespondApproval(context.Context, Session, bool, string) error
|
|
}
|
|
type CLIAdapter struct {
|
|
// Backend is the machine-local pane/process implementation. Client is
|
|
// retained as a compatibility alias for existing in-process callers and
|
|
// tests; new worker code sets Backend explicitly.
|
|
Backend Backend
|
|
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
|
|
// Remote, when set by a federation worker, is pushed after the scratch
|
|
// commit and before the pane claim is released. Git is the cross-machine
|
|
// transport; a CAS handoff must never point at an unpushed anchor.
|
|
Remote string
|
|
}
|
|
|
|
func (a CLIAdapter) backend() (Backend, error) {
|
|
if a.Backend != nil {
|
|
return a.Backend, nil
|
|
}
|
|
if a.Client != nil {
|
|
return a.Client, nil
|
|
}
|
|
return nil, fmt.Errorf("adapter: backend required")
|
|
}
|
|
|
|
// 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 holds the agent's small, labelled answer during release.
|
|
// It is not a report: the worker parses it, derives the protocol facts, and
|
|
// seals the resulting canonical JSON.
|
|
const HandoffReportFile = ".orchestra-handoff-report.md"
|
|
|
|
// LaunchContextFile is where the exact instruction a session was launched with
|
|
// is written, in the worktree, at launch. Burn-in inspects it: the only
|
|
// question worth asking of a run is whether the agent was told what the task
|
|
// wants, what was most recently decided, which phase it is in, what is merely
|
|
// history, and what to do next. Reading it back from pane scrollback is not
|
|
// the same thing, because the harness reflows and truncates it.
|
|
const LaunchContextFile = ".orchestra/launch.md"
|
|
|
|
// WriteLaunchContext records that instruction. It never fails a launch: the
|
|
// evidence is worth having, and is not worth refusing to start work over.
|
|
func WriteLaunchContext(worktree, prompt string) error {
|
|
path := filepath.Join(worktree, LaunchContextFile)
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
// Orchestra's own scratch directory must not show up as the session's
|
|
// work. A .gitignore of "*" ignores the directory including itself, so the
|
|
// tree stays clean for the agent, for the gate, and for review evidence.
|
|
// The worker's done marker lives here too and had the same problem.
|
|
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*\n"), 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, []byte(prompt), 0o644)
|
|
}
|
|
|
|
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) {
|
|
backend, err := a.backend()
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
s, err := backend.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 := backend.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 {
|
|
backend, err := a.backend()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if client, ok := backend.(*Client); ok {
|
|
client.BindAgent(s.PaneID, s.AgentName)
|
|
}
|
|
if err := backend.Prompt(ctx, s.PaneID, text, wait); err != nil {
|
|
return err
|
|
}
|
|
// Every Orchestra-originated pane write confirms (F20). This is the
|
|
// shared path for handoff and rotation prompts, so leaving it unconfirmed
|
|
// left the exact failure F20 exists to catch: a prompt sitting unsubmitted
|
|
// in the editor while Orchestra waits for a reply that cannot come.
|
|
c, ok := backend.(InputConfirmer)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
evidence, err := c.ConfirmInput(ctx, s, text)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Printf("input to %s confirmed: %s", s.PaneID, evidence)
|
|
return nil
|
|
}
|
|
|
|
const handoffPrompt = `Orchestra is about to rotate this task. Write ONLY the following labelled answers to ` + HandoffReportFile + `, then stop. Output nothing else.
|
|
|
|
NEXT: the single next action (one line, at most 200 characters).
|
|
WHY: why that is next (one line, at most 200 characters).
|
|
REMAINING: outstanding items, one line each, at most 200 characters each. If none: NONE.
|
|
DEAD ENDS: approaches tried that failed — "tried X → failed because Y", one per line. If none: NONE.
|
|
OPEN Q: unresolved decisions, one line each. If none: NONE.
|
|
LEARNED: constraints discovered that are NOT in TASK.md, one line each. If none: NONE.
|
|
|
|
Do NOT include: what you completed (the diff shows it), the goal or done-criteria (TASK.md holds them), git SHAs/branches/paths, or a prose summary. No headings and no report. Do not edit TASK.md.`
|
|
|
|
// 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")
|
|
case "phase_changed":
|
|
sb.WriteString("This task has moved to its next work phase, so this session's context is no longer the right one for it. This is not a judgement about your work: the next phase starts fresh with the result you sealed. Stop at a clean point and hand off.\n")
|
|
case "reconcile_failure":
|
|
sb.WriteString("Orchestra cannot currently read the human input for this task, so it can no longer guarantee your instructions are current. Stop at a clean point and hand off. This is not a judgement about your work.\n")
|
|
}
|
|
fmt.Fprintf(&sb, "Before you stop, write the labelled handoff answers requested below to %s at the worktree root (reason: %q).\n\n%s", HandoffReportFile, reason, handoffPrompt[strings.Index(handoffPrompt, "NEXT:"):])
|
|
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("Do not add a prose summary, completed-work narration, or protocol JSON. 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)
|
|
}
|
|
|
|
// DecisionNotifier delivers newly recorded human decisions to a live agent at
|
|
// a verified turn boundary. Optional, like ConventionsNotifier: an adapter
|
|
// with no live pane omits it.
|
|
//
|
|
// The text is rendered by the caller, never here. Orchestra keeps one place
|
|
// that decides how a decision becomes model-visible text.
|
|
type DecisionNotifier interface {
|
|
NotifyDecisions(context.Context, Session, string) error
|
|
}
|
|
|
|
func (a CLIAdapter) NotifyDecisions(ctx context.Context, s Session, text string) error {
|
|
return a.prompt(ctx, s, text, time.Minute)
|
|
}
|
|
|
|
// Release reads the semantic report the agent wrote at the worktree root,
|
|
// derives and validates the canonical handoff from the worktree's real Git
|
|
// state, 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.
|
|
// PreparedRelease is the durable, coordinator-independent half of a release.
|
|
// The worker persists it before publishing TaskReleased so a lost HTTP reply
|
|
// never requires reconstructing (or deleting) the agent's report.
|
|
type PreparedRelease struct {
|
|
Ref string
|
|
AnchorSHA string
|
|
}
|
|
|
|
// PrepareRelease seals an immutable Git checkpoint and uploads its canonical
|
|
// handoff, but deliberately leaves both the pane claim and report in place.
|
|
// The caller controls the retryable transaction around coordinator acceptance.
|
|
func (a CLIAdapter) PrepareRelease(ctx context.Context, s Session) (PreparedRelease, error) {
|
|
if a.CAS == nil {
|
|
return PreparedRelease{}, fmt.Errorf("adapter: CAS store required to upload handoff")
|
|
}
|
|
// A federation worker always supplies the immutable task hash and remote.
|
|
// The empty-hash case is retained solely for old in-process adapter users;
|
|
// it is not reachable from the worker release path.
|
|
if s.TaskFileSHA != "" {
|
|
if a.Remote == "" {
|
|
return PreparedRelease{}, fmt.Errorf("adapter: project remote required for checkpoint")
|
|
}
|
|
if err := continuity.VerifyTaskFile(s.Worktree, s.TaskFileSHA); err != nil {
|
|
return PreparedRelease{}, fmt.Errorf("adapter: verify immutable TASK.md: %w", err)
|
|
}
|
|
}
|
|
path := filepath.Join(s.Worktree, HandoffReportFile)
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return PreparedRelease{}, fmt.Errorf("adapter: semantic handoff report not written yet (%s): %w", path, err)
|
|
}
|
|
if strings.TrimSpace(string(b)) == "" {
|
|
return PreparedRelease{}, fmt.Errorf("adapter: semantic handoff report is empty")
|
|
}
|
|
h, err := canonicalHandoff(s, string(b), a.lastObservedCommand(s))
|
|
if err != nil {
|
|
return PreparedRelease{}, err
|
|
}
|
|
// Always checkpoint and push, including already-committed clean work. Git
|
|
// is the cross-machine transport, so merely observing a local clean HEAD is
|
|
// not a sufficient anchor.
|
|
branch := "orchestra/scratch/" + h.Meta.ID
|
|
if err := continuity.ScratchCommit(s.Worktree, branch, "orchestra: pre-release WIP snapshot ("+h.Meta.ID+")"); err != nil {
|
|
return PreparedRelease{}, fmt.Errorf("adapter: scratch commit: %w", err)
|
|
}
|
|
anchor, err := HeadSHA(s.Worktree)
|
|
if err != nil {
|
|
return PreparedRelease{}, fmt.Errorf("adapter: read checkpoint HEAD: %w", err)
|
|
}
|
|
if a.Remote != "" {
|
|
if err := continuity.ScratchPush(s.Worktree, branch, a.Remote); err != nil {
|
|
return PreparedRelease{}, fmt.Errorf("adapter: push checkpoint: %w", err)
|
|
}
|
|
out, err := exec.CommandContext(ctx, "git", "-C", s.Worktree, "ls-remote", a.Remote, "refs/heads/"+branch).Output()
|
|
if err != nil || !strings.HasPrefix(string(out), anchor+"\t") {
|
|
return PreparedRelease{}, fmt.Errorf("adapter: verify pushed anchor: got %q: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
}
|
|
h.Anchor.GitSHA, h.Anchor.Branch, h.Anchor.Dirty = anchor, branch, nil
|
|
ref, err := continuity.Save(h, a.CAS)
|
|
if err != nil {
|
|
return PreparedRelease{}, fmt.Errorf("adapter: upload handoff: %w", err)
|
|
}
|
|
return PreparedRelease{Ref: ref, AnchorSHA: anchor}, nil
|
|
}
|
|
|
|
// ReleaseAgent drops only herdr's harness binding. It does not close the pane:
|
|
// a predecessor stays recoverable until the successor has validated pickup.
|
|
func (a CLIAdapter) ReleaseAgent(ctx context.Context, s Session) error {
|
|
backend, err := a.backend()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := backend.ReleaseAgent(ctx, s, a.Harness); err != nil {
|
|
return fmt.Errorf("adapter: release agent through %s: %w", backend.Kind(), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Release is retained for the coordinator's legacy local path. Federation
|
|
// workers use PrepareRelease and ReleaseAgent as separate durable phases.
|
|
func (a CLIAdapter) Release(ctx context.Context, s Session) (string, error) {
|
|
p, err := a.PrepareRelease(ctx, s)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := a.ReleaseAgent(ctx, s); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.Remove(filepath.Join(s.Worktree, HandoffReportFile)); err != nil && !os.IsNotExist(err) {
|
|
return "", fmt.Errorf("adapter: remove transferred semantic report: %w", err)
|
|
}
|
|
return p.Ref, nil
|
|
}
|
|
|
|
// canonicalHandoff keeps Git-derived protocol facts on the worker that owns
|
|
// the checkout. Every authored field comes from the validated agent answer;
|
|
// it never fabricates task intent or a circular next action.
|
|
func canonicalHandoff(s Session, answer, command string) (continuity.Handoff, error) {
|
|
authored, err := parseHandoffAnswer(answer)
|
|
if err != nil {
|
|
return continuity.Handoff{}, fmt.Errorf("adapter: invalid handoff answer: %w", err)
|
|
}
|
|
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: handoffReason(s)},
|
|
Anchor: continuity.Anchor{GitSHA: sha, Branch: strings.TrimSpace(string(branchOut)), Dirty: dirty},
|
|
Action: authored.Action,
|
|
Command: command,
|
|
Remaining: authored.Remaining,
|
|
DeadEnds: authored.DeadEnds,
|
|
OpenQuestions: authored.OpenQuestions,
|
|
Learned: authored.Learned,
|
|
}, nil
|
|
}
|
|
|
|
type handoffAnswer struct {
|
|
Action, Why string
|
|
Remaining []string
|
|
DeadEnds []continuity.DeadEnd
|
|
OpenQuestions, Learned []string
|
|
}
|
|
|
|
func parseHandoffAnswer(answer string) (handoffAnswer, error) {
|
|
var out handoffAnswer
|
|
sections := map[string][]string{}
|
|
var current string
|
|
for _, raw := range strings.Split(strings.ReplaceAll(answer, "\r\n", "\n"), "\n") {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
for _, name := range []string{"NEXT", "WHY", "REMAINING", "DEAD ENDS", "OPEN Q", "LEARNED"} {
|
|
prefix := name + ":"
|
|
if strings.HasPrefix(line, prefix) {
|
|
current = name
|
|
if value := strings.TrimSpace(strings.TrimPrefix(line, prefix)); value != "" {
|
|
sections[name] = append(sections[name], value)
|
|
}
|
|
goto parsed
|
|
}
|
|
}
|
|
if current == "" {
|
|
return out, fmt.Errorf("unexpected line %q", line)
|
|
}
|
|
sections[current] = append(sections[current], strings.TrimSpace(strings.TrimPrefix(line, "- ")))
|
|
parsed:
|
|
}
|
|
for _, name := range []string{"NEXT", "WHY", "REMAINING", "DEAD ENDS", "OPEN Q", "LEARNED"} {
|
|
if len(sections[name]) == 0 {
|
|
return out, fmt.Errorf("missing %s", name)
|
|
}
|
|
}
|
|
if len(sections["NEXT"]) != 1 || len(sections["WHY"]) != 1 {
|
|
return out, fmt.Errorf("NEXT and WHY each require one line")
|
|
}
|
|
out.Action = sections["NEXT"][0] + " — " + sections["WHY"][0]
|
|
for _, name := range []string{"REMAINING", "OPEN Q", "LEARNED"} {
|
|
values, err := answerList(sections[name])
|
|
if err != nil {
|
|
return out, fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
switch name {
|
|
case "REMAINING":
|
|
out.Remaining = values
|
|
case "OPEN Q":
|
|
out.OpenQuestions = values
|
|
case "LEARNED":
|
|
out.Learned = values
|
|
}
|
|
}
|
|
deadEnds, err := answerList(sections["DEAD ENDS"])
|
|
if err != nil {
|
|
return out, fmt.Errorf("DEAD ENDS: %w", err)
|
|
}
|
|
for _, item := range deadEnds {
|
|
parts := strings.SplitN(item, "→", 2)
|
|
if len(parts) != 2 {
|
|
return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'")
|
|
}
|
|
if !strings.HasPrefix(parts[0], "tried ") || !strings.HasPrefix(strings.TrimSpace(parts[1]), "failed because ") {
|
|
return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'")
|
|
}
|
|
tried := strings.TrimSpace(strings.TrimPrefix(parts[0], "tried "))
|
|
why := strings.TrimSpace(strings.TrimPrefix(parts[1], "failed because "))
|
|
if tried == "" || why == "" {
|
|
return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'")
|
|
}
|
|
out.DeadEnds = append(out.DeadEnds, continuity.DeadEnd{Tried: tried, WhyFailed: why})
|
|
}
|
|
if err := (continuity.Handoff{Meta: continuity.Meta{ID: "answer", Reason: "manual"}, Anchor: continuity.Anchor{GitSHA: strings.Repeat("0", 40), Branch: "answer"}, Action: out.Action, Remaining: out.Remaining, DeadEnds: out.DeadEnds, OpenQuestions: out.OpenQuestions, Learned: out.Learned}).Validate(); err != nil {
|
|
return out, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func answerList(lines []string) ([]string, error) {
|
|
if len(lines) == 1 && lines[0] == "NONE" {
|
|
return nil, nil
|
|
}
|
|
for _, line := range lines {
|
|
if line == "NONE" {
|
|
return nil, fmt.Errorf("NONE must be the only value")
|
|
}
|
|
}
|
|
return lines, nil
|
|
}
|
|
|
|
func (a CLIAdapter) lastObservedCommand(s Session) string {
|
|
calls, err := a.Activity(context.Background(), s)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for i := len(calls) - 1; i >= 0; i-- {
|
|
if calls[i].Kind != "command" {
|
|
continue
|
|
}
|
|
// Skip the write Orchestra itself asked for. The handoff prompt tells
|
|
// the agent to write HandoffReportFile and stop, so that write is
|
|
// almost always the last command in the pane. Carrying it into
|
|
// Handoff.Command made every rotation fail its own circularity check
|
|
// with "must not point to a handoff or report" (F37). The successor
|
|
// wants the last command that was real work.
|
|
if continuity.IsCircularCommand(calls[i].Key) {
|
|
continue
|
|
}
|
|
return calls[i].Key
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func handoffReason(s Session) string {
|
|
switch s.HandoffReason {
|
|
case "threshold", "milestone", "thrash", "manual", "reconcile_failure", "phase_changed":
|
|
return s.HandoffReason
|
|
default:
|
|
return "threshold"
|
|
}
|
|
}
|
|
|
|
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) {
|
|
out, err := exec.Command("git", "-C", root, "status", "--porcelain=v1", "-z").Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
paths := map[string]bool{}
|
|
deleted := map[string]bool{}
|
|
parts := strings.Split(string(out), "\x00")
|
|
for i := 0; i < len(parts); i++ {
|
|
record := parts[i]
|
|
if len(record) < 4 {
|
|
continue
|
|
}
|
|
status, path := record[:2], record[3:]
|
|
if path == HandoffFile || path == HandoffReportFile || path == ".orchestra/done" || strings.HasPrefix(path, ".orchestra/") {
|
|
continue
|
|
}
|
|
paths[path] = true
|
|
deleted[path] = strings.Contains(status, "D")
|
|
// A rename/copy record has the original path as the next NUL item.
|
|
if status[0] == 'R' || status[0] == 'C' || status[1] == 'R' || status[1] == 'C' {
|
|
i++
|
|
}
|
|
}
|
|
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 {
|
|
d := continuity.Dirty{Path: path, Deleted: deleted[path]}
|
|
if !d.Deleted {
|
|
sum := sha256sum(filepath.Join(root, path))
|
|
if len(sum) == 0 {
|
|
return nil, fmt.Errorf("adapter: hash dirty file %s", path)
|
|
}
|
|
d.SHA256 = hex.EncodeToString(sum)
|
|
}
|
|
dirty = append(dirty, d)
|
|
}
|
|
return dirty, nil
|
|
}
|
|
|
|
func agentForSession(_ Session, fallback string) string {
|
|
// pane.release_agent identifies the harness binding, not herdr's
|
|
// machine-global terminal name. AgentName is only for prompt routing;
|
|
// passing it here is accepted by herdr but leaves the binding intact.
|
|
// Keep the configured harness for both new and pre-B16 session records.
|
|
return fallback
|
|
}
|
|
func (a CLIAdapter) Kill(ctx context.Context, s Session) error {
|
|
backend, err := a.backend()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return backend.Kill(ctx, s)
|
|
}
|
|
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) {
|
|
backend, err := a.backend()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return backend.AgentStatus(ctx, s)
|
|
}
|
|
|
|
func (a CLIAdapter) AgentBlocker(ctx context.Context, s Session) (string, error) {
|
|
text, err := a.PaneCapture(ctx, s, "recent")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
text = strings.TrimSpace(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) {
|
|
backend, err := a.backend()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return backend.PaneCapture(ctx, s, source)
|
|
}
|
|
|
|
// RespondApproval only acts on harness prompts that visibly expose a y/n
|
|
// choice. This deliberately refuses unknown dialog layouts rather than
|
|
// guessing an Enter key could mean approval.
|
|
func (a CLIAdapter) RespondApproval(ctx context.Context, s Session, grant bool, expectedCapture string) error {
|
|
current, err := a.PaneCapture(ctx, s, "recent")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current != expectedCapture {
|
|
return fmt.Errorf("approval prompt changed")
|
|
}
|
|
low := strings.ToLower(current)
|
|
if !strings.Contains(low, "[y/n]") && !strings.Contains(low, "(y/n)") {
|
|
return fmt.Errorf("approval prompt has no unambiguous y/n confirmation")
|
|
}
|
|
input := "n\n"
|
|
if grant {
|
|
input = "y\n"
|
|
}
|
|
backend, err := a.backend()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return backend.SendText(ctx, s, input)
|
|
}
|
|
|
|
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.Harness == "opencode" {
|
|
u, err := OpenCodeSessionUsage(s.SessionID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return Fraction(u, a.Window), nil
|
|
}
|
|
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
|
|
}
|
|
|
|
// ResolveSessionIdentity discovers and returns the harness-native session
|
|
// identity. Callers persist the returned Session before relying on occupancy,
|
|
// so restart recovery keeps observing the same harness session.
|
|
func (a CLIAdapter) ResolveSessionIdentity(s Session) (Session, error) {
|
|
if a.Harness == "opencode" {
|
|
if s.SessionID != "" {
|
|
return s, nil
|
|
}
|
|
id, err := OpenCodeSessionID(s.Worktree)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
s.SessionID = id
|
|
return s, nil
|
|
}
|
|
if s.SessionFile != "" {
|
|
return s, nil
|
|
}
|
|
path, err := a.resolveSessionFile(s)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
s.SessionFile = path
|
|
return s, 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:
|
|
return "", fmt.Errorf("adapter: harness %q has no session-file resolver", a.Harness)
|
|
}
|
|
}
|
|
|
|
var Claude = func(c *Client, w int64, cas continuity.CAS) CLIAdapter {
|
|
return CLIAdapter{Backend: c, Client: c, Harness: "claude", Window: w, Usage: ClaudeUsage, CAS: cas}
|
|
}
|
|
var Codex = func(c *Client, w int64, cas continuity.CAS) CLIAdapter {
|
|
return CLIAdapter{Backend: c, Client: c, Harness: "codex", Window: w, Usage: CodexUsage, CAS: cas}
|
|
}
|
|
var OpenCode = func(c *Client, w int64, cas continuity.CAS) CLIAdapter {
|
|
return CLIAdapter{Backend: c, Client: c, Harness: "opencode", Window: w, Usage: OpenCodeUsage, CAS: cas}
|
|
}
|