182 lines
6.0 KiB
Go
182 lines
6.0 KiB
Go
// Package reaper implements the OBSERVE-layer polling timer that supplies the
|
|
// LCM with per-session runtime liveness probes.
|
|
//
|
|
// The reaper only reports facts — it never writes session rows directly. The LCM
|
|
// consumes these facts through ApplyRuntimeObservation. A probe error is
|
|
// reported as a probe-failure fact, never collapsed to "alive" or "dead".
|
|
package reaper
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
|
)
|
|
|
|
// DefaultTickInterval is the cadence used when Config.Tick is zero. It mirrors
|
|
// the design doc's 5s sampling window for runtime liveness.
|
|
const DefaultTickInterval = 5 * time.Second
|
|
|
|
// Config holds the externally-tunable knobs for a Reaper. Every field is
|
|
// optional; zero values fall back to safe defaults so production wiring and
|
|
// tests can both stay terse.
|
|
type Config struct {
|
|
// Tick is the interval between ticks. <=0 means DefaultTickInterval.
|
|
Tick time.Duration
|
|
// Clock supplies ObservedAt stamps. nil means time.Now. Injected in tests so
|
|
// assertions don't race wallclock.
|
|
Clock func() time.Time
|
|
// Logger receives operational diagnostics (probe errors, skipped sessions,
|
|
// LCM call failures). The reaper logs but does not propagate these errors
|
|
// because a single failed probe must not kill the loop. nil means
|
|
// slog.Default.
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
type sessionSource interface {
|
|
ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error)
|
|
}
|
|
|
|
type runtimeObservationSink interface {
|
|
ApplyRuntimeObservation(ctx context.Context, id domain.SessionID, f ports.RuntimeFacts) error
|
|
}
|
|
|
|
type runtimeProber interface {
|
|
IsAlive(context.Context, ports.RuntimeHandle) (bool, error)
|
|
}
|
|
|
|
// Reaper is the polling timer. Construct it with New; start the background
|
|
// goroutine with Start, or drive a single cycle synchronously with Tick.
|
|
type Reaper struct {
|
|
sink runtimeObservationSink
|
|
sessions sessionSource
|
|
runtime runtimeProber
|
|
tick time.Duration
|
|
clock func() time.Time
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// New constructs a Reaper. sink is the lifecycle fact destination; sessions
|
|
// supplies the rows to probe; runtime checks whether a stored handle is alive.
|
|
func New(sink runtimeObservationSink, sessions sessionSource, runtime runtimeProber, cfg Config) *Reaper {
|
|
r := &Reaper{
|
|
sink: sink,
|
|
sessions: sessions,
|
|
runtime: runtime,
|
|
tick: cfg.Tick,
|
|
clock: cfg.Clock,
|
|
logger: cfg.Logger,
|
|
}
|
|
if r.tick <= 0 {
|
|
r.tick = DefaultTickInterval
|
|
}
|
|
if r.clock == nil {
|
|
r.clock = time.Now
|
|
}
|
|
if r.logger == nil {
|
|
r.logger = slog.Default()
|
|
}
|
|
return r
|
|
}
|
|
|
|
// Start launches the background goroutine and returns a channel that closes
|
|
// once the loop has exited. The loop exits on ctx cancellation; the channel
|
|
// gives the daemon a clean shutdown hook (wait on it after cancel to confirm
|
|
// the reaper has stopped before tearing down dependencies).
|
|
func (r *Reaper) Start(ctx context.Context) <-chan struct{} {
|
|
done := make(chan struct{})
|
|
go r.loop(ctx, done)
|
|
return done
|
|
}
|
|
|
|
func (r *Reaper) loop(ctx context.Context, done chan<- struct{}) {
|
|
defer close(done)
|
|
t := time.NewTicker(r.tick)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
if err := r.Tick(ctx); err != nil {
|
|
r.logger.Error("reaper: tick failed", "err", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tick runs one observation cycle: it enumerates non-terminated sessions,
|
|
// probes each one's runtime, and reports each result back as a fact.
|
|
//
|
|
// Tick is exported so the daemon (and tests) can drive cycles synchronously,
|
|
// and so the Start goroutine has a single chokepoint to log against.
|
|
//
|
|
// Errors: only the session-listing failure is propagated, since it short-
|
|
// circuits the rest of the cycle. Per-session ApplyRuntimeObservation failures
|
|
// are logged but never propagated — one failed call must not bring down the loop.
|
|
func (r *Reaper) Tick(ctx context.Context) error {
|
|
now := r.clock()
|
|
|
|
sessions, err := r.sessions.ListAllSessions(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, sess := range sessions {
|
|
if sess.IsTerminated {
|
|
continue
|
|
}
|
|
r.probeOne(ctx, sess, now)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// probeOne handles a single session's probe + fact-report. Every probe result —
|
|
// alive, dead, or failed — is reported as a fact to the LCM. The reaper does
|
|
// not optimize away the "alive" case; the reaper has no business deciding what
|
|
// counts as a no-op. The LCM diffs and only writes on actual change.
|
|
func (r *Reaper) probeOne(ctx context.Context, sess domain.SessionRecord, now time.Time) {
|
|
handle, ok := handleFromRecord(sess)
|
|
if !ok {
|
|
// A session in the running-set without a handle is an anomaly worth
|
|
// surfacing (MarkSpawned should have set both keys). Warn rather
|
|
// than Debug so it doesn't hide behind a noisy log level.
|
|
r.logger.Warn("reaper: session has no runtime handle metadata, skipping",
|
|
"session", sess.ID)
|
|
return
|
|
}
|
|
alive, probeErr := r.runtime.IsAlive(ctx, handle)
|
|
facts := ports.RuntimeFacts{ObservedAt: now}
|
|
switch {
|
|
case probeErr != nil:
|
|
// Failed probe must NOT be collapsed to alive — that would let a
|
|
// transient tmux outage hide a really-dead session, and a
|
|
// transient adapter bug terminate a really-alive one. Report failed
|
|
// and let the LCM arbitrate.
|
|
facts.Probe = ports.ProbeFailed
|
|
r.logger.Debug("reaper: probe error reported as failed fact",
|
|
"session", sess.ID, "err", probeErr)
|
|
case alive:
|
|
facts.Probe = ports.ProbeAlive
|
|
default:
|
|
facts.Probe = ports.ProbeDead
|
|
}
|
|
|
|
if err := r.sink.ApplyRuntimeObservation(ctx, sess.ID, facts); err != nil {
|
|
r.logger.Error("reaper: ApplyRuntimeObservation failed",
|
|
"session", sess.ID, "err", err)
|
|
}
|
|
}
|
|
|
|
// handleFromRecord reconstructs the RuntimeHandle stored on the session by
|
|
// MarkSpawned. An empty handle id means the session cannot be probed.
|
|
func handleFromRecord(rec domain.SessionRecord) (ports.RuntimeHandle, bool) {
|
|
id := rec.Metadata.RuntimeHandleID
|
|
if id == "" {
|
|
return ports.RuntimeHandle{}, false
|
|
}
|
|
return ports.RuntimeHandle{ID: id}, true
|
|
}
|