agent-orchestrator/docs/architecture.md

188 lines
10 KiB
Markdown

# 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_failing``changes_requested`
`mergeable``approved``review_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 serialization** — `keyedMutex` 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.
- **Spawn** — `Workspace.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).
- **Kill** — `LCM.OnKillRequested``Runtime.Destroy``Workspace.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.