Files
orchestra/internal/herdr/tmux.go
T
kami 7f12c7fc37 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>
2026-08-26 18:31:20 +04:00

337 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)