Files
hermes-agent/website/docs/developer-guide/gateway-session-lifecycle.md
teknium1 1136f135dd fix(gateway): crash recovery never redelivers a suppressed reply; tz-safe turn marker
Review follow-ups on the crash-left reply adoption:

- A persisted reply is now judged the way live delivery would have judged
  it. A bare silence marker ([SILENT] / SILENT / NO_REPLY ...) on an
  internal turn, or the reply to a diagnostic wake whose chat policy mutes
  diagnostics (read in the routed profile's scope, as the adapter does), is
  owed nothing: the marker is cleared and nothing is sent or resumed. A
  human turn's bare silence marker becomes the same "returned only a silence
  marker" notice the live path sends. Before, the raw "NO_REPLY" reached the
  user as a "Recovered reply".
- The active-turn marker start is written as aware UTC and compared as
  epoch seconds (startup adoption and recover_interrupted_turns). A naive
  local wall clock read by a process in another zone (DST, container vs
  unit TZ) was hours off: a fresh in-flight turn was dropped as stale, or a
  previous turn's reply could be adopted as this one's. updated_at stays
  naive local for the older binary's recency heuristic; a pre-upgrade naive
  marker still reads as local time.
- Reply timestamps go through coerce_epoch instead of float(), so one odd
  transcript row cannot abort the whole recovery pass.
2026-09-23 14:55:08 -07:00

34 KiB

title, description
title description
Gateway Session Lifecycle SessionSource, SessionEntry, SessionStore, session-key rules and multi-user isolation in the gateway

Session Lifecycle

Audience: Gateway developers and maintainers Source files: gateway/session.py (~1200 lines + session_*.py siblings), gateway/run.py (~5500 lines facade + run_*.py phases), gateway/config.py Last updated: 2026-06-16

Overview

A session represents a continuous conversation between the agent and one or more users on a messaging platform. The session lifecycle governs when conversations persist, when they reset, how they survive gateway restarts, and how messages queue during concurrent operations.

The session system lives primarily in two modules:

  • gateway/session.py — Data model (SessionSource, SessionEntry, SessionContext), key generation (build_session_key), and the main store (SessionStore).
  • gateway/run.py — Gateway runner (GatewayRunner) facade that wires sessions into the message processing pipeline; the phases live in run_*.py siblings: session housekeeping (run_watchers.py), agent caching (run_agent_cache.py), restart recovery (session_recovery.py), and message queuing (run_busy.py).

1. SessionSource — Message Origin Descriptor

SessionSource is a frozen record of where a message came from. It is attached to every incoming MessageEvent and used for routing, isolation, and context injection.

Fields

Field Type Default Description
platform Platform (required) Enum identifying the messaging platform (telegram, discord, slack, signal, whatsapp, matrix, local, etc.).
chat_id str (required) Platform-level chat/group/channel identifier. Routed through the adapter's chat_id_key transform.
chat_name Optional[str] None Human-readable name of the chat or group.
chat_type str "dm" One of "dm", "group", "channel", "thread". Controls session key generation and isolation.
user_id Optional[str] None Platform-specific user identifier. Used for authorization and per-user session isolation.
user_name Optional[str] None Display name of the message author. Injected into system prompt.
thread_id Optional[str] None Forum topic / Discord thread / Slack thread identifier. Differentiates threaded conversations.
chat_topic Optional[str] None Channel topic or description (Discord channel topic, Slack channel purpose).
user_id_alt Optional[str] None Platform-specific stable alternative ID (Signal UUID, Feishu union_id). Used when user_id is ephemeral.
chat_id_alt Optional[str] None Signal group internal ID — maps a Signal group V2 identifier to its canonical form.
is_bot bool False True when the message author is a bot or webhook (Discord bots).
guild_id Optional[str] None Discord guild / Slack workspace / Matrix server scope identifier.
parent_chat_id Optional[str] None Parent channel when chat_id refers to a thread.
message_id Optional[str] None ID of the triggering message. Used for pin/reply/react operations and Discord ID injection (the injected [Triggering message id: …] note rides the API-bound message only; the persisted user row keeps the authored text).
role_authorized bool False True when adapter granted access via a platform role (not individual user ID).

Key Methods

  • description (property: str) — Human-readable summary e.g. "DM with Alice", "group: My Group, thread: 12345".
  • to_dict() / from_dict() — Serialization round-trip for persistence in sessions.json.

2. SessionEntry — Active Session Record

SessionEntry is the per-session metadata record stored in memory and persisted to {sessions_dir}/sessions.json. Each entry maps a session_key to its current session_id.

Fields

Field Type Default Description
session_key str (required) Deterministic key identifying the conversation lane (see §4).
session_id str (required) Unique identifier for this specific conversation incarnation. Format: YYYYMMDD_HHMMSS_<8hex>.
created_at datetime (required) When this session incarnation was created.
updated_at datetime (required) Last activity timestamp used for resource housekeeping.
origin Optional[SessionSource] None The source that created this session, used for delivery routing.
display_name Optional[str] None Chat display name (sourced from SessionSource.chat_name).
platform Optional[Platform] None Platform enum persisted for routing across restarts.
chat_type str "dm" Chat type, also persisted for policy lookup.
input_tokens int 0 Cumulative LLM input (prompt) tokens consumed.
output_tokens int 0 Cumulative LLM output (completion) tokens consumed.
cache_read_tokens int 0 Cumulative prompt cache read tokens.
cache_write_tokens int 0 Cumulative prompt cache write tokens.
total_tokens int 0 Total token count across all turns.
estimated_cost_usd float 0.0 Estimated cumulative USD cost.
cost_status str "unknown" Cost tracking status label.
last_prompt_tokens int 0 Last API-reported prompt token count. Used for accurate compression pre-check.

Boolean Flags (State Machine)

SessionEntry has several boolean flags that form a simple state machine governing session behavior on the next access.

Flag Type Default Description
was_auto_reset bool False Set when explicit suspension causes a replacement session. Also retained for historical records.
auto_reset_reason Optional[str] None "suspended" for explicit suspension; older rows may retain historical reset reasons.
reset_had_activity bool False Whether the replaced session had prior activity.
is_fresh_reset bool False Set by explicit /new or /reset. Triggers topic/channel skill re-injection on first message. Distinguished from was_auto_reset to avoid misleading "session expired" notices.
expiry_finalized bool False Historical finalization fence retained for recovery; no timer writes it.
suspended bool False Hard force-wipe signal. Set by /stop or stuck-loop escalation (3+ consecutive restart failures). On next get_or_create_session(), forces a new session_id regardless of resume_pending.
resume_pending bool False Soft recovery marker. Set by recover_interrupted_turns() (crash recovery of a marked, unreplied turn) or drain timeout. On next access, preserves the existing session_id — the user continues on the same transcript. Cleared after the next successful turn completes.
resume_reason Optional[str] None Why resume was marked: "restart_timeout", "shutdown_timeout", "restart_interrupted".
last_resume_marked_at Optional[datetime] None Timestamp of the last resume-pending marking.

State Transition Logic (get_or_create_session)

                    ┌──────────┐
                    │  Incoming │
                    │  Message  │
                    └────┬─────┘
                         │
                         ▼
              ┌──────────────────────┐
              │  session_key exists  │──── No ──► Create fresh SessionEntry
              │  AND !force_new      │
              └──────────┬───────────┘
                         │ Yes
                         ▼
              ┌──────────────────────┐
              │  entry.suspended?    │──── Yes ──► Auto-reset: new session_id
              └──────────┬───────────┘           (reason="suspended")
                         │ No
                         ▼
              ┌──────────────────────┐
              │ entry.resume_pending?│──── Yes ──► Return existing entry
              └──────────┬───────────┘           (preserve session_id)
                         │ No                     Clear flag on next successful turn
                         ▼
              ┌──────────────────────┐
              │   Policy says reset? │──── Yes ──► Auto-reset: new session_id
              └──────────┬───────────┘           (reason="idle"/"daily")
                         │ No
                         ▼
              ┌──────────────────────┐
              │  Return existing     │
              │  entry, bump         │
              │  updated_at          │
              └──────────────────────┘

Priority order in get_or_create_session():

  1. suspended=True → always force-reset (hard wipe)
  2. resume_pending=True → preserve session_id (soft recovery)
  3. No trigger → return existing entry (bump updated_at)

3. SessionStore — Storage and Operations

SessionStore is the main storage layer. It maintains an in-memory dict (_entries) persisted to sessions.json, with SQLite (SessionDB) as the canonical store for session metadata and message transcripts.

Constructor

SessionStore(sessions_dir: Path, config: GatewayConfig, has_active_processes_fn=None)
  • sessions_dir — Directory where sessions.json lives.
  • config — GatewayConfig instance for routing and housekeeping settings.
  • has_active_processes_fn — Optional callback keyed by session_key to check for running background processes. Sessions with active processes are protected from routing-entry pruning.

Operations (Methods)

Method Description
get_or_create_session(source, force_new=False) Core entry point. Returns existing or creates new SessionEntry. Evaluates explicit suspension and restart recovery state. Creates/ends SQLite records.
update_session(session_key, last_prompt_tokens=None) Lightweight metadata update after an interaction. Bumps updated_at, optionally records last_prompt_tokens.
reset_session(session_key, display_name=None) Explicit reset (from /new or /reset). Creates new session_id, sets is_fresh_reset=True. Ends old SQLite session, creates new one.
switch_session(session_key, target_session_id, *, expected_session_id=None) Switch to a different existing session ID (from /resume). Ends current SQLite session, reopens target. With expected_session_id= the repoint is a compare-and-swap: returns None without switching when the key no longer points at that session, so a caller that resolved against a snapshot across an await (async-delegation re-pin, Telegram topic-binding heal) cannot overwrite a concurrent /new or /resume.
suspend_session(session_key) Mark session as suspended=True (from /stop). Forces auto-reset on next access.
mark_resume_pending(session_key, reason) Mark session as resume_pending=True (from drain timeout). Preserves session_id on next access. Will NOT override suspended=True.
clear_resume_pending(session_key) Clear resume_pending after a successful resumed turn. Called from gateway after run_conversation() returns.
recover_interrupted_turns(max_age_seconds) Crash recovery: promote durable active-turn markers the dead process left behind to resume_pending=True (restart_interrupted). Sessions without a marker finished their turn and are left alone. Called on startup after unclean shutdown.
prune_old_entries(max_age_days) Drop entries older than max_age_days (based on updated_at). Skips suspended entries and sessions with active processes.
list_sessions(active_minutes=None) Return all sessions, optionally filtered by recent activity. Sorted by updated_at descending.
lookup_by_session_id(session_id) Find the active SessionEntry for a persisted session ID.
has_any_sessions() Check if any sessions have ever been created (uses SQLite for history, not just in-memory dict).
append_to_transcript(session_id, message, skip_db=False) Append a message to SQLite transcript. skip_db=True prevents duplicate writes when the agent already persisted.
rewrite_transcript(session_id, messages) Full replacement of session transcript (used by /retry, /undo, /compress).
load_transcript(session_id) Load all messages from a session's SQLite transcript.
rewind_session(session_id, n=1) Back up n user turns via soft-delete (keeps audit trail); thin wrapper over SessionDB.rewind_user_turn (hermes_state_rewind.py), the one rewind shared with CLI /undo//retry and the TUI. Returns {rewound_count, turns_undone, target_text}.

Internal Helpers

  • _ensure_loaded() / _ensure_loaded_locked() — Load sessions.json into _entries dict.
  • _save() — Atomic write to sessions.json via temp file + atomic_replace.
  • _generate_session_key(source) — Delegates to build_session_key() with config params.

Storage Layout

{sessions_dir}/
  sessions.json          # In-memory _entries dict, persisted as JSON
                           Maps session_key → SessionEntry (metadata only)
  {session_id}.jsonl     # (Legacy, removed in spec 002)

The canonical transcript store is SQLite via SessionDB (from hermes_state). The sessions.json file persists the session_key → session_id mapping and entry metadata (flags, timestamps, token counts). If SQLite is unavailable, the store falls back to JSONL, but this is a degradation path.


4. SessionKey Generation Rules

Session keys are deterministic strings that identify a conversation lane. They are generated by build_session_key(source, group_sessions_per_user, thread_sessions_per_user).

Key Format

agent:main:{platform}:{chat_type}[:{chat_id}][:{thread_id}][:{participant_id}]

DM Rules

Scenario Key
DM with chat_id agent:main:telegram:dm:12345
DM with chat_id + thread agent:main:telegram:dm:12345:thread_678
DM without chat_id, with participant_id agent:main:signal:dm:user_abc
DM without chat_id or participant_id agent:main:telegram:dm
WhatsApp DM (canonicalized) agent:main:whatsapp:dm:{canonical_number}
  • DMs always include chat_id when present, isolating each private conversation.
  • thread_id further differentiates threaded DMs within the same DM chat.
  • Without chat_id, falls back to user_id_alt or user_id as participant_id.
  • Without any identifier, all DMs on that platform collapse to one shared session.

Group/Channel Rules

Scenario Key
Group chat agent:main:telegram:group:-10012345
Group chat, per-user isolation agent:main:telegram:group:-10012345:user_abc
Thread in group, shared agent:main:discord:group:12345:thread_678
Thread in group, per-user agent:main:discord:group:12345:thread_678:user_abc
Channel agent:main:slack:channel:C12345
WhatsApp group (canonicalized) agent:main:whatsapp:group:{canonical_id}:{participant}
  • chat_id identifies the parent group/channel.
  • thread_id differentiates threads within that parent.
  • Per-user isolation (append participant_id) is controlled by:
    • group_sessions_per_user (default: True) — group/channel sessions are isolated.
    • thread_sessions_per_user (default: False) — threads are shared by default (Telegram forum topics, Discord threads, Slack threads all share one session per thread).
  • participant_id = user_id_alt or user_id (in that priority).
  • WhatsApp identifiers are canonicalized to handle JID/LID alias flips.

Special Case: WhatApp

WhatsApp phone numbers go through canonical_whatsapp_identifier() which strips the @s.whatsapp.net suffix and normalizes to E.164 format. This prevents session fragmentation when the bridge returns different alias forms of the same phone number.


5. Multi-User Isolation Strategy

Multi-user isolation determines whether multiple users in the same chat share a conversation or each get their own private session.

Decision Logic (is_shared_multi_user_session)

def is_shared_multi_user_session(source, *, group_sessions_per_user, thread_sessions_per_user):
    if source.chat_type == "dm":
        return False  # DMs are always private
    if source.thread_id:
        return not thread_sessions_per_user  # Threads: shared unless per-user
    return not group_sessions_per_user       # Groups: isolated unless shared

Summary

Chat Type Default Config Control
DM Private (never shared) N/A
Group/Channel Per-user isolation group_sessions_per_user (default: True)
Thread (forum, discord) Shared (all participants see same context) thread_sessions_per_user (default: False)

Impact on System Prompt

When shared_multi_user_session=True, the system prompt omits a fixed user name and instead states: "Multi-user {thread|session} — messages are prefixed with [sender name]. Multiple users may participate." Individual sender names are prefixed on each user message by the gateway at runtime, preserving prompt caching (the system prompt doesn't change per-turn).


6. Explicit Conversation Boundaries

Inactivity and wall-clock time never rotate a conversation. /new and /reset create an explicit boundary; context compression continues to manage long histories. Legacy timer configuration is ignored. The existing SessionResetPolicy datatype is inert compatibility data, not a runtime policy.

Explicit suspension still creates a boundary on the next inbound turn. Recovery respects explicit and historical finalized boundaries rather than reopening them. Resource-only eviction and WebSocket orphan reaping leave conversations resumable.


7. Restart Recovery Flow

The restart recovery system ensures that in-flight sessions are preserved across gateway restarts, crashes, and drain timeouts. It is the solution to issue #7536.

Startup Recovery Sequence

Gateway starts
       │
       ▼
┌───────────────────────────────┐
│ Check for .clean_shutdown     │── Exists? ──► Skip suspension (clean exit)
│ marker                        │
└───────────────────────────────┘
       │ Missing
       ▼
┌───────────────────────────────┐
│ _recover_unclean_sessions()   │── Marked turn with a persisted reply
│                               │   → delivery ledger (sent, marked);
│                               │   marked turn without one →
│                               │   resume_pending (once)
└───────────────────────────────┘
       │
       ▼
┌───────────────────────────────┐
│ _suspend_stuck_loop_sessions()│── Suspends sessions that have been
│                               │   active across 3+ restarts
└───────────────────────────────┘
       │
       ▼
┌───────────────────────────────┐
│ Queue inbound messages while  │
│ startup restore runs          │
│ (_startup_restore_in_progress)│
└───────────────────────────────┘
       │
       ▼
┌───────────────────────────────┐
│ For each adapter, find        │
│ resume_pending sessions →     │
│ synthesize MessageEvent and   │
│ run _handle_message to let    │
│ the agent auto-continue       │
└───────────────────────────────┘

Crash recovery (_recover_unclean_sessions)

Called on gateway startup when no .clean_shutdown marker exists (a crash or unexpected exit). It acts only on durable active-turn markers, never on recency: a chat that was merely active shortly before the crash finished its turn and is not answered again.

The marker is set when a turn starts and is held until the final reply is in the delivery ledger (the adapter releases it right after record_delivery_obligation), or until nothing more is owed (streamed reply, suppressed or empty response). So a marker left at startup means one of two things:

  • The reply was persisted but never ledgered. The stored transcript reply is recorded as an unowned ledger row and the marker is cleared; the boot sweep delivers it once with the "Recovered reply" notice. The turn is not regenerated. The reply is judged the way live delivery would have judged it: a bare silence marker ([SILENT], NO_REPLY, ...) on an internal turn, or the reply to a diagnostic wake the chat's policy mutes, is owed nothing (the marker is cleared, nothing is sent or resumed). A human turn's bare silence marker becomes the same "returned only a silence marker" notice the live path sends.
  • No reply was persisted. recover_interrupted_turns() sets resume_pending=True, resume_reason="restart_interrupted", and the turn auto-resumes once.

The marker's start time is stored as aware UTC and compared as epoch seconds, so a restart in a different local zone (DST change, container vs. unit TZ) neither drops a fresh marker as stale nor adopts the previous turn's reply as this one's. A marker written by an older build (naive local time) is read as host-local time.

A turn already in the ledger is redelivered by the ledger sweep, which also clears any resume_pending for that session, so it is never both delivered and re-answered.

Stuck-Loop Detection (_suspend_stuck_loop_sessions)

Counts consecutive restarts via a JSON file ({HERMES_HOME}/restart_counts.json). If a session has been active across 3+ consecutive restarts, it's auto-suspended so the user gets a clean slate.

Drain-Timeout Marking

On graceful shutdown/restart, the drain system calls mark_resume_pending() for any session that was mid-turn when the drain timeout fired. Reasons:

  • "restart_timeout" — killed during restart drain
  • "shutdown_timeout" — killed during shutdown drain
  • "restart_interrupted" — crash recovery of a marked, unreplied turn (from recover_interrupted_turns)

All three reasons are in _AUTO_RESUME_REASONS and eligible for startup auto-resume.

Auto-Resume on Next Access

When get_or_create_session() encounters resume_pending=True:

  1. It returns the existing entry without creating a new session_id.
  2. The existing transcript is loaded intact.
  3. The marking is not cleared here — it survives until the next successful turn completes (clear_resume_pending() is called from the gateway after run_conversation() returns a real response).
  4. If the resumed turn is interrupted again, the resume_pending flag remains set, and the next restart will retry. The stuck-loop counter handles terminal escalation (3 retries → suspended).

Clean Shutdown Marker (.clean_shutdown)

Written at the end of a graceful shutdown. On next startup:

  • If present: skip crash recovery entirely and discard orphan turn markers. Active agents were already drained, so no sessions are stuck.
  • Then delete the marker.

This prevents unwanted auto-resets after hermes update, hermes gateway restart, or /restart.


8. Message Queuing Flow

The message queuing system handles two scenarios:

  1. Interrupt follow-ups — When a user sends multiple messages while the agent is processing, subsequent messages are queued as single-slot pending messages.
  2. /queue FIFO — Explicit /queue commands that must each produce their own full agent turn, in order, without merging.

Data Structures

adapter._pending_messages: Dict[session_key, MessageEvent]
    └── Single "next-up" slot per session. Overwritten on repeat sends
        (burst collapse). Shared with photo-burst follow-ups.

self._queued_events: Dict[session_key, List[MessageEvent]]
    └── Overflow buffer. Each /queue invocation appends here when the
        slot is occupied. Promoted one-at-a-time after each drain.

Enqueue (_enqueue_fifo)

_enqueue_fifo(session_key, event, adapter)
       │
       ▼
┌───────────────────────────────────────┐
│ Is slot free?                         │
│ (session_key NOT in _pending_messages)│── Yes ──► Place event in slot
└───────────────────────────────────────┘
       │ No
       ▼
Append to _queued_events[session_key] (overflow tail)

Dequeue / Promotion (_promote_queued_event)

Called at the drain site after the slot was consumed. If there's an overflow item:

  • When pending_event is None (slot was empty), return overflow head as the new event.
  • When pending_event exists, stage overflow head in the slot for the next recursion.
  • If no adapter available, push back to _queued_events (don't silently drop).

Queue Depth

_queue_depth(session_key, adapter) returns len(overflow) + (1 if slot occupied else 0).

Clearing

Queued events for a session are cleared on /new and /reset (via _handle_reset_command). /stop drops the single-slot follow-up the user sent during the interrupted turn. An internal wake parked in either store (an async-delegation completion notice, a kanban/cron notify+wake) survives all three commands: _interrupt_and_clear_session leaves it in the slot (promoting it out of the overflow when a discarded human follow-up held the slot) so the post-command drain starts it right away instead of the session idling until the next user message. Whether a wake pinned to a session that /new just closed may still run is decided at processing time (_resolve_async_delegation_session, fail-closed).

Both commands also end the session's background delegations (tools.async_delegation. interrupt_for_session, selected by routing key and by the spawner's durable session id): _interrupt_and_clear_session fans the stop out for the busy path, and _handle_stop_command does the same for an idle session whose dispatching turn already ended (replying "Stopped" rather than "No active task to stop"). The turn's own hard interrupt never reaches those units — they are detached from _active_children at dispatch — so without the fan-out they run to completion and wake the chat minutes later. Each stopped unit still finalizes normally and re-enters as its completion notice with status="interrupted" and the child's partial output. /new and /reset already did this in _handle_reset_command; the shared helper's earlier call is idempotent there (a hard interrupt requested twice is one stop).

FIFO Invariant

Each /queue invocation produces exactly one full agent turn, in FIFO order, with no merging. The single-slot _pending_messages + overflow _queued_events design ensures that repeated sends during an active turn don't cause out-of-order processing.


9. Session Context Injection

SessionContext is built from a SessionSource and GatewayConfig and injected into the agent's system prompt. It tells the agent:

  • Where the current message came from
  • What platforms are connected
  • Where it can deliver scheduled task outputs
  • Whether this is a shared multi-user session

Construction (build_session_context)

def build_session_context(source, config, session_entry=None) -> SessionContext
  1. Collects connected platforms from config.
  2. Collects home channels for each platform.
  3. Determines shared_multi_user_session via is_shared_multi_user_session().
  4. Attaches session metadata (key, id, timestamps) if session_entry is provided.

PII Redaction (build_session_context_prompt)

The dynamic system prompt section (## Current Session Context) can optionally redact personally identifiable information before sending to the LLM:

  • User IDs → user_<12hex> (SHA-256 prefix)
  • Chat IDs → <platform>:<12hex> or just <12hex>
  • Platforms excluded from redaction: Discord (needs raw IDs for @mentions), and any plugin-registered platform not marked pii_safe.

Redaction applies only to the system prompt text. Routing, session keys, and adapter operations always use the original values.


10. Background Housekeeping

The _session_housekeeping_watcher periodically sweeps idle cached agents, sheds cache entries under memory pressure, and prunes old routing entries hourly. It never ends a transcript because of inactivity or the time of day.

TTL, LRU and pressure eviction commit the live transcript to memory providers before soft-releasing clients. Active turns remain protected; terminal, browser and background process resources survive soft release. Routing-entry pruning preserves the canonical SQLite transcript, and live processes protect their routing entries from pruning. Historical expiry_finalized flags remain recovery fences but are no longer written by a timer watcher.


11. Agent Cache

The gateway maintains an LRU cache of AIAgent instances keyed by session_key to preserve prompt caching across turns.

Cache Properties

  • Max size: 128 entries (agent.agent_cache.max_size, default _AGENT_CACHE_MAX_SIZE).
  • Eviction policy: Least-recently-used (LRU via OrderedDict).
  • Idle TTL: 3600s (1h) — agent.agent_cache.idle_ttl_secs, enforced by _session_housekeeping_watcher.
  • Memory budget: agent.agent_cache.memory_high_mb (default auto) — see below.
  • Lock: _agent_cache_lock (threading) for thread safety.

Memory-Pressure Eviction

A cached agent pins _session_messages, the full live transcript including tool outputs — tens of MB on a session with 100+ tool calls. The entry cap and the idle TTL are both blind to that: a gateway serving many chats keeps every warm transcript resident (agents that took a turn within the TTL are never idle-swept), so RSS climbs until the cgroup throttles and SIGTERM can no longer flush inside systemd's stop timeout (#80764).

_sweep_agent_cache_under_pressure() is the valve. Each watcher tick it compares anonymous memory against memory_high_mb — the cgroup's own memory.stat anon when the gateway runs under a cgroup limit (the scope the budget is charged against, so same-unit children such as execute_code kernels count; #110549), otherwise the process's own anonymous RSS; over budget, it evicts LRU agents through the same soft path the cap enforcer uses (_commit_then_release_soft), then runs malloc_trim so the freed arenas actually return to the OS. Evicted sessions rebuild their transcript from the persisted session on the next turn.

Three classes of session are never shed:

  • agents currently mid-turn (their clients and sandboxes are in use);
  • the protect_recent most-recently-used sessions (their prompt cache is worth the most);
  • any session whose live transcript has not finished reaching disk — transcript_persistence_caught_up() compares _last_flushed_db_idx against len(_session_messages), the same divergence the FTS write-corruption guard reacts to when it preserves live history over a lagging transcript.

memory_high_mb: auto derives the budget from the cgroup limit the gateway runs under (memory.high, then memory.max, then cgroup v1), falling back to total RAM when uncapped. Set a number to pin it, or 0/off to disable the pass entirely. Helpers live in gateway/agent_cache_pressure.py.

Cache Lifecycle

Message arrives
    │
    ▼
get_or_create_session()  →  session_key obtained
    │
    ▼
Lookup _agent_cache[session_key]
    │
    ├── Hit → move_to_end(), reuse AIAgent (preserves prompt cache)
    │
    └── Miss → create new AIAgent, store in cache
                (if at capacity, popitem(last=False) evicts LRU entry)
    │
    ▼
run_conversation()  →  agent processes message
    │
    ▼
Housekeeping soft-releases idle agents without ending transcripts

Cleanup Flow

Resource eviction removes the cached agent and commits memory before soft-releasing clients. Full _cleanup_agent_resources(agent) teardown is reserved for actual conversation boundaries and shutdown.


Appendix: Key Configuration

Config Key Type Default Description
group_sessions_per_user bool true Isolate group/channel sessions per user
thread_sessions_per_user bool false Isolate thread sessions per user
session_store_max_age_days int 0 Prune sessions older than N days (0=disabled)
agent.gateway_auto_continue_freshness int 3600 Seconds for resume freshness window
agent.gateway_timeout int 1800 Agent turn timeout (30 min default)
agent.agent_cache.max_size int 128 LRU entry cap on cached AIAgents
agent.agent_cache.idle_ttl_secs int 3600 Evict agents idle this long
agent.agent_cache.memory_high_mb int/str auto Anon-RSS budget above which LRU transcripts are shed
agent.agent_cache.max_evictions_per_pass int 16 Cap on sessions shed per pressure pass
agent.agent_cache.protect_recent int 8 MRU sessions the pressure pass never touches

State database and FTS recovery

The canonical transcript lives in the sessions and messages tables. FTS5 tables and their sync triggers are derived indexes that can be detached and rebuilt without deleting canonical messages. See State DB recovery for the bounded live failure mode and the explicit repair procedure.

Conversation lifetime

No idle or daily reset settings are supported. Explicit /new and /reset, compaction, suspension and crash recovery retain their separate lifecycle roles.