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
+36 -2
View File
@@ -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 {
+8
View File
@@ -141,6 +141,14 @@ type Session struct {
Worktree string `json:"worktree"`
Harness string `json:"harness"`
HerdrID string `json:"herdr_id,omitempty"`
// SessionFile is the filesystem path to the harness's own session/
// transcript state (a Claude Code transcript, a Codex rollout, ...).
// ClaudeUsage/CodexUsage/OpenCodeUsage read *this*, never the herdr pane
// id — occupancy is a property of the harness's session state, not of
// the pane multiplexing it. Left empty until resolved (see
// CLIAdapter.Occupancy), since the file may not exist yet immediately
// after lease.
SessionFile string `json:"session_file,omitempty"`
}
func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error {
+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 {
+74
View File
@@ -1,8 +1,11 @@
package herdr
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestClaudeStopHookUsage(t *testing.T) {
@@ -19,3 +22,74 @@ func TestCodexRolloutFallback(t *testing.T) {
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")
}
}
+18 -1
View File
@@ -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") {