fix herdr launch safety and task context

This commit is contained in:
kami
2026-07-28 13:20:28 +04:00
parent 636ed8a811
commit 58793a5aa3
8 changed files with 826 additions and 74 deletions
+129 -25
View File
@@ -8,7 +8,9 @@ import (
"fmt"
"orchestra/internal/continuity"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
@@ -33,6 +35,13 @@ type Adapter interface {
Occupancy(Session) (float64, error)
}
// PromptLeaser accepts the complete task-specific launch instruction. It is
// optional so non-interactive adapters and existing tests retain the small
// Lease seam, while remote worktrees are never left with only an opaque ID.
type PromptLeaser interface {
LeasePrompt(context.Context, string, string, string) (Session, error)
}
type WorktreeCreator interface {
CreateWorktree(context.Context, string, string, string) (string, error)
}
@@ -74,6 +83,10 @@ type CLIAdapter struct {
// uploads the one the agent wrote (herdr does not write handoffs, §6.1).
const HandoffFile = ".orchestra-handoff.json"
// HandoffReportFile is the only handoff artifact an opaque harness authors.
// The worker which owns the checkout derives and seals the canonical JSON.
const HandoffReportFile = ".orchestra-handoff-report.md"
func (a CLIAdapter) CreateWorktree(ctx context.Context, repo, root, taskID string) (string, error) {
path, err := a.Client.Worktree(ctx, repo, filepath.Join(root, taskID), "orchestra/"+taskID)
if err != nil {
@@ -86,6 +99,17 @@ func (a CLIAdapter) CreateWorktree(ctx context.Context, repo, root, taskID strin
}
func (a CLIAdapter) Lease(ctx context.Context, task, worktree string) (Session, error) {
return a.LeasePrompt(ctx, task, worktree, defaultTaskPrompt(task))
}
func defaultTaskPrompt(task string) string {
return fmt.Sprintf("Begin Orchestra task %s. Inspect the repository, understand the task context, and proceed with the requested work.", task)
}
// LeasePrompt starts a harness and sends an immutable copy of the task's
// actionable instruction. This is required for a herdr-hosted remote
// worktree: homesrv cannot safely write/read that machine's TASK.md.
func (a CLIAdapter) LeasePrompt(ctx context.Context, task, worktree, prompt string) (Session, error) {
if a.Client == nil {
return Session{}, fmt.Errorf("adapter: client required")
}
@@ -93,11 +117,22 @@ func (a CLIAdapter) Lease(ctx context.Context, task, worktree string) (Session,
if err != nil {
return Session{}, err
}
if err := a.Client.Prompt(ctx, s.PaneID, fmt.Sprintf("Begin Orchestra task %s. Inspect the repository, understand the task context, and proceed with the requested work.", task), time.Minute); err != nil {
return Session{}, err
// The initial instruction is an asynchronous launch message. Waiting for
// idle here turns a normal long-running first turn into a false lease
// failure (and TaskBlocked) even though herdr accepted the prompt.
if err := a.Client.Prompt(ctx, s.PaneID, prompt, 0); err != nil {
// The request may have reached herdr even when its response was lost.
// Preserve the live session so Coordinator can reconcile completion.
return s, err
}
return s, nil
}
func (a CLIAdapter) prompt(ctx context.Context, s Session, text string, wait time.Duration) error {
a.Client.BindAgent(s.PaneID, s.AgentName)
return a.Client.Prompt(ctx, s.PaneID, text, wait)
}
// bootstrapPrompt implements the §6.2 pickup procedure: the plane has already
// run ValidatePickup before this is ever sent (Coordinator.Start blocks the
// task and never bootstraps on failure), so this prompt does not ask the
@@ -111,19 +146,12 @@ This worktree's anchor and TASK.md have already been verified by the plane befor
4. Continue the task from there.`
func (a CLIAdapter) Bootstrap(ctx context.Context, s Session, ref string) error {
return a.Client.Prompt(ctx, s.PaneID, fmt.Sprintf(bootstrapPrompt, ref), time.Minute)
return a.prompt(ctx, s, fmt.Sprintf(bootstrapPrompt, ref), time.Minute)
}
// handoffPrompt is Phase 4 item 2's missing half (AUDIT.md): Release already
// validates and uploads a §6.1 handoff the agent wrote, but nothing ever told
// the agent that convention exists. rotate() sends this once occupancy crosses
// the hard threshold at a turn boundary, mirroring the .orchestra-report.md
// convention B3 established for completion — the plane still never invents a
// handoff, it only asks the agent to produce one, then validates it in Release.
const handoffPrompt = `Orchestra is about to rotate this task to a fresh session (context budget reached).
Before you stop, write a §6.1 handoff to ` + HandoffFile + ` at the worktree root, a JSON object with at least:
{"meta":{"id":"<any stable string for this handoff>"},"anchor":{"git_sha":"<current HEAD via git rev-parse HEAD>","branch":"<current branch>","dirty":[{"path":"<repo-relative path>","sha256":"<sha256 of its current contents>"}, ...for any uncommitted files]},"knowledge":{...whatever structured context the next agent needs...}}
Do not edit TASK.md. Do not fabricate the git_sha or dirty file hashes — read them for real. Once written, stop normally.`
Before you stop, write a concise semantic handoff report to ` + HandoffReportFile + ` at the worktree root: what changed, validation evidence, remaining work, review findings, and dead ends/open questions.
Do not write protocol JSON, git anchors, or file hashes; Orchestra's checkout worker collects and validates those facts. Do not edit TASK.md. Once written, stop normally.`
// RequestHandoff prompts the agent to write HandoffFile before Release reads
// it. Optional capability: adapters without a live pane (tests, etc.) can
@@ -133,7 +161,7 @@ type HandoffRequester interface {
}
func (a CLIAdapter) RequestHandoff(ctx context.Context, s Session) error {
return a.Client.Prompt(ctx, s.PaneID, handoffPrompt, time.Minute)
return a.prompt(ctx, s, handoffPrompt, time.Minute)
}
// ReasonedHandoffRequester is RequestHandoff's counterpart for the two
@@ -155,7 +183,7 @@ func (a CLIAdapter) RequestHandoffReason(ctx context.Context, s Session, reason
case "milestone":
sb.WriteString("A coherent unit of work looks complete (a successful commit). If the next step is independent of what you just did, this is a good point to hand off.\n")
}
fmt.Fprintf(&sb, "Before you stop, write a §6.1 handoff to %s at the worktree root with meta.reason=%q", HandoffFile, reason)
fmt.Fprintf(&sb, "Before you stop, write a concise semantic handoff report to %s at the worktree root (reason: %q)", HandoffReportFile, reason)
if len(deadEnds) > 0 {
sb.WriteString(" and a dead_ends entry for each of the following:\n")
for _, d := range deadEnds {
@@ -164,8 +192,8 @@ func (a CLIAdapter) RequestHandoffReason(ctx context.Context, s Session, reason
} else {
sb.WriteString(".\n")
}
sb.WriteString("Use the same anchor convention as any other handoff: the real git_sha via 'git rev-parse HEAD', the real branch, and a real sha256 of any uncommitted files — never fabricated. Do not edit TASK.md. Once written, stop normally.")
return a.Client.Prompt(ctx, s.PaneID, sb.String(), time.Minute)
sb.WriteString("Include what changed, validation evidence, remaining work, review findings, and any dead ends. Do not write protocol JSON, git anchors, or file hashes; Orchestra collects those. Do not edit TASK.md. Once written, stop normally.")
return a.prompt(ctx, s, sb.String(), time.Minute)
}
// Activity resolves the harness's tool-call history the same way Occupancy
@@ -203,8 +231,9 @@ type ConventionsNotifier interface {
}
func (a CLIAdapter) NotifyConventionsChanged(ctx context.Context, s Session) error {
return a.Client.Prompt(ctx, s.PaneID, conventionsPrompt, time.Minute)
return a.prompt(ctx, s, conventionsPrompt, time.Minute)
}
// Release reads the §6.1 handoff the agent wrote to HandoffFile at the
// worktree root, validates its schema and anchor against the worktree's real
// HEAD, uploads it to CAS, and only then releases herdr's claim on the pane
@@ -219,22 +248,23 @@ func (a CLIAdapter) Release(ctx context.Context, s Session) (string, error) {
if a.CAS == nil {
return "", fmt.Errorf("adapter: CAS store required to upload handoff")
}
path := filepath.Join(s.Worktree, HandoffFile)
path := filepath.Join(s.Worktree, HandoffReportFile)
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("adapter: handoff not written yet (%s): %w", path, err)
return "", fmt.Errorf("adapter: semantic handoff report not written yet (%s): %w", path, err)
}
h, err := continuity.Decode(b)
if strings.TrimSpace(string(b)) == "" {
return "", fmt.Errorf("adapter: semantic handoff report is empty")
}
h, err := canonicalHandoff(s, string(b))
if err != nil {
return "", fmt.Errorf("adapter: invalid handoff: %w", err)
return "", err
}
sha, err := HeadSHA(s.Worktree)
if err != nil {
return "", fmt.Errorf("adapter: read worktree HEAD: %w", err)
}
if h.Anchor.GitSHA != sha {
return "", fmt.Errorf("adapter: handoff anchor %s does not match worktree HEAD %s", h.Anchor.GitSHA, sha)
}
_ = sha
for _, d := range h.Anchor.Dirty {
if hex.EncodeToString(sha256sum(filepath.Join(s.Worktree, d.Path))) != d.SHA256 {
return "", fmt.Errorf("adapter: handoff dirty file changed since it was written: %s", d.Path)
@@ -264,12 +294,86 @@ func (a CLIAdapter) Release(ctx context.Context, s Session) (string, error) {
if err := a.Client.Call(ctx, "pane.release_agent", map[string]any{
"pane_id": s.PaneID,
"source": "herdr:" + a.Harness,
"agent": a.Harness,
"agent": agentForSession(s, a.Harness),
}, nil); err != nil {
return "", fmt.Errorf("adapter: pane.release_agent: %w", err)
}
return ref, nil
}
// canonicalHandoff keeps Git-derived protocol facts on the worker that owns
// the checkout. The harness contributes only the semantic report (B17).
func canonicalHandoff(s Session, report string) (continuity.Handoff, error) {
sha, err := HeadSHA(s.Worktree)
if err != nil {
return continuity.Handoff{}, fmt.Errorf("adapter: read worktree HEAD: %w", err)
}
branchOut, err := exec.Command("git", "-C", s.Worktree, "branch", "--show-current").Output()
if err != nil {
return continuity.Handoff{}, fmt.Errorf("adapter: read worktree branch: %w", err)
}
dirty, err := dirtyFiles(s.Worktree)
if err != nil {
return continuity.Handoff{}, err
}
return continuity.Handoff{
Meta: continuity.Meta{ID: handoffID(s), Reason: "threshold"},
Anchor: continuity.Anchor{GitSHA: sha, Branch: strings.TrimSpace(string(branchOut)), Dirty: dirty},
Goal: "Continue Orchestra task " + s.PaneID,
DoneWhen: []string{"Task completion is reported to Orchestra"},
Action: "Read the semantic handoff report and continue the task.",
Command: "cat " + HandoffReportFile,
Remaining: []string{report},
}, nil
}
func handoffID(s Session) string {
id := s.AgentName
if id == "" {
id = s.PaneID
}
id = strings.Trim(invalidAgentName.ReplaceAllString(strings.ToLower(id), "-"), "-_")
if id == "" {
return "session"
}
return id
}
func dirtyFiles(root string) ([]continuity.Dirty, error) {
paths := map[string]bool{}
for _, args := range [][]string{{"diff", "--name-only", "-z"}, {"ls-files", "--others", "--exclude-standard", "-z"}} {
out, err := exec.Command("git", append([]string{"-C", root}, args...)...).Output()
if err != nil {
return nil, err
}
for _, path := range strings.Split(string(out), "\x00") {
if path != "" && path != HandoffFile {
paths[path] = true
}
}
}
keys := make([]string, 0, len(paths))
for path := range paths {
keys = append(keys, path)
}
sort.Strings(keys)
dirty := make([]continuity.Dirty, 0, len(keys))
for _, path := range keys {
sum := sha256sum(filepath.Join(root, path))
if len(sum) == 0 {
return nil, fmt.Errorf("adapter: hash dirty file %s", path)
}
dirty = append(dirty, continuity.Dirty{Path: path, SHA256: hex.EncodeToString(sum)})
}
return dirty, nil
}
func agentForSession(s Session, fallback string) string {
if s.AgentName != "" {
return s.AgentName
}
return fallback // compatibility with session records created before B16
}
func (a CLIAdapter) Kill(ctx context.Context, s Session) error {
return a.Client.Call(ctx, "pane.close", map[string]any{"pane_id": s.PaneID}, nil)
}