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
+42 -4
View File
@@ -454,8 +454,8 @@ func (w *worker) start(ctx context.Context, t domain.Task, ref string) error {
// Acknowledging a launch means the harness accepted the instruction, not
// that the adapter call returned nil. Without this the worker reported a
// started agent while the prompt sat unsubmitted in the input editor.
if c, ok := backend.(herdr.LaunchConfirmer); ok {
evidence, confirmErr := c.ConfirmLaunch(ctx, s, submitted)
if c, ok := backend.(herdr.InputConfirmer); ok {
evidence, confirmErr := c.ConfirmInput(ctx, s, submitted)
if confirmErr != nil {
// An unsubmitted prompt leaves a live pane that nothing owns, and
// a retained session would make the retry skip this task
@@ -673,6 +673,11 @@ func (w *worker) sendLine(ctx context.Context, s herdr.Session, line string) err
if err := backend.SendKeys(ctx, s, []string{"ENTER"}); err != nil {
return fmt.Errorf("submit %q: %w", line, err)
}
// A lost Enter here leaves the session mid-rollover with /clear sitting in
// the editor, which is worse than a lost launch: nothing retries it.
if err := w.confirmInput(ctx, s, line); err != nil {
return fmt.Errorf("submit %q: %w", line, err)
}
return nil
}
@@ -962,7 +967,9 @@ func (w *worker) renewLeases(ctx context.Context) {
continue
}
adapter := herdr.CLIAdapter{Backend: w.executionBackend(), Harness: w.harness}
text, err := adapter.PaneCapture(ctx, s, "recent")
// Input lines are excluded: keystrokes arriving at a pane, from
// Orchestra or from anyone else, are not the agent doing work.
text, err := w.paneProgress(ctx, adapter, s)
if err != nil {
w.recordError(fmt.Errorf("validate lease %s: %w", taskID, err))
continue
@@ -1641,10 +1648,41 @@ func (w *worker) federatedTurn(ctx context.Context, id string, a herdr.Adapter,
_ = w.save()
}
// sendPrompt delivers Orchestra-originated input and confirms the harness took
// it. A phase continuation or a decision notice whose Enter is lost strands the
// session exactly as a lost launch does.
func (w *worker) sendPrompt(ctx context.Context, s herdr.Session, text string) error {
backend := w.executionBackend()
if backend == nil {
return fmt.Errorf("execution backend is not configured")
}
return backend.Prompt(ctx, s.PaneID, text, time.Minute)
if err := backend.Prompt(ctx, s.PaneID, text, time.Minute); err != nil {
return err
}
return w.confirmInput(ctx, s, text)
}
// confirmInput is the one place Orchestra proves a write landed. A backend
// whose own protocol acknowledges input does not implement InputConfirmer and
// needs no second opinion.
func (w *worker) confirmInput(ctx context.Context, s herdr.Session, text string) error {
c, ok := w.executionBackend().(herdr.InputConfirmer)
if !ok {
return nil
}
evidence, err := c.ConfirmInput(ctx, s, text)
if err != nil {
return err
}
log.Printf("input to %s confirmed: %s", s.PaneID, evidence)
return nil
}
// paneProgress reports pane content with input lines removed where the backend
// can separate them, and falls back to the raw capture where it cannot.
func (w *worker) paneProgress(ctx context.Context, adapter herdr.CLIAdapter, s herdr.Session) (string, error) {
if p, ok := w.executionBackend().(herdr.PaneProgress); ok {
return p.PaneProgress(ctx, s)
}
return adapter.PaneCapture(ctx, s, "recent")
}
+47 -7
View File
@@ -370,10 +370,17 @@ func TestApprovalResponseOpenCodeAllowOnce(t *testing.T) {
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
type recordingBackend struct {
status string
capture string
calls []string
prompts []string
status string
capture string
progress string
calls []string
prompts []string
}
// PaneProgress makes this stub the kind of backend that can separate harness
// output from input, which is what the worker must prefer over a raw capture.
func (b *recordingBackend) PaneProgress(context.Context, herdr.Session) (string, error) {
return b.progress, nil
}
func (b *recordingBackend) Kind() string { return "recording" }
@@ -406,7 +413,7 @@ func TestRenewLeasesRequiresProgress(t *testing.T) {
rw.Write([]byte(`{}`))
}))
defer api.Close()
backend := &recordingBackend{status: "idle", capture: "same screen"}
backend := &recordingBackend{status: "idle", progress: "same screen"}
w := &worker{
api: federation.Client{BaseURL: api.URL, WorkerID: "h", Token: "t"},
backend: backend,
@@ -425,7 +432,7 @@ func TestRenewLeasesRequiresProgress(t *testing.T) {
if renewals != 1 {
t.Fatalf("busy agent renewals=%d, want 1", renewals)
}
backend.status, backend.capture = "idle", "new output"
backend.status, backend.progress = "idle", "new output"
l := w.leases["task"]
l.Until = time.Now()
w.leases["task"] = l
@@ -764,7 +771,7 @@ type launchBackend struct {
}
func (b *launchBackend) LaunchTransport(string) herdr.LaunchTransport { return b.transport }
func (b *launchBackend) ConfirmLaunch(context.Context, herdr.Session, string) (string, error) {
func (b *launchBackend) ConfirmInput(context.Context, herdr.Session, string) (string, error) {
if !b.confirmed {
return "", fmt.Errorf("%w: editor still holds the prompt", herdr.ErrPromptNotSubmitted)
}
@@ -915,3 +922,36 @@ func TestReconcileLeasesKeepsWorkerLocalObservations(t *testing.T) {
t.Fatalf("observations survived a new epoch: %+v", got)
}
}
// The live shape of the run-3 stall: someone typed into the pane, the capture
// changed, and nothing the agent did changed at all. Renewal must refuse.
func TestRenewLeasesIgnoresPaneInput(t *testing.T) {
renewals := 0
api := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
renewals++
rw.Write([]byte(`{}`))
}))
defer api.Close()
backend := &recordingBackend{status: "idle", capture: "screen\n\u276f ", progress: "screen"}
w := &worker{
api: federation.Client{BaseURL: api.URL, WorkerID: "h", Token: "t"},
backend: backend,
harness: "claude",
sessions: map[string]herdr.Session{"task": {PaneID: "pane"}},
leases: map[string]lease{"task": {Epoch: "e", Version: 1, Until: time.Now(), ProgressSHA: domain.Hash([]byte("screen"))}},
quarantined: map[string]bool{},
statePath: filepath.Join(t.TempDir(), "state.json"),
}
// Only the input line changes. The agent has done nothing.
backend.capture = "screen\n\u276f go ahead and implement it"
w.renewLeases(context.Background())
if renewals != 0 {
t.Fatalf("pane input renewed the lease %d times", renewals)
}
// Harness output moves the digest, and only then does the lease renew.
backend.progress = "screen\nedited the script"
w.renewLeases(context.Background())
if renewals != 1 {
t.Fatalf("renewals=%d, want 1 after real progress", renewals)
}
}