agent-orchestrator/backend/internal/lifecycle/manager.go

143 lines
4.6 KiB
Go

// Package lifecycle implements the synchronous reducer that writes durable
// session lifecycle facts. It deliberately keeps the session model small:
// activity_state plus an is_terminated bit are the only persisted status-like
// facts on the session row.
package lifecycle
import (
"context"
"fmt"
"sync"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
type sessionStore interface {
GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
UpdateSession(ctx context.Context, rec domain.SessionRecord) error
}
// Manager reduces runtime, activity, spawn, and termination observations into durable session facts.
// It also owns agent nudges caused by PR observations, including merge-conflict, CI-failure, and review-feedback prompts.
type Manager struct {
store sessionStore
messenger ports.AgentMessenger
mu sync.Mutex
window time.Duration
clock func() time.Time
react reactionState
}
// New builds a Lifecycle Manager over the session store it writes and the messenger it uses for agent nudges.
func New(store sessionStore, messenger ports.AgentMessenger) *Manager {
return &Manager{store: store, messenger: messenger, window: defaultRecentActivityWindow, clock: time.Now, react: newReactionState()}
}
func (m *Manager) mutate(ctx context.Context, id domain.SessionID, fn func(domain.SessionRecord, time.Time) (domain.SessionRecord, bool)) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil || !ok {
return err
}
now := m.clock()
next, changed := fn(rec, now)
if !changed {
return nil
}
next.UpdatedAt = now
if err := m.store.UpdateSession(ctx, next); err != nil {
return err
}
return nil
}
// ApplyRuntimeObservation only writes when runtime liveness is unambiguous. A
// failed probe or liveness disagreement is ignored; no transient lifecycle state is stored.
func (m *Manager) ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error {
return m.mutate(ctx, id, func(cur domain.SessionRecord, now time.Time) (domain.SessionRecord, bool) {
if cur.IsTerminated || !runtimeClearlyDead(f, cur.Activity, now, m.window) {
return cur, false
}
next := cur
next.IsTerminated = true
next.Activity = domain.Activity{State: domain.ActivityExited, LastActivityAt: timeOr(f.ObservedAt, now)}
return next, true
})
}
// ApplyActivitySignal records an authoritative agent activity signal.
func (m *Manager) ApplyActivitySignal(ctx context.Context, id domain.SessionID, s ports.ActivitySignal) error {
if !s.Valid {
return nil
}
return m.mutate(ctx, id, func(cur domain.SessionRecord, now time.Time) (domain.SessionRecord, bool) {
if cur.IsTerminated {
return cur, false
}
next := cur
act := domain.Activity{State: s.State, LastActivityAt: timeOr(s.Timestamp, now)}
if sameActivity(cur.Activity, act) {
return cur, false
}
next.Activity = act
if s.State == domain.ActivityExited {
next.IsTerminated = true
}
return next, true
})
}
// MarkSpawned marks a newly spawned or restored session live and stores runtime/workspace handles.
func (m *Manager) MarkSpawned(ctx context.Context, id domain.SessionID, metadata domain.SessionMetadata) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("lifecycle: MarkSpawned for unknown session %q", id)
}
now := m.clock()
rec.IsTerminated = false
rec.Activity = domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}
rec.Metadata = mergeMetadata(rec.Metadata, metadata)
rec.UpdatedAt = now
return m.store.UpdateSession(ctx, rec)
}
// MarkTerminated marks a session terminated without tearing down external resources.
func (m *Manager) MarkTerminated(ctx context.Context, id domain.SessionID) error {
return m.mutate(ctx, id, func(cur domain.SessionRecord, now time.Time) (domain.SessionRecord, bool) {
if cur.IsTerminated {
return cur, false
}
cur.IsTerminated = true
cur.Activity = domain.Activity{State: domain.ActivityExited, LastActivityAt: now}
return cur, true
})
}
func sameActivity(a, b domain.Activity) bool {
return a.State == b.State && a.LastActivityAt.Equal(b.LastActivityAt)
}
func mergeMetadata(base, in domain.SessionMetadata) domain.SessionMetadata {
set := func(dst *string, v string) {
if v != "" {
*dst = v
}
}
set(&base.Branch, in.Branch)
set(&base.WorkspacePath, in.WorkspacePath)
set(&base.RuntimeHandleID, in.RuntimeHandleID)
set(&base.AgentSessionID, in.AgentSessionID)
set(&base.Prompt, in.Prompt)
return base
}