feat(orchestrator): soft occupancy threshold requests handoff early (S11 partial)
Coordinator.Soft (default 0.55, ORCHESTRA_OCCUPANCY_SOFT) makes rotate() and TurnDecision request a handoff advisory-only once occupancy crosses the soft threshold, well before Hard forces a release/rotation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
This commit is contained in:
@@ -882,6 +882,34 @@ and `TaskAmended` in `store.apply` now populate/update all four fields
|
||||
as amendable. Covered by `TestTaskAmendedAppliesAllFields`
|
||||
(`internal/store/store_test.go`).
|
||||
|
||||
## S11 — partial fix, 2026-07-27 (soft threshold)
|
||||
|
||||
Of S11's four missing pieces (soft ~55% threshold, milestone rotation, thrash
|
||||
detection, agent-initiated `ROTATE`), only the first is landed here.
|
||||
|
||||
`Coordinator` gained a `Soft float64` field (default 0.55 via `soft()` when
|
||||
unset, configurable through `ORCHESTRA_OCCUPANCY_SOFT`). Both `rotate()` (the
|
||||
periodic ticker) and `TurnDecision` (the synchronous per-turn path) now check
|
||||
occupancy against `Soft` before `Hard`: at or above soft but below hard, they
|
||||
call the adapter's `HandoffRequester.RequestHandoff` once (same
|
||||
"ask, don't invent" convention as the hard-threshold path,
|
||||
`Session.HandoffRequested` guarding re-prompts) and — for `TurnDecision` —
|
||||
return `prepare_handoff` without requiring a turn boundary, since this is
|
||||
advisory: the agent keeps working, the task stays leased. Only once occupancy
|
||||
clears `Hard` does the existing boundary-check/release path run. Covered by
|
||||
`TestTurnDecision/"prepare_handoff at soft threshold, below hard, without a
|
||||
turn boundary"` (internal/orchestrator/rotation_test.go), asserting a
|
||||
handoff request fires and the task remains `StateLeased`.
|
||||
|
||||
**Still open from S11:** milestone rotation, thrash detection (N failed test
|
||||
runs / same file M times / identical tool calls as a circuit breaker with
|
||||
`reason=thrash` and populated `dead_ends`), and agent-initiated `ROTATE`. All
|
||||
three need either transcript/tool-call introspection this repo doesn't yet
|
||||
have a source for, or an explicit in-band signal from the agent — bigger than
|
||||
a threshold comparison, not attempted this pass.
|
||||
|
||||
`go build ./...`, `go vet ./...`, `go test ./...` all pass.
|
||||
|
||||
### Design consequences (not yet implemented)
|
||||
|
||||
1. **Percentages are a level, not a delta.**
|
||||
|
||||
@@ -120,6 +120,9 @@ func main() {
|
||||
if v, parseErr := strconv.ParseFloat(os.Getenv("ORCHESTRA_OCCUPANCY_HARD"), 64); parseErr == nil && v > 0 && v < 1 {
|
||||
hard = v
|
||||
}
|
||||
if v, parseErr := strconv.ParseFloat(os.Getenv("ORCHESTRA_OCCUPANCY_SOFT"), 64); parseErr == nil && v > 0 && v < 1 {
|
||||
coordinator.Soft = v
|
||||
}
|
||||
go func() {
|
||||
if monitorErr := coordinator.Monitor(context.Background(), hard, 30*time.Second); monitorErr != nil {
|
||||
log.Printf("orchestrator monitor: %v", monitorErr)
|
||||
|
||||
@@ -180,6 +180,23 @@ type Coordinator struct {
|
||||
// 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
|
||||
// Soft is the advisory occupancy threshold (spec §5.3: "soft ~55%
|
||||
// threshold") at which TurnDecision starts asking the agent to prepare a
|
||||
// handoff — non-blocking, doesn't require a turn boundary — well before
|
||||
// Hard forces one. Zero means "use the package default" (see
|
||||
// defaultSoft), so existing callers that never set this field keep
|
||||
// working unchanged.
|
||||
Soft float64
|
||||
}
|
||||
|
||||
// defaultSoft is used whenever Coordinator.Soft is unset (zero value).
|
||||
const defaultSoft = 0.55
|
||||
|
||||
func (c *Coordinator) soft() float64 {
|
||||
if c.Soft > 0 {
|
||||
return c.Soft
|
||||
}
|
||||
return defaultSoft
|
||||
}
|
||||
|
||||
type MonitorHealth struct {
|
||||
@@ -538,7 +555,23 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
|
||||
}
|
||||
reason := "threshold"
|
||||
occupancy, err := a.Occupancy(session)
|
||||
if err != nil || occupancy < hard {
|
||||
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
|
||||
}
|
||||
// Face B is treated as required, not best-effort (spec §5.2/§5.3):
|
||||
@@ -639,9 +672,28 @@ func (c *Coordinator) TurnDecision(ctx context.Context, taskID string) (string,
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("orchestrator: occupancy: %w", err)
|
||||
}
|
||||
if occupancy < c.Hard {
|
||||
if occupancy < c.soft() {
|
||||
return TurnContinue, nil
|
||||
}
|
||||
if occupancy < c.Hard {
|
||||
// Soft threshold (§5.3): advisory only. Ask the agent to start
|
||||
// preparing a handoff well before Hard forces one, but don't block
|
||||
// the turn on a boundary check — the agent is free to keep working.
|
||||
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
|
||||
}
|
||||
if boundary, ok := a.(herdr.TurnBoundary); ok {
|
||||
atBoundary, boundaryErr := boundary.AtTurnBoundary(ctx, session)
|
||||
if boundaryErr != nil {
|
||||
|
||||
@@ -218,6 +218,26 @@ func TestTurnDecision(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prepare_handoff at soft threshold, below hard, without a turn boundary", func(t *testing.T) {
|
||||
a := &handoffRequestingAdapter{fakeAdapter: fakeAdapter{occupancy: .6, boundary: false}}
|
||||
c, st, task := newCoordinator(&a.fakeAdapter)
|
||||
c.Adapters = adapters{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 a.requests == 0 {
|
||||
t.Fatal("RequestHandoff was never invoked at the soft threshold")
|
||||
}
|
||||
got, ok := st.Task(task.ID)
|
||||
if !ok || got.State != domain.StateLeased {
|
||||
t.Fatalf("task state=%v ok=%v, want still leased (soft threshold is advisory)", got.State, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prepare_handoff requests handoff without releasing", func(t *testing.T) {
|
||||
a := &handoffRequestingAdapter{fakeAdapter: fakeAdapter{occupancy: .95, boundary: true}}
|
||||
c, st, task := newCoordinator(&a.fakeAdapter)
|
||||
|
||||
+11
-2
@@ -273,8 +273,17 @@ Fixed so far:
|
||||
correct admit token accepted, same-id-different-token rejected as a
|
||||
hijack, same-id-same-token (legitimate restart) still succeeds.
|
||||
|
||||
Not yet started: Codex/opencode Stop-hook-equivalent scripts, S8, S11. See
|
||||
`AUDIT.md` for the full plan.
|
||||
- **S11 (partial — soft threshold only)** — added `Coordinator.Soft`
|
||||
(default 0.55, `ORCHESTRA_OCCUPANCY_SOFT` override). Both `rotate()` and
|
||||
`TurnDecision` now request a handoff once occupancy crosses `Soft`, well
|
||||
before `Hard` forces one; `TurnDecision` returns `prepare_handoff` for this
|
||||
advisory case without requiring a turn boundary, leaving the task leased.
|
||||
Covered by a new `TestTurnDecision` subtest. **Not done:** milestone
|
||||
rotation, thrash detection, agent-initiated `ROTATE` — see AUDIT.md's S11
|
||||
section for why those are bigger than a threshold check.
|
||||
|
||||
Not yet started: Codex/opencode Stop-hook-equivalent scripts, S8, S11's
|
||||
milestone/thrash/agent-`ROTATE` pieces. See `AUDIT.md` for the full plan.
|
||||
|
||||
**Phase 0 done (2026-07-27):** this box has live TCP reachability to the real
|
||||
herdr instance at `192.168.1.105:9245` — verified by hand (raw JSON-RPC
|
||||
|
||||
Reference in New Issue
Block a user