checkpoint: multi-repo Gitea ingestion, per-project repos, rotation anchor_sha fix
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.
This commit is contained in:
+120
-15
@@ -2,7 +2,9 @@ package herdr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -15,6 +17,10 @@ type Adapter interface {
|
||||
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 {
|
||||
@@ -26,6 +32,18 @@ type RotationSignal interface {
|
||||
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
|
||||
@@ -33,15 +51,29 @@ type CLIAdapter struct {
|
||||
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")
|
||||
}
|
||||
var s Session
|
||||
e := a.Client.Call(ctx, "pane.create", map[string]string{"harness": a.Harness, "task_id": task, "worktree": worktree}, &s)
|
||||
s.Harness = a.Harness
|
||||
s.Worktree = worktree
|
||||
return s, e
|
||||
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)
|
||||
@@ -57,23 +89,96 @@ 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) {
|
||||
var r struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := a.Client.Call(ctx, "pane.status", s, &r); err != nil {
|
||||
status, err := a.AgentStatus(ctx, s)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !IsBusy(r.Status), nil
|
||||
return !IsBusy(status), nil
|
||||
}
|
||||
func (a CLIAdapter) PaneExited(ctx context.Context, s Session) (bool, error) {
|
||||
var r struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := a.Client.Call(ctx, "pane.status", s, &r); err != nil {
|
||||
status, err := a.AgentStatus(ctx, s)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.EqualFold(r.Status, "exited") || strings.EqualFold(r.Status, "dead"), nil
|
||||
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"`
|
||||
|
||||
+113
-14
@@ -13,6 +13,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -22,13 +23,15 @@ var ErrProtocol = errors.New("herdr protocol error")
|
||||
type Request struct {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
// Herdr's JSON-RPC decoder requires params to be present, including for
|
||||
// parameterless calls such as ping. Encode nil as an explicit JSON null.
|
||||
Params any `json:"params"`
|
||||
}
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
@@ -38,14 +41,39 @@ type Client struct {
|
||||
dial func() (net.Conn, error)
|
||||
mu sync.Mutex
|
||||
next uint64
|
||||
panes map[string]string
|
||||
}
|
||||
|
||||
func New(path string) *Client { return &Client{Path: path, Timeout: 10 * time.Second} }
|
||||
type WorktreeInfo struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type worktreeResponse struct {
|
||||
Path string `json:"path"`
|
||||
Worktree WorktreeInfo `json:"worktree"`
|
||||
RootPane struct {
|
||||
PaneID string `json:"pane_id"`
|
||||
Agent string `json:"agent"`
|
||||
} `json:"root_pane"`
|
||||
Workspace struct {
|
||||
RootPane struct {
|
||||
PaneID string `json:"pane_id"`
|
||||
} `json:"root_pane"`
|
||||
} `json:"workspace"`
|
||||
}
|
||||
|
||||
func New(path string) *Client {
|
||||
return &Client{Path: path, Timeout: 10 * time.Second, panes: map[string]string{}}
|
||||
}
|
||||
func (c *Client) conn() (net.Conn, error) {
|
||||
if c.dial != nil {
|
||||
return c.dial()
|
||||
}
|
||||
return net.DialTimeout("unix", c.Path, c.Timeout)
|
||||
network := "unix"
|
||||
if strings.Contains(c.Path, "://") || (strings.Contains(c.Path, ":") && !strings.HasPrefix(c.Path, "/")) {
|
||||
network = "tcp"
|
||||
}
|
||||
return net.DialTimeout(network, c.Path, c.Timeout)
|
||||
}
|
||||
func (c *Client) Call(ctx context.Context, method string, params any, out any) error {
|
||||
c.mu.Lock()
|
||||
@@ -62,6 +90,9 @@ func (c *Client) Call(ctx context.Context, method string, params any, out any) e
|
||||
} else if c.Timeout > 0 {
|
||||
_ = cn.SetDeadline(time.Now().Add(c.Timeout))
|
||||
}
|
||||
if params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
if err = json.NewEncoder(cn).Encode(Request{ID: id, Method: method, Params: params}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -79,8 +110,8 @@ func (c *Client) Call(ctx context.Context, method string, params any, out any) e
|
||||
}
|
||||
|
||||
type PingResult struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Version string `json:"version"`
|
||||
Protocol json.RawMessage `json:"protocol"`
|
||||
Version json.RawMessage `json:"version"`
|
||||
}
|
||||
|
||||
func (c *Client) Ping(ctx context.Context) (PingResult, error) {
|
||||
@@ -93,8 +124,14 @@ func (c *Client) CheckProtocol(ctx context.Context, want string) error {
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if want != "" && p.Protocol != want {
|
||||
return fmt.Errorf("%w: want %s, got %s", ErrProtocol, want, p.Protocol)
|
||||
if want != "" {
|
||||
var text string
|
||||
if err := json.Unmarshal(p.Protocol, &text); err != nil {
|
||||
text = string(p.Protocol)
|
||||
}
|
||||
if text != want {
|
||||
return fmt.Errorf("%w: want %s, got %s", ErrProtocol, want, text)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -103,18 +140,80 @@ type Session struct {
|
||||
PaneID string `json:"pane_id"`
|
||||
Worktree string `json:"worktree"`
|
||||
Harness string `json:"harness"`
|
||||
HerdrID string `json:"herdr_id,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error {
|
||||
p := map[string]any{"pane_id": pane, "prompt": text, "wait": map[string]any{"until": "turn_end", "timeout_ms": wait.Milliseconds()}}
|
||||
p := map[string]any{"target": pane, "text": text}
|
||||
if wait > 0 {
|
||||
p["wait"] = map[string]any{"until": []string{"idle"}, "timeout_ms": wait.Milliseconds()}
|
||||
}
|
||||
return c.Call(ctx, "agent.prompt", p, nil)
|
||||
}
|
||||
func (c *Client) Worktree(ctx context.Context, path, branch string) (string, error) {
|
||||
var r struct {
|
||||
Path string `json:"path"`
|
||||
func (c *Client) Worktree(ctx context.Context, cwd, path, branch string) (string, error) {
|
||||
var r worktreeResponse
|
||||
// Protocol 17 requires exactly one of path or branch. Use the explicit
|
||||
// path so the worker owns the checkout location; herdr manages the branch
|
||||
// associated with that worktree.
|
||||
p := map[string]any{"cwd": cwd, "path": path}
|
||||
e := c.Call(ctx, "worktree.create", p, &r)
|
||||
if e != nil && strings.Contains(strings.ToLower(e.Error()), "already exists") {
|
||||
e = c.Call(ctx, "worktree.open", map[string]any{"cwd": cwd, "path": path}, &r)
|
||||
}
|
||||
e := c.Call(ctx, "worktree.create", map[string]string{"path": path, "branch": branch}, &r)
|
||||
return r.Path, e
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
if r.RootPane.PaneID != "" {
|
||||
c.mu.Lock()
|
||||
c.panes[path] = r.RootPane.PaneID
|
||||
c.mu.Unlock()
|
||||
}
|
||||
if r.Path != "" {
|
||||
return r.Path, nil
|
||||
}
|
||||
return r.Worktree.Path, nil
|
||||
}
|
||||
|
||||
func (c *Client) StartAgent(ctx context.Context, cwd, path, branch, harness, taskID string) (Session, error) {
|
||||
c.mu.Lock()
|
||||
paneID := c.panes[path]
|
||||
c.mu.Unlock()
|
||||
if paneID == "" {
|
||||
return Session{}, fmt.Errorf("herdr: no pane recorded for worktree %s", path)
|
||||
}
|
||||
var s Session
|
||||
if err := c.Call(ctx, "agent.start", map[string]any{
|
||||
"pane_id": paneID,
|
||||
"kind": harness,
|
||||
"name": harness,
|
||||
"args": []string{},
|
||||
}, &s); err != nil {
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "already") {
|
||||
return Session{}, err
|
||||
}
|
||||
}
|
||||
s.PaneID = paneID
|
||||
s.Worktree = path
|
||||
s.Harness = harness
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// HeadSHA returns the current commit of a worktree. The rotation path uses
|
||||
// this to populate TaskReleased.anchor_sha without trusting the adapter's
|
||||
// opaque handoff-ref return value.
|
||||
func HeadSHA(root string) (string, error) {
|
||||
out, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sha := string(out)
|
||||
if len(sha) > 0 && sha[len(sha)-1] == '\n' {
|
||||
sha = sha[:len(sha)-1]
|
||||
}
|
||||
if len(sha) != 40 {
|
||||
return "", fmt.Errorf("herdr: unexpected HEAD output %q", sha)
|
||||
}
|
||||
return sha, nil
|
||||
}
|
||||
|
||||
// AnchorValid checks the split-then-close safety condition without trusting a
|
||||
|
||||
Reference in New Issue
Block a user