diff --git a/internal/herdr/tmux.go b/internal/herdr/tmux.go index b086aa3..7e7d888 100644 --- a/internal/herdr/tmux.go +++ b/internal/herdr/tmux.go @@ -461,6 +461,18 @@ func (b *TmuxBackend) PaneProgress(ctx context.Context, s Session) (string, erro // first: three exact-editor resubmits, then observation only. const launchResubmitLimit = 3 +// launchSettle is how long an empty editor must stay empty before it counts as +// proof of submission. A TUI takes tens of milliseconds to render pasted text, +// and the first poll can run inside that gap. F33: a poll 7ms after the submit +// found nothing in the editor, reported confirmation=editor_cleared, and the +// launch text then sat unsent in the input box for the rest of the lease. The +// two launches that actually worked took ~500ms and a second Enter, so the +// difference between success and this false positive was scheduling luck. +// +// Only the never-observed case waits. Text seen and then gone is real +// evidence, and busy, blocked or queued still confirm immediately. +const launchSettle = time.Second + // ConfirmInput 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 @@ -487,12 +499,18 @@ func (b *TmuxBackend) ConfirmInput(ctx context.Context, s Session, submitted str kind, attempts, first.UTC().Format(time.RFC3339Nano), b.now().UTC().Format(time.RFC3339Nano)) } var last InputState + // Whether the editor was ever observed holding what we submitted. Without + // it, "empty" is indistinguishable from "not rendered yet". + seen := false for { state, err := b.inputState(ctx, s) if err != nil { return "", err } last = state + if state.Active && sameInput(state.Text, submitted) { + seen = true + } switch { case state.Active && sameInput(state.Text, submitted): // Exactly what was submitted still owns the cursor, so the submit @@ -529,6 +547,12 @@ func (b *TmuxBackend) ConfirmInput(ctx context.Context, s Session, submitted str // session now needs an operator. return evidence("blocked"), nil default: + // An editor that never held the text may simply not have + // rendered it yet. Give it the settle window before calling an + // empty box proof that the harness took the prompt. + if !seen && b.now().Sub(first) < launchSettle { + break + } return evidence("editor_cleared"), nil } } diff --git a/internal/herdr/tmux_test.go b/internal/herdr/tmux_test.go index b358439..52d34f1 100644 --- a/internal/herdr/tmux_test.go +++ b/internal/herdr/tmux_test.go @@ -445,3 +445,49 @@ func TestMissingSessionIsRecognisedOnAnEmptyRunningServer(t *testing.T) { } } } + +// TestEmptyEditorIsNotProofBeforeItEverHeldTheText guards F33. The submit +// races the TUI's render: a poll 7ms after Enter found an empty editor, called +// it confirmation=editor_cleared, and the launch text then sat unsent for the +// whole lease. Run 5 died on exactly that. An editor that never held the text +// has to keep being observed, and once the text appears the resubmit path can +// do its job. +func TestEmptyEditorIsNotProofBeforeItEverHeldTheText(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "tmux") + calls := filepath.Join(dir, "calls") + // capture-pane reports an empty editor on the first two polls, exactly as a + // TUI that has not rendered the paste yet, then shows the submitted text. + 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 ;; + send-keys) exit 0 ;; + display-message) + case "$*" in + *cursor_y*) printf '1\n' ;; + *) printf '0\tclaude\n' ;; + esac + exit 0 ;; + capture-pane) + n=$(cat ` + calls + ` 2>/dev/null || echo 0); n=$((n+1)); echo $n > ` + calls + ` + if [ "$n" -le 2 ]; then printf '\xe2\x9d\xaf \n\n'; else printf '\xe2\x9d\xaf hello world\n\n'; fi + exit 0 ;; +esac +exit 0 +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + b := &TmuxBackend{Binary: bin, LaunchConfirmTimeout: 300 * time.Millisecond, LaunchConfirmPoll: 5 * time.Millisecond} + _, err := b.ConfirmInput(context.Background(), Session{PaneID: "s:1.0"}, "hello world") + if err == nil { + t.Fatal("an editor still holding the submitted text was reported as confirmed") + } + if !errors.Is(err, ErrPromptNotSubmitted) { + t.Fatalf("err=%v, want ErrPromptNotSubmitted", err) + } +}