feat(orchestrator): agent-initiated ROTATE via handoff reason=manual (S11)
rotate() and TurnDecision now check the agent's own handoff for
meta.reason=="manual" before evaluating occupancy/turn-boundary — per
spec §5.3, that reason is itself the boundary signal ("a coherent unit
finished and the next is independent"), so it bypasses both checks and
releases immediately. Extracted the shared release-and-anchor-certify
tail into Coordinator.finishRelease so the manual path gets the same
anchor safety guarantee as the threshold path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
This commit is contained in:
@@ -536,6 +536,22 @@ func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) {
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
// handoffReason reads HandoffFile from the worktree, if present, and returns
|
||||
// its meta.reason ("threshold|milestone|thrash|manual" per §6.1). An unread
|
||||
// able or invalid file returns "" — callers treat that as "no signal yet",
|
||||
// never as "manual".
|
||||
func handoffReason(worktree string) string {
|
||||
b, err := os.ReadFile(filepath.Join(worktree, herdr.HandoffFile))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
h, err := continuity.Decode(b)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return h.Meta.Reason
|
||||
}
|
||||
|
||||
func (c *Coordinator) rotate(ctx context.Context, hard float64) {
|
||||
c.loadSessions()
|
||||
c.mu.Lock()
|
||||
@@ -554,45 +570,55 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
|
||||
continue
|
||||
}
|
||||
reason := "threshold"
|
||||
occupancy, err := a.Occupancy(session)
|
||||
if err != nil || occupancy < c.soft() {
|
||||
continue
|
||||
}
|
||||
if occupancy < hard {
|
||||
// Soft threshold (§5.3): request a handoff early, advisory only
|
||||
// — no release, no turn-boundary requirement.
|
||||
if requester, ok := a.(herdr.HandoffRequester); ok {
|
||||
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr != nil && !session.HandoffRequested {
|
||||
if reqErr := requester.RequestHandoff(ctx, session); reqErr == nil {
|
||||
session.HandoffRequested = true
|
||||
c.mu.Lock()
|
||||
c.sessions[taskID] = session
|
||||
_ = c.saveSessionsLocked()
|
||||
c.mu.Unlock()
|
||||
// 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"
|
||||
} else {
|
||||
occupancy, err := a.Occupancy(session)
|
||||
if err != nil || occupancy < c.soft() {
|
||||
continue
|
||||
}
|
||||
if occupancy < hard {
|
||||
// Soft threshold (§5.3): request a handoff early, advisory
|
||||
// only — no release, no turn-boundary requirement.
|
||||
if requester, ok := a.(herdr.HandoffRequester); ok {
|
||||
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr != nil && !session.HandoffRequested {
|
||||
if reqErr := requester.RequestHandoff(ctx, session); reqErr == nil {
|
||||
session.HandoffRequested = true
|
||||
c.mu.Lock()
|
||||
c.sessions[taskID] = session
|
||||
_ = c.saveSessionsLocked()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Face B is treated as required, not best-effort (spec §5.2/§5.3):
|
||||
// an adapter that supports the turn-boundary probe but fails to
|
||||
// answer it blocks this tick's release rather than silently
|
||||
// proceeding as if mid-turn interruption were safe. Only an
|
||||
// adapter that genuinely does not implement TurnBoundary at all
|
||||
// falls back to occupancy-only thresholding, and that fallback is
|
||||
// recorded so it is observable (MonitorHealth.TurnBoundaryDegraded)
|
||||
// instead of invisible.
|
||||
if boundary, ok := a.(herdr.TurnBoundary); ok {
|
||||
atBoundary, boundaryErr := boundary.AtTurnBoundary(ctx, session)
|
||||
if boundaryErr != nil {
|
||||
// Face B is treated as required, not best-effort (spec
|
||||
// §5.2/§5.3): an adapter that supports the turn-boundary probe
|
||||
// but fails to answer it blocks this tick's release rather than
|
||||
// silently proceeding as if mid-turn interruption were safe.
|
||||
// Only an adapter that genuinely does not implement
|
||||
// TurnBoundary at all falls back to occupancy-only
|
||||
// thresholding, and that fallback is recorded so it is
|
||||
// observable (MonitorHealth.TurnBoundaryDegraded) instead of
|
||||
// invisible.
|
||||
if boundary, ok := a.(herdr.TurnBoundary); ok {
|
||||
atBoundary, boundaryErr := boundary.AtTurnBoundary(ctx, session)
|
||||
if boundaryErr != nil {
|
||||
c.recordTurnBoundaryDegraded()
|
||||
continue
|
||||
}
|
||||
if !atBoundary {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
c.recordTurnBoundaryDegraded()
|
||||
continue
|
||||
}
|
||||
if !atBoundary {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
c.recordTurnBoundaryDegraded()
|
||||
}
|
||||
if requester, ok := a.(herdr.HandoffRequester); ok {
|
||||
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr != nil {
|
||||
@@ -668,6 +694,12 @@ 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
|
||||
// turn-boundary probe and release immediately.
|
||||
if handoffReason(session.Worktree) == "manual" {
|
||||
return c.finishRelease(ctx, taskID, task, session, a, "manual")
|
||||
}
|
||||
occupancy, err := a.Occupancy(session)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("orchestrator: occupancy: %w", err)
|
||||
@@ -720,6 +752,15 @@ func (c *Coordinator) TurnDecision(ctx context.Context, taskID string) (string,
|
||||
return TurnPrepareHandoff, nil
|
||||
}
|
||||
}
|
||||
return c.finishRelease(ctx, taskID, task, session, a, "threshold")
|
||||
}
|
||||
|
||||
// finishRelease runs the common release tail shared by TurnDecision's
|
||||
// threshold path and its agent-initiated-ROTATE (reason=manual) shortcut:
|
||||
// call Adapter.Release, certify the anchor against the real worktree HEAD,
|
||||
// and emit TaskReleased. Any failure refuses rather than emitting a
|
||||
// TaskReleased payload that would fail validation and strand the session.
|
||||
func (c *Coordinator) finishRelease(ctx context.Context, taskID string, task domain.Task, session herdr.Session, a herdr.Adapter, reason string) (string, error) {
|
||||
ref, err := a.Release(ctx, session)
|
||||
if err != nil || ref == "" {
|
||||
return TurnRefuse, nil
|
||||
@@ -730,7 +771,7 @@ func (c *Coordinator) TurnDecision(ctx context.Context, taskID string) (string,
|
||||
// invalid TaskReleased payload, same as rotate()'s bare continue.
|
||||
return TurnRefuse, nil
|
||||
}
|
||||
b, _ := json.Marshal(map[string]string{"handoff_ref": ref, "reason": "threshold", "anchor_sha": anchorSHA})
|
||||
b, _ := json.Marshal(map[string]string{"handoff_ref": ref, "reason": reason, "anchor_sha": anchorSHA})
|
||||
e := domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: taskID, Version: task.Version + 1, Payload: b, Surface: string(authz.System)}
|
||||
if err := c.Store.Append(e); err != nil {
|
||||
return TurnRefuse, nil
|
||||
|
||||
@@ -257,6 +257,54 @@ func TestTurnDecision(t *testing.T) {
|
||||
t.Fatalf("task state=%v ok=%v, want leased", got.State, ok)
|
||||
}
|
||||
})
|
||||
|
||||
// Agent-initiated ROTATE (spec §5.3): a handoff the agent wrote with
|
||||
// reason=manual is itself the boundary signal ("a coherent unit
|
||||
// finished and the next is independent") — it must release immediately,
|
||||
// bypassing occupancy and the turn-boundary probe entirely, not wait for
|
||||
// either to agree.
|
||||
t.Run("manual reason bypasses occupancy and turn boundary", func(t *testing.T) {
|
||||
a := &fakeAdapter{occupancy: 0, boundary: false}
|
||||
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": "h2", "reason": "manual", "rotation_index": 0},
|
||||
"anchor": map[string]any{"git_sha": head, "branch": "orchestra/t1"},
|
||||
"goal": "g", "done_when": []string{"x"}, "action": "prompt", "command": "go test ./...",
|
||||
}
|
||||
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 an agent-initiated manual rotate")
|
||||
}
|
||||
got, ok := st.Task(task.ID)
|
||||
if !ok || got.State != domain.StateQueued {
|
||||
t.Fatalf("task state=%v ok=%v, want queued", got.State, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStartBlocksOnInvalidPickup guards AUDIT.md's B6/Phase 4 item 4:
|
||||
|
||||
Reference in New Issue
Block a user