Session Management
SessionKey taxonomy, SessionService actor pipeline, SessionEvent log, presence opt-in, per-session run registry, and template-env deterministic ordering.
Session Management
Sessions are the stateful backbone of every Aleph run. Each session has a structured SessionKey, an append-only SessionEvent log persisted through SessionService, and a per-session run mutex enforced by SessionRunRegistry. This page covers the key taxonomy, the event log, the actor pipeline, presence opt-in, and the cross-cutting lifecycle concerns.
For how sessions are created during request routing, see Gateway Architecture. For how sessions feed into the harness, see Harness.
SessionKey Taxonomy
Every session is identified by a SessionKey (src/routing/session_key.rs). Six variants:
pub enum SessionKey {
Main {
agent_id: String,
#[serde(default = "default_main_key")]
main_key: String,
#[serde(default)]
epoch: u32,
},
DirectMessage {
agent_id: String,
channel: String,
peer_id: String,
#[serde(default)]
dm_scope: DmScope,
#[serde(default)]
epoch: u32,
},
Group {
agent_id: String,
channel: String,
peer_kind: PeerKind,
peer_id: String,
thread_id: Option<String>,
},
Task {
agent_id: String,
task_type: String,
task_id: String,
},
Subagent {
parent_key: Box<Self>,
subagent_id: String,
},
Ephemeral {
agent_id: String,
ephemeral_id: String,
},
}Serialized Forms
| Variant | Format | Example |
|---|---|---|
Main | agent:{agent_id}:{main_key}[:s{epoch}] | agent:main:main |
DirectMessage (PerPeer) | agent:{agent_id}:dm:{peer_id}[:s{epoch}] | agent:main:dm:user123 |
DirectMessage (PerChannelPeer) | agent:{agent_id}:{channel}:dm:{peer_id}[:s{epoch}] | agent:main:telegram:dm:user123 |
Group | agent:{agent_id}:{channel}:{peer_kind}:{peer_id}[:thread:{thread_id}] | agent:main:discord:group:guild-abc |
Task | agent:{agent_id}:{task_type}:{task_id} | agent:main:cron:daily-summary |
Subagent | {parent_key.to_key_string()}:subagent:{subagent_id} | subagent:agent:main:translator |
Ephemeral | agent:{agent_id}:ephemeral:{ephemeral_id} | agent:main:ephemeral:a1b2c3 |
epoch suffixes are appended only when epoch > 0, so a session can be renamed without losing its prior history (:sN after the standard segments).
DM Scope Strategies
[session] dm_scope controls how direct messages collapse:
| Value | Behavior |
|---|---|
per-peer (default) | Each sender gets an independent session (cross-channel by peer) |
per-channel-peer | Each channel × sender gets an independent session (recommended for multi-user) |
main | All DMs collapse into this agent's Main session |
A single-owner bot can set dm_scope = "main" so all DMs across Telegram / Slack / Panel share the same agent:<id>:main context. Multiple users in the allowlist must use per-channel-peer because main would collapse everyone into the same session.
SessionService
SessionService (src/session/service.rs) is the public facade over the event log:
#[async_trait]
pub trait SessionService: Send + Sync + 'static {
async fn attach(&self, id: SessionId) -> Result<SessionHandle, SessionError>;
async fn get_events(
&self,
id: &SessionId,
from: Option<EventSeq>,
to: Option<EventSeq>,
) -> Result<Vec<SessionEventRecord>, SessionError>;
async fn emit_event(
&self,
id: &SessionId,
event: SessionEvent,
) -> Result<EventSeq, SessionError>;
async fn subscribe(
&self,
id: &SessionId,
) -> Result<broadcast::Receiver<SessionEventRecord>, SessionError>;
async fn wake(&self, id: &SessionId) -> Result<SessionHandle, SessionError>;
async fn detach(&self, id: &SessionId) -> Result<(), SessionError>;
}The default implementation is InProcessActorSessionService (src/session/in_process.rs), which spawns one tokio task per session. Each actor (src/session/actor.rs) replays events from SQLite on start, then serves commands until its inbox closes or the idle timeout fires (default 30 min). wake() shuts down the old actor (5s grace), spawns a fresh actor that replays from SQLite, and writes a SessionWoken { prior_head } event — the canonical crash-recovery path.
SessionId = crate::routing::session_key::SessionKey — sessions are identified by the same key used everywhere else in the gateway.
A process-wide fallback (set_global_session_service / global_session_service) lets edge-path callers without a local reference still emit through the actor pipeline.
Storage
CREATE TABLE session_events (
session_id TEXT NOT NULL,
seq INTEGER NOT NULL,
turn_id TEXT,
event_type TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, seq)
);Writes are synchronous; SQLite runs in WAL mode; the (session_id, seq) primary key enforces monotonic ordering per session.
SessionEvent Variants
src/session/events.rs::SessionEvent is a #[non_exhaustive] enum covering the full session lifecycle:
| Variant | Purpose |
|---|---|
SessionCreated { identity, at } | First event on the log; freezes SessionIdentityMeta |
SessionWoken { at, prior_head } | Crash-recovery checkpoint after wake() |
SessionDetached { at } | Actor was stopped; events remain on disk |
RunStarted { run_id, at, project_root } | A harness run began; project_root is stamped so a crash-resume lands back in the same folder |
RunFinished { run_id, outcome, at } | Harness run reached a terminal state |
TurnStarted { turn_id, trigger, at } | A Think→Act turn began; trigger distinguishes user / synthetic / verifier-veto / grace |
TurnEnded { turn_id, outcome, at } | Turn reached its terminal outcome |
UserMessage { turn_id, content, at, synthetic } | User input (or verifier-veto nudge / grace hint when synthetic = true) |
AssistantMessage { turn_id, content, usage, at } | Per-call LLM output; usage is the per-call token breakdown (calls and assistant rows are 1:1) |
AssistantRunMeta { turn_id, run_id, context_tokens, context_window, total_tokens, input_tokens, output_tokens, … } | One authoritative billing report per run; the session-level counters ride here and nowhere else |
SystemMessage { turn_id, content, at } | Internal system signal (e.g. [Context Summary] headers used by split sub-sessions to rebuild) |
ToolCallRequested { call_id, tool_name, args, at } | A tool call entered Act |
ToolCallApproved { call_id, source, approver, at } / ToolCallDenied { call_id, reason, at } | Approval-card verdict |
ToolResult { call_id, output, at } / ToolError { call_id, error, at } | Act outcome (paired by call_id) |
SubagentSpawned { subagent_id, parent_turn_id, at } / SubagentReturned { subagent_id, result, at } | Subagent lifecycle |
BudgetUpdated { kind, value, at } | Context-budget directive escalation |
CompactionPerformed { kind, before_tokens, after_tokens, at } | Compaction pipeline output |
SessionForked { child_session_id, parent_turn_id, at } | A turn forked into a child session (e.g. SplitSession directive) |
Error { kind, message, at } | Domain error event |
The read-side helper src/session/projection.rs::project_messages turns an event range into Vec<ProjectedMessage> for consumers that want a classic message-history view rather than raw events.
True Multi-Session Parallelism
SessionRunRegistry (in the gateway execution engine) is a per-session mutex: one agent can run in several sessions at once, but any single session stays mutually exclusive. The earlier per-agent lock starved multi-session use and was replaced under 26.7.7+.
Presence Opt-In
src/tasks/presence/ carries a Slack-style host presence snapshot onto the Gateway event bus:
pub struct PresenceSnapshot {
pub hostname: String,
pub username: String,
pub platform: PlatformTag,
pub idle_seconds: Option<u64>,
pub idle_state: IdleState,
pub captured_at: DateTime<Utc>,
}PresenceReporter::spawn ticks at PresenceConfig::interval_secs (default 30s), collects (hostname, username, platform, idle_seconds) via the SystemCapability trait, and publishes TopicEvent keyed by host.presence.update. Default disabled — PresenceConfig::enabled = false out of the box to avoid leaking PII over the event bus. Operators opt in per-host.
Brain lives in src/tasks/presence/ (policy, scheduling, publish format); limb stays in the desktop/ crates that implement SystemCapability (mirrors the cron / mic-level brain–limb split).
Hashed Carryover Filename
Cron job carryover files (src/tasks/cron/carryover.rs) are written under a name that survives collisions without hiding them:
fn carryover_filename(job_id: &str) -> String {
let safe = sanitise_job_id(job_id);
let hash = short_hash(job_id.as_bytes()); // 32-bit FNV-1a, 8 hex chars
format!("{safe}-{hash}.json")
}The sanitized prefix stays grep-friendly; the FNV-1a suffix makes collisions distinguishable. Two jobs with the same human-readable id end up with different files instead of silently overwriting each other.
Deterministic Template-Env Ordering
NodeRegistry::list_environments() (cluster-side template-env projection) sorts results by (name, id):
envs.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id)));The cluster.environments_list handler also orders online nodes before offline before alphabetical. The earlier unsorted HashMap iteration leaked registry order into the Panel / node_list view; the deterministic ordering is pinned by list_environments_is_sorted_by_name_then_id and environments_list_orders_online_before_offline_and_by_name.
SessionEvent System (high-level)
The harness emits one SessionEvent per Think step plus a single AssistantRunMeta per run, so calls and assistant rows are 1:1 and token attribution is exact. The MessageProjector materializes events into the legacy messages table on every write; a boot-time ProjectionReconciler back-fills any drift between the event log and the projection. tool_call_id and tool_name are stamped onto each row, and AssistantRunMeta carries run_id so the projector can attach run-level metadata without coupling the hot path to storage.
Session RPC Methods
| Method | Description |
|---|---|
session.get | Get session info |
session.list | List sessions (filter by activity, agent, …) |
session.history | Get message history (legacy projection) |
session.compact | Trigger compression |
session.delete | Delete a session |
Live-Hot-Reloadable Execution Caps
The [execution] run-cap section is Live: an arc-swap global semaphore backs max_runs_global / max_runs_per_agent, so an operator can change the cap without restarting the daemon.
Related Pages
- Architecture Overview — Session positioning in the runtime topology
- Gateway Architecture — How session keys are resolved from incoming requests
- Harness — How the loop reads / writes events
- Memory — Where extracted facts land after compaction
- Session Service — Full actor pipeline +
wake()semantics