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:
@@ -187,6 +187,9 @@ type Coordinator struct {
|
||||
// defaultSoft), so existing callers that never set this field keep
|
||||
// working unchanged.
|
||||
Soft float64
|
||||
// Thrash tunes DetectThrash's three circuit breakers (§5.3). Zero-value
|
||||
// fields fall back to herdr's own defaults, so leaving this unset works.
|
||||
Thrash herdr.ThrashConfig
|
||||
}
|
||||
|
||||
// defaultSoft is used whenever Coordinator.Soft is unset (zero value).
|
||||
@@ -199,6 +202,59 @@ func (c *Coordinator) soft() float64 {
|
||||
return defaultSoft
|
||||
}
|
||||
|
||||
// checkActivityTriggers is S11's milestone/thrash pair: given an adapter that
|
||||
// implements herdr.ActivityReader, read its tool-call history and evaluate
|
||||
// both detectors. thrash takes priority (a circuit breaker overrides a
|
||||
// coherent-looking commit), same as the caller would want either way since
|
||||
// only one handoff request happens per tick. Returns the reason to request
|
||||
// ("thrash"/"milestone") and its dead ends, or "" if neither fired or the
|
||||
// adapter has no activity source at all — the latter is not degraded-and-
|
||||
// recorded the way TurnBoundary's absence is, since these two triggers are
|
||||
// additive on top of threshold/manual rotation, not a required safety gate.
|
||||
func checkActivityTriggers(ctx context.Context, a herdr.Adapter, session herdr.Session, cfg herdr.ThrashConfig) (reason string, deadEnds []continuity.DeadEnd) {
|
||||
reader, ok := a.(herdr.ActivityReader)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
calls, err := reader.Activity(ctx, session)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
if thrash, de := herdr.DetectThrash(calls, cfg); thrash {
|
||||
return "thrash", de
|
||||
}
|
||||
if herdr.DetectMilestone(calls) {
|
||||
return "milestone", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// requestReasonedHandoff is the shared "ask once, remember we asked" wiring
|
||||
// checkActivityTriggers' two callers (rotate, TurnDecision) both need — same
|
||||
// HandoffRequested guard the occupancy-driven HandoffRequester path already
|
||||
// uses, so a repeated thrash/milestone detection on later ticks doesn't
|
||||
// reprompt every time before the agent has finished writing the file.
|
||||
func (c *Coordinator) requestReasonedHandoff(ctx context.Context, taskID string, session herdr.Session, a herdr.Adapter, reason string, deadEnds []continuity.DeadEnd) {
|
||||
if session.HandoffRequested {
|
||||
return
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr == nil {
|
||||
return
|
||||
}
|
||||
requester, ok := a.(herdr.ReasonedHandoffRequester)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := requester.RequestHandoffReason(ctx, session, reason, deadEnds); err != nil {
|
||||
return
|
||||
}
|
||||
session.HandoffRequested = true
|
||||
c.mu.Lock()
|
||||
c.sessions[taskID] = session
|
||||
_ = c.saveSessionsLocked()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
type MonitorHealth struct {
|
||||
Running bool `json:"running"`
|
||||
LastRun time.Time `json:"last_run"`
|
||||
@@ -570,14 +626,20 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
|
||||
continue
|
||||
}
|
||||
reason := "threshold"
|
||||
// Agent-initiated ROTATE (§5.3): the agent itself wrote a handoff
|
||||
// with reason=manual — "a coherent unit finished and the next is
|
||||
// independent". That is the boundary signal in its own right; skip
|
||||
// occupancy and the turn-boundary probe and go straight to release.
|
||||
manual := handoffReason(session.Worktree) == "manual"
|
||||
if manual {
|
||||
reason = "manual"
|
||||
// Agent-initiated ROTATE, and the two orchestrator-detected triggers
|
||||
// (§5.3: manual / milestone / thrash) all short-circuit the same way
|
||||
// once a handoff carrying that reason already exists: the boundary
|
||||
// question has already been answered, so skip occupancy and the
|
||||
// turn-boundary probe and go straight to release.
|
||||
existingReason := handoffReason(session.Worktree)
|
||||
bypass := existingReason == "manual" || existingReason == "milestone" || existingReason == "thrash"
|
||||
if bypass {
|
||||
reason = existingReason
|
||||
} else {
|
||||
if trigger, deadEnds := checkActivityTriggers(ctx, a, session, c.Thrash); trigger != "" {
|
||||
c.requestReasonedHandoff(ctx, taskID, session, a, trigger, deadEnds)
|
||||
continue
|
||||
}
|
||||
occupancy, err := a.Occupancy(session)
|
||||
if err != nil || occupancy < c.soft() {
|
||||
continue
|
||||
@@ -694,11 +756,16 @@ func (c *Coordinator) TurnDecision(ctx context.Context, taskID string) (string,
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("orchestrator: adapter: %w", err)
|
||||
}
|
||||
// Agent-initiated ROTATE (§5.3): a handoff already written with
|
||||
// reason=manual is itself the boundary signal — skip occupancy and the
|
||||
// Agent-initiated ROTATE, and the two orchestrator-detected triggers
|
||||
// (§5.3: manual / milestone / thrash): a handoff already written with one
|
||||
// of these reasons is itself the boundary signal — skip occupancy and the
|
||||
// turn-boundary probe and release immediately.
|
||||
if handoffReason(session.Worktree) == "manual" {
|
||||
return c.finishRelease(ctx, taskID, task, session, a, "manual")
|
||||
if existingReason := handoffReason(session.Worktree); existingReason == "manual" || existingReason == "milestone" || existingReason == "thrash" {
|
||||
return c.finishRelease(ctx, taskID, task, session, a, existingReason)
|
||||
}
|
||||
if trigger, deadEnds := checkActivityTriggers(ctx, a, session, c.Thrash); trigger != "" {
|
||||
c.requestReasonedHandoff(ctx, taskID, session, a, trigger, deadEnds)
|
||||
return TurnPrepareHandoff, nil
|
||||
}
|
||||
occupancy, err := a.Occupancy(session)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user