edbe98fc5e
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>
138 lines
5.5 KiB
Go
138 lines
5.5 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")
|
|
|
|
// InputConfirmer 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.
|
|
//
|
|
// Every write Orchestra originates goes through this, not only the launch: a
|
|
// lost Enter on a phase continuation or a context reset strands the session
|
|
// exactly as a lost launch does, and burn-in run 3 showed the Enter is lost
|
|
// often enough to matter.
|
|
//
|
|
// ConfirmInput returns the evidence that convinced it, or an error wrapping
|
|
// ErrPromptNotSubmitted when the submission cannot be observed.
|
|
type InputConfirmer interface {
|
|
ConfirmInput(ctx context.Context, s Session, submitted string) (string, error)
|
|
}
|
|
|
|
// PaneProgress is optional. It reports pane content with harness input lines
|
|
// removed, so that typing into a pane is not mistaken for the agent doing
|
|
// work. Orchestra does not guarantee delivery of input it did not originate,
|
|
// but it must never count that input as progress.
|
|
type PaneProgress interface {
|
|
PaneProgress(ctx context.Context, s Session) (string, error)
|
|
}
|