OTP Supervision
The agent daemon uses a two-level supervisor tree for coordinated startup, shutdown, and health monitoring. Every component that needs long-lived process management declares itself via a supervised flag on its shape record.
Supervisor Tree
agent_supervisor.gleam (root)
|
|-- 1. Starts Logger (registered as agent_logger)
|
|-- 2. plugins/gateways/supervisor.gleam
| |-- Starts gateways with supervised: True
| | (e.g., Telegram poller)
|
|-- 3. services/supervisor/supervisor.gleam
|-- Starts services with supervised: True
|-- Cron (conditional on config)
|-- Pulse (conditional on config)
|-- Also initializes: Harness, Notifications (utility, not supervised)Start Order
Logger starts first and registers as
agent_logger, a named Erlang process accessible globally. All subsequent log calls throughout the system route through this process.Gateway supervisor starts next, before the service supervisor. This is deliberate: services like Pulse (scheduled prompts) and Cron (scheduled jobs) may produce messages at startup, and those messages need to be deliverable via gateways like Telegram. Starting gateways first ensures the delivery channel is ready.
Service supervisor starts last, bringing up supervised services (cron, pulse) and initializing utility services (harness, notifications).
The Service Shape
Every built-in service conforms to the Service type, defined in core/service.gleam:
pub type Service {
Service(
name: String,
supervised: Bool,
start: fn() -> Result(Nil, String),
stop: fn() -> Nil,
health: fn() -> Bool,
)
}| Field | Purpose |
|---|---|
name | Human-readable identifier for logging and diagnostics |
supervised | If True, the supervisor starts and monitors this service as a long-running child |
start | Boot function -- returns Ok(Nil) on success or Error(String) with a diagnostic message |
stop | Tear-down function -- called during graceful shutdown |
health | Lightweight liveness check -- returns True if the service is operational |
A service with supervised: False is a pure module. Its start is never called by the supervisor -- it exists only to satisfy the shape contract.
The Plugin Shape
Every plugin (built-in or user-provided) conforms to the Plugin type, defined in plugins/types.gleam:
pub type Plugin {
Plugin(
name: String,
description: String,
plugin_type: PluginType,
supervised: Bool,
start: fn() -> Result(Nil, String),
stop: fn() -> Nil,
health: fn() -> Bool,
)
}
pub type PluginType {
Tool
Gateway
Hook
MemoryProvider
}The Plugin shape extends Service with two additional fields:
| Extra field | Purpose |
|---|---|
description | User-facing summary shown in help text and plugin listings |
plugin_type | Discriminates between Tool, Gateway, Hook, and MemoryProvider |
Like services, plugins self-declare whether they need supervision via the supervised flag. The gateway supervisor iterates over all registered plugins and starts only those with supervised: True.
The supervised Flag
The supervised boolean on both Service and Plugin is a self-declaration. Each component decides whether it needs OTP process management:
supervised: True-- The component spawns long-running work (a polling loop, a timer, a TCP listener). The supervisor starts it, monitors its health, and can restart it on failure.supervised: False-- The component is a pure module. It exposes functions that are called on-demand, with no persistent process state.
Examples of supervised components:
- Cron -- runs a polling loop that checks for due cron jobs on a timer
- Pulse -- runs a timer loop that fires scheduled prompts
- Telegram gateway -- runs a long-polling fetch loop for incoming messages
Examples of non-supervised components:
- Logger -- registers a named process at startup but doesn't run a loop (started separately, outside the supervisor's child management)
- Harness -- pure validation logic called on-demand
- Notifications -- DND rule coordination, no persistent loop
- Tokens -- stateless token counting functions
- Context -- stateless system prompt assembly
SQLite Process Isolation
SQLite NIFs in the BEAM are process-specific. A connection opened in process A cannot be used from process B. This has important implications for the supervisor tree:
- The main process (the CLI loop or gateway handler) opens a primary SQLite connection via
DbConnector.open(). - Spawned service processes (cron, pulse, titler) must open their own independent connections. They cannot share the main process's connection.
- The reflection hook spawns a background process for memory consolidation -- it opens its own connection via
DbConnector.open()so it does not share state with the conversation loop.
The DbConnector.open(db_path) function is part of the DbConnector behaviour record, passed to every component that needs database access. Each component calls open in its own process context, producing a process-local connection.