19bffaf77d
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.
96 lines
2.9 KiB
Go
96 lines
2.9 KiB
Go
package herdr
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestClaudeStopHookUsage(t *testing.T) {
|
|
// The hook parser's path validation is independent from transcript IO.
|
|
_, path, err := ClaudeStopHookUsage(strings.NewReader(`{"transcript_path":"/tmp/transcript.jsonl"}`))
|
|
if path != "/tmp/transcript.jsonl" || err == nil {
|
|
t.Fatalf("path=%q err=%v", path, err)
|
|
}
|
|
}
|
|
|
|
func TestCodexRolloutFallback(t *testing.T) {
|
|
paths, err := CodexRolloutPaths(t.TempDir())
|
|
if err == nil || len(paths) != 0 {
|
|
t.Fatalf("paths=%v err=%v", paths, err)
|
|
}
|
|
}
|
|
|
|
// TestClaudeUsageIsLastTurnNotCumulative guards the exact trap the spec
|
|
// (§5.2.1) calls out by name: occupancy must reflect the current context
|
|
// window (the last turn's usage snapshot), not a running total across many
|
|
// turns. A transcript with one huge early turn and a small final turn must
|
|
// report low occupancy, because Claude Code's own usage lines are already
|
|
// cumulative-per-turn snapshots, not deltas to be summed.
|
|
func TestClaudeUsageIsLastTurnNotCumulative(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "transcript.jsonl")
|
|
lines := []string{
|
|
`{"message":{"usage":{"input_tokens":180000,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"output_tokens":500}}}`,
|
|
`{"message":{"usage":{"input_tokens":2000,"cache_read_input_tokens":500,"cache_creation_input_tokens":0,"output_tokens":100}}}`,
|
|
}
|
|
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u, err := ClaudeUsage(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
frac := Fraction(u, 200000)
|
|
if frac > 0.05 {
|
|
t.Fatalf("occupancy=%v, want low (last-turn usage, not the 180000-token first turn)", frac)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSessionFileNewestByMtime(t *testing.T) {
|
|
worktree := t.TempDir()
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
abs, err := filepath.Abs(worktree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
enc := strings.ReplaceAll(abs, "/", "-")
|
|
dir := filepath.Join(home, ".claude", "projects", enc)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := filepath.Join(dir, "old.jsonl")
|
|
newer := filepath.Join(dir, "new.jsonl")
|
|
if err := os.WriteFile(old, []byte("{}\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(newer, []byte("{}\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Now()
|
|
if err := os.Chtimes(old, now.Add(-time.Hour), now.Add(-time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chtimes(newer, now, now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := ClaudeSessionFile(worktree)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != newer {
|
|
t.Fatalf("got %s, want newest transcript %s", got, newer)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSessionFileMissingIsHardError(t *testing.T) {
|
|
worktree := t.TempDir()
|
|
t.Setenv("HOME", t.TempDir())
|
|
if _, err := ClaudeSessionFile(worktree); err == nil {
|
|
t.Fatal("expected error for missing session transcript directory, got nil")
|
|
}
|
|
}
|