Reconcile docs with reality; fix module graph, token compare, health #1
@@ -910,6 +910,43 @@ a threshold comparison, not attempted this pass.
|
||||
|
||||
`go build ./...`, `go vet ./...`, `go test ./...` all pass.
|
||||
|
||||
## S11 — agent-initiated ROTATE, landed 2026-07-27
|
||||
|
||||
The last of S11's four pieces that fits an in-band signal (milestone and
|
||||
thrash both need transcript/tool-call introspection this repo has no source
|
||||
for — still open). §5.3: *"agent-initiated `ROTATE` → emitted when a coherent
|
||||
unit finishes and the next is independent."* `continuity.Handoff`'s schema
|
||||
already anticipated this — `reasons["manual"]` was valid since B5 landed, but
|
||||
nothing ever checked for it.
|
||||
|
||||
New `handoffReason(worktree)` (`internal/orchestrator/orchestrator.go`) reads
|
||||
`HandoffFile` if present and returns its decoded `meta.reason`, or `""` if
|
||||
absent/invalid — never `"manual"` on a bad read, so a malformed handoff can't
|
||||
accidentally short-circuit rotation. Both `rotate()` and `TurnDecision` check
|
||||
this first: if the agent already wrote a handoff with `reason=manual`, that
|
||||
**is** the boundary signal, so occupancy and the turn-boundary probe are
|
||||
skipped entirely and release proceeds straight away with `reason: "manual"`
|
||||
in the emitted `TaskReleased`. Below that check, the existing
|
||||
threshold/soft/hard logic is unchanged.
|
||||
|
||||
Extracted the release tail (`Release` → anchor certification → `TaskReleased`
|
||||
append) shared between the threshold and manual paths in `TurnDecision` into
|
||||
`Coordinator.finishRelease`, since the manual path needed to reach the exact
|
||||
same anchor-safety logic (never emit a payload with an uncertifiable anchor)
|
||||
without going through occupancy/boundary gating first.
|
||||
|
||||
Covered by `TestTurnDecision/"manual reason bypasses occupancy and turn
|
||||
boundary"` (`internal/orchestrator/rotation_test.go`): occupancy=0,
|
||||
boundary=false (both would refuse/continue under every other path), a
|
||||
`.orchestra-handoff.json` with `reason=manual` written directly to the
|
||||
worktree, and asserts `TurnRotateNow` + `Release` invoked + task state
|
||||
`StateQueued`.
|
||||
|
||||
**Still open from S11:** milestone rotation and thrash detection — both need
|
||||
a source of transcript/tool-call data this repo doesn't have yet.
|
||||
|
||||
`go build ./...`, `go vet ./...`, `go test ./...` all pass.
|
||||
|
||||
### Design consequences (not yet implemented)
|
||||
|
||||
1. **Percentages are a level, not a delta.**
|
||||
|
||||
@@ -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:
|
||||
|
||||
+14
-1
@@ -282,8 +282,21 @@ Fixed so far:
|
||||
rotation, thrash detection, agent-initiated `ROTATE` — see AUDIT.md's S11
|
||||
section for why those are bigger than a threshold check.
|
||||
|
||||
- **S11 (agent-initiated ROTATE)** — the schema already accepted
|
||||
`meta.reason: "manual"` (since B5) but nothing checked for it. New
|
||||
`handoffReason(worktree)` reads a written `.orchestra-handoff.json` and, if
|
||||
its reason is `"manual"`, both `rotate()` and `TurnDecision` skip occupancy
|
||||
and the turn-boundary probe entirely and release immediately — the agent's
|
||||
own handoff *is* the boundary signal per §5.3 ("a coherent unit finished and
|
||||
the next is independent"). Extracted the shared release-and-certify tail
|
||||
into `Coordinator.finishRelease` so the manual path reaches the same
|
||||
anchor-safety guarantee as the threshold path. Covered by a new
|
||||
`TestTurnDecision` subtest (occupancy=0, boundary=false — every other path
|
||||
would refuse or continue — asserting rotation happens anyway once a
|
||||
`reason=manual` handoff exists).
|
||||
|
||||
Not yet started: Codex/opencode Stop-hook-equivalent scripts, S8, S11's
|
||||
milestone/thrash/agent-`ROTATE` pieces. See `AUDIT.md` for the full plan.
|
||||
milestone/thrash 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