From 32220d929b274c050e29de76396f60ee20dac808 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 28 Aug 2026 01:23:48 +0400 Subject: [PATCH] Stop an unrendered editor from confirming a launch ConfirmInput's first poll can run before the TUI renders the pasted text. It then finds an empty editor, falls through to the idle branch, and reports confirmation=editor_cleared. The launch text sits unsent for the whole lease while the coordinator believes the agent is working. Run 5 died on exactly that: first_submit_at 21:13:19.570, confirmed_at 21:13:19.577. Seven milliseconds. The two launches that worked took ~500ms and a second Enter, so the difference was scheduling luck. An empty editor is only proof once it has held the text, or once it has stayed empty past a settle window. Text seen and then gone still confirms at once, and so do busy, blocked and queued. Waiting only happens in the never-observed case, which is the one that cannot be told apart from a slow render. With this, the failing case reaches the existing resubmit path instead: the text appears, is recognised as unsubmitted, and Enter is resent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011xsXyr5J1RACo71YeKG3Pu --- internal/herdr/tmux.go | 24 +++++++++++++++++++ internal/herdr/tmux_test.go | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) 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) + } +}