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:
@@ -417,7 +417,21 @@ func (w *worker) start(ctx context.Context, t domain.Task, ref string) error {
|
||||
return fmt.Errorf("build context: %w", err)
|
||||
}
|
||||
prompt := built.System + "\n\n" + built.Task
|
||||
// The transport decides what is submitted, never what the agent receives:
|
||||
// the file holds the exact bytes agentctx rendered either way.
|
||||
submitted, transport := prompt, herdr.LaunchInline
|
||||
if lt, ok := backend.(herdr.LaunchTransporter); ok {
|
||||
transport = lt.LaunchTransport(w.harness)
|
||||
}
|
||||
if transport == herdr.LaunchFileRef {
|
||||
submitted = herdr.LaunchReference
|
||||
}
|
||||
if writeErr := herdr.WriteLaunchContext(s.Worktree, prompt); writeErr != nil {
|
||||
// Under LaunchFileRef the file is the instruction, so a failed write
|
||||
// is a failed launch rather than lost evidence.
|
||||
if transport == herdr.LaunchFileRef {
|
||||
return fmt.Errorf("launch context %s: %w", t.ID, writeErr)
|
||||
}
|
||||
w.recordError(fmt.Errorf("launch context %s: %w", t.ID, writeErr))
|
||||
}
|
||||
// The launch instruction carried these, so the first turn boundary must
|
||||
@@ -431,9 +445,29 @@ func (w *worker) start(ctx context.Context, t domain.Task, ref string) error {
|
||||
}
|
||||
// A prompt response can be lost after the backend accepted it. Persist the
|
||||
// session first so the worker can reconcile/release it after restart.
|
||||
if err := backend.Prompt(ctx, s.PaneID, prompt, 0); err != nil {
|
||||
if err := backend.Prompt(ctx, s.PaneID, submitted, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
// 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 confirmErr != nil {
|
||||
// An unsubmitted prompt leaves a live pane that nothing owns, and
|
||||
// a retained session would make the retry skip this task
|
||||
// entirely. Reclaim both so the released lease can be re-leased.
|
||||
if killErr := backend.Kill(ctx, s); killErr != nil {
|
||||
w.recordError(fmt.Errorf("kill unlaunched pane %s: %w", t.ID, killErr))
|
||||
}
|
||||
delete(w.sessions, t.ID)
|
||||
if saveErr := w.save(); saveErr != nil {
|
||||
w.recordError(fmt.Errorf("save after failed launch %s: %w", t.ID, saveErr))
|
||||
}
|
||||
return fmt.Errorf("launch %s: %w", t.ID, confirmErr)
|
||||
}
|
||||
log.Printf("launch %s confirmed: %s", t.ID, evidence)
|
||||
}
|
||||
if l, ok := w.leases[t.ID]; ok {
|
||||
if err := w.api.Start(ctx, t.ID, l.Epoch, l.Version, w.sessionEvidence(ctx, t.ID, s)); err != nil {
|
||||
return fmt.Errorf("ack start: %w", err)
|
||||
@@ -451,6 +485,11 @@ func (w *worker) start(ctx context.Context, t domain.Task, ref string) error {
|
||||
}
|
||||
|
||||
func classifyLaunchError(err error, sessionStarted bool) string {
|
||||
// Positive evidence that the harness never accepted the prompt is not
|
||||
// uncertainty. Release the lease so the existing retry path can take it.
|
||||
if errors.Is(err, herdr.ErrPromptNotSubmitted) {
|
||||
return "prompt_not_submitted"
|
||||
}
|
||||
if sessionStarted {
|
||||
// A prompt response can be lost after herdr accepted it. Never reclaim
|
||||
// that pane just because its acknowledgement was uncertain.
|
||||
|
||||
Reference in New Issue
Block a user