f54fb0036d
Burn-in run 3 failed three times with prompt_not_submitted: the launch text reached the editor every attempt and the following Enter never took effect. Six isolated probes of the same code path, environment and worktree all submitted on the first Enter, so the submit is not deterministic and waiting for it to land is not enough. F17 first. pendingInput scanned every line beginning with the prompt marker, but a queued or already-accepted message renders with the same prefix. Only the editor that owns the pane cursor is unsubmitted input, so inputState asks tmux for the cursor row and reads the editor around it, joining soft-wrapped rows. On that footing ConfirmLaunch becomes an active submit protocol: resend Enter while the live editor still holds exactly what was submitted, at most three times and no closer together than two poll intervals, then observe until the deadline. Queued input confirms rather than fails. The evidence records confirmation kind, submit_attempts and both timestamps, so a harness that needs a second Enter is distinguishable from one that needs none. Verified live against a real Claude Code pane: confirmation=editor_cleared submit_attempts=1, no spurious resend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
390 lines
13 KiB
Go
390 lines
13 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// F17, found live during burn-in run 3: a queued or already-accepted prompt
|
||
// renders with the same "❯" prefix as an unsubmitted one, so only cursor
|
||
// ownership can tell them apart.
|
||
func TestInputStateReadsTheEditorOwningTheCursor(t *testing.T) {
|
||
wrapped := "\u2500\u2500\u2500\u2500\n\u276f @.orchestra/launch.md is your complete Orchestra launch instruction. Read\n .orchestra/launch.md now and follow it.\n\u2500\u2500\u2500\u2500\n"
|
||
b := paneTmux(t, wrapped, 2)
|
||
state, err := b.inputState(context.Background(), Session{PaneID: "s:1.0"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !state.Active || !sameInput(state.Text, LaunchReference) {
|
||
t.Fatalf("state=%+v, want the wrapped launch reference in the active editor", state)
|
||
}
|
||
|
||
// The same text, but the cursor sits in the empty editor below it: the
|
||
// harness took the prompt and queued it.
|
||
queued := " \u276f @.orchestra/launch.md is your complete Orchestra launch instruction. Read\n .orchestra/launch.md now and follow it.\n\u2500\u2500\u2500\u2500\n\u276f Press up to edit queued messages\n"
|
||
b = paneTmux(t, queued, 3)
|
||
state, err = b.inputState(context.Background(), Session{PaneID: "s:1.0"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !state.Active || sameInput(state.Text, LaunchReference) {
|
||
t.Fatalf("state=%+v, want the queued marker rather than the launch text", state)
|
||
}
|
||
if !queuedInput(state.Text) {
|
||
t.Fatalf("state=%+v, want queued input recognized", state)
|
||
}
|
||
}
|
||
|
||
// Run 3 proved one Enter is not enough. A launch still owning the cursor must
|
||
// be resubmitted, and the evidence must say how many submits it took.
|
||
func TestConfirmLaunchResubmitsUntilTheEditorClears(t *testing.T) {
|
||
b, keys := submitTmux(t, 2)
|
||
evidence, err := b.ConfirmLaunch(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference)
|
||
if err != nil {
|
||
t.Fatalf("confirm: %v", err)
|
||
}
|
||
if !strings.Contains(evidence, "submit_attempts=2") {
|
||
t.Fatalf("evidence=%q, want two submits recorded", evidence)
|
||
}
|
||
if got := enterCount(t, keys); got != 1 {
|
||
t.Fatalf("resent Enter %d times, want 1", got)
|
||
}
|
||
}
|
||
|
||
// The intervention is bounded independently of the observation deadline: a TUI
|
||
// that already accepted the prompt must not receive a fortieth Enter.
|
||
func TestConfirmLaunchBoundsResubmission(t *testing.T) {
|
||
b, keys := submitTmux(t, 99)
|
||
// Generous deadline on purpose: the cap must be what stops the resends.
|
||
b.LaunchConfirmTimeout = 3 * time.Second
|
||
if _, err := b.ConfirmLaunch(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference); !errors.Is(err, ErrPromptNotSubmitted) {
|
||
t.Fatalf("err=%v, want ErrPromptNotSubmitted", err)
|
||
}
|
||
if got := enterCount(t, keys); got != launchResubmitLimit {
|
||
t.Fatalf("resent Enter %d times, want the %d-resubmit cap", got, launchResubmitLimit)
|
||
}
|
||
}
|
||
|
||
// Queued is submitted. Treating it as a stalled editor would kill a pane whose
|
||
// harness had already accepted the instruction.
|
||
func TestConfirmLaunchAcceptsQueuedInput(t *testing.T) {
|
||
pane := "\u2500\u2500\u2500\u2500\n\u276f Press up to edit queued messages\n"
|
||
b := paneTmux(t, pane, 1)
|
||
evidence, err := b.ConfirmLaunch(context.Background(), Session{PaneID: "s:1.0"}, LaunchReference)
|
||
if err != nil {
|
||
t.Fatalf("confirm: %v", err)
|
||
}
|
||
if !strings.Contains(evidence, "confirmation=queued") {
|
||
t.Fatalf("evidence=%q, want queued input confirmed", evidence)
|
||
}
|
||
}
|
||
|
||
// paneTmux serves one fixed pane with the cursor on the given row.
|
||
func paneTmux(t *testing.T, pane string, cursor int) *TmuxBackend {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
paneFile := filepath.Join(dir, "pane")
|
||
if err := os.WriteFile(paneFile, []byte(pane), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
bin := filepath.Join(dir, "tmux")
|
||
script := `#!/bin/sh
|
||
cmd=""
|
||
for a in "$@"; do
|
||
case "$a" in
|
||
capture-pane|has-session|display-message|send-keys) cmd=$a; break;;
|
||
esac
|
||
done
|
||
case "$cmd" in
|
||
has-session) exit 0 ;;
|
||
capture-pane) cat ` + paneFile + ` ; exit 0 ;;
|
||
display-message)
|
||
case "$*" in
|
||
*cursor_y*) printf '` + fmt.Sprint(cursor) + `\n' ;;
|
||
*) printf '0\tclaude\n' ;;
|
||
esac
|
||
exit 0 ;;
|
||
esac
|
||
exit 0
|
||
`
|
||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return &TmuxBackend{Binary: bin, LaunchConfirmTimeout: time.Second, LaunchConfirmPoll: 5 * time.Millisecond}
|
||
}
|
||
|
||
// submitTmux holds the launch reference in the active editor until the given
|
||
// number of submits has been received, then clears it. The first submit is the
|
||
// one Prompt already sent, so submitOn=2 means one resend is required.
|
||
func submitTmux(t *testing.T, submitOn int) (*TmuxBackend, string) {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
keys := filepath.Join(dir, "keys")
|
||
bin := filepath.Join(dir, "tmux")
|
||
script := `#!/bin/sh
|
||
dir=` + dir + `
|
||
cmd=""
|
||
for a in "$@"; do
|
||
case "$a" in
|
||
capture-pane|has-session|display-message|send-keys) cmd=$a; break;;
|
||
esac
|
||
done
|
||
submits=$(cat $dir/submits 2>/dev/null || echo 1)
|
||
case "$cmd" in
|
||
has-session) exit 0 ;;
|
||
send-keys)
|
||
case "$*" in
|
||
*Enter*)
|
||
echo Enter >> ` + keys + `
|
||
echo $((submits+1)) > $dir/submits ;;
|
||
esac
|
||
exit 0 ;;
|
||
capture-pane)
|
||
if [ "$submits" -ge "` + fmt.Sprint(submitOn) + `" ]; then
|
||
printf 'esc to interrupt\n\xe2\x9d\xaf \n'
|
||
else
|
||
printf '\xe2\x94\x80\x0a\xe2\x9d\xaf ` + LaunchReference + `\n'
|
||
fi
|
||
exit 0 ;;
|
||
display-message)
|
||
case "$*" in
|
||
*cursor_y*)
|
||
if [ "$submits" -ge "` + fmt.Sprint(submitOn) + `" ]; then printf '1\n'; else printf '1\n'; fi ;;
|
||
*) printf '0\tclaude\n' ;;
|
||
esac
|
||
exit 0 ;;
|
||
esac
|
||
exit 0
|
||
`
|
||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return &TmuxBackend{Binary: bin, LaunchConfirmTimeout: time.Second, LaunchConfirmPoll: 5 * time.Millisecond}, keys
|
||
}
|
||
|
||
func enterCount(t *testing.T, path string) int {
|
||
t.Helper()
|
||
b, err := os.ReadFile(path)
|
||
if os.IsNotExist(err) {
|
||
return 0
|
||
}
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return len(strings.Fields(string(b)))
|
||
}
|
||
|
||
// 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)
|
||
case "$*" in
|
||
*cursor_y*) printf '0\n' ;;
|
||
*) printf '0\tclaude\n' ;;
|
||
esac
|
||
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")
|
||
}
|
||
}
|