fix herdr launch safety and task context
This commit is contained in:
+129
-25
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user