feat(orchestrator): milestone rotation and thrash detection (S11)
Closes the last two S11 triggers. internal/herdr/activity.go normalizes tool/function calls per harness (ClaudeActivity verified against the existing transcript format, CodexActivity best-effort/unverified, OpenCodeActivity refuses — no confirmed per-tool-call source exists) and implements the three thrash rules plus a narrow milestone check (successful git commit as the last call). CLIAdapter.RequestHandoffReason asks the agent to write a handoff with meta.reason set, same "ask, don't invent" pattern as the existing handoff/ report requests. rotate() and TurnDecision generalize the manual-bypass shortcut to manual/milestone/thrash and request (never directly release) on a detected trigger. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/continuity"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/herdr"
|
||||
"orchestra/internal/orchestrator"
|
||||
@@ -578,6 +579,189 @@ func (a *handoffRequestingAdapter) RequestHandoff(context.Context, herdr.Session
|
||||
return nil
|
||||
}
|
||||
|
||||
// activityAdapter is fakeAdapter plus S11's milestone/thrash pair: it reports
|
||||
// a fixed tool-call history and records reasoned handoff requests, so tests
|
||||
// can drive checkActivityTriggers without a real herdr transcript.
|
||||
type activityAdapter struct {
|
||||
fakeAdapter
|
||||
calls []herdr.ToolCall
|
||||
activityErr error
|
||||
reasonAsked []string
|
||||
deadEndsSeen []continuity.DeadEnd
|
||||
}
|
||||
|
||||
func (a *activityAdapter) Activity(context.Context, herdr.Session) ([]herdr.ToolCall, error) {
|
||||
return a.calls, a.activityErr
|
||||
}
|
||||
|
||||
func (a *activityAdapter) RequestHandoffReason(_ context.Context, _ herdr.Session, reason string, deadEnds []continuity.DeadEnd) error {
|
||||
a.reasonAsked = append(a.reasonAsked, reason)
|
||||
a.deadEndsSeen = deadEnds
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestActivityTriggersRequestReasonedHandoffWithoutReleasing guards S11's two
|
||||
// orchestrator-detected rotation triggers (milestone, thrash): both rotate()
|
||||
// and TurnDecision must ask for a reasoned handoff — never release — the
|
||||
// first time the trigger fires, entirely independent of occupancy (both
|
||||
// cases here use occupancy=0, far below even the soft threshold).
|
||||
func TestActivityTriggersRequestReasonedHandoffWithoutReleasing(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
run(t, repo, "init")
|
||||
run(t, repo, "config", "user.email", "t@t")
|
||||
run(t, repo, "config", "user.name", "t")
|
||||
run(t, repo, "commit", "--allow-empty", "-m", "init")
|
||||
|
||||
newCoordinator := func(a *activityAdapter) (*orchestrator.Coordinator, *store.Store, domain.Task) {
|
||||
s, err := store.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: "t1", Surface: string(authz.System), Payload: mustJSON(map[string]any{
|
||||
"source": "jsonl", "external_id": "1", "project": "p",
|
||||
})}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := s.Tasks()[0]
|
||||
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{path: repo}, Adapters: adapters{a}, StatePath: t.TempDir() + "/sessions.json", Hard: .8}
|
||||
leaseEvt, err := s.Lease(task.ID, "h1", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Start(context.Background(), leaseEvt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c, s, task
|
||||
}
|
||||
|
||||
thrashCalls := []herdr.ToolCall{
|
||||
{Name: "Bash", Kind: "command", Key: "go test ./...", Success: false, IsTest: true},
|
||||
{Name: "Bash", Kind: "command", Key: "go test ./...", Success: false, IsTest: true},
|
||||
{Name: "Bash", Kind: "command", Key: "go test ./...", Success: false, IsTest: true},
|
||||
}
|
||||
milestoneCalls := []herdr.ToolCall{
|
||||
{Name: "Bash", Kind: "command", Key: "git commit -m done", Success: true},
|
||||
}
|
||||
|
||||
t.Run("thrash requests a reasoned handoff and does not release, via TurnDecision", func(t *testing.T) {
|
||||
a := &activityAdapter{fakeAdapter: fakeAdapter{occupancy: 0, boundary: true}, calls: thrashCalls}
|
||||
c, st, task := newCoordinator(a)
|
||||
decision, err := c.TurnDecision(context.Background(), task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision != orchestrator.TurnPrepareHandoff {
|
||||
t.Fatalf("decision=%q want %q", decision, orchestrator.TurnPrepareHandoff)
|
||||
}
|
||||
if len(a.reasonAsked) != 1 || a.reasonAsked[0] != "thrash" {
|
||||
t.Fatalf("reasonAsked=%v want [thrash]", a.reasonAsked)
|
||||
}
|
||||
if len(a.deadEndsSeen) == 0 {
|
||||
t.Fatal("want populated dead ends for the thrash trigger")
|
||||
}
|
||||
if a.releases != 0 {
|
||||
t.Fatal("Release must not be invoked on a bare thrash detection")
|
||||
}
|
||||
got, ok := st.Task(task.ID)
|
||||
if !ok || got.State != domain.StateLeased {
|
||||
t.Fatalf("task state=%v ok=%v, want still leased", got.State, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("milestone requests a reasoned handoff and does not release, via TurnDecision", func(t *testing.T) {
|
||||
a := &activityAdapter{fakeAdapter: fakeAdapter{occupancy: 0, boundary: true}, calls: milestoneCalls}
|
||||
c, st, task := newCoordinator(a)
|
||||
decision, err := c.TurnDecision(context.Background(), task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision != orchestrator.TurnPrepareHandoff {
|
||||
t.Fatalf("decision=%q want %q", decision, orchestrator.TurnPrepareHandoff)
|
||||
}
|
||||
if len(a.reasonAsked) != 1 || a.reasonAsked[0] != "milestone" {
|
||||
t.Fatalf("reasonAsked=%v want [milestone]", a.reasonAsked)
|
||||
}
|
||||
if a.releases != 0 {
|
||||
t.Fatal("Release must not be invoked on a bare milestone detection")
|
||||
}
|
||||
got, ok := st.Task(task.ID)
|
||||
if !ok || got.State != domain.StateLeased {
|
||||
t.Fatalf("task state=%v ok=%v, want still leased", got.State, ok)
|
||||
}
|
||||
})
|
||||
|
||||
// Once the agent has actually written a thrash/milestone-reasoned
|
||||
// handoff, that reason is itself the boundary signal (same as manual) —
|
||||
// TurnDecision must release immediately, bypassing occupancy/boundary.
|
||||
t.Run("a written thrash handoff bypasses occupancy and releases", func(t *testing.T) {
|
||||
a := &activityAdapter{fakeAdapter: fakeAdapter{occupancy: 0, boundary: false}, calls: thrashCalls}
|
||||
c, st, task := newCoordinator(a)
|
||||
artifactRef, err := st.PutArtifact([]byte("handoff"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.ref = artifactRef
|
||||
head, err := herdr.HeadSHA(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handoff := map[string]any{
|
||||
"meta": map[string]any{"id": "h3", "reason": "thrash", "rotation_index": 0},
|
||||
"anchor": map[string]any{"git_sha": head, "branch": "orchestra/t1"},
|
||||
"goal": "g", "done_when": []string{"x"}, "action": "prompt", "command": "go test ./...",
|
||||
"dead_ends": []map[string]any{{"tried": "go test ./...", "why_failed": "failed 3 times"}},
|
||||
}
|
||||
b, err := json.Marshal(handoff)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handoffPath := repo + "/" + herdr.HandoffFile
|
||||
if err := os.WriteFile(handoffPath, b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Remove(handoffPath) })
|
||||
|
||||
decision, err := c.TurnDecision(context.Background(), task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision != orchestrator.TurnRotateNow {
|
||||
t.Fatalf("decision=%q want %q", decision, orchestrator.TurnRotateNow)
|
||||
}
|
||||
if a.releases == 0 {
|
||||
t.Fatal("Release was never invoked for a written thrash handoff")
|
||||
}
|
||||
got, ok := st.Task(task.ID)
|
||||
if !ok || got.State != domain.StateQueued {
|
||||
t.Fatalf("task state=%v ok=%v, want queued", got.State, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rotate() also requests a reasoned handoff on thrash, without releasing", func(t *testing.T) {
|
||||
a := &activityAdapter{fakeAdapter: fakeAdapter{occupancy: 0, boundary: true}, calls: thrashCalls}
|
||||
c, st, task := newCoordinator(a)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go c.Monitor(ctx, .8, time.Millisecond)
|
||||
|
||||
deadline := time.Now().Add(300 * time.Millisecond)
|
||||
for time.Now().Before(deadline) && len(a.reasonAsked) == 0 {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if len(a.reasonAsked) == 0 || a.reasonAsked[0] != "thrash" {
|
||||
t.Fatalf("reasonAsked=%v want a thrash request from rotate()", a.reasonAsked)
|
||||
}
|
||||
if a.releases != 0 {
|
||||
t.Fatal("rotate() must not release on a bare thrash detection")
|
||||
}
|
||||
got, ok := st.Task(task.ID)
|
||||
if !ok || got.State != domain.StateLeased {
|
||||
t.Fatalf("task state=%v ok=%v, want still leased", got.State, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRotationRequestsHandoffBeforeReleasing guards Phase 4 item 2 (AUDIT.md):
|
||||
// rotate() must not call Release until the agent has been told to write its
|
||||
// §6.1 handoff and the file actually exists — never invent or skip the ask.
|
||||
|
||||
Reference in New Issue
Block a user