Confirm every write Orchestra sends, and count none of them as progress

F20. Only the launch confirmed its submit. A decision notice at a turn
boundary, and /clear or @HANDOFF.md during a context reset, were
fire-and-forget through the same transport that loses an Enter often enough
that the launch needed three resubmits. A lost Enter on the context-reset path
is the worst of them: it strands the session mid-rollover and nothing retries
it. LaunchConfirmer is therefore InputConfirmer, ConfirmLaunch is ConfirmInput,
and sendPrompt and sendLine both go through it.

Orchestra does not try to guarantee delivery of input it did not originate.
But it must never read that input as work, which is the F16 half. Burn-in run
3 stalled with an unexplained "go ahead and implement it" in the editor, and
the renewal check hashed the whole capture, so those keystrokes read as
progress and the lease kept renewing around an idle agent. PaneProgress drops
input lines from the capture, which the -J join makes exact: a wrapped input
block is one line beginning with the prompt marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 14:44:04 +04:00
parent 770cc6a74b
commit edbe98fc5e
5 changed files with 175 additions and 30 deletions
+23 -3
View File
@@ -27,7 +27,7 @@ type TmuxBackend struct {
Command string
// Binary is test/packaging override for tmux itself.
Binary string
// LaunchConfirmTimeout and LaunchConfirmPoll bound ConfirmLaunch. They are
// LaunchConfirmTimeout and LaunchConfirmPoll bound ConfirmInput. They are
// tunable because how fast a terminal harness visibly reacts is a property
// of the host, not of this code. Zero values mean 10s and 250ms.
LaunchConfirmTimeout time.Duration
@@ -428,12 +428,32 @@ func (b *TmuxBackend) inputState(ctx context.Context, s Session) (InputState, er
return InputState{Text: strings.TrimSpace(strings.Join(parts, " ")), Active: true}, nil
}
// PaneProgress hashes what the harness produced, not what someone typed at it.
// The capture joins wrapped lines, so an input block is one line beginning
// with the prompt marker and dropping those lines removes it whole. Found live
// during burn-in run 3: unexplained keystrokes in a pane kept a stalled lease
// renewing, because the renewal check hashed the whole capture.
func (b *TmuxBackend) PaneProgress(ctx context.Context, s Session) (string, error) {
text, err := b.PaneCapture(ctx, s, "recent")
if err != nil {
return "", err
}
var kept []string
for _, line := range strings.Split(text, "\n") {
if promptLine.MatchString(line) {
continue
}
kept = append(kept, line)
}
return strings.Join(kept, "\n"), 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
// 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
// effect. So this resends Enter while the live editor still holds exactly what
@@ -442,7 +462,7 @@ const launchResubmitLimit = 3
//
// 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) {
func (b *TmuxBackend) ConfirmInput(ctx context.Context, s Session, submitted string) (string, error) {
timeout, poll := b.LaunchConfirmTimeout, b.LaunchConfirmPoll
if timeout <= 0 {
timeout = 10 * time.Second