257 lines
6.4 KiB
Go
257 lines
6.4 KiB
Go
// Package orchestrator connects router lease events to an opaque herdr
|
|
// session. It is deliberately small: scheduling remains in router and the
|
|
// adapter remains the only component that knows how to drive a harness.
|
|
package orchestrator
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/herdr"
|
|
"orchestra/internal/store"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Worktrees interface {
|
|
Create(context.Context, domain.Task) (string, error)
|
|
}
|
|
type Adapters interface {
|
|
Adapter(string) (herdr.Adapter, error)
|
|
}
|
|
|
|
// GitWorktrees creates one isolated checkout per task. The root is expected
|
|
// to be a clone containing the project's remote; callers may set a separate
|
|
// root per deployment.
|
|
type GitWorktrees struct {
|
|
Root string
|
|
Repo string
|
|
}
|
|
|
|
func (w GitWorktrees) Create(ctx context.Context, t domain.Task) (string, error) {
|
|
if w.Root == "" || w.Repo == "" {
|
|
return "", fmt.Errorf("worktree: root and repo required")
|
|
}
|
|
if err := os.MkdirAll(w.Root, 0755); err != nil {
|
|
return "", err
|
|
}
|
|
p := filepath.Join(w.Root, t.ID)
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p, nil
|
|
}
|
|
branch := "orchestra/" + t.ID
|
|
cmd := exec.CommandContext(ctx, "git", "-C", w.Repo, "worktree", "add", "-b", branch, p, "HEAD")
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return "", fmt.Errorf("%s: %w", string(out), err)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
type AdapterFactory struct{ Herdrs map[string]herdr.Adapter }
|
|
|
|
func (f AdapterFactory) Adapter(id string) (herdr.Adapter, error) {
|
|
a, ok := f.Herdrs[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("adapter %q not registered", id)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
type Coordinator struct {
|
|
Store *store.Store
|
|
Worktrees Worktrees
|
|
Adapters Adapters
|
|
StatePath string
|
|
mu sync.Mutex
|
|
sessions map[string]herdr.Session
|
|
loaded bool
|
|
}
|
|
|
|
func (c *Coordinator) loadSessions() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.loaded {
|
|
return
|
|
}
|
|
c.loaded = true
|
|
c.sessions = map[string]herdr.Session{}
|
|
if c.StatePath == "" {
|
|
return
|
|
}
|
|
b, err := os.ReadFile(c.StatePath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = json.Unmarshal(b, &c.sessions)
|
|
}
|
|
|
|
func (c *Coordinator) saveSessionsLocked() error {
|
|
if c.StatePath == "" {
|
|
return nil
|
|
}
|
|
b, err := json.Marshal(c.sessions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := c.StatePath + ".tmp"
|
|
if err = os.WriteFile(tmp, b, 0600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, c.StatePath)
|
|
}
|
|
|
|
// Reconcile drops mappings whose task lease did not survive restart and kills
|
|
// their recoverable herdr sessions so an orphan cannot keep consuming a slot.
|
|
func (c *Coordinator) Reconcile(ctx context.Context) error {
|
|
c.loadSessions()
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for taskID, session := range c.sessions {
|
|
t, ok := c.Store.Task(taskID)
|
|
if ok && t.State == domain.StateLeased {
|
|
continue
|
|
}
|
|
if a, err := c.Adapters.Adapter(session.Harness); err == nil {
|
|
_ = a.Kill(ctx, session)
|
|
}
|
|
delete(c.sessions, taskID)
|
|
}
|
|
return c.saveSessionsLocked()
|
|
}
|
|
|
|
// Monitor performs conservative hard-threshold rotation. The adapter owns
|
|
// the handoff creation; the coordinator only publishes its content address
|
|
// and frees the lease for pickup by the router.
|
|
func (c *Coordinator) Monitor(ctx context.Context, hard float64, interval time.Duration) error {
|
|
if err := c.Reconcile(ctx); err != nil {
|
|
return err
|
|
}
|
|
if interval <= 0 {
|
|
interval = 30 * time.Second
|
|
}
|
|
t := time.NewTicker(interval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-t.C:
|
|
c.rotate(ctx, hard)
|
|
}
|
|
}
|
|
}
|
|
func (c *Coordinator) rotate(ctx context.Context, hard float64) {
|
|
c.loadSessions()
|
|
c.mu.Lock()
|
|
sessions := make(map[string]herdr.Session, len(c.sessions))
|
|
for id, s := range c.sessions {
|
|
sessions[id] = s
|
|
}
|
|
c.mu.Unlock()
|
|
for taskID, session := range sessions {
|
|
task, ok := c.Store.Task(taskID)
|
|
if !ok || task.State != domain.StateLeased {
|
|
continue
|
|
}
|
|
a, err := c.Adapters.Adapter(session.Harness)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
reason := "threshold"
|
|
if signal, ok := a.(herdr.RotationSignal); ok {
|
|
if r, signalErr := signal.RotationSignal(ctx, session); signalErr == nil && r != "" {
|
|
reason = r
|
|
}
|
|
}
|
|
occupancy, err := a.Occupancy(session)
|
|
if err != nil || (occupancy < hard && reason == "threshold") {
|
|
continue
|
|
}
|
|
if boundary, ok := a.(herdr.TurnBoundary); ok {
|
|
atBoundary, boundaryErr := boundary.AtTurnBoundary(ctx, session)
|
|
if boundaryErr == nil && !atBoundary {
|
|
continue
|
|
}
|
|
}
|
|
ref, err := a.Release(ctx, session)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if ref == "" {
|
|
continue
|
|
}
|
|
b, _ := json.Marshal(map[string]string{"handoff_ref": ref, "reason": reason})
|
|
e := domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: taskID, Version: task.Version + 1, Payload: b}
|
|
if c.Store.Append(e) == nil {
|
|
c.mu.Lock()
|
|
delete(c.sessions, taskID)
|
|
_ = c.saveSessionsLocked()
|
|
c.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Coordinator) Start(ctx context.Context, e domain.Event) error {
|
|
if e.Type != "TaskLeased" {
|
|
return nil
|
|
}
|
|
if c.Store == nil || c.Worktrees == nil || c.Adapters == nil {
|
|
return fmt.Errorf("orchestrator: dependencies required")
|
|
}
|
|
c.loadSessions()
|
|
t, ok := c.Store.Task(e.TaskID)
|
|
if !ok {
|
|
return domain.ErrNotFound
|
|
}
|
|
var p struct {
|
|
HarnessID string `json:"harness_id"`
|
|
HandoffRef string `json:"handoff_ref"`
|
|
}
|
|
if err := json.Unmarshal(e.Payload, &p); err != nil || p.HarnessID == "" {
|
|
return fmt.Errorf("orchestrator: invalid lease")
|
|
}
|
|
w, err := c.Worktrees.Create(ctx, t)
|
|
if err != nil {
|
|
return c.block(t, "worktree: "+err.Error())
|
|
}
|
|
a, err := c.Adapters.Adapter(p.HarnessID)
|
|
if err != nil {
|
|
return c.block(t, "adapter: "+err.Error())
|
|
}
|
|
s, err := a.Lease(ctx, t.ID, w)
|
|
if err != nil {
|
|
return c.block(t, "lease: "+err.Error())
|
|
}
|
|
if p.HandoffRef != "" {
|
|
if err = a.Bootstrap(ctx, s, p.HandoffRef); err != nil {
|
|
_ = a.Kill(ctx, s)
|
|
return c.block(t, "bootstrap: "+err.Error())
|
|
}
|
|
}
|
|
c.mu.Lock()
|
|
if c.sessions == nil {
|
|
c.sessions = map[string]herdr.Session{}
|
|
}
|
|
c.sessions[t.ID] = s
|
|
err = c.saveSessionsLocked()
|
|
c.mu.Unlock()
|
|
return err
|
|
}
|
|
|
|
func (c *Coordinator) block(t domain.Task, reason string) error {
|
|
b, _ := json.Marshal(map[string]string{"blocker": reason})
|
|
return c.Store.Append(domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: t.ID, Version: t.Version + 1, Payload: b})
|
|
}
|
|
|
|
func (c *Coordinator) Session(taskID string) (herdr.Session, bool) {
|
|
c.loadSessions()
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
s, ok := c.sessions[taskID]
|
|
return s, ok
|
|
}
|