persist and reconcile runtime sessions
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user