fix(herdr): occupancy reads harness session state, not herdr pane id (B1)

CLIAdapter.Occupancy called a.Usage(s.PaneID), but ClaudeUsage/CodexUsage/
OpenCodeUsage all take a filesystem path to session state, not a herdr pane
id. Every call failed with "open <pane-id>: no such file", and
Coordinator.rotate silently `continue`d past every failure, so occupancy
always looked unmeasurable and rotation never fired.

Add herdr.Session.SessionFile and CLIAdapter.resolveSessionFile:
  - claude: ClaudeSessionFile resolves the transcript by newest-mtime under
    Claude Code's own encoded project directory
    (~/.claude/projects/<abs-worktree-with-/-as-minus>/*.jsonl). This is the
    Phase-1 fallback; the Stop hook's transcript_path (Phase 2) is the
    authoritative source once wired.
  - codex: routes through the existing CodexActiveUsage sqlite/rollout
    discovery instead of the pane id.
  - opencode: resolution needs a live session id from the SSE/status API,
    not derivable from the worktree alone — refuses loudly with a pointer
    to AUDIT.md Phase 1 rather than guessing a path, per the spec's "verify
    against a live session before wiring any trigger" (§5.2.1).

A missing/unreadable session file is now a hard error, not a silent
zero-usage Usage{}. SessionHealth gained Occupancy/OccupancyError fields,
populated every refreshSessionHealth tick, so GET /v1/tasks/{id}/health
makes the number rotation decides on observable before trusting it.

Tests: TestClaudeUsageIsLastTurnNotCumulative guards the exact trap named
in §5.2.1 (large early-turn total, small last-turn usage -> low occupancy).
TestClaudeSessionFileNewestByMtime and TestClaudeSessionFileMissingIsHardError
cover the resolver.

AUDIT.md B1. Live verification against a real Claude Code session (the
spec's own acceptance bar for this phase) still needs to happen on a host
with an actual session — not possible from this sandbox.
This commit is contained in:
kami
2026-07-27 18:49:13 +04:00
parent ac38b59322
commit 19bffaf77d
5 changed files with 175 additions and 3 deletions
+18 -1
View File
@@ -173,6 +173,12 @@ type SessionHealth struct {
Blocker string `json:"blocker,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
LastError string `json:"last_error,omitempty"`
// Occupancy and OccupancyError make the number rotation actually decides
// on observable (spec §5.2.1: verify this against a live session before
// trusting it). A resolution/read failure is recorded here rather than
// silently treated as "not time to rotate yet" by a bare continue.
Occupancy float64 `json:"occupancy,omitempty"`
OccupancyError string `json:"occupancy_error,omitempty"`
}
// adapterFor resolves the herdr adapter for a session. Session.HerdrID (the
@@ -241,12 +247,23 @@ func (c *Coordinator) refreshSessionHealth(ctx context.Context) {
if err != nil {
continue
}
var h SessionHealth
h.UpdatedAt = time.Now().UTC()
if occ, occErr := a.Occupancy(session); occErr != nil {
h.OccupancyError = occErr.Error()
} else {
h.Occupancy = occ
}
p, ok := a.(herdr.AgentStatus)
if !ok {
c.healthMu.Lock()
c.health.Sessions[taskID] = h
c.healthMu.Unlock()
continue
}
status, err := p.AgentStatus(ctx, session)
h := SessionHealth{Status: status, WaitingForApproval: waitingForApproval(status), UpdatedAt: time.Now().UTC()}
h.Status = status
h.WaitingForApproval = waitingForApproval(status)
if err != nil {
h.LastError = err.Error()
} else if blocker, ok := a.(herdr.AgentBlocker); ok && strings.EqualFold(status, "blocked") {