Files
orchestra/internal/herdr/tmux.go
T
kami 1fd82f863c Acknowledge a launch only when the harness accepted it
Burn-in run 2 recorded TaskLaunchAcknowledged, opened a pane, and ran nothing
for fifteen minutes. The launch instruction sat in Claude Code's input editor
as "[Pasted text #1 +66 lines]" at zero tokens and zero elapsed. Two separate
bugs produced that.

The transport was wrong for the harness. TmuxBackend.Prompt writes the whole
instruction with send-keys -l and then sends Enter, and the TUI coalesces the
fast multi-line write into a paste that absorbs the following Enter. Launch
transport is now a backend property rather than one universal prompt format:
claude on tmux submits a single line pointing at .orchestra/launch.md, every
other harness keeps the inline path it was verified on. agentctx is unchanged
and the file still holds the exact bytes Orchestra rendered, so what the agent
receives is identical either way. Under the file transport a failed write is
now a failed launch, because there the file is the instruction.

The acknowledgement was also wrong. It meant "Prompt returned nil", not "the
harness accepted the prompt". Backends may now implement ConfirmLaunch, and
the tmux one polls until the input editor clears and the agent is observably
busy, blocked on approval, or at least no longer holding the text. An editor
that still holds the prompt at the deadline is a definite failure. The worker
kills the pane, drops the session so the retry starts clean, and returns
ErrPromptNotSubmitted, which classifies as prompt_not_submitted rather than
launch_uncertain. That class already falls through to TaskReleased, so the
existing retry path takes it and no lease is held on a launch that never
happened.

The confirmation bound is tunable because how fast a terminal harness reacts
is a property of the host. It is not a sleep before the submit: the submit is
deterministic, and this waits for the harness to visibly react to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 00:46:56 +04:00

428 lines
13 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"
"regexp"
"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
// LaunchConfirmTimeout and LaunchConfirmPoll bound ConfirmLaunch. They are
// tunable because how fast a terminal harness visibly reacts is a property
// of the host, not of this code. Zero values mean 10s and 250ms.
LaunchConfirmTimeout time.Duration
LaunchConfirmPoll time.Duration
// Now is a test seam for the confirmation deadline.
Now func() time.Time
}
func (b *TmuxBackend) now() time.Time {
if b.Now != nil {
return b.Now()
}
return time.Now()
}
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)
// LaunchTransport keeps the file-reference launch scoped to the harness whose
// paste handling requires it, rather than making it the universal prompt
// format. Every other harness keeps the inline path it was verified on.
func (b *TmuxBackend) LaunchTransport(harness string) LaunchTransport {
if harness == "claude" {
return LaunchFileRef
}
return LaunchInline
}
// promptLine matches the harness input editor's own line. Its remainder is
// what is still sitting unsubmitted, whether that is literal text or the
// TUI's own "[Pasted text #1 +66 lines]" placeholder.
var promptLine = regexp.MustCompile(`(?m)^[ \t]*[>][ \t]*(.*)$`)
// pendingInput returns whatever the input editor still holds.
func pendingInput(pane string) string {
m := promptLine.FindAllStringSubmatch(pane, -1)
if len(m) == 0 {
return ""
}
return strings.TrimSpace(m[len(m)-1][1])
}
// ConfirmLaunch waits for observable proof that the harness accepted the
// prompt. An input editor that still holds the submitted text is definite
// failure, which is the whole point: TaskLaunchAcknowledged previously meant
// "Prompt returned nil" and so reported a launch that never happened.
//
// This is not a timing workaround for the submit itself. The submit is
// deterministic; this waits for the harness to visibly react to it.
func (b *TmuxBackend) ConfirmLaunch(ctx context.Context, s Session, submitted string) (string, error) {
timeout, poll := b.LaunchConfirmTimeout, b.LaunchConfirmPoll
if timeout <= 0 {
timeout = 10 * time.Second
}
if poll <= 0 {
poll = 250 * time.Millisecond
}
deadline := b.now().Add(timeout)
var last string
for {
pane, err := b.PaneCapture(ctx, s, "recent")
if err != nil {
return "", err
}
if last = pendingInput(pane); last == "" {
status, statusErr := b.AgentStatus(ctx, s)
if statusErr != nil {
return "", statusErr
}
switch status {
case "exited":
return "", fmt.Errorf("%w: pane %s exited before the harness reacted", ErrPromptNotSubmitted, s.PaneID)
case "busy":
return "input editor cleared, agent busy", nil
case "blocked":
// A permission dialog is the harness acting on the
// instruction, so submission is proven even though the
// session now needs an operator.
return "input editor cleared, agent awaiting approval", nil
default:
return "input editor cleared", nil
}
}
if !b.now().Before(deadline) {
return "", fmt.Errorf("%w: pane %s still holds %q after %s", ErrPromptNotSubmitted, s.PaneID, last, timeout)
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(poll):
}
}
}