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
+39
View File
@@ -30,6 +30,45 @@ func Fraction(u Usage, w int64) float64 {
}
return f
}
// ClaudeSessionFile resolves the transcript file for a Claude Code session
// running against worktree, by newest-mtime under Claude Code's encoded
// project directory (~/.claude/projects/<abs-worktree-path-with-/-replaced-
// by-minus>/*.jsonl). This is the "resolve at lease time" fallback called
// out in the spec (§5.2.1); the Stop hook's transcript_path (see
// ClaudeStopHookUsage) is the authoritative source once wired.
func ClaudeSessionFile(worktree string) (string, error) {
abs, err := filepath.Abs(worktree)
if err != nil {
return "", err
}
enc := strings.ReplaceAll(abs, "/", "-")
home := os.Getenv("HOME")
dir := filepath.Join(home, ".claude", "projects", enc)
matches, err := filepath.Glob(filepath.Join(dir, "*.jsonl"))
if err != nil {
return "", err
}
if len(matches) == 0 {
return "", fmt.Errorf("claude: no session transcripts found in %s", dir)
}
var newest string
var newestMod time.Time
for _, m := range matches {
fi, err := os.Stat(m)
if err != nil {
continue
}
if fi.ModTime().After(newestMod) {
newestMod = fi.ModTime()
newest = m
}
}
if newest == "" {
return "", fmt.Errorf("claude: could not stat any session transcript in %s", dir)
}
return newest, nil
}
func ClaudeUsage(p string) (Usage, error) {
f, e := os.Open(p)
if e != nil {