From 19bffaf77d907a5274108995cbd61ceb4446fecc Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 27 Jul 2026 18:49:13 +0400 Subject: [PATCH] fix(herdr): occupancy reads harness session state, not herdr pane id (B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 : 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//*.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. --- internal/herdr/adapter.go | 38 +++++++++++++- internal/herdr/herdr.go | 8 +++ internal/herdr/occupancy.go | 39 ++++++++++++++ internal/herdr/occupancy_test.go | 74 +++++++++++++++++++++++++++ internal/orchestrator/orchestrator.go | 19 ++++++- 5 files changed, 175 insertions(+), 3 deletions(-) diff --git a/internal/herdr/adapter.go b/internal/herdr/adapter.go index 970e786..d517a8d 100644 --- a/internal/herdr/adapter.go +++ b/internal/herdr/adapter.go @@ -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 { diff --git a/internal/herdr/herdr.go b/internal/herdr/herdr.go index 77c723c..f90a398 100644 --- a/internal/herdr/herdr.go +++ b/internal/herdr/herdr.go @@ -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 { diff --git a/internal/herdr/occupancy.go b/internal/herdr/occupancy.go index 6600e82..8d997a1 100644 --- a/internal/herdr/occupancy.go +++ b/internal/herdr/occupancy.go @@ -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//*.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 { diff --git a/internal/herdr/occupancy_test.go b/internal/herdr/occupancy_test.go index 3941df8..67a98eb 100644 --- a/internal/herdr/occupancy_test.go +++ b/internal/herdr/occupancy_test.go @@ -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") + } +} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index c76d451..36e291c 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -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") {