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:
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user