agent-orchestrator/docs/architecture.md

10 KiB

LCM + Session Manager — architecture

This is the deterministic core of the backend daemon. It supervises agent
sessions and keeps exactly one true status per session.

1. Mental model: OBSERVE → DECIDE → ACT

The backend owns no agent state. git/GitHub own PR/CI/review truth; the agent's
own files own its activity. The job, per session, is one loop:

OBSERVE            →   DECIDE              →   ACT
(impure, external)     (pure, total)           (impure)
raw facts              one canonical status    persist + react

In the rewrite the OBSERVE step lives outside the LCM (separate owners),
and the LCM is a synchronous reducer invoked with facts:

SCM poller     ─ ApplySCMObservation ──┐
reaper         ─ ApplyRuntimeObservation┤
activity hooks ─ ApplyActivitySignal ───┼─▶ LCM:  load canonical
Session Mgr    ─ OnSpawnCompleted ──────┘         → pure DECIDE
               ─ OnKillRequested                  → diff → persist (merge-patch)
reaper tick    ─ TickEscalations                  → if transition: react (ACT)

The LCM never polls. The reaper (a timer, owned elsewhere) drives liveness
sampling and duration-based escalation by calling in.

2. Canonical state model — the crown jewel

The only thing persisted per session is CanonicalSessionLifecycle
(backend/internal/domain/lifecycle.go). The single-word display status is
derived on read and never stored — this is the most important invariant; it
prevents canonical truth and display from drifting.

CanonicalSessionLifecycle
  Version    schema version of the record shape
  Revision   monotonic write counter (optimistic-concurrency token)
  Session    (state, reason)   working/idle/needs_input/stuck/detecting/done/terminated
  PR         (state, reason)   none/open/merged/closed
  Runtime    (state, reason)   unknown/alive/exited/missing/probe_failed
  Activity   last-known agent activity (+ timestamp, source)   ← decider input
  Detecting  anti-flap quarantine memory (nil unless quarantined) ← decider input

DeriveLegacyStatus (domain/status.go) is the sole producer of the
display SessionStatus. Precedence: terminal/hard session states map directly
(they outrank PR facts) → a merged PR wins → an open PR maps by reason → else the
soft session state. So an idle worker with a CI-failing open PR displays
ci_failed, but a needs_input session shows needs_input regardless of the PR.

Session (domain/session.go) is the read-model: a SessionRecord
(persistence shape, identity + lifecycle + metadata) plus the derived Status.
The Session Manager is the single producer of Status — it attaches it on
read; the store and API never recompute or persist it.

3. Package layout (backend/internal/)

domain/                 the vocabulary (imports only the std lib → no cycles)
  lifecycle.go          CanonicalSessionLifecycle + all sub-states/enums
  status.go             SessionStatus + DeriveLegacyStatus (sole display producer)
  session.go            SessionRecord (persisted) + Session (read-model) + id types
  decide/               the PURE core — total, deterministic, zero I/O
    types.go            LifecycleDecision + Probe/OpenPR/Detecting inputs + tuning consts
    decide.go           the deciders + the anti-flap quarantine + HashEvidence
ports/                  the boundaries (interfaces + DTOs)
  inbound.go            LifecycleManager, SessionManager (we implement)
  outbound.go           LifecycleStore, Notifier, AgentMessenger, Runtime/Agent/Workspace
  facts.go              SCMFacts, RuntimeFacts, ActivitySignal, SpawnOutcome, KillReason
lifecycle/              the LCM implementation (DECIDE + ACT)
  manager.go            the Apply* pipeline, per-session lock, patch diffing
  decide_bridge.go      fact→decide-input translation + the composition rules
  reactions.go          the reaction table + escalation engine + TickEscalations
session/                the SM implementation (explicit mutations)
  manager.go            Spawn/Kill/Restore/Cleanup/List/Get/Send + rollback

domain + ports are the committed, stabilized integration boundary.
Everything else implements behind it.

4. The pure DECIDE core (domain/decide)

Total, deterministic, side-effect-free functions — the highest-value test
surface (table-tested to 100%). Key ones:

  • ResolveProbeDecision — runtime/process liveness. An explicit kill
    short-circuits to terminal; a failed probe is never read as death (routes
    to detecting), as does any probe disagreement; only runtime-dead +
    process-dead + no-recent-activity reaches killed.
  • ResolveOpenPRDecision — the PR ladder: ci_failingchanges_requested
    mergeableapprovedreview_pending → idle-beyond → else pr_open.
  • ResolveTerminalPRStateDecision — merged → merged (park idle awaiting a
    human decision); closed → idle.
  • CreateDetectingDecision — the anti-flap quarantine. Counts attempts and
    hashes the timestamp-stripped evidence; escalates to stuck only after 3
    consecutive unchanged-evidence ticks or 5 minutes since first entering
    detecting (StartedAt is preserved across the whole episode). Changing
    evidence resets the counter.

5. The LCM (lifecycle)

Implements ports.LifecycleManager. Every Apply*/On* entrypoint runs the
same pipeline (manager.go):

withLock(session):                       ← per-session serialization
  load canonical → decideFn (build sparse patch) → if changed: persist → load after
return transition (before, after)

then, after the lock releases, react() fires the mapped reaction.

  • Per-session serializationkeyedMutex hands out one lock per session id
    (parallel across sessions, serial within one). Entries are reference-counted
    and evicted when the last holder releases, so the map stays bounded.
  • Composition rules (decide_bridge.go) — two observers must not fight over
    the session axis. Liveness (runtime probes) owns the runtime + death/detecting
    axis; activity owns working/idle/waiting. isLivenessOwned decides when a
    healthy probe may recover a state (e.g. detecting → working) vs. when it
    must not clobber an activity-owned needs_input/blocked. A high-confidence
    activity signal may resolve a detecting session; an open PR writes only the
    PR axis and lets DeriveLegacyStatus surface it.
  • Detecting-memory lifecycle — a decision with Detecting == nil clears the
    persisted quarantine memory (LifecyclePatch.ClearDetecting) so a stale prior
    can't leak into a later episode.
  • ACT — reactions + escalation (reactions.go) — on a genuine status
    transition, react() maps it to a reaction (send-to-agent / notify;
    auto-merge exists but is off by default) and dispatches it. A
    per-(session,reaction) escalation tracker counts attempts; it escalates
    (notifies a human and silences further auto-dispatch) when a numeric cap or a
    duration is exceeded. The ci-failed budget is persistent across CI
    oscillation within an open PR and re-arms on genuine recovery. TickEscalations
    (called by the reaper) fires the duration-based escalations the synchronous
    LCM can't wake itself for; it notifies outside the lock.

6. The Session Manager (session)

Implements ports.SessionManager — the explicit-mutation plumbing. It never
derives/observes lifecycle state; it routes outcomes to the LCM.

  • SpawnWorkspace.Create → build prompt → Runtime.Create (env
    AO_SESSION_ID/AO_PROJECT_ID/AO_ISSUE_ID) → seed the initial record
    (not_started/spawn_requested) via the store → LCM.OnSpawnCompleted.
    Eager rollback unwinds prior steps on failure; an OnSpawnCompleted failure
    routes the seeded orphan to terminal-errored (the store has no delete; a later
    Cleanup reclaims it).
  • KillLCM.OnKillRequestedRuntime.DestroyWorkspace.Destroy,
    honoring the worktree-remove safety: after git worktree prune, a still-
    registered path is never rm -rf'd (it may hold the agent's uncommitted work)
    — the refusal is surfaced, not forced.
  • Restore — reopen via PatchLifecycle (not re-seed): session →
    not_started, PR → cleared_on_restore; relaunch with the agent's resume
    command; runtime is rolled back on a post-create failure.
  • List/Get — read records and attach the derived Status. Send — via
    AgentMessenger. Cleanup — tear down terminal/stale sessions, skipping
    paths with uncommitted work.

7. Load-bearing invariants

  1. Persist canonical; derive display. Never store the display status.
  2. One authority for death. Only the DECIDE pipeline (via detecting) writes
    inferred terminal states; the SM's explicit-kill path goes through
    OnKillRequested. Everything else that notices a dead runtime persists
    detecting, never terminated.
  3. Failed probe ≠ dead. Timed-out/errored probes route to detecting.
  4. Evidence-hash debounce prevents flapping signals from terminating live
    work; the 5-minute cap is a whole-episode wall-clock safety net.
  5. PR facts dominate the soft session states once a PR exists.
  6. Merge-patch persistence — writes touch only changed keys; the store is the
    single disk writer (atomic write + lock + CDC).
  7. Sticky activity states (waiting_input/blocked) do not decay by clock.
  8. Worktree-remove safety on teardown.

8. Concurrency & testing

  • Within a session, the per-session lock serializes the load→decide→persist
    read-modify-write. react() runs outside the lock (so a busy-waiting
    send-to-agent never holds the session mutex) — see status.md for the
    integration-time follow-up this implies.
  • Tests use in-memory fakes for every outbound port, so the LCM and SM are
    fully testable with no real adapters. The SM tests drive the real
    lifecycle.Manager for spawn/kill round-trips, so the SM↔LCM contract is
    genuinely exercised. The decide package is table-tested in isolation.