v3 workflow: intent, phases, review, submission, enforcement, burn-in
The v3 stack, previously an uncommitted working tree, plus this session's two units and the burn-in instrument. This commit is the burn-in build identity: coordinator and worker must both report this revision before a task is created. Workflow (earlier sessions, uncommitted until now): human decision events and reduction, source cursors and reconcile-before-launch, turn-boundary reconciliation, internal/agentctx as the single renderer, ace-fca phases with sealed artifacts, the trajectory gate, bounded grilling, independent review, task pr enforcement, and human review reflection. Capability restrictions at the agent boundary: an authz.Agent surface at GatedWrite may ask and may not act. It also fixes two bugs the unit exposed -- gated surfaces could not reach the two endpoints written for them, and RequestHumanDecision would block an unowned task while rejecting a question from the session that did own it. Turn-boundary reconcile-failure escalation: a streak of consecutive failures asks the session to hand off, fenced on the lease epoch, with reconcile_failure as a real handoff reason. The worker was dropping the coordinator's verdict on the floor; it now acts on it. Burn-in: herdr.WriteLaunchContext dumps the exact agentctx.Build result to <worktree>/.orchestra/launch.md at every launch, local and federated. BURNIN.md is the runbook. deploy/build.sh stamps both binaries from one commit. go build, go vet and go test ./... pass, 20 packages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+90
-57
@@ -29,7 +29,6 @@ func sha256sum(path string) []byte {
|
||||
|
||||
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)
|
||||
@@ -70,6 +69,10 @@ 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
|
||||
@@ -84,6 +87,16 @@ type CLIAdapter struct {
|
||||
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
|
||||
@@ -95,6 +108,24 @@ const HandoffFile = ".orchestra-handoff.json"
|
||||
// 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)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); 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))
|
||||
}
|
||||
@@ -107,17 +138,18 @@ func defaultTaskPrompt(task string) string {
|
||||
// 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")
|
||||
backend, err := a.backend()
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
s, err := a.Client.StartAgent(ctx, worktree, worktree, "orchestra/"+task, a.Harness, task)
|
||||
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 := a.Client.Prompt(ctx, s.PaneID, prompt, 0); err != nil {
|
||||
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
|
||||
@@ -126,24 +158,14 @@ func (a CLIAdapter) LeasePrompt(ctx context.Context, task, worktree, prompt stri
|
||||
}
|
||||
|
||||
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)
|
||||
backend, err := a.backend()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if client, ok := backend.(*Client); ok {
|
||||
client.BindAgent(s.PaneID, s.AgentName)
|
||||
}
|
||||
return backend.Prompt(ctx, s.PaneID, text, wait)
|
||||
}
|
||||
|
||||
const handoffPrompt = `Orchestra is about to rotate this task. Write ONLY the following labelled answers to ` + HandoffReportFile + `, then stop. Output nothing else.
|
||||
@@ -186,6 +208,8 @@ func (a CLIAdapter) RequestHandoffReason(ctx context.Context, s Session, reason
|
||||
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 "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 {
|
||||
@@ -238,6 +262,20 @@ func (a CLIAdapter) NotifyConventionsChanged(ctx context.Context, s Session) err
|
||||
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
|
||||
@@ -317,12 +355,12 @@ func (a CLIAdapter) PrepareRelease(ctx context.Context, s Session) (PreparedRele
|
||||
// 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 {
|
||||
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)
|
||||
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
|
||||
}
|
||||
@@ -482,7 +520,7 @@ func (a CLIAdapter) lastObservedCommand(s Session) string {
|
||||
|
||||
func handoffReason(s Session) string {
|
||||
switch s.HandoffReason {
|
||||
case "threshold", "milestone", "thrash", "manual":
|
||||
case "threshold", "milestone", "thrash", "manual", "reconcile_failure":
|
||||
return s.HandoffReason
|
||||
default:
|
||||
return "threshold"
|
||||
@@ -553,7 +591,11 @@ func agentForSession(_ Session, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
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)
|
||||
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)
|
||||
@@ -570,25 +612,19 @@ func (a CLIAdapter) PaneExited(ctx context.Context, s Session) (bool, error) {
|
||||
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 {
|
||||
backend, err := a.backend()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return statusFromAgentResult(r), nil
|
||||
return backend.AgentStatus(ctx, s)
|
||||
}
|
||||
|
||||
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 {
|
||||
text, err := a.PaneCapture(ctx, s, "recent")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := strings.TrimSpace(r.Read.Text)
|
||||
text = strings.TrimSpace(text)
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, raw := range lines {
|
||||
line := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "┃"))
|
||||
@@ -607,18 +643,11 @@ func (a CLIAdapter) AgentBlocker(ctx context.Context, s Session) (string, error)
|
||||
}
|
||||
|
||||
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 {
|
||||
backend, err := a.backend()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Read.Text, nil
|
||||
return backend.PaneCapture(ctx, s, source)
|
||||
}
|
||||
|
||||
// RespondApproval only acts on harness prompts that visibly expose a y/n
|
||||
@@ -640,7 +669,11 @@ func (a CLIAdapter) RespondApproval(ctx context.Context, s Session, grant bool,
|
||||
if grant {
|
||||
input = "y\n"
|
||||
}
|
||||
return a.Client.Call(ctx, "pane.send_text", map[string]any{"pane_id": s.PaneID, "text": input}, nil)
|
||||
backend, err := a.backend()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return backend.SendText(ctx, s, input)
|
||||
}
|
||||
|
||||
func statusFromAgentResult(v any) string {
|
||||
@@ -739,11 +772,11 @@ func (a CLIAdapter) resolveSessionFile(s Session) (string, error) {
|
||||
}
|
||||
|
||||
var Claude = func(c *Client, w int64, cas continuity.CAS) CLIAdapter {
|
||||
return CLIAdapter{Client: c, Harness: "claude", Window: w, Usage: ClaudeUsage, CAS: cas}
|
||||
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{Client: c, Harness: "codex", Window: w, Usage: CodexUsage, CAS: cas}
|
||||
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{Client: c, Harness: "opencode", Window: w, Usage: OpenCodeUsage, CAS: cas}
|
||||
return CLIAdapter{Backend: c, Client: c, Harness: "opencode", Window: w, Usage: OpenCodeUsage, CAS: cas}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package herdr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Backend is the machine-local terminal/process seam used by a federation
|
||||
// worker. Herdr remains the default implementation; tmux is a deliberately
|
||||
// smaller alternative for Claude Code hosts that do not run herdr.
|
||||
//
|
||||
// The interface deals only in local session operations. Git checkout and
|
||||
// lease ownership stay with orchestra-worker regardless of the backend.
|
||||
type Backend interface {
|
||||
Kind() string
|
||||
Check(context.Context) error
|
||||
Worktree(context.Context, string, string, string) (string, error)
|
||||
StartAgent(context.Context, string, string, string, string, string) (Session, error)
|
||||
Prompt(context.Context, string, string, time.Duration) error
|
||||
Kill(context.Context, Session) error
|
||||
AgentStatus(context.Context, Session) (string, error)
|
||||
PaneCapture(context.Context, Session, string) (string, error)
|
||||
SendText(context.Context, Session, string) error
|
||||
SendKeys(context.Context, Session, []string) error
|
||||
ReleaseAgent(context.Context, Session, string) error
|
||||
}
|
||||
|
||||
// Kind identifies the existing JSON-RPC backend.
|
||||
func (c *Client) Kind() string { return "herdr" }
|
||||
|
||||
// Check verifies the live protocol rather than treating an open socket as a
|
||||
// healthy execution backend.
|
||||
func (c *Client) Check(ctx context.Context) error { return c.CheckProtocol(ctx, "17") }
|
||||
|
||||
func (c *Client) Kill(ctx context.Context, s Session) error {
|
||||
return c.Call(ctx, "pane.close", map[string]any{"pane_id": s.PaneID}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) AgentStatus(ctx context.Context, s Session) (string, error) {
|
||||
var result map[string]any
|
||||
if err := c.Call(ctx, "agent.get", map[string]any{"target": s.PaneID}, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return statusFromAgentResult(result), nil
|
||||
}
|
||||
|
||||
func (c *Client) PaneCapture(ctx context.Context, s Session, source string) (string, error) {
|
||||
if source == "" {
|
||||
source = "recent"
|
||||
}
|
||||
var result struct {
|
||||
Read struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"read"`
|
||||
}
|
||||
if err := c.Call(ctx, "pane.read", map[string]any{"pane_id": s.PaneID, "source": source}, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Read.Text, nil
|
||||
}
|
||||
|
||||
func (c *Client) SendText(ctx context.Context, s Session, text string) error {
|
||||
return c.Call(ctx, "pane.send_text", map[string]any{"pane_id": s.PaneID, "text": text}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) SendKeys(ctx context.Context, s Session, keys []string) error {
|
||||
return c.Call(ctx, "pane.send_keys", map[string]any{"pane_id": s.PaneID, "keys": keys}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) ReleaseAgent(ctx context.Context, s Session, harness string) error {
|
||||
return c.Call(ctx, "pane.release_agent", map[string]any{
|
||||
"pane_id": s.PaneID,
|
||||
"source": "herdr:" + harness,
|
||||
"agent": agentForSession(s, harness),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
var _ Backend = (*Client)(nil)
|
||||
@@ -160,6 +160,11 @@ type Session struct {
|
||||
// session's lease was created — the immutable-spec hash continuity's
|
||||
// pickup validation compares against on the next rotation (§6.2).
|
||||
TaskFileSHA string `json:"task_file_sha,omitempty"`
|
||||
// DeliveredDecisions holds the ids of the human decisions this session has
|
||||
// already been shown. A decision recorded while the lease is live is
|
||||
// delivered at the next verified turn boundary, and recording it here is
|
||||
// what stops the same correction being re-sent every turn.
|
||||
DeliveredDecisions []string `json:"delivered_decisions,omitempty"`
|
||||
// HandoffRequested is set once rotate() has prompted the agent to write
|
||||
// its §6.1 handoff (HandoffFile) — avoids re-sending the same prompt
|
||||
// every tick while Release keeps waiting for the file to appear.
|
||||
@@ -174,6 +179,15 @@ type Session struct {
|
||||
// tracking lives at the orchestra layer, never trusted from the agent's
|
||||
// cached view.
|
||||
ConventionsHash string `json:"conventions_hash,omitempty"`
|
||||
// ContextHandoffSHA is the last HANDOFF.md content consumed by Claude's
|
||||
// in-place /clear rollover. It is initialized when the session starts so
|
||||
// an older checked-in HANDOFF.md is not mistaken for a fresh hook result.
|
||||
ContextHandoffSHA string `json:"context_handoff_sha,omitempty"`
|
||||
// ContextResetSHA/ContextResetPhase make the two-command Claude rollover
|
||||
// recoverable across worker restarts. They are unrelated to the canonical
|
||||
// cross-worker handoff transaction above.
|
||||
ContextResetSHA string `json:"context_reset_sha,omitempty"`
|
||||
ContextResetPhase string `json:"context_reset_phase,omitempty"`
|
||||
}
|
||||
|
||||
// bootDeadline bounds the retry loops below. Freshly created panes/agents
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package herdr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TmuxBackend runs one Claude Code process per isolated tmux session. It is
|
||||
// intentionally Claude-only for now: Codex and OpenCode keep using the
|
||||
// verified herdr protocol until their terminal behavior has been exercised
|
||||
// against a live installation.
|
||||
type TmuxBackend struct {
|
||||
// Socket is a tmux socket name (-L) or an absolute socket path (-S).
|
||||
// An empty value uses the isolated socket name "orchestra".
|
||||
Socket string
|
||||
// Command is the Claude Code executable. An empty value resolves "claude"
|
||||
// through PATH.
|
||||
Command string
|
||||
// Binary is test/packaging override for tmux itself.
|
||||
Binary string
|
||||
}
|
||||
|
||||
func NewTmuxBackend(socket, command string) *TmuxBackend {
|
||||
return &TmuxBackend{Socket: socket, Command: command}
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) Kind() string { return "tmux" }
|
||||
|
||||
func (b *TmuxBackend) binary() string {
|
||||
if b.Binary != "" {
|
||||
return b.Binary
|
||||
}
|
||||
return "tmux"
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) socketArgs() []string {
|
||||
socket := b.Socket
|
||||
if socket == "" {
|
||||
socket = "orchestra"
|
||||
}
|
||||
if filepath.IsAbs(socket) {
|
||||
return []string{"-S", socket}
|
||||
}
|
||||
return []string{"-L", socket}
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) command(ctx context.Context, args ...string) ([]byte, error) {
|
||||
all := append(b.socketArgs(), args...)
|
||||
out, err := exec.CommandContext(ctx, b.binary(), all...).CombinedOutput()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("tmux %s: %s: %w", strings.Join(args, " "), strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) Check(ctx context.Context) error {
|
||||
if _, err := exec.LookPath(b.binary()); err != nil {
|
||||
return fmt.Errorf("tmux backend: %w", err)
|
||||
}
|
||||
// tmux -V does not require a server to exist. An idle backend is healthy
|
||||
// and will create its isolated server with the first session.
|
||||
if out, err := exec.CommandContext(ctx, b.binary(), "-V").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("tmux backend: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
command := b.Command
|
||||
if command == "" {
|
||||
command = "claude"
|
||||
}
|
||||
if _, err := exec.LookPath(command); err != nil {
|
||||
return fmt.Errorf("tmux backend Claude command: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) Worktree(_ context.Context, _ string, path, _ string) (string, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tmux backend worktree: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("tmux backend worktree %s is not a directory", path)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func tmuxSessionName(taskID string) string {
|
||||
id := strings.Trim(invalidAgentName.ReplaceAllString(strings.ToLower(taskID), "-"), "-_")
|
||||
if id == "" {
|
||||
id = "session"
|
||||
}
|
||||
if len(id) > 36 {
|
||||
id = strings.TrimRight(id[:36], "-_")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(taskID))
|
||||
return fmt.Sprintf("orchestra-%s-%x", id, sum[:4])
|
||||
}
|
||||
|
||||
func tmuxSession(paneID string) string {
|
||||
if before, _, ok := strings.Cut(paneID, ":"); ok {
|
||||
return before
|
||||
}
|
||||
return paneID
|
||||
}
|
||||
|
||||
func tmuxTarget(paneID string) string { return "=" + paneID }
|
||||
|
||||
func (b *TmuxBackend) hasSession(ctx context.Context, session string) (bool, error) {
|
||||
out, err := b.command(ctx, "has-session", "-t", "="+session)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
message := strings.ToLower(string(out) + " " + err.Error())
|
||||
if strings.Contains(message, "can't find session") || strings.Contains(message, "no server running") || strings.Contains(message, "no sessions") || (strings.Contains(message, "error connecting to") && strings.Contains(message, "no such file")) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) StartAgent(ctx context.Context, _, path, _, harness, taskID string) (Session, error) {
|
||||
if !strings.EqualFold(harness, "claude") {
|
||||
return Session{}, fmt.Errorf("tmux backend: harness %q is unsupported; only claude is enabled", harness)
|
||||
}
|
||||
if _, err := b.Worktree(ctx, "", path, ""); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
command := b.Command
|
||||
if command == "" {
|
||||
command = "claude"
|
||||
}
|
||||
resolved, err := exec.LookPath(command)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("tmux backend Claude command: %w", err)
|
||||
}
|
||||
session := tmuxSessionName(taskID)
|
||||
existing, err := b.hasSession(ctx, session)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
if !existing {
|
||||
if _, err := b.command(ctx, "new-session", "-d", "-s", session, "-c", path, resolved); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
}
|
||||
// The exact pane id is resolved below. Deployments may configure tmux
|
||||
// base-index/base-pane-index, so neither index is assumed to be zero.
|
||||
paneOut, err := b.command(ctx, "list-panes", "-t", "="+session, "-F", "#{session_name}:#{window_index}.#{pane_index}")
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
paneID := strings.TrimSpace(strings.SplitN(string(paneOut), "\n", 2)[0])
|
||||
if paneID == "" {
|
||||
return Session{}, fmt.Errorf("tmux backend: session %s has no pane", session)
|
||||
}
|
||||
if existing {
|
||||
out, err := b.command(ctx, "display-message", "-p", "-t", tmuxTarget(paneID), "#{pane_current_path}")
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
current, currentErr := filepath.Abs(strings.TrimSpace(string(out)))
|
||||
want, wantErr := filepath.Abs(path)
|
||||
if currentErr != nil || wantErr != nil || current != want {
|
||||
return Session{}, fmt.Errorf("tmux backend: existing session %s belongs to %q, not %q", session, current, want)
|
||||
}
|
||||
} else {
|
||||
if _, err := b.command(ctx, "set-window-option", "-t", tmuxTarget(paneID), "remain-on-exit", "on"); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
}
|
||||
s := Session{PaneID: paneID, Worktree: path, Harness: "claude", AgentName: session}
|
||||
if err := b.confirmClaudeWorkspaceTrust(ctx, s); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
status, err := b.AgentStatus(ctx, s)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
if status == "exited" || status == "dead" {
|
||||
return Session{}, fmt.Errorf("tmux backend: Claude exited while starting session %s", session)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) confirmClaudeWorkspaceTrust(ctx context.Context, s Session) error {
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
accepted := false
|
||||
for {
|
||||
text, err := b.PaneCapture(ctx, s, "recent")
|
||||
if err == nil && claudeWorkspaceTrustPrompt(text) {
|
||||
if !accepted {
|
||||
if err := b.SendText(ctx, s, "1"); err != nil {
|
||||
return fmt.Errorf("tmux backend: accept Claude workspace trust: %w", err)
|
||||
}
|
||||
if err := b.SendKeys(ctx, s, []string{"Enter"}); err != nil {
|
||||
return fmt.Errorf("tmux backend: accept Claude workspace trust: %w", err)
|
||||
}
|
||||
accepted = true
|
||||
}
|
||||
}
|
||||
// Claude's input prompt is the readiness boundary. A banner or partially
|
||||
// painted fullscreen UI is not enough: input sent there can be lost.
|
||||
if err == nil && !claudeWorkspaceTrustPrompt(text) && strings.Contains(text, "❯") {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("tmux backend: Claude input prompt did not become ready in session %s", tmuxSession(s.PaneID))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) Prompt(ctx context.Context, pane, text string, _ time.Duration) error {
|
||||
s := Session{PaneID: pane}
|
||||
status, err := b.AgentStatus(ctx, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == "blocked" {
|
||||
return fmt.Errorf("tmux backend: refusing prompt while pane %s shows a permission dialog", pane)
|
||||
}
|
||||
if err := b.SendText(ctx, s, text); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.SendKeys(ctx, s, []string{"Enter"})
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) Kill(ctx context.Context, s Session) error {
|
||||
exists, err := b.hasSession(ctx, tmuxSession(s.PaneID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
_, err = b.command(ctx, "kill-session", "-t", "="+tmuxSession(s.PaneID))
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) paneState(ctx context.Context, s Session) (dead bool, command string, err error) {
|
||||
out, err := b.command(ctx, "display-message", "-p", "-t", tmuxTarget(s.PaneID), "#{pane_dead}\t#{pane_current_command}")
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimSpace(string(out)), "\t", 2)
|
||||
dead = len(parts) > 0 && parts[0] == "1"
|
||||
if len(parts) == 2 {
|
||||
command = parts[1]
|
||||
}
|
||||
return dead, command, nil
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) AgentStatus(ctx context.Context, s Session) (string, error) {
|
||||
exists, err := b.hasSession(ctx, tmuxSession(s.PaneID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return "exited", nil
|
||||
}
|
||||
dead, command, err := b.paneState(ctx, s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if dead || command == "" {
|
||||
return "exited", nil
|
||||
}
|
||||
text, err := b.PaneCapture(ctx, s, "recent")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if permissionPrompt(text) {
|
||||
return "blocked", nil
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
for _, marker := range []string{"esc to interrupt", "ctrl+c to interrupt", "press esc to interrupt"} {
|
||||
if strings.Contains(lower, marker) {
|
||||
return "busy", nil
|
||||
}
|
||||
}
|
||||
return "idle", nil
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) PaneCapture(ctx context.Context, s Session, source string) (string, error) {
|
||||
start := "-200"
|
||||
if source != "" && source != "recent" {
|
||||
start = "-1000"
|
||||
}
|
||||
out, err := b.command(ctx, "capture-pane", "-p", "-J", "-S", start, "-t", tmuxTarget(s.PaneID))
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) SendText(ctx context.Context, s Session, text string) error {
|
||||
_, err := b.command(ctx, "send-keys", "-t", tmuxTarget(s.PaneID), "-l", "--", text)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *TmuxBackend) SendKeys(ctx context.Context, s Session, keys []string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, key := range keys {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return errors.New("tmux backend: empty key name")
|
||||
}
|
||||
}
|
||||
args := []string{"send-keys", "-t", tmuxTarget(s.PaneID)}
|
||||
args = append(args, keys...)
|
||||
_, err := b.command(ctx, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// tmux has no separate agent binding to release. Keeping the session alive is
|
||||
// the tmux equivalent of herdr's split-then-close protocol; the worker kills
|
||||
// it only after successor pickup has been validated.
|
||||
func (b *TmuxBackend) ReleaseAgent(ctx context.Context, s Session, _ string) error {
|
||||
exists, err := b.hasSession(ctx, tmuxSession(s.PaneID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("tmux backend: session %s is not running", tmuxSession(s.PaneID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ Backend = (*TmuxBackend)(nil)
|
||||
@@ -0,0 +1,100 @@
|
||||
package herdr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTmuxBackendStartsCapturesPromptsAndKillsClaude(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("requires tmux")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
harness := filepath.Join(dir, "fake-claude")
|
||||
script := "#!/bin/sh\nprintf '❯ ready\\n'\nwhile IFS= read -r line; do printf 'GOT:%s\\n' \"$line\"; done\n"
|
||||
if err := os.WriteFile(harness, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := NewTmuxBackend(filepath.Join(t.TempDir(), "tmux.sock"), harness)
|
||||
if err := b.Check(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := b.StartAgent(context.Background(), dir, dir, "", "claude", "tmux-backend-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = b.Kill(context.Background(), s) })
|
||||
if s.Worktree != dir || s.Harness != "claude" || !strings.Contains(s.PaneID, ":") || !strings.Contains(s.PaneID, ".") {
|
||||
t.Fatalf("unexpected session: %+v", s)
|
||||
}
|
||||
if err := b.Prompt(context.Background(), s.PaneID, "hello from Orchestra", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
capture, err := b.PaneCapture(context.Background(), s, "recent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(capture, "GOT:hello from Orchestra") {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("prompt was not captured: %q", capture)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
for _, line := range []string{"/clear", "@HANDOFF.md"} {
|
||||
if err := b.SendText(context.Background(), s, line); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.SendKeys(context.Background(), s, []string{"ENTER"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
deadline = time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
capture, err := b.PaneCapture(context.Background(), s, "recent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(capture, "GOT:/clear") && strings.Contains(capture, "GOT:@HANDOFF.md") {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("Claude rollover lines were not captured: %q", capture)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if status, err := b.AgentStatus(context.Background(), s); err != nil || status != "idle" {
|
||||
t.Fatalf("status=%q err=%v", status, err)
|
||||
}
|
||||
if err := b.ReleaseAgent(context.Background(), s, "claude"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Kill(context.Background(), s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.PaneCapture(context.Background(), s, "recent"); err == nil {
|
||||
t.Fatal("killed tmux session remained readable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxBackendRefusesUnverifiedHarnesses(t *testing.T) {
|
||||
b := NewTmuxBackend("test", "true")
|
||||
if _, err := b.StartAgent(context.Background(), "", t.TempDir(), "", "codex", "task"); err == nil {
|
||||
t.Fatal("tmux backend accepted Codex before its terminal behavior was implemented")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxSessionNameKeepsCollisionResistantSuffix(t *testing.T) {
|
||||
a := tmuxSessionName(strings.Repeat("same-prefix", 10) + "-one")
|
||||
b := tmuxSessionName(strings.Repeat("same-prefix", 10) + "-two")
|
||||
if a == b || len(a) > 64 || len(b) > 64 {
|
||||
t.Fatalf("unsafe tmux session names %q %q", a, b)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user