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:
@@ -0,0 +1,193 @@
|
||||
package herdr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeJSONL(t *testing.T, lines []string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "transcript.jsonl")
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func claudeToolLine(t *testing.T, toolUseID, name string, input map[string]any) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(map[string]any{
|
||||
"message": map[string]any{
|
||||
"content": []map[string]any{
|
||||
{"type": "tool_use", "id": toolUseID, "name": name, "input": input},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func claudeResultLine(t *testing.T, toolUseID string, isError bool) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(map[string]any{
|
||||
"message": map[string]any{
|
||||
"content": []map[string]any{
|
||||
{"type": "tool_result", "tool_use_id": toolUseID, "is_error": isError},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestClaudeActivityPairsToolUseWithResult(t *testing.T) {
|
||||
path := writeJSONL(t, []string{
|
||||
claudeToolLine(t, "t1", "Bash", map[string]any{"command": "go test ./..."}),
|
||||
claudeResultLine(t, "t1", true),
|
||||
claudeToolLine(t, "t2", "Edit", map[string]any{"file_path": "main.go"}),
|
||||
claudeResultLine(t, "t2", false),
|
||||
// an unresolved tool_use (still mid-turn) must be dropped, not reported
|
||||
claudeToolLine(t, "t3", "Bash", map[string]any{"command": "echo hi"}),
|
||||
})
|
||||
calls, err := ClaudeActivity(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls=%+v, want 2 (unresolved tool_use dropped)", calls)
|
||||
}
|
||||
if calls[0].Name != "Bash" || calls[0].Kind != "command" || calls[0].Key != "go test ./..." || calls[0].Success || !calls[0].IsTest {
|
||||
t.Fatalf("calls[0]=%+v", calls[0])
|
||||
}
|
||||
if calls[1].Name != "Edit" || calls[1].Kind != "file" || calls[1].Key != "main.go" || !calls[1].Success {
|
||||
t.Fatalf("calls[1]=%+v", calls[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectThrashConsecutiveTestFailures(t *testing.T) {
|
||||
calls := []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},
|
||||
}
|
||||
thrash, deadEnds := DetectThrash(calls, ThrashConfig{})
|
||||
if !thrash {
|
||||
t.Fatalf("want thrash on 3 consecutive test failures (default threshold)")
|
||||
}
|
||||
if len(deadEnds) != 1 || deadEnds[0].Tried != "go test ./..." {
|
||||
t.Fatalf("deadEnds=%+v", deadEnds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectThrashStopsCountingAtASuccess(t *testing.T) {
|
||||
calls := []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: true, IsTest: true},
|
||||
{Name: "Bash", Kind: "command", Key: "go test ./...", Success: false, IsTest: true},
|
||||
}
|
||||
// Only one failure since the last success — must not thrash on the
|
||||
// default threshold of 3, even though 3 failures exist in the window.
|
||||
if thrash, _ := DetectThrash(calls, ThrashConfig{}); thrash {
|
||||
t.Fatalf("want no thrash: only 1 failure since the last passing run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectThrashSameFileEditedTooManyTimes(t *testing.T) {
|
||||
var calls []ToolCall
|
||||
for i := 0; i < 5; i++ {
|
||||
calls = append(calls, ToolCall{Name: "Edit", Kind: "file", Key: "main.go", Success: true})
|
||||
}
|
||||
thrash, deadEnds := DetectThrash(calls, ThrashConfig{})
|
||||
if !thrash {
|
||||
t.Fatalf("want thrash on 5 edits to the same file (default threshold)")
|
||||
}
|
||||
found := false
|
||||
for _, d := range deadEnds {
|
||||
if strings.Contains(d.Tried, "main.go") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("deadEnds=%+v, want an entry naming main.go", deadEnds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectThrashIgnoresReadsForFileEditCount(t *testing.T) {
|
||||
var calls []ToolCall
|
||||
for i := 0; i < 10; i++ {
|
||||
calls = append(calls, ToolCall{Name: "Read", Kind: "file", Key: "main.go", Success: true})
|
||||
}
|
||||
// Read is not an edit tool; repeated reads of the same file must never
|
||||
// trip the "edited too many times" rule.
|
||||
if thrash, _ := DetectThrash(calls, ThrashConfig{}); thrash {
|
||||
t.Fatalf("want no thrash: Read is not an edit tool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectThrashIdenticalCallsRepeated(t *testing.T) {
|
||||
var calls []ToolCall
|
||||
for i := 0; i < 4; i++ {
|
||||
calls = append(calls, ToolCall{Name: "Bash", Kind: "command", Key: "npm install lodash", Success: false})
|
||||
}
|
||||
thrash, _ := DetectThrash(calls, ThrashConfig{})
|
||||
if !thrash {
|
||||
t.Fatalf("want thrash on the identical call repeated 4 times (default threshold)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectThrashCustomThresholds(t *testing.T) {
|
||||
calls := []ToolCall{
|
||||
{Name: "Edit", Kind: "file", Key: "main.go", Success: true},
|
||||
{Name: "Edit", Kind: "file", Key: "main.go", Success: true},
|
||||
}
|
||||
if thrash, _ := DetectThrash(calls, ThrashConfig{}); thrash {
|
||||
t.Fatalf("2 edits must not thrash at the default threshold of 5")
|
||||
}
|
||||
if thrash, _ := DetectThrash(calls, ThrashConfig{MaxSameFileEdits: 2}); !thrash {
|
||||
t.Fatalf("2 edits must thrash once the threshold is lowered to 2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMilestoneOnSuccessfulCommit(t *testing.T) {
|
||||
calls := []ToolCall{
|
||||
{Name: "Bash", Kind: "command", Key: "go build ./...", Success: true},
|
||||
{Name: "Bash", Kind: "command", Key: "git commit -m fix", Success: true},
|
||||
}
|
||||
if !DetectMilestone(calls) {
|
||||
t.Fatalf("want milestone: last call is a successful git commit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMilestoneRequiresSuccess(t *testing.T) {
|
||||
calls := []ToolCall{
|
||||
{Name: "Bash", Kind: "command", Key: "git commit -m fix", Success: false},
|
||||
}
|
||||
if DetectMilestone(calls) {
|
||||
t.Fatalf("want no milestone: the commit failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMilestoneRequiresLastCall(t *testing.T) {
|
||||
calls := []ToolCall{
|
||||
{Name: "Bash", Kind: "command", Key: "git commit -m fix", Success: true},
|
||||
{Name: "Bash", Kind: "command", Key: "go test ./...", Success: true},
|
||||
}
|
||||
if DetectMilestone(calls) {
|
||||
t.Fatalf("want no milestone: the commit isn't the most recent call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeActivityRefusesRatherThanGuess(t *testing.T) {
|
||||
if _, err := OpenCodeActivity("/nonexistent"); err == nil {
|
||||
t.Fatalf("want an error: opencode tool-call activity has no verified source")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user