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:
@@ -188,12 +188,46 @@ func (a CLIAdapter) RotationSignal(ctx context.Context, s Session) (string, erro
|
||||
}
|
||||
return r.Reason, nil
|
||||
}
|
||||
// Occupancy reads the harness's own session state — never the herdr pane id,
|
||||
// which ClaudeUsage/CodexUsage/OpenCodeUsage cannot open (spec §5.2.1: "the
|
||||
// whole rotation system rests on this number"). A session file that cannot
|
||||
// be resolved or read is a hard error, not a silently-empty Usage{}, so
|
||||
// callers (Coordinator.rotate, refreshSessionHealth) surface it instead of
|
||||
// mistaking "we don't know" for "occupancy is zero".
|
||||
func (a CLIAdapter) Occupancy(s Session) (float64, error) {
|
||||
if a.Usage == nil {
|
||||
return 0, fmt.Errorf("adapter: usage reader required")
|
||||
}
|
||||
u, e := a.Usage(s.PaneID)
|
||||
return Fraction(u, a.Window), e
|
||||
path := s.SessionFile
|
||||
if path == "" {
|
||||
resolved, err := a.resolveSessionFile(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("adapter: resolve session file: %w", err)
|
||||
}
|
||||
path = resolved
|
||||
}
|
||||
u, e := a.Usage(path)
|
||||
if e != nil {
|
||||
return 0, fmt.Errorf("adapter: read usage from %s: %w", path, e)
|
||||
}
|
||||
return Fraction(u, a.Window), nil
|
||||
}
|
||||
|
||||
func (a CLIAdapter) resolveSessionFile(s Session) (string, error) {
|
||||
switch a.Harness {
|
||||
case "claude":
|
||||
return ClaudeSessionFile(s.Worktree)
|
||||
case "codex":
|
||||
_, path, err := CodexActiveUsage("")
|
||||
return path, err
|
||||
default:
|
||||
// opencode's session-file resolution needs the running session id,
|
||||
// which is only available via the SSE/status API (OpenCodeStatus),
|
||||
// not derivable from the worktree alone. Per AUDIT.md Phase 1, wiring
|
||||
// this needs verification against a live opencode instance before it
|
||||
// can drive rotation — refuse loudly rather than guess a path.
|
||||
return "", fmt.Errorf("adapter: harness %q has no session-file resolver; verify against a live session first (AUDIT.md Phase 1)", a.Harness)
|
||||
}
|
||||
}
|
||||
|
||||
var Claude = func(c *Client, w int64) CLIAdapter {
|
||||
|
||||
Reference in New Issue
Block a user