Add federation worker and canonical handoffs

This commit is contained in:
kami
2026-07-28 16:17:18 +04:00
parent 58793a5aa3
commit 2cecbc4015
22 changed files with 1429 additions and 108 deletions
+177 -25
View File
@@ -75,6 +75,10 @@ type CLIAdapter struct {
// release. Nil disables Release (adapters built without one refuse
// loudly rather than skip validation).
CAS continuity.CAS
// Remote, when set by a federation worker, is pushed after the scratch
// commit and before the pane claim is released. Git is the cross-machine
// transport; a CAS handoff must never point at an unpushed anchor.
Remote string
}
// HandoffFile is the convention the agent writes its §6.1 handoff to before
@@ -83,8 +87,9 @@ 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.
// HandoffReportFile holds the agent's small, labelled answer during release.
// It is not a report: the worker parses it, derives the protocol facts, and
// seals the resulting canonical JSON.
const HandoffReportFile = ".orchestra-handoff-report.md"
func (a CLIAdapter) CreateWorktree(ctx context.Context, repo, root, taskID string) (string, error) {
@@ -149,9 +154,16 @@ func (a CLIAdapter) Bootstrap(ctx context.Context, s Session, ref string) error
return a.prompt(ctx, s, fmt.Sprintf(bootstrapPrompt, ref), time.Minute)
}
const handoffPrompt = `Orchestra is about to rotate this task to a fresh session (context budget reached).
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.`
const handoffPrompt = `Orchestra is about to rotate this task. Write ONLY the following labelled answers to ` + HandoffReportFile + `, then stop. Output nothing else.
NEXT: the single next action (one line).
WHY: why that is next (one line).
REMAINING: outstanding items, one line each. If none: NONE.
DEAD ENDS: approaches tried that failed — "tried X → failed because Y", one per line. If none: NONE.
OPEN Q: unresolved decisions, one line each. If none: NONE.
LEARNED: constraints discovered that are NOT in TASK.md, one line each. If none: NONE.
Do NOT include: what you completed (the diff shows it), the goal or done-criteria (TASK.md holds them), git SHAs/branches/paths, or a prose summary. No headings and no report. Do not edit TASK.md.`
// RequestHandoff prompts the agent to write HandoffFile before Release reads
// it. Optional capability: adapters without a live pane (tests, etc.) can
@@ -183,7 +195,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 concise semantic handoff report to %s at the worktree root (reason: %q)", HandoffReportFile, reason)
fmt.Fprintf(&sb, "Before you stop, write the labelled handoff answers requested below to %s at the worktree root (reason: %q).\n\n%s", HandoffReportFile, reason, handoffPrompt[strings.Index(handoffPrompt, "NEXT:"):])
if len(deadEnds) > 0 {
sb.WriteString(" and a dead_ends entry for each of the following:\n")
for _, d := range deadEnds {
@@ -192,7 +204,7 @@ func (a CLIAdapter) RequestHandoffReason(ctx context.Context, s Session, reason
} else {
sb.WriteString(".\n")
}
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.")
sb.WriteString("Do not add a prose summary, completed-work narration, or protocol JSON. Once written, stop normally.")
return a.prompt(ctx, s, sb.String(), time.Minute)
}
@@ -234,9 +246,9 @@ func (a CLIAdapter) NotifyConventionsChanged(ctx context.Context, s Session) err
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
// Release reads the semantic report the agent wrote at the worktree root,
// derives and validates the canonical handoff from the worktree's real Git
// state, uploads it to CAS, and only then releases herdr's claim on the pane
// via the real pane.release_agent(pane_id, source, agent) method (confirmed
// live against herdr, AUDIT.md Phase 0 — the invented "pane.release" never
// existed and could never have returned a handoff_ref regardless, since
@@ -256,7 +268,7 @@ func (a CLIAdapter) Release(ctx context.Context, s Session) (string, error) {
if strings.TrimSpace(string(b)) == "" {
return "", fmt.Errorf("adapter: semantic handoff report is empty")
}
h, err := canonicalHandoff(s, string(b))
h, err := canonicalHandoff(s, string(b), a.lastObservedCommand(s))
if err != nil {
return "", err
}
@@ -270,6 +282,20 @@ func (a CLIAdapter) Release(ctx context.Context, s Session) (string, error) {
return "", fmt.Errorf("adapter: handoff dirty file changed since it was written: %s", d.Path)
}
}
// The semantic report is transferred in the CAS handoff, not in the
// scratch checkout. Keeping it in the scratch commit makes a successor
// mistake the predecessor's report for a newly requested handoff and can
// cause an immediate release/pickup loop.
dirty := h.Anchor.Dirty[:0]
for _, d := range h.Anchor.Dirty {
if filepath.Clean(d.Path) != HandoffReportFile {
dirty = append(dirty, d)
}
}
h.Anchor.Dirty = dirty
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("adapter: remove transferred semantic report: %w", err)
}
// Atomically commit whatever the handoff described as dirty onto a
// per-task scratch branch (§6.2 step 3) *before* uploading, so the
// successor's pickup validation collapses to a single HEAD compare
@@ -279,6 +305,11 @@ func (a CLIAdapter) Release(ctx context.Context, s Session) (string, error) {
if err := continuity.ScratchCommit(s.Worktree, branch, "orchestra: pre-release WIP snapshot ("+h.Meta.ID+")"); err != nil {
return "", fmt.Errorf("adapter: scratch commit: %w", err)
}
if a.Remote != "" {
if err := continuity.ScratchPush(s.Worktree, branch, a.Remote); err != nil {
return "", fmt.Errorf("adapter: push scratch branch: %w", err)
}
}
newSHA, err := HeadSHA(s.Worktree)
if err != nil {
return "", fmt.Errorf("adapter: read scratch HEAD: %w", err)
@@ -302,8 +333,13 @@ func (a CLIAdapter) Release(ctx context.Context, s Session) (string, error) {
}
// 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) {
// the checkout. Every authored field comes from the validated agent answer;
// it never fabricates task intent or a circular next action.
func canonicalHandoff(s Session, answer, command string) (continuity.Handoff, error) {
authored, err := parseHandoffAnswer(answer)
if err != nil {
return continuity.Handoff{}, fmt.Errorf("adapter: invalid handoff answer: %w", err)
}
sha, err := HeadSHA(s.Worktree)
if err != nil {
return continuity.Handoff{}, fmt.Errorf("adapter: read worktree HEAD: %w", err)
@@ -317,16 +353,131 @@ func canonicalHandoff(s Session, report string) (continuity.Handoff, error) {
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},
Meta: continuity.Meta{ID: handoffID(s), Reason: handoffReason(s)},
Anchor: continuity.Anchor{GitSHA: sha, Branch: strings.TrimSpace(string(branchOut)), Dirty: dirty},
Action: authored.Action,
Command: command,
Remaining: authored.Remaining,
DeadEnds: authored.DeadEnds,
OpenQuestions: authored.OpenQuestions,
Learned: authored.Learned,
}, nil
}
type handoffAnswer struct {
Action, Why string
Remaining []string
DeadEnds []continuity.DeadEnd
OpenQuestions, Learned []string
}
func parseHandoffAnswer(answer string) (handoffAnswer, error) {
var out handoffAnswer
sections := map[string][]string{}
var current string
for _, raw := range strings.Split(strings.ReplaceAll(answer, "\r\n", "\n"), "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
for _, name := range []string{"NEXT", "WHY", "REMAINING", "DEAD ENDS", "OPEN Q", "LEARNED"} {
prefix := name + ":"
if strings.HasPrefix(line, prefix) {
current = name
if value := strings.TrimSpace(strings.TrimPrefix(line, prefix)); value != "" {
sections[name] = append(sections[name], value)
}
goto parsed
}
}
if current == "" {
return out, fmt.Errorf("unexpected line %q", line)
}
sections[current] = append(sections[current], strings.TrimSpace(strings.TrimPrefix(line, "- ")))
parsed:
}
for _, name := range []string{"NEXT", "WHY", "REMAINING", "DEAD ENDS", "OPEN Q", "LEARNED"} {
if len(sections[name]) == 0 {
return out, fmt.Errorf("missing %s", name)
}
}
if len(sections["NEXT"]) != 1 || len(sections["WHY"]) != 1 {
return out, fmt.Errorf("NEXT and WHY each require one line")
}
out.Action = sections["NEXT"][0] + " — " + sections["WHY"][0]
for _, name := range []string{"REMAINING", "OPEN Q", "LEARNED"} {
values, err := answerList(sections[name])
if err != nil {
return out, fmt.Errorf("%s: %w", name, err)
}
switch name {
case "REMAINING":
out.Remaining = values
case "OPEN Q":
out.OpenQuestions = values
case "LEARNED":
out.Learned = values
}
}
deadEnds, err := answerList(sections["DEAD ENDS"])
if err != nil {
return out, fmt.Errorf("DEAD ENDS: %w", err)
}
for _, item := range deadEnds {
parts := strings.SplitN(item, "→", 2)
if len(parts) != 2 {
return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'")
}
if !strings.HasPrefix(parts[0], "tried ") || !strings.HasPrefix(strings.TrimSpace(parts[1]), "failed because ") {
return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'")
}
tried := strings.TrimSpace(strings.TrimPrefix(parts[0], "tried "))
why := strings.TrimSpace(strings.TrimPrefix(parts[1], "failed because "))
if tried == "" || why == "" {
return out, fmt.Errorf("DEAD ENDS: want 'tried X → failed because Y'")
}
out.DeadEnds = append(out.DeadEnds, continuity.DeadEnd{Tried: tried, WhyFailed: why})
}
if err := (continuity.Handoff{Meta: continuity.Meta{ID: "answer", Reason: "manual"}, Anchor: continuity.Anchor{GitSHA: strings.Repeat("0", 40), Branch: "answer"}, Action: out.Action, Remaining: out.Remaining, DeadEnds: out.DeadEnds, OpenQuestions: out.OpenQuestions, Learned: out.Learned}).Validate(); err != nil {
return out, err
}
return out, nil
}
func answerList(lines []string) ([]string, error) {
if len(lines) == 1 && lines[0] == "NONE" {
return nil, nil
}
for _, line := range lines {
if line == "NONE" {
return nil, fmt.Errorf("NONE must be the only value")
}
}
return lines, nil
}
func (a CLIAdapter) lastObservedCommand(s Session) string {
calls, err := a.Activity(context.Background(), s)
if err != nil {
return ""
}
for i := len(calls) - 1; i >= 0; i-- {
if calls[i].Kind == "command" {
return calls[i].Key
}
}
return ""
}
func handoffReason(s Session) string {
switch s.HandoffReason {
case "threshold", "milestone", "thrash", "manual":
return s.HandoffReason
default:
return "threshold"
}
}
func handoffID(s Session) string {
id := s.AgentName
if id == "" {
@@ -368,11 +519,12 @@ func dirtyFiles(root string) ([]continuity.Dirty, error) {
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 agentForSession(_ Session, fallback string) string {
// pane.release_agent identifies the harness binding, not herdr's
// machine-global terminal name. AgentName is only for prompt routing;
// passing it here is accepted by herdr but leaves the binding intact.
// Keep the configured harness for both new and pre-B16 session records.
return fallback
}
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)