1fd82f863c
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>
428 lines
13 KiB
Go
428 lines
13 KiB
Go
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):
|
||
}
|
||
}
|
||
}
|