Files
orchestra/internal/herdr/adapter.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

242 lines
7.7 KiB
Go

package herdr
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
)
type Adapter interface {
Lease(context.Context, string, string) (Session, error)
Bootstrap(context.Context, Session, string) error
Release(context.Context, Session) (string, error)
Kill(context.Context, Session) error
Occupancy(Session) (float64, error)
}
type WorktreeCreator interface {
CreateWorktree(context.Context, string, string, string) (string, error)
}
// TurnBoundary is optional so older herdr deployments remain usable. A true
// result means the current harness turn has ended and handoff is safe.
type TurnBoundary interface {
AtTurnBoundary(context.Context, Session) (bool, error)
}
type RotationSignal interface {
RotationSignal(context.Context, Session) (string, error)
}
type PaneExit interface {
PaneExited(context.Context, Session) (bool, error)
}
// AgentStatus is a live, non-lifecycle status reported by herdr. Consumers
// must not infer task completion or release from it.
type AgentStatus interface {
AgentStatus(context.Context, Session) (string, error)
}
type AgentBlocker interface {
AgentBlocker(context.Context, Session) (string, error)
}
type PaneCapture interface {
PaneCapture(context.Context, Session, string) (string, error)
}
type CLIAdapter struct {
Client *Client
Harness string
Window int64
Usage func(string) (Usage, error)
}
func (a CLIAdapter) CreateWorktree(ctx context.Context, repo, root, taskID string) (string, error) {
path, err := a.Client.Worktree(ctx, repo, filepath.Join(root, taskID), "orchestra/"+taskID)
if err != nil {
return "", err
}
if path == "" {
return "", fmt.Errorf("adapter: herdr returned empty worktree path")
}
return path, nil
}
func (a CLIAdapter) Lease(ctx context.Context, task, worktree string) (Session, error) {
if a.Client == nil {
return Session{}, fmt.Errorf("adapter: client required")
}
s, err := a.Client.StartAgent(ctx, worktree, worktree, "orchestra/"+task, a.Harness, task)
if err != nil {
return Session{}, err
}
if err := a.Client.Prompt(ctx, s.PaneID, fmt.Sprintf("Begin Orchestra task %s. Inspect the repository, understand the task context, and proceed with the requested work.", task), 0); err != nil {
return Session{}, err
}
return s, nil
}
func (a CLIAdapter) Bootstrap(ctx context.Context, s Session, ref string) error {
return a.Client.Prompt(ctx, s.PaneID, fmt.Sprintf("Read handoff %s, validate the anchor and TASK.md, then continue.", ref), time.Minute)
}
func (a CLIAdapter) Release(ctx context.Context, s Session) (string, error) {
var r struct {
Ref string `json:"handoff_ref"`
}
e := a.Client.Call(ctx, "pane.release", s, &r)
return r.Ref, e
}
func (a CLIAdapter) Kill(ctx context.Context, s Session) error {
return a.Client.Call(ctx, "pane.kill", s, nil)
}
func (a CLIAdapter) AtTurnBoundary(ctx context.Context, s Session) (bool, error) {
status, err := a.AgentStatus(ctx, s)
if err != nil {
return false, err
}
return !IsBusy(status), nil
}
func (a CLIAdapter) PaneExited(ctx context.Context, s Session) (bool, error) {
status, err := a.AgentStatus(ctx, s)
if err != nil {
return false, err
}
return strings.EqualFold(status, "exited") || strings.EqualFold(status, "dead"), nil
}
func (a CLIAdapter) AgentStatus(ctx context.Context, s Session) (string, error) {
// Current herdr protocol exposes agent state through agent.get; older
// Orchestra code used pane.status, which is not a valid protocol method.
var r map[string]any
if err := a.Client.Call(ctx, "agent.get", map[string]any{"target": s.PaneID}, &r); err != nil {
return "", err
}
return statusFromAgentResult(r), nil
}
func (a CLIAdapter) AgentBlocker(ctx context.Context, s Session) (string, error) {
var r struct {
Read struct {
Text string `json:"text"`
} `json:"read"`
}
if err := a.Client.Call(ctx, "pane.read", map[string]any{"pane_id": s.PaneID, "source": "recent"}, &r); err != nil {
return "", err
}
text := strings.TrimSpace(r.Read.Text)
lines := strings.Split(text, "\n")
for i, raw := range lines {
line := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "┃"))
if !strings.EqualFold(line, "Permission required") && !strings.EqualFold(line, "Approval required") && !strings.HasPrefix(strings.ToLower(line), "waiting for") {
continue
}
for _, next := range lines[i+1:] {
command := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(next), "┃"))
if strings.HasPrefix(command, "$ ") {
return strings.ToLower(line) + ": shell command `" + strings.TrimSpace(strings.TrimPrefix(command, "$ ")) + "`", nil
}
}
return strings.ToLower(line), nil
}
return "", nil
}
func (a CLIAdapter) PaneCapture(ctx context.Context, s Session, source string) (string, error) {
if source == "" {
source = "recent"
}
var r struct {
Read struct {
Text string `json:"text"`
} `json:"read"`
}
if err := a.Client.Call(ctx, "pane.read", map[string]any{"pane_id": s.PaneID, "source": source}, &r); err != nil {
return "", err
}
return r.Read.Text, nil
}
func statusFromAgentResult(v any) string {
if m, ok := v.(map[string]any); ok {
for _, key := range []string{"status", "agent_status", "state"} {
if s, ok := m[key].(string); ok && s != "" {
return s
}
}
for _, child := range m {
if s := statusFromAgentResult(child); s != "" {
return s
}
}
}
if a, ok := v.([]any); ok {
for _, child := range a {
if s := statusFromAgentResult(child); s != "" {
return s
}
}
}
return ""
}
var _ = json.RawMessage{}
func (a CLIAdapter) RotationSignal(ctx context.Context, s Session) (string, error) {
var r struct {
Reason string `json:"reason"`
}
if err := a.Client.Call(ctx, "pane.rotation_signal", s, &r); err != nil {
return "", err
}
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")
}
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 {
return CLIAdapter{Client: c, Harness: "claude", Window: w, Usage: ClaudeUsage}
}
var Codex = func(c *Client, w int64) CLIAdapter {
return CLIAdapter{Client: c, Harness: "codex", Window: w, Usage: CodexUsage}
}
var OpenCode = func(c *Client, w int64) CLIAdapter {
return CLIAdapter{Client: c, Harness: "opencode", Window: w, Usage: OpenCodeUsage}
}