Aleph
Architecture

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

VariantFormatExample
Mainagent:{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
Groupagent:{agent_id}:{channel}:{peer_kind}:{peer_id}[:thread:{thread_id}]agent:main:discord:group:guild-abc
Taskagent:{agent_id}:{task_type}:{task_id}agent:main:cron:daily-summary
Subagent{parent_key.to_key_string()}:subagent:{subagent_id}subagent:agent:main:translator
Ephemeralagent:{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:

ValueBehavior
per-peer (default)Each sender gets an independent session (cross-channel by peer)
per-channel-peerEach channel × sender gets an independent session (recommended for multi-user)
mainAll 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:

VariantPurpose
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 disabledPresenceConfig::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

MethodDescription
session.getGet session info
session.listList sessions (filter by activity, agent, …)
session.historyGet message history (legacy projection)
session.compactTrigger compression
session.deleteDelete 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.


On this page