7f12c7fc37
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>
337 lines
10 KiB
Go
337 lines
10 KiB
Go
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)
|