Confirm a launch from the editor that owns the cursor

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>
This commit is contained in:
2026-08-27 12:53:56 +04:00
parent 1888d4280e
commit f54fb0036d
2 changed files with 288 additions and 37 deletions
+119 -24
View File
@@ -9,6 +9,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -360,27 +361,87 @@ func (b *TmuxBackend) LaunchTransport(harness string) LaunchTransport {
return LaunchInline return LaunchInline
} }
// promptLine matches the harness input editor's own line. Its remainder is // promptLine matches a harness input line. A queued or already-accepted
// what is still sitting unsubmitted, whether that is literal text or the // message renders with the same prefix, so a match alone proves nothing about
// TUI's own "[Pasted text #1 +66 lines]" placeholder. // what is still unsubmitted; see inputState.
var promptLine = regexp.MustCompile(`(?m)^[ \t]*[>][ \t]*(.*)$`) var promptLine = regexp.MustCompile(`(?m)^[ \t]*[>][ \t]*(.*)$`)
// pendingInput returns whatever the input editor still holds. // separatorRow matches the rule Claude Code draws above and below its editor.
func pendingInput(pane string) string { var separatorRow = regexp.MustCompile(`^[\s─━┄┅┈┉-]+$`)
m := promptLine.FindAllStringSubmatch(pane, -1)
if len(m) == 0 { // InputState is what the interactive editor holds. Active distinguishes the
return "" // live editor from queued or already-submitted input: both render with the
} // same "" prefix, but only the live editor owns the pane cursor (F17,
return strings.TrimSpace(m[len(m)-1][1]) // found live during burn-in run 3).
type InputState struct {
Text string
Active bool
} }
// ConfirmLaunch waits for observable proof that the harness accepted the // sameInput compares editor content to what was submitted. Soft wrapping may
// prompt. An input editor that still holds the submitted text is definite // break the text at any column and re-indent the continuation, so the
// failure, which is the whole point: TaskLaunchAcknowledged previously meant // comparison ignores whitespace entirely.
// "Prompt returned nil" and so reported a launch that never happened. 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 // The returned evidence records how many submits it took, which is the only
// deterministic; this waits for the harness to visibly react to it. // 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) { func (b *TmuxBackend) ConfirmLaunch(ctx context.Context, s Session, submitted string) (string, error) {
timeout, poll := b.LaunchConfirmTimeout, b.LaunchConfirmPoll timeout, poll := b.LaunchConfirmTimeout, b.LaunchConfirmPoll
if timeout <= 0 { if timeout <= 0 {
@@ -389,14 +450,42 @@ func (b *TmuxBackend) ConfirmLaunch(ctx context.Context, s Session, submitted st
if poll <= 0 { if poll <= 0 {
poll = 250 * time.Millisecond poll = 250 * time.Millisecond
} }
deadline := b.now().Add(timeout) first := b.now()
var last string 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 { for {
pane, err := b.PaneCapture(ctx, s, "recent") state, err := b.inputState(ctx, s)
if err != nil { if err != nil {
return "", err 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) status, statusErr := b.AgentStatus(ctx, s)
if statusErr != nil { if statusErr != nil {
return "", statusErr return "", statusErr
@@ -405,18 +494,18 @@ func (b *TmuxBackend) ConfirmLaunch(ctx context.Context, s Session, submitted st
case "exited": case "exited":
return "", fmt.Errorf("%w: pane %s exited before the harness reacted", ErrPromptNotSubmitted, s.PaneID) return "", fmt.Errorf("%w: pane %s exited before the harness reacted", ErrPromptNotSubmitted, s.PaneID)
case "busy": case "busy":
return "input editor cleared, agent busy", nil return evidence("busy"), nil
case "blocked": case "blocked":
// A permission dialog is the harness acting on the // A permission dialog is the harness acting on the
// instruction, so submission is proven even though the // instruction, so submission is proven even though the
// session now needs an operator. // session now needs an operator.
return "input editor cleared, agent awaiting approval", nil return evidence("blocked"), nil
default: default:
return "input editor cleared", nil return evidence("editor_cleared"), nil
} }
} }
if !b.now().Before(deadline) { 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 { select {
case <-ctx.Done(): 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")
}
+169 -13
View File
@@ -140,22 +140,173 @@ func TestLaunchTransportIsHarnessSpecific(t *testing.T) {
} }
} }
// The exact F15 signature: the TUI coalesced the launch text into a paste and // F17, found live during burn-in run 3: a queued or already-accepted prompt
// absorbed the Enter, leaving the instruction in the input editor. // renders with the same "" prefix as an unsubmitted one, so only cursor
func TestPendingInputSeesAnUnsubmittedPaste(t *testing.T) { // ownership can tell them apart.
stalled := " b141e4f6 - none | 0/1.0m ctx\n [Pasted text #1 +66 lines]\n────────────\n" func TestInputStateReadsTheEditorOwningTheCursor(t *testing.T) {
if got := pendingInput(stalled); got != "[Pasted text #1 +66 lines]" { 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"
t.Fatalf("pendingInput=%q, want the unsubmitted paste", got) 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 != "" { if !state.Active || !sameInput(state.Text, LaunchReference) {
t.Fatalf("pendingInput=%q, want empty for a cleared editor", got) 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 != "" { if !state.Active || sameInput(state.Text, LaunchReference) {
t.Fatalf("pendingInput=%q, want empty when no editor line is visible", got) 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 // fakeTmux scripts capture-pane so confirmation can be driven without waiting
@@ -179,7 +330,12 @@ for a in "$@"; do
done done
case "$cmd" in case "$cmd" in
has-session) exit 0 ;; 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) capture-pane)
if [ "` + fail + `" = "1" ]; then echo "no server running" >&2; exit 1; fi 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 n=$(cat $state 2>/dev/null || echo 0); n=$((n+1)); echo $n > $state