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>
234 lines
8.0 KiB
Go
234 lines
8.0 KiB
Go
package herdr
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"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)
|
||
}
|
||
}
|
||
|
||
// The launch dump is Orchestra's scratch space, not the session's work. A
|
||
// worktree that starts dirty pollutes the gate, the review diff, and the
|
||
// agent's own reading of `git status`.
|
||
func TestWriteLaunchContextLeavesTheWorktreeClean(t *testing.T) {
|
||
dir := t.TempDir()
|
||
for _, args := range [][]string{{"init"}, {"config", "user.email", "t@t"}, {"config", "user.name", "t"}, {"commit", "--allow-empty", "-m", "init"}} {
|
||
if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil {
|
||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||
}
|
||
}
|
||
if err := WriteLaunchContext(dir, "the instruction"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
b, err := os.ReadFile(filepath.Join(dir, LaunchContextFile))
|
||
if err != nil || string(b) != "the instruction" {
|
||
t.Fatalf("launch context = %q, err %v", b, err)
|
||
}
|
||
out, err := exec.Command("git", "-C", dir, "status", "--short").CombinedOutput()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if strings.TrimSpace(string(out)) != "" {
|
||
t.Fatalf("worktree is dirty after a launch dump:\n%s", out)
|
||
}
|
||
}
|
||
|
||
func TestLaunchTransportIsHarnessSpecific(t *testing.T) {
|
||
b := &TmuxBackend{}
|
||
if got := b.LaunchTransport("claude"); got != LaunchFileRef {
|
||
t.Fatalf("claude transport=%q, want %q", got, LaunchFileRef)
|
||
}
|
||
for _, harness := range []string{"opencode", "codex", ""} {
|
||
if got := b.LaunchTransport(harness); got != LaunchInline {
|
||
t.Fatalf("%s transport=%q, want %q", harness, got, LaunchInline)
|
||
}
|
||
}
|
||
}
|
||
|
||
// The exact F15 signature: the TUI coalesced the launch text into a paste and
|
||
// absorbed the Enter, leaving the instruction in the input editor.
|
||
func TestPendingInputSeesAnUnsubmittedPaste(t *testing.T) {
|
||
stalled := " b141e4f6 - none | 0/1.0m ctx\n❯ [Pasted text #1 +66 lines]\n────────────\n"
|
||
if got := pendingInput(stalled); got != "[Pasted text #1 +66 lines]" {
|
||
t.Fatalf("pendingInput=%q, want the unsubmitted paste", got)
|
||
}
|
||
if got := pendingInput("esc to interrupt\n❯ \n"); got != "" {
|
||
t.Fatalf("pendingInput=%q, want empty for a cleared editor", got)
|
||
}
|
||
if got := pendingInput("❯ @.orchestra/launch.md is your complete Orchestra launch instruction."); got == "" {
|
||
t.Fatal("a one-line reference still sitting in the editor must count as pending")
|
||
}
|
||
if got := pendingInput("no editor line here"); got != "" {
|
||
t.Fatalf("pendingInput=%q, want empty when no editor line is visible", got)
|
||
}
|
||
}
|
||
|
||
// fakeTmux scripts capture-pane so confirmation can be driven without waiting
|
||
// on a real harness. clearAfter is how many captures still show the stalled
|
||
// editor before it clears.
|
||
func fakeTmux(t *testing.T, clearAfter int, failCapture bool) *TmuxBackend {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
bin := filepath.Join(dir, "tmux")
|
||
fail := "0"
|
||
if failCapture {
|
||
fail = "1"
|
||
}
|
||
script := `#!/bin/sh
|
||
state=` + filepath.Join(dir, "count") + `
|
||
cmd=""
|
||
for a in "$@"; do
|
||
case "$a" in
|
||
capture-pane|has-session|display-message) cmd=$a; break;;
|
||
esac
|
||
done
|
||
case "$cmd" in
|
||
has-session) exit 0 ;;
|
||
display-message) printf '0\tclaude\n'; exit 0 ;;
|
||
capture-pane)
|
||
if [ "` + fail + `" = "1" ]; then echo "no server running" >&2; exit 1; fi
|
||
n=$(cat $state 2>/dev/null || echo 0); n=$((n+1)); echo $n > $state
|
||
if [ "$n" -le "` + fmt.Sprint(clearAfter) + `" ]; then
|
||
printf '\xe2\x9d\xaf [Pasted text #1 +66 lines]\n'
|
||
else
|
||
printf 'esc to interrupt\n\xe2\x9d\xaf \n'
|
||
fi
|
||
exit 0 ;;
|
||
esac
|
||
exit 0
|
||
`
|
||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return &TmuxBackend{Binary: bin, LaunchConfirmTimeout: 2 * time.Second, LaunchConfirmPoll: 5 * time.Millisecond}
|
||
}
|
||
|
||
func TestConfirmLaunchAcceptsObservedActivity(t *testing.T) {
|
||
b := fakeTmux(t, 1, false)
|
||
evidence, err := b.ConfirmLaunch(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference)
|
||
if err != nil {
|
||
t.Fatalf("confirm: %v", err)
|
||
}
|
||
if !strings.Contains(evidence, "busy") {
|
||
t.Fatalf("evidence=%q, want the observed activity named", evidence)
|
||
}
|
||
}
|
||
|
||
// Prompt returning nil is not proof. An editor that still holds the text at
|
||
// the deadline is a launch failure, and it must be the one class that releases
|
||
// the lease instead of holding it as uncertain.
|
||
func TestConfirmLaunchFailsWhileTheEditorStillHoldsThePrompt(t *testing.T) {
|
||
b := fakeTmux(t, 1000, false)
|
||
b.LaunchConfirmTimeout = 60 * time.Millisecond
|
||
_, err := b.ConfirmLaunch(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference)
|
||
if !errors.Is(err, ErrPromptNotSubmitted) {
|
||
t.Fatalf("err=%v, want ErrPromptNotSubmitted", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "Pasted text") {
|
||
t.Fatalf("err=%v, want the stalled editor content quoted", err)
|
||
}
|
||
}
|
||
|
||
// A confirmation that cannot read the pane must not become an acknowledgement.
|
||
func TestConfirmLaunchSurfacesTransportErrors(t *testing.T) {
|
||
b := fakeTmux(t, 0, true)
|
||
if _, err := b.ConfirmLaunch(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference); err == nil {
|
||
t.Fatal("a failed pane capture must not confirm a launch")
|
||
}
|
||
}
|