ce6f02f9e6
Pre-existing uncommitted work found at session start: rotation now emits anchor_sha on TaskReleased (previously silently dropped by store.Append validation), multi-repo Gitea provider support, per-project git worktree roots, and associated test coverage. Committing as a checkpoint before starting remediation work tracked in AUDIT.md.
208 lines
6.3 KiB
Go
208 lines
6.3 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
|
|
}
|
|
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
|
|
}
|
|
|
|
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}
|
|
}
|