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

288 lines
10 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"
"log/slog"
"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
// ListPRsBySession returns every PR row tracked for the session. The
// reducer reads it to apply the multi-PR completion rule (terminate only
// when no open PR remains and at least one merged) and to suppress
// merge-conflict nudges on PRs stacked behind an open parent.
ListPRsBySession(ctx context.Context, id domain.SessionID) ([]domain.PullRequest, error)
// GetPRLastNudgeSignature / UpdatePRLastNudgeSignature persist the
// reaction-dedup map so nudges survive a daemon restart.
GetPRLastNudgeSignature(ctx context.Context, prURL string) (string, error)
UpdatePRLastNudgeSignature(ctx context.Context, prURL, payload string) error
}
// notificationSink is the optional lifecycle-to-notification-producer boundary.
type notificationSink interface {
Notify(ctx context.Context, intent ports.NotificationIntent) error
}
// Option customizes a Manager.
type Option func(*Manager)
// WithNotificationSink wires lifecycle notification intents to a write-side producer.
func WithNotificationSink(sink notificationSink) Option {
return func(m *Manager) { m.notifications = sink }
}
// WithTelemetry wires lifecycle activity transitions to the shared telemetry sink.
func WithTelemetry(sink ports.EventSink) Option {
return func(m *Manager) { m.telemetry = sink }
}
// 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
notifications notificationSink
mu sync.Mutex
window time.Duration
clock func() time.Time
react reactionState
telemetry ports.EventSink
}
// 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, opts ...Option) *Manager {
// UTC so activity-driven LastActivityAt/UpdatedAt match spawn-stamped
// timestamps (the session manager clock is UTC too); a local clock here left
// `ao session get` showing created in UTC but updated in local time. A
// WithClock option may still override this in tests.
clock := func() time.Time { return time.Now().UTC() }
m := &Manager{store: store, messenger: messenger, window: defaultRecentActivityWindow, clock: clock, react: newReactionState()}
for _, opt := range opts {
opt(m)
}
return m
}
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
}
var intent *ports.NotificationIntent
m.mu.Lock()
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil {
m.mu.Unlock()
return err
}
if !ok {
m.mu.Unlock()
return fmt.Errorf("%w: %s", ports.ErrSessionNotFound, id)
}
now := m.clock()
if rec.IsTerminated {
m.mu.Unlock()
return nil
}
prevState := rec.Activity.State
prevAt := rec.Activity.LastActivityAt
next := rec
act := domain.Activity{State: s.State, LastActivityAt: timeOr(s.Timestamp, now)}
// A same-state repeat is still a write when it is the FIRST signal for
// this spawn: the receipt itself is a durable fact (it clears the
// no_signal display status). Hook deliveries are best-effort, so the
// first to ARRIVE may match the seeded state — e.g. a turn's "active"
// POST is lost and its Stop hook lands idle on the idle-seeded row.
if sameActivity(rec.Activity, act) && !rec.FirstSignalAt.IsZero() {
m.mu.Unlock()
return nil
}
next.Activity = act
if next.FirstSignalAt.IsZero() {
next.FirstSignalAt = timeOr(s.Timestamp, now)
}
if s.State == domain.ActivityExited {
next.IsTerminated = true
}
next.UpdatedAt = now
if err := m.store.UpdateSession(ctx, next); err != nil {
m.mu.Unlock()
return err
}
if rec.Activity.State != domain.ActivityWaitingInput && next.Activity.State == domain.ActivityWaitingInput && !next.IsTerminated {
intent = &ports.NotificationIntent{
Type: domain.NotificationNeedsInput,
SessionID: next.ID,
ProjectID: next.ProjectID,
CreatedAt: next.Activity.LastActivityAt,
SessionDisplayName: next.DisplayName,
}
}
waitingEvents := m.waitingInputEvents(next, prevState, prevAt, now)
m.mu.Unlock()
for _, ev := range waitingEvents {
m.emitTelemetry(ctx, ev)
}
m.emitNotification(ctx, intent)
return nil
}
func (m *Manager) waitingInputEvents(next domain.SessionRecord, prevState domain.ActivityState, prevAt, now time.Time) []ports.TelemetryEvent {
if m.telemetry == nil {
return nil
}
projectID := next.ProjectID
sessionID := next.ID
var events []ports.TelemetryEvent
if prevState != domain.ActivityWaitingInput && next.Activity.State == domain.ActivityWaitingInput && !next.IsTerminated {
events = append(events, ports.TelemetryEvent{
Name: "ao.session.waiting_input_entered",
Source: "lifecycle",
OccurredAt: now.UTC(),
Level: ports.TelemetryLevelInfo,
ProjectID: &projectID,
SessionID: &sessionID,
Payload: map[string]any{
"state": string(next.Activity.State),
},
})
}
if prevState == domain.ActivityWaitingInput && next.Activity.State != domain.ActivityWaitingInput {
payload := map[string]any{
"state": string(next.Activity.State),
"dwell_ms": now.Sub(prevAt).Milliseconds(),
"exited_to": string(next.Activity.State),
}
events = append(events, ports.TelemetryEvent{
Name: "ao.session.waiting_input_exited",
Source: "lifecycle",
OccurredAt: now.UTC(),
Level: ports.TelemetryLevelInfo,
ProjectID: &projectID,
SessionID: &sessionID,
Payload: payload,
})
}
return events
}
func (m *Manager) emitTelemetry(ctx context.Context, ev ports.TelemetryEvent) {
if m.telemetry == nil {
return
}
m.telemetry.Emit(ctx, ev)
}
func (m *Manager) emitNotification(ctx context.Context, intent *ports.NotificationIntent) {
if intent == nil || m.notifications == nil {
return
}
if err := m.notifications.Notify(ctx, *intent); err != nil {
slog.Default().Warn("lifecycle: notification failed", "session", intent.SessionID, "type", intent.Type, "err", err)
}
}
// 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}
// Each spawn/restore must re-prove its hook pipeline: clear the receipt so
// a relaunch with broken hooks degrades to no_signal instead of inheriting
// a stale "signals worked once" fact.
rec.FirstSignalAt = time.Time{}
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
})
}
// sameActivity reports whether two activity signals describe the same state.
// LastActivityAt is intentionally ignored: same-state repeats (e.g. a stream
// of idle notifications) must not rewrite UpdatedAt or fan out a CDC event.
// LastActivityAt now marks when this state was first entered since the last
// transition, which is the timestamp a UI actually wants.
func sameActivity(a, b domain.Activity) bool {
return a.State == b.State
}
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
}