Files
orchestra/internal/herdr/backend.go
T
kami 1fd82f863c 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>
2026-08-27 00:46:56 +04:00

125 lines
4.8 KiB
Go

package herdr
import (
"context"
"errors"
"time"
)
// Backend is the machine-local terminal/process seam used by a federation
// worker. Herdr remains the default implementation; tmux is a deliberately
// smaller alternative for Claude Code hosts that do not run herdr.
//
// The interface deals only in local session operations. Git checkout and
// lease ownership stay with orchestra-worker regardless of the backend.
type Backend interface {
Kind() string
Check(context.Context) error
Worktree(context.Context, string, string, string) (string, error)
StartAgent(context.Context, string, string, string, string, string) (Session, error)
Prompt(context.Context, string, string, time.Duration) error
Kill(context.Context, Session) error
AgentStatus(context.Context, Session) (string, error)
PaneCapture(context.Context, Session, string) (string, error)
SendText(context.Context, Session, string) error
SendKeys(context.Context, Session, []string) error
ReleaseAgent(context.Context, Session, string) error
}
// Kind identifies the existing JSON-RPC backend.
func (c *Client) Kind() string { return "herdr" }
// Check verifies the live protocol rather than treating an open socket as a
// healthy execution backend.
func (c *Client) Check(ctx context.Context) error { return c.CheckProtocol(ctx, "17") }
func (c *Client) Kill(ctx context.Context, s Session) error {
return c.Call(ctx, "pane.close", map[string]any{"pane_id": s.PaneID}, nil)
}
func (c *Client) AgentStatus(ctx context.Context, s Session) (string, error) {
var result map[string]any
if err := c.Call(ctx, "agent.get", map[string]any{"target": s.PaneID}, &result); err != nil {
return "", err
}
return statusFromAgentResult(result), nil
}
func (c *Client) PaneCapture(ctx context.Context, s Session, source string) (string, error) {
if source == "" {
source = "recent"
}
var result struct {
Read struct {
Text string `json:"text"`
} `json:"read"`
}
if err := c.Call(ctx, "pane.read", map[string]any{"pane_id": s.PaneID, "source": source}, &result); err != nil {
return "", err
}
return result.Read.Text, nil
}
func (c *Client) SendText(ctx context.Context, s Session, text string) error {
return c.Call(ctx, "pane.send_text", map[string]any{"pane_id": s.PaneID, "text": text}, nil)
}
func (c *Client) SendKeys(ctx context.Context, s Session, keys []string) error {
return c.Call(ctx, "pane.send_keys", map[string]any{"pane_id": s.PaneID, "keys": keys}, nil)
}
func (c *Client) ReleaseAgent(ctx context.Context, s Session, harness string) error {
return c.Call(ctx, "pane.release_agent", map[string]any{
"pane_id": s.PaneID,
"source": "herdr:" + harness,
"agent": agentForSession(s, harness),
}, nil)
}
var _ Backend = (*Client)(nil)
// LaunchTransport says how a backend delivers a task's launch instruction.
//
// The instruction itself never changes: agentctx renders one canonical text
// and WriteLaunchContext stores those exact bytes at LaunchContextFile. Only
// the delivery differs, because a terminal harness is not a protocol.
type LaunchTransport string
const (
// LaunchInline sends the whole instruction as the prompt.
LaunchInline LaunchTransport = "inline"
// LaunchFileRef sends one line pointing at LaunchContextFile. Claude Code
// coalesces a fast multi-line literal write into a paste and absorbs the
// following Enter into it, so an inline launch is delivered and never
// submitted. A one-line prompt does not trigger paste detection. Found on
// burn-in run 2, 2026-08-26.
LaunchFileRef LaunchTransport = "file_ref"
)
// LaunchReference is the one-line prompt LaunchFileRef submits. It names the
// file two ways on purpose: the @ form is the harness's own file-reference
// convention, and the bare path stays readable if the harness declines to
// expand a reference into an ignored directory.
const LaunchReference = "@" + LaunchContextFile + " is your complete Orchestra launch instruction. Read .orchestra/launch.md now and follow it."
// LaunchTransporter is optional. A backend that does not implement it sends
// the instruction inline.
type LaunchTransporter interface {
LaunchTransport(harness string) LaunchTransport
}
// ErrPromptNotSubmitted means the prompt reached the harness's input and was
// never submitted. It is a launch failure with positive evidence, not an
// uncertain one: the lease must be released and retried rather than held.
var ErrPromptNotSubmitted = errors.New("prompt_not_submitted")
// LaunchConfirmer is optional. A backend that does not implement it treats a
// successful Prompt as proof of submission, which is only sound where the
// backend's own protocol acknowledges the prompt.
//
// ConfirmLaunch returns the evidence that convinced it, or an error wrapping
// ErrPromptNotSubmitted when the submission cannot be observed.
type LaunchConfirmer interface {
ConfirmLaunch(ctx context.Context, s Session, submitted string) (string, error)
}