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