feat(orchestrator): wire turn-decision endpoint and QuotaReported producer
Closes Phase 2 items 1-2 (AUDIT.md): Coordinator.TurnDecision evaluates occupancy/turn-boundary/handoff state synchronously per turn and returns continue/prepare_handoff/rotate_now/refuse, exposed via POST /v1/harness/turn. The Claude Stop hook now calls it on ordinary turn boundaries instead of no-op'ing, and exits 2 on refuse. Also closes B7's post-hoc producer: /v1/harness/complete now appends a QuotaReported event from the completing lease's harness usage, so the router's quota-availability filter and the brief's quota_consumed stop evaluating against a permanent zero. Live per-harness push producers (Claude statusline, Codex rollout tail) remain unbuilt — investigation recorded in AUDIT.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
This commit is contained in:
@@ -175,6 +175,11 @@ type Coordinator struct {
|
||||
loaded bool
|
||||
healthMu sync.RWMutex
|
||||
health MonitorHealth
|
||||
// Hard is the occupancy threshold Monitor's periodic rotate() runs
|
||||
// against, mirrored here so TurnDecision (the synchronous, per-turn
|
||||
// counterpart driven by the Face-B stop hook) evaluates the same
|
||||
// threshold rather than needing its own copy passed in by the caller.
|
||||
Hard float64
|
||||
}
|
||||
|
||||
type MonitorHealth struct {
|
||||
@@ -367,6 +372,7 @@ func (c *Coordinator) Monitor(ctx context.Context, hard float64, interval time.D
|
||||
c.setMonitorHealth(err, 0)
|
||||
return err
|
||||
}
|
||||
c.Hard = hard
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
@@ -595,6 +601,95 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
|
||||
}
|
||||
}
|
||||
|
||||
// Turn decision verdicts (spec §5.3, AUDIT.md Phase 2 items 1-2). These are
|
||||
// the only valid results of TurnDecision and the only values the
|
||||
// POST /v1/harness/turn endpoint may return.
|
||||
const (
|
||||
TurnContinue = "continue"
|
||||
TurnPrepareHandoff = "prepare_handoff"
|
||||
TurnRotateNow = "rotate_now"
|
||||
TurnRefuse = "refuse"
|
||||
)
|
||||
|
||||
// TurnDecision evaluates a single leased task's rotation state synchronously,
|
||||
// at a harness-reported turn boundary, and acts on the result. It mirrors
|
||||
// rotate()'s per-task logic (occupancy → turn-boundary → handoff-file →
|
||||
// release) but is invoked once per turn from the Face-B stop hook instead of
|
||||
// on Monitor's ticker, so an agent that's about to stop gets an authoritative
|
||||
// answer instead of waiting for the next tick. `refuse` covers every case
|
||||
// where continuing to let the harness stop would be unsafe: the turn
|
||||
// boundary can't be verified, or release/anchor certification failed.
|
||||
func (c *Coordinator) TurnDecision(ctx context.Context, taskID string) (string, error) {
|
||||
c.loadSessions()
|
||||
c.mu.Lock()
|
||||
session, ok := c.sessions[taskID]
|
||||
c.mu.Unlock()
|
||||
if !ok {
|
||||
return "", fmt.Errorf("orchestrator: no session for task %q", taskID)
|
||||
}
|
||||
task, ok := c.Store.Task(taskID)
|
||||
if !ok || task.State != domain.StateLeased {
|
||||
return "", fmt.Errorf("orchestrator: task %q not leased", taskID)
|
||||
}
|
||||
a, err := c.adapterFor(taskID, session)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("orchestrator: adapter: %w", err)
|
||||
}
|
||||
occupancy, err := a.Occupancy(session)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("orchestrator: occupancy: %w", err)
|
||||
}
|
||||
if occupancy < c.Hard {
|
||||
return TurnContinue, nil
|
||||
}
|
||||
if boundary, ok := a.(herdr.TurnBoundary); ok {
|
||||
atBoundary, boundaryErr := boundary.AtTurnBoundary(ctx, session)
|
||||
if boundaryErr != nil {
|
||||
c.recordTurnBoundaryDegraded()
|
||||
return TurnRefuse, nil
|
||||
}
|
||||
if !atBoundary {
|
||||
return TurnRefuse, nil
|
||||
}
|
||||
} else {
|
||||
c.recordTurnBoundaryDegraded()
|
||||
}
|
||||
if requester, ok := a.(herdr.HandoffRequester); ok {
|
||||
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr != nil {
|
||||
if !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()
|
||||
}
|
||||
}
|
||||
return TurnPrepareHandoff, nil
|
||||
}
|
||||
}
|
||||
ref, err := a.Release(ctx, session)
|
||||
if err != nil || ref == "" {
|
||||
return TurnRefuse, nil
|
||||
}
|
||||
anchorSHA, err := herdr.HeadSHA(session.Worktree)
|
||||
if err != nil {
|
||||
// Cannot certify the anchor: refuse rather than release with an
|
||||
// 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})
|
||||
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
|
||||
}
|
||||
c.mu.Lock()
|
||||
delete(c.sessions, taskID)
|
||||
_ = c.saveSessionsLocked()
|
||||
c.mu.Unlock()
|
||||
return TurnRotateNow, nil
|
||||
}
|
||||
|
||||
func (c *Coordinator) Start(ctx context.Context, e domain.Event) error {
|
||||
if e.Type != "TaskLeased" {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user