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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011xsXyr5J1RACo71YeKG3Pu
This commit is contained in:
@@ -461,6 +461,18 @@ func (b *TmuxBackend) PaneProgress(ctx context.Context, s Session) (string, erro
|
|||||||
// first: three exact-editor resubmits, then observation only.
|
// first: three exact-editor resubmits, then observation only.
|
||||||
const launchResubmitLimit = 3
|
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
|
// 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
|
// 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
|
// 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))
|
kind, attempts, first.UTC().Format(time.RFC3339Nano), b.now().UTC().Format(time.RFC3339Nano))
|
||||||
}
|
}
|
||||||
var last InputState
|
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 {
|
for {
|
||||||
state, err := b.inputState(ctx, s)
|
state, err := b.inputState(ctx, s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
last = state
|
last = state
|
||||||
|
if state.Active && sameInput(state.Text, submitted) {
|
||||||
|
seen = true
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case state.Active && sameInput(state.Text, submitted):
|
case state.Active && sameInput(state.Text, submitted):
|
||||||
// Exactly what was submitted still owns the cursor, so the submit
|
// 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.
|
// session now needs an operator.
|
||||||
return evidence("blocked"), nil
|
return evidence("blocked"), nil
|
||||||
default:
|
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
|
return evidence("editor_cleared"), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user