diff --git a/internal/herdr/tmux.go b/internal/herdr/tmux.go index 65bc473..882353c 100644 --- a/internal/herdr/tmux.go +++ b/internal/herdr/tmux.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "regexp" + "strconv" "strings" "time" ) @@ -360,27 +361,87 @@ func (b *TmuxBackend) LaunchTransport(harness string) LaunchTransport { 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. +// promptLine matches a harness input line. A queued or already-accepted +// message renders with the same prefix, so a match alone proves nothing about +// what is still unsubmitted; see inputState. 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]) +// separatorRow matches the rule Claude Code draws above and below its editor. +var separatorRow = regexp.MustCompile(`^[\s─━┄┅┈┉-]+$`) + +// InputState is what the interactive editor holds. Active distinguishes the +// live editor from queued or already-submitted input: both render with the +// same "❯" prefix, but only the live editor owns the pane cursor (F17, +// found live during burn-in run 3). +type InputState struct { + Text string + Active bool } -// 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. +// sameInput compares editor content to what was submitted. Soft wrapping may +// break the text at any column and re-indent the continuation, so the +// comparison ignores whitespace entirely. +func sameInput(a, b string) bool { + strip := func(s string) string { return strings.Join(strings.Fields(s), "") } + return strip(a) == strip(b) +} + +// inputState reads the editor that owns the cursor. Scanning every line +// beginning with "❯" cannot tell an unsubmitted prompt from one the +// harness already queued, which is why this asks tmux where the cursor is. +func (b *TmuxBackend) inputState(ctx context.Context, s Session) (InputState, error) { + out, err := b.command(ctx, "display-message", "-p", "-t", tmuxTarget(s.PaneID), "#{cursor_y}") + if err != nil { + return InputState{}, err + } + row, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return InputState{}, fmt.Errorf("tmux backend: cursor row %q: %w", strings.TrimSpace(string(out)), err) + } + // Screen rows, unjoined: -J merges wrapped rows and would invalidate the + // cursor row index. + raw, err := b.command(ctx, "capture-pane", "-p", "-t", tmuxTarget(s.PaneID)) + if err != nil { + return InputState{}, err + } + rows := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if row < 0 || row >= len(rows) { + return InputState{}, nil + } + start := -1 + for i := row; i >= 0; i-- { + if promptLine.MatchString(rows[i]) { + start = i + break + } + if separatorRow.MatchString(rows[i]) { + break + } + } + if start < 0 { + return InputState{}, nil + } + parts := []string{strings.TrimSpace(promptLine.FindStringSubmatch(rows[start])[1])} + for i := start + 1; i <= row; i++ { + parts = append(parts, strings.TrimSpace(rows[i])) + } + return InputState{Text: strings.TrimSpace(strings.Join(parts, " ")), Active: true}, nil +} + +// launchResubmitLimit bounds the intervention independently of the observation +// deadline. A slow TUI must not receive a fortieth Enter after it accepted the +// first: three exact-editor resubmits, then observation only. +const launchResubmitLimit = 3 + +// ConfirmLaunch drives the submit to a decision instead of assuming one Enter +// landed. Burn-in run 3 proved the submit is not deterministic: the text +// reached the editor on all three attempts and the following Enter never took +// effect. So this resends Enter while the live editor still holds exactly what +// was submitted, up to launchResubmitLimit times, then observes until the +// deadline. // -// This is not a timing workaround for the submit itself. The submit is -// deterministic; this waits for the harness to visibly react to it. +// The returned evidence records how many submits it took, which is the only +// way to tell a harness that needs a second Enter from one that needed none. func (b *TmuxBackend) ConfirmLaunch(ctx context.Context, s Session, submitted string) (string, error) { timeout, poll := b.LaunchConfirmTimeout, b.LaunchConfirmPoll if timeout <= 0 { @@ -389,14 +450,42 @@ func (b *TmuxBackend) ConfirmLaunch(ctx context.Context, s Session, submitted st if poll <= 0 { poll = 250 * time.Millisecond } - deadline := b.now().Add(timeout) - var last string + first := b.now() + deadline := first.Add(timeout) + attempts, resubmits := 1, 0 + var lastResubmit time.Time + evidence := func(kind string) string { + return fmt.Sprintf("confirmation=%s submit_attempts=%d first_submit_at=%s confirmed_at=%s", + kind, attempts, first.UTC().Format(time.RFC3339Nano), b.now().UTC().Format(time.RFC3339Nano)) + } + var last InputState for { - pane, err := b.PaneCapture(ctx, s, "recent") + state, err := b.inputState(ctx, s) if err != nil { return "", err } - if last = pendingInput(pane); last == "" { + last = state + switch { + case state.Active && sameInput(state.Text, submitted): + // Exactly what was submitted still owns the cursor, so the submit + // did not take. Resend Enter, spaced so a TUI that accepted the + // previous one cannot receive another inside its own redraw. + if resubmits < launchResubmitLimit && (lastResubmit.IsZero() || b.now().Sub(lastResubmit) >= 2*poll) { + if err := b.SendKeys(ctx, s, []string{"Enter"}); err != nil { + return "", err + } + resubmits++ + attempts++ + lastResubmit = b.now() + } + case state.Active && queuedInput(state.Text): + // The harness accepted the instruction and parked it behind the + // current turn. Queued is submitted. + return evidence("queued"), nil + case state.Active && state.Text != "": + // Something else is in the editor, a paste placeholder for + // instance. Not proof of acceptance, so keep observing. + default: status, statusErr := b.AgentStatus(ctx, s) if statusErr != nil { return "", statusErr @@ -405,18 +494,18 @@ func (b *TmuxBackend) ConfirmLaunch(ctx context.Context, s Session, submitted st 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 + return evidence("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 + return evidence("blocked"), nil default: - return "input editor cleared", nil + return evidence("editor_cleared"), nil } } if !b.now().Before(deadline) { - return "", fmt.Errorf("%w: pane %s still holds %q after %s", ErrPromptNotSubmitted, s.PaneID, last, timeout) + return "", fmt.Errorf("%w: pane %s still holds %q after %s and %d submits", ErrPromptNotSubmitted, s.PaneID, last.Text, timeout, attempts) } select { case <-ctx.Done(): @@ -425,3 +514,9 @@ func (b *TmuxBackend) ConfirmLaunch(ctx context.Context, s Session, submitted st } } } + +// queuedInput recognizes the editor Claude Code shows once it has taken a +// prompt and parked it behind the running turn. +func queuedInput(text string) bool { + return strings.Contains(strings.ToLower(text), "queued message") +} diff --git a/internal/herdr/tmux_test.go b/internal/herdr/tmux_test.go index 424edca..49900ba 100644 --- a/internal/herdr/tmux_test.go +++ b/internal/herdr/tmux_test.go @@ -140,22 +140,173 @@ func TestLaunchTransportIsHarnessSpecific(t *testing.T) { } } -// 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) +// 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 got := pendingInput("esc to interrupt\n❯ \n"); got != "" { - t.Fatalf("pendingInput=%q, want empty for a cleared editor", got) + if !state.Active || !sameInput(state.Text, LaunchReference) { + t.Fatalf("state=%+v, want the wrapped launch reference in the active editor", state) } - 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") + + // 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 got := pendingInput("no editor line here"); got != "" { - t.Fatalf("pendingInput=%q, want empty when no editor line is visible", got) + 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 @@ -179,7 +330,12 @@ for a in "$@"; do done case "$cmd" in has-session) exit 0 ;; - display-message) printf '0\tclaude\n'; 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