Files
orchestra/internal/herdr/occupancy.go
T
kami 19bffaf77d 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.
2026-07-27 18:49:13 +04:00

234 lines
6.2 KiB
Go

package herdr
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
type Usage struct{ Input, CacheRead, CacheWrite, Output int64 }
func (u Usage) Numerator() int64 { return u.Input + u.CacheRead + u.CacheWrite }
func Fraction(u Usage, w int64) float64 {
if w <= 0 {
return 0
}
f := float64(u.Numerator()) / float64(w)
if f < 0 {
return 0
}
if f > 1 {
return 1
}
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 {
return Usage{}, e
}
defer f.Close()
s := bufio.NewScanner(f)
var last Usage
for s.Scan() {
var x struct {
Message struct {
Usage struct {
Input int64 `json:"input_tokens"`
Read int64 `json:"cache_read_input_tokens"`
Write int64 `json:"cache_creation_input_tokens"`
Output int64 `json:"output_tokens"`
} `json:"usage"`
} `json:"message"`
}
if json.Unmarshal(s.Bytes(), &x) == nil && x.Message.Usage.Input > 0 {
last = Usage{x.Message.Usage.Input, x.Message.Usage.Read, x.Message.Usage.Write, x.Message.Usage.Output}
}
}
return last, s.Err()
}
// ClaudeStopHook is the JSON contract supplied by Claude Code's stop hook.
// Keeping the hook parser here makes ingestion independent of the hook's shell.
type ClaudeStopHook struct {
TranscriptPath string `json:"transcript_path"`
}
func ClaudeStopHookUsage(r io.Reader) (Usage, string, error) {
var h ClaudeStopHook
if err := json.NewDecoder(r).Decode(&h); err != nil {
return Usage{}, "", err
}
if h.TranscriptPath == "" {
return Usage{}, "", fmt.Errorf("claude stop hook: transcript_path required")
}
u, err := ClaudeUsage(h.TranscriptPath)
return u, h.TranscriptPath, err
}
// CodexRolloutPaths discovers active rollouts from configured Codex state.
// sqlite3 is intentionally used as an optional bridge: Codex owns the schema
// and deployments may not ship a Go sqlite driver.
func CodexRolloutPaths(home string) ([]string, error) {
if home == "" {
home = os.Getenv("CODEX_HOME")
}
if home == "" {
home = filepath.Join(os.Getenv("HOME"), ".codex")
}
var paths []string
matches, _ := filepath.Glob(filepath.Join(home, "state_*.sqlite"))
for _, db := range matches {
out, err := exec.Command("sqlite3", db, "select rollout_path from threads where rollout_path is not null;").Output()
if err == nil {
for _, p := range strings.Fields(string(out)) {
if p != "" {
paths = append(paths, p)
}
}
}
}
if len(paths) == 0 {
paths, _ = filepath.Glob(filepath.Join(home, "sessions", "*", "*", "*", "rollout-*.jsonl"))
}
if len(paths) == 0 {
return nil, fmt.Errorf("codex: no active rollout found in %s", home)
}
return paths, nil
}
func CodexActiveUsage(home string) (Usage, string, error) {
paths, err := CodexRolloutPaths(home)
if err != nil {
return Usage{}, "", err
}
for i := len(paths) - 1; i >= 0; i-- {
if _, e := os.Stat(paths[i]); e == nil {
u, e := CodexUsage(paths[i])
return u, paths[i], e
}
}
return Usage{}, "", os.ErrNotExist
}
func CodexUsage(p string) (Usage, error) {
f, e := os.Open(p)
if e != nil {
return Usage{}, e
}
defer f.Close()
s := bufio.NewScanner(f)
var u Usage
for s.Scan() {
var x struct {
Payload struct {
Type string `json:"type"`
Info struct {
Last struct {
Input int64 `json:"input"`
Read int64 `json:"cached_input"`
} `json:"last_token_usage"`
} `json:"info"`
} `json:"payload"`
}
if json.Unmarshal(s.Bytes(), &x) == nil && x.Payload.Type == "token_count" {
u = Usage{x.Payload.Info.Last.Input, x.Payload.Info.Last.Read, 0, 0}
}
}
return u, s.Err()
}
func OpenCodeUsage(p string) (Usage, error) {
f, e := os.Open(p)
if e != nil {
return Usage{}, e
}
defer f.Close()
var x struct {
Tokens struct {
Input int64 `json:"input"`
Output int64 `json:"output"`
Cache struct {
Read int64 `json:"read"`
Write int64 `json:"write"`
} `json:"cache"`
} `json:"tokens"`
}
e = json.NewDecoder(f).Decode(&x)
return Usage{x.Tokens.Input, x.Tokens.Cache.Read, x.Tokens.Cache.Write, x.Tokens.Output}, e
}
// OpenCodeStatus probes the server fast path. Callers can use the returned
// status and fall back to OpenCodeUsage when the SSE/server is unavailable.
func OpenCodeStatus(ctx context.Context, baseURL, sessionID string) (string, error) {
if baseURL == "" {
baseURL = "http://127.0.0.1:4096"
}
u := strings.TrimRight(baseURL, "/") + "/session/" + sessionID
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", err
}
c := &http.Client{Timeout: 5 * time.Second}
resp, err := c.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("opencode status: http %s", resp.Status)
}
var x struct {
Status string `json:"status"`
}
if err := json.NewDecoder(resp.Body).Decode(&x); err != nil {
return "", err
}
return x.Status, nil
}
func IsBusy(s string) bool { return strings.EqualFold(s, "busy") }