Add federation worker and canonical handoffs
This commit is contained in:
+177
-25
@@ -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)
|
||||
|
||||
+123
-41
@@ -3,8 +3,6 @@ package herdr
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"orchestra/internal/continuity"
|
||||
@@ -61,15 +59,20 @@ func fakeHerdr(t *testing.T) *Client {
|
||||
|
||||
func validHandoff(anchorSHA string) continuity.Handoff {
|
||||
return continuity.Handoff{
|
||||
Meta: continuity.Meta{ID: "t1", Reason: "threshold", RotationIndex: 1},
|
||||
Anchor: continuity.Anchor{GitSHA: anchorSHA, Branch: "main"},
|
||||
Goal: "finish the thing",
|
||||
DoneWhen: []string{"tests pass"},
|
||||
Action: "continue",
|
||||
Command: "go test ./...",
|
||||
Meta: continuity.Meta{ID: "t1", Reason: "threshold", RotationIndex: 1},
|
||||
Anchor: continuity.Anchor{GitSHA: anchorSHA, Branch: "main"},
|
||||
Action: "run the focused tests",
|
||||
Command: "go test ./...",
|
||||
}
|
||||
}
|
||||
|
||||
const validAnswer = `NEXT: run the focused tests
|
||||
WHY: confirm the current implementation before changing it
|
||||
REMAINING: NONE
|
||||
DEAD ENDS: NONE
|
||||
OPEN Q: NONE
|
||||
LEARNED: NONE`
|
||||
|
||||
func TestReleaseUploadsHandoffAndReleasesAgent(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
@@ -81,11 +84,7 @@ func TestReleaseUploadsHandoffAndReleasesAgent(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, err := continuity.Encode(validHandoff(head))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), b, 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -105,8 +104,77 @@ func TestReleaseUploadsHandoffAndReleasesAgent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Anchor.GitSHA == head {
|
||||
t.Fatal("semantic report should be snapshotted before publication")
|
||||
if got.Anchor.GitSHA != head {
|
||||
t.Fatal("semantic report must not be included in the successor scratch anchor")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(repo, HandoffReportFile)); !os.IsNotExist(err) {
|
||||
t.Fatalf("transferred semantic report still present: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalHandoffCarriesCoordinatorReason(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "t@t")
|
||||
runGit(t, repo, "config", "user.name", "t")
|
||||
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cas := &memCAS{}
|
||||
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: cas}
|
||||
ref, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo, HandoffReason: "thrash"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := continuity.Load(ref, cas)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Meta.Reason != "thrash" {
|
||||
t.Fatalf("canonical reason = %q, want thrash", got.Meta.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseUsesHarnessBindingNotDisplayName(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "t@t")
|
||||
runGit(t, repo, "config", "user.name", "t")
|
||||
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
request := make(chan Request, 1)
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
var req Request
|
||||
if json.NewDecoder(bufio.NewReader(conn)).Decode(&req) == nil {
|
||||
request <- req
|
||||
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: json.RawMessage(`{}`)})
|
||||
}
|
||||
}()
|
||||
a := CLIAdapter{Client: &Client{Path: ln.Addr().String(), dial: func() (net.Conn, error) { return net.Dial("tcp", ln.Addr().String()) }}, Harness: "opencode", CAS: &memCAS{}}
|
||||
if _, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo, AgentName: "oc-task-specific"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := <-request
|
||||
p, _ := json.Marshal(req.Params)
|
||||
var got struct {
|
||||
Agent string `json:"agent"`
|
||||
}
|
||||
_ = json.Unmarshal(p, &got)
|
||||
if got.Agent != "opencode" {
|
||||
t.Fatalf("release agent = %q, want harness binding opencode", got.Agent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +187,43 @@ func TestReleaseRefusesWithoutHandoffFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseRefusesNarrativeOrCircularHandoffAnswer(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "t@t")
|
||||
runGit(t, repo, "config", "user.name", "t")
|
||||
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
|
||||
bad := `NEXT: Continue the task from the handoff
|
||||
WHY: the predecessor asked for it
|
||||
REMAINING: what changed\n# a markdown report
|
||||
DEAD ENDS: NONE
|
||||
OPEN Q: NONE
|
||||
LEARNED: NONE`
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(bad), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: &memCAS{}}
|
||||
if _, err := a.Release(context.Background(), Session{PaneID: "p1", Worktree: repo}); err == nil {
|
||||
t.Fatal("expected invalid agent answer to refuse release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHandoffAnswerPreservesOnlyAuthoredFields(t *testing.T) {
|
||||
a, err := parseHandoffAnswer(`NEXT: inspect the failing integration test
|
||||
WHY: isolate the regression before changing production code
|
||||
REMAINING: fix the assertion after identifying the cause
|
||||
DEAD ENDS: tried rerunning the whole suite → failed because it obscures the relevant failure
|
||||
OPEN Q: whether the remote worker has the updated fixture
|
||||
LEARNED: the fixture requires a committed scratch branch
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Remaining) != 1 || len(a.DeadEnds) != 1 || len(a.OpenQuestions) != 1 || len(a.Learned) != 1 {
|
||||
t.Fatalf("parsed answer lost authored fields: %#v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDoesNotTrustAgentSuppliedAnchor(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
@@ -126,11 +231,7 @@ func TestReleaseDoesNotTrustAgentSuppliedAnchor(t *testing.T) {
|
||||
runGit(t, repo, "config", "user.name", "t")
|
||||
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
|
||||
|
||||
b, err := continuity.Encode(validHandoff("deaddeaddeaddeaddeaddeaddeaddeaddeaddead"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), b, 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: &memCAS{}}
|
||||
@@ -154,14 +255,7 @@ func TestReleaseScratchCommitsDirtyFilesBeforeUpload(t *testing.T) {
|
||||
if err := os.WriteFile(wipPath, []byte("in progress"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256([]byte("in progress"))
|
||||
h := validHandoff(head)
|
||||
h.Anchor.Dirty = []continuity.Dirty{{Path: "wip.txt", SHA256: hex.EncodeToString(sum[:])}}
|
||||
b, err := continuity.Encode(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), b, 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -203,22 +297,10 @@ func TestReleaseDoesNotTrustAgentSuppliedDirtyFile(t *testing.T) {
|
||||
runGit(t, repo, "config", "user.email", "t@t")
|
||||
runGit(t, repo, "config", "user.name", "t")
|
||||
runGit(t, repo, "commit", "--allow-empty", "-m", "init")
|
||||
head, err := HeadSHA(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(repo, "wip.txt"), []byte("changed after handoff written"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := sha256.Sum256([]byte("original content"))
|
||||
h := validHandoff(head)
|
||||
h.Anchor.Dirty = []continuity.Dirty{{Path: "wip.txt", SHA256: hex.EncodeToString(stale[:])}}
|
||||
b, err := continuity.Encode(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), b, 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(repo, HandoffReportFile), []byte(validAnswer), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := CLIAdapter{Client: fakeHerdr(t), Harness: "claude", CAS: &memCAS{}}
|
||||
|
||||
+13
-3
@@ -160,6 +160,10 @@ type Session struct {
|
||||
// its §6.1 handoff (HandoffFile) — avoids re-sending the same prompt
|
||||
// every tick while Release keeps waiting for the file to appear.
|
||||
HandoffRequested bool `json:"handoff_requested,omitempty"`
|
||||
// HandoffReason is selected by the coordinator when it asks for the
|
||||
// semantic report. The checkout worker, rather than the harness, copies
|
||||
// it into the canonical handoff it seals at release time.
|
||||
HandoffReason string `json:"handoff_reason,omitempty"`
|
||||
// ConventionsHash is continuity.ConventionsHash of the project's shared
|
||||
// *.md docs (AGENTS.md/CLAUDE.md/VOCAB.md) at the time this session was
|
||||
// last notified of (or started with) their content — §6.3's staleness
|
||||
@@ -455,10 +459,16 @@ func agentName(harness, taskID string) string {
|
||||
id = "session"
|
||||
}
|
||||
name := prefix + "-" + id
|
||||
if len(name) > 32 {
|
||||
name = strings.TrimRight(name[:32], "-_")
|
||||
if len(name) <= 32 {
|
||||
return name
|
||||
}
|
||||
return name
|
||||
// Keeping only the leading task-id characters made distinct long task
|
||||
// IDs collide in herdr's machine-global name namespace. Reserve a stable
|
||||
// digest suffix so truncation remains bounded *and* task-specific.
|
||||
sum := sha256.Sum256([]byte(taskID))
|
||||
const suffixLen = 8
|
||||
keep := 32 - len(prefix) - 1 - 1 - suffixLen // prefix + "-" + stem + "-" + digest
|
||||
return prefix + "-" + strings.TrimRight(id[:keep], "-_") + "-" + fmt.Sprintf("%x", sum[:])[:suffixLen]
|
||||
}
|
||||
|
||||
// harnessStartArgs stays empty for Claude: --dangerously-skip-permissions
|
||||
|
||||
@@ -89,6 +89,17 @@ func TestAgentNameIsBoundedAndValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentNameLongIDsDoNotCollide(t *testing.T) {
|
||||
first := agentName("claude", "task-with-a-very-long-shared-prefix-aaaaaaaa")
|
||||
second := agentName("claude", "task-with-a-very-long-shared-prefix-bbbbbbbb")
|
||||
if first == second {
|
||||
t.Fatalf("long task IDs collided: %q", first)
|
||||
}
|
||||
if len(first) > 32 || len(second) > 32 {
|
||||
t.Fatalf("agent name exceeds limit: %q / %q", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptDoesNotRetryAmbiguousDelivery(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user