143 lines
4.6 KiB
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
|
|
}
|