persist and reconcile runtime sessions

This commit is contained in:
kami
2026-07-26 20:28:41 +04:00
parent afac166989
commit ad32f29cd5
3 changed files with 67 additions and 2 deletions
+2 -1
View File
@@ -17,6 +17,7 @@ import (
"orchestra/internal/router" "orchestra/internal/router"
"orchestra/internal/store" "orchestra/internal/store"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -65,7 +66,7 @@ func main() {
log.Printf("herdr %s has unsupported harness %q", h.ID, h.Harness) log.Printf("herdr %s has unsupported harness %q", h.ID, h.Harness)
} }
} }
coordinator := &orchestrator.Coordinator{Store: s, Worktrees: orchestrator.GitWorktrees{Root: root, Repo: repo}, Adapters: orchestrator.AdapterFactory{Herdrs: adapters}} coordinator := &orchestrator.Coordinator{Store: s, StatePath: filepath.Join(dir, "runtime-sessions.json"), Worktrees: orchestrator.GitWorktrees{Root: root, Repo: repo}, Adapters: orchestrator.AdapterFactory{Herdrs: adapters}}
rt.OnLease = func(e domain.Event) error { return coordinator.Start(context.Background(), e) } rt.OnLease = func(e domain.Event) error { return coordinator.Start(context.Background(), e) }
hard := 0.75 hard := 0.75
if v, parseErr := strconv.ParseFloat(os.Getenv("ORCHESTRA_OCCUPANCY_HARD"), 64); parseErr == nil && v > 0 && v < 1 { if v, parseErr := strconv.ParseFloat(os.Getenv("ORCHESTRA_OCCUPANCY_HARD"), 64); parseErr == nil && v > 0 && v < 1 {
+63 -1
View File
@@ -65,14 +65,71 @@ type Coordinator struct {
Store *store.Store Store *store.Store
Worktrees Worktrees Worktrees Worktrees
Adapters Adapters Adapters Adapters
StatePath string
mu sync.Mutex mu sync.Mutex
sessions map[string]herdr.Session 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 // Monitor performs conservative hard-threshold rotation. The adapter owns
// the handoff creation; the coordinator only publishes its content address // the handoff creation; the coordinator only publishes its content address
// and frees the lease for pickup by the router. // and frees the lease for pickup by the router.
func (c *Coordinator) Monitor(ctx context.Context, hard float64, interval time.Duration) error { 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 { if interval <= 0 {
interval = 30 * time.Second interval = 30 * time.Second
} }
@@ -88,6 +145,7 @@ func (c *Coordinator) Monitor(ctx context.Context, hard float64, interval time.D
} }
} }
func (c *Coordinator) rotate(ctx context.Context, hard float64) { func (c *Coordinator) rotate(ctx context.Context, hard float64) {
c.loadSessions()
c.mu.Lock() c.mu.Lock()
sessions := make(map[string]herdr.Session, len(c.sessions)) sessions := make(map[string]herdr.Session, len(c.sessions))
for id, s := range c.sessions { for id, s := range c.sessions {
@@ -119,6 +177,7 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
if c.Store.Append(e) == nil { if c.Store.Append(e) == nil {
c.mu.Lock() c.mu.Lock()
delete(c.sessions, taskID) delete(c.sessions, taskID)
_ = c.saveSessionsLocked()
c.mu.Unlock() c.mu.Unlock()
} }
} }
@@ -131,6 +190,7 @@ func (c *Coordinator) Start(ctx context.Context, e domain.Event) error {
if c.Store == nil || c.Worktrees == nil || c.Adapters == nil { if c.Store == nil || c.Worktrees == nil || c.Adapters == nil {
return fmt.Errorf("orchestrator: dependencies required") return fmt.Errorf("orchestrator: dependencies required")
} }
c.loadSessions()
t, ok := c.Store.Task(e.TaskID) t, ok := c.Store.Task(e.TaskID)
if !ok { if !ok {
return domain.ErrNotFound return domain.ErrNotFound
@@ -165,8 +225,9 @@ func (c *Coordinator) Start(ctx context.Context, e domain.Event) error {
c.sessions = map[string]herdr.Session{} c.sessions = map[string]herdr.Session{}
} }
c.sessions[t.ID] = s c.sessions[t.ID] = s
err = c.saveSessionsLocked()
c.mu.Unlock() c.mu.Unlock()
return nil return err
} }
func (c *Coordinator) block(t domain.Task, reason string) error { func (c *Coordinator) block(t domain.Task, reason string) error {
@@ -175,6 +236,7 @@ func (c *Coordinator) block(t domain.Task, reason string) error {
} }
func (c *Coordinator) Session(taskID string) (herdr.Session, bool) { func (c *Coordinator) Session(taskID string) (herdr.Session, bool) {
c.loadSessions()
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
s, ok := c.sessions[taskID] s, ok := c.sessions[taskID]
+2
View File
@@ -59,6 +59,8 @@ Harness registration now uses configured `harness` and `protocol` fields, pings
Added bounded `POST /v1/artifacts` CAS upload support for report/handoff evidence. It returns the verified content hash used by lifecycle events and rejects empty or oversized uploads. Added bounded `POST /v1/artifacts` CAS upload support for report/handoff evidence. It returns the verified content hash used by lifecycle events and rejects empty or oversized uploads.
The coordinator now persists active task→herdr session mappings in an atomic runtime state file, reloads them after restart, reconciles them against durable task leases, kills stale recoverable sessions, and removes orphan mappings before monitoring begins.
Recommended order: Recommended order:
1. Add the orchestration coordinator: lease → worktree → harness session → bootstrap → lifecycle events. 1. Add the orchestration coordinator: lease → worktree → harness session → bootstrap → lifecycle events.