Acknowledge a launch only when the harness accepted it

Burn-in run 2 recorded TaskLaunchAcknowledged, opened a pane, and ran nothing
for fifteen minutes. The launch instruction sat in Claude Code's input editor
as "[Pasted text #1 +66 lines]" at zero tokens and zero elapsed. Two separate
bugs produced that.

The transport was wrong for the harness. TmuxBackend.Prompt writes the whole
instruction with send-keys -l and then sends Enter, and the TUI coalesces the
fast multi-line write into a paste that absorbs the following Enter. Launch
transport is now a backend property rather than one universal prompt format:
claude on tmux submits a single line pointing at .orchestra/launch.md, every
other harness keeps the inline path it was verified on. agentctx is unchanged
and the file still holds the exact bytes Orchestra rendered, so what the agent
receives is identical either way. Under the file transport a failed write is
now a failed launch, because there the file is the instruction.

The acknowledgement was also wrong. It meant "Prompt returned nil", not "the
harness accepted the prompt". Backends may now implement ConfirmLaunch, and
the tmux one polls until the input editor clears and the agent is observably
busy, blocked on approval, or at least no longer holding the text. An editor
that still holds the prompt at the deadline is a definite failure. The worker
kills the pane, drops the session so the retry starts clean, and returns
ErrPromptNotSubmitted, which classifies as prompt_not_submitted rather than
launch_uncertain. That class already falls through to TaskReleased, so the
existing retry path takes it and no lease is held on a launch that never
happened.

The confirmation bound is tunable because how fast a terminal harness reacts
is a property of the host. It is not a sleep before the submit: the submit is
deterministic, and this waits for the harness to visibly react to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 00:46:56 +04:00
parent a5d361b59f
commit 1fd82f863c
5 changed files with 402 additions and 1 deletions
+119
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
@@ -715,3 +716,121 @@ func TestFederatedTurnActsOnPrepareHandoffVerdict(t *testing.T) {
t.Fatalf("handoff re-requested: %v", backend.prompts)
}
}
// launchBackend is a recordingBackend that also declares a launch transport
// and a confirmation outcome, which is the seam F15 added.
type launchBackend struct {
recordingBackend
transport herdr.LaunchTransport
confirmed bool
killed int
}
func (b *launchBackend) LaunchTransport(string) herdr.LaunchTransport { return b.transport }
func (b *launchBackend) ConfirmLaunch(context.Context, herdr.Session, string) (string, error) {
if !b.confirmed {
return "", fmt.Errorf("%w: editor still holds the prompt", herdr.ErrPromptNotSubmitted)
}
return "input editor cleared, agent busy", nil
}
func (b *launchBackend) Kill(context.Context, herdr.Session) error { b.killed++; return nil }
// startFixture builds the git remote, local clone and coordinator stub that
// worker.start needs, and returns a worker wired to the given backend.
func startFixture(t *testing.T, backend herdr.Backend, harness string) (*worker, domain.Task, string) {
t.Helper()
remote := filepath.Join(t.TempDir(), "remote.git")
if out, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil {
t.Fatalf("remote: %v %s", err, out)
}
seed := t.TempDir()
for _, a := range [][]string{{"init", seed}, {"-C", seed, "config", "user.email", "t@t"}, {"-C", seed, "config", "user.name", "t"}, {"-C", seed, "commit", "--allow-empty", "-m", "init"}, {"-C", seed, "remote", "add", "origin", remote}, {"-C", seed, "push", "-u", "origin", "HEAD:master"}} {
if out, err := exec.Command("git", a...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v %s", a, err, out)
}
}
repo := filepath.Join(t.TempDir(), "repo")
if out, err := exec.Command("git", "clone", remote, repo).CombinedOutput(); err != nil {
t.Fatal(string(out))
}
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/intent") {
json.NewEncoder(w).Encode(domain.EffectiveIntent{Task: domain.Task{ID: "task", Title: "test", Description: "do work"}})
return
}
w.WriteHeader(200)
}))
t.Cleanup(api.Close)
root := filepath.Join(filepath.Dir(repo), "worktrees")
w := &worker{
api: federation.Client{BaseURL: api.URL, WorkerID: "h", Token: "t"}, harnessID: "h",
backend: backend, repo: repo, root: root, remote: "origin", harness: harness,
tasks: map[string]domain.Task{}, sessions: map[string]herdr.Session{}, leases: map[string]lease{},
releases: map[string]releaseTransaction{}, quarantined: map[string]bool{},
statePath: filepath.Join(t.TempDir(), "state.json"), hard: .75,
}
return w, domain.Task{ID: "task", Source: "s", ExternalID: "x", Project: "p", Title: "test", Description: "do work"}, root
}
// The claude/tmux launch submits one line, while the agent still receives the
// exact bytes agentctx rendered, through the file the line points at.
func TestClaudeLaunchSubmitsAFileReferenceAndConfirmsIt(t *testing.T) {
backend := &launchBackend{transport: herdr.LaunchFileRef, confirmed: true}
w, task, root := startFixture(t, backend, "claude")
if err := w.start(context.Background(), task, ""); err != nil {
t.Fatal(err)
}
if len(backend.prompts) != 1 || backend.prompts[0] != herdr.LaunchReference {
t.Fatalf("submitted %q, want the one-line launch reference", backend.prompts)
}
b, err := os.ReadFile(filepath.Join(root, "task", herdr.LaunchContextFile))
if err != nil {
t.Fatalf("launch context: %v", err)
}
for _, want := range []string{"Authority order", "## Goal", "do work"} {
if !strings.Contains(string(b), want) {
t.Fatalf("launch context missing %q", want)
}
}
if _, ok := w.sessions[task.ID]; !ok {
t.Fatal("confirmed launch did not keep its session")
}
}
// Prompt returning nil is not an acknowledgement. An unconfirmed launch must
// reclaim the pane, drop the session so the retry can start clean, and
// classify as prompt_not_submitted so the lease is released rather than held.
func TestUnconfirmedLaunchFailsAndReclaimsThePane(t *testing.T) {
backend := &launchBackend{transport: herdr.LaunchFileRef, confirmed: false}
w, task, _ := startFixture(t, backend, "claude")
err := w.start(context.Background(), task, "")
if !errors.Is(err, herdr.ErrPromptNotSubmitted) {
t.Fatalf("start err=%v, want ErrPromptNotSubmitted", err)
}
if _, ok := w.sessions[task.ID]; ok {
t.Fatal("a launch that never happened must not leave a session behind")
}
if backend.killed != 1 {
t.Fatalf("killed %d panes, want 1", backend.killed)
}
if got := classifyLaunchError(err, false); got != "prompt_not_submitted" {
t.Fatalf("class=%q, want prompt_not_submitted", got)
}
// Even with a session still recorded, positive evidence outranks the
// uncertain classification that would otherwise hold the lease forever.
if got := classifyLaunchError(err, true); got != "prompt_not_submitted" {
t.Fatalf("class with session=%q, want prompt_not_submitted", got)
}
}
// A backend that cannot confirm keeps the inline launch it was verified on.
func TestInlineTransportSubmitsTheWholeInstruction(t *testing.T) {
backend := &launchBackend{transport: herdr.LaunchInline, confirmed: true}
w, task, _ := startFixture(t, backend, "opencode")
if err := w.start(context.Background(), task, ""); err != nil {
t.Fatal(err)
}
if len(backend.prompts) != 1 || !strings.Contains(backend.prompts[0], "Authority order") {
t.Fatalf("submitted %q, want the whole instruction", backend.prompts)
}
}