# Lazy Hydration & Multi-Tenant Scaling Design **Status:** Phases 1–5 shipped on `main`. Two items in the original Phase 4 list (per-bot multi-agent routing, channel-state externalization to Postgres) turned out not to be required under the single-gateway / one-bot-per-agent model and are dropped from scope; see Phase 4 #4–#5. **Goal:** Support 1000–2000 agents per gateway instance on a 64 GB Railway container --- ## Background (historical — pre-refactor) Before this work the gateway eagerly hydrated every agent at boot: - `apps/gateway/src/index.ts` iterated every row in `agents` on startup (under `autoStartAgents=true`) and called `instances.start()`. - `AgentInstanceManager.runners: Map` was populated forever; there was no idle eviction, no LRU, no TTL. - Per-agent in-process resources held by the runner: scheduler timers, MCP client connections, channel adapters (Slack Socket Mode WebSocket, Discord WebSocket, Telegram polling loop), session event broker, workspace/container manager. Per-agent baseline ≈ 6–15 MB. With 1000–2000 agents this is 10–20 GB of always-hot memory plus full event-loop pressure even when most agents are idle. ## Target Architecture ``` ┌─ Gateway (long-lived) ──────────────────────────────────┐ │ │ │ • agents.status read-through (DB is source of truth) │ │ • Runner LRU cache + idle eviction │ │ • Single-flight hydration (Map>) │ │ • Central cron scheduler (scans schedules.next_run_at) │ │ • Channel connection pool │ │ - Telegram: webhook routing + polling loop pool │ │ - Slack: Socket Mode connection pool │ │ - Discord: Gateway WebSocket connection pool │ │ • Inbound router → hydrate-on-demand │ │ │ └─────────────────────────────────────────────────────────┘ ↓ hydrate when needed AgentRunner (short-lived, LRU) • Session + Claude streaming • MCP clients (loaded async; observable status) • Tool execution • Workspace / container ``` ### Core principle `agents.status` (DB column, source of truth) determines whether the gateway accepts requests for an agent. The in-memory `runners` Map is purely a hydration cache — its presence is an optimization, never a policy decision. ``` request arrives ↓ runners.get(agentId)? ├─ present → handle directly └─ absent → SELECT status, config_json, security_json, workspace_dir FROM agents WHERE agent_id = ? ├─ active → hydrate runner, handle ├─ disabled → 403 └─ missing → 404 ``` ## Design Decisions ### 1. `agents.status` column (replaces in-memory start/stop) ```sql ALTER TABLE agents ADD COLUMN status text NOT NULL DEFAULT 'active'; -- 'active' | 'disabled' ``` - Single source of truth; survives gateway restart with no recovery state. - `PATCH /api/agents/:id { status: 'disabled' }` updates DB, then in-process actively evicts the runner if hot. - Multi-gateway later: pub/sub via Postgres `LISTEN/NOTIFY` to broadcast disable events. Out of scope for v1. - Migration must be registered in `packages/store/drizzle/meta/_journal.json` (drizzle silently skips unregistered migrations). ### 2. Lazy hydration with single-flight - `runners` map stores `Promise` (not `Runner`) so concurrent requests for a cold agent share one hydration. - Idle TTL (default 30 min). A periodic ticker evicts runners whose `lastActivityAt` is older than the TTL **and** which have no active sessions / WS subscribers / in-flight work. - Hydration cost budget: DB read + AgentRunner construction + (deferred) MCP. The sandbox/container is already lazy-provisioned on first `exec` or file I/O, so it does not contribute to hydration latency. With async MCP loading, cold start is sub-second; the first tool call that touches the sandbox pays the existing provision cost (unchanged from today). ### 3. Central cron scheduler The `schedules` and `schedule_runs` tables already exist (`packages/store/src/schema.ts:287-325`) with all needed fields: `cron_expression`, `next_run_at`, `last_run_at`, `status`, `consecutive_errors`, `run_count`. Index `idx_schedules_next_run` on `(agent_id, next_run_at)` already supports the central scan. What changes: - Remove the per-runner `Scheduler` instance from `apps/agent/src/agent-runner.ts:216`. - Add a single gateway-level `CentralScheduler` that scans `schedules WHERE status='active' AND next_run_at <= now()` every N seconds. - On hit: hydrate the agent → fire one schedule run → update `last_run_at` / `next_run_at` / `consecutive_errors` → optionally evict if no other activity. Side benefits: - Catchup semantics on gateway restart become explicit (rows with overdue `next_run_at` are visible in the DB; current `setInterval` model loses these silently). - Central observability: schedule runs, failures, retries all flow through one code path. ### 4. MCP async loading - Runner becomes responsive without waiting for MCP servers to connect. - Tool list is dynamic: connected MCP tools are merged in as connections come up. - Agent has visible MCP connection state (e.g. agent can answer "the GitHub MCP server is still connecting, try again in a moment"). - On rehydration after eviction, reconnect cost is paid once on first tool call (acceptable). This is independently valuable beyond lazy hydration: it also fixes "one bad MCP server kills the entire agent boot" today. ### 5. Channel connection pooling All three channel adapters currently hold per-agent in-process state and (for Slack/Discord/Telegram-polling) a per-agent persistent connection. This blocks lazy eviction. Channel adapters get refactored so the **connection holder lives in the gateway**, independent of any runner. #### 5.1 Telegram Keep both webhook and polling modes. - **Webhook**: already gateway-routed via `/api/agents/:agentId/channels/telegram/webhook` (`apps/gateway/src/app.ts:2031-2048`). No changes. - **Polling**: move the `getUpdates` loop out of the runner into a gateway-level pool: `Map`. Loop runs regardless of runner state; on each batch, route updates → hydrate agent → dispatch. Webhook is preferred for memory efficiency; polling stays available for deployments without a public URL. #### 5.2 Slack Keep Socket Mode for now. Move the `SocketModeClient` from per-runner to gateway-level pool: `Map`. Inbound events route via `(team_id, channel_id) → agent_id` lookup → hydrate → dispatch. If per-tenant bots scale poorly (one socket per agent), a future migration to Slack Events API (HTTP webhook) can be considered. Out of scope for v1. #### 5.3 Discord Discord has no inbound webhook mechanism — Gateway WebSocket is mandatory. Same pattern: gateway-level pool `Map`, inbound `MessageCreate` → routing table → hydrate. **Open question:** Is a Discord bot per-agent, or do multiple agents share one bot? This determines whether Discord is the scaling ceiling. #### 5.4 Channel state externalization Per-agent in-memory state currently held by adapters must move to Postgres so it survives runner eviction: - `chatSessions` (Telegram), `channelSessions` (Slack/Discord) — in-flight conversation state - `lastEventIds` — SSE dedup cursors - `pendingApprovals`, `pendingAsyncApprovals` — approval flow state - `chatLocks`, `turnQueues` — serialization primitives (these become DB-backed advisory locks or rely on agent-level single-flight) This is the largest hidden cost of Phase 4 — bigger than the connection pooling itself. ### 6. Remove `autoStartAgents` Once lazy hydration, central cron, and channel pooling are in place, `autoStartAgents` has no purpose. **Drop it entirely** in the same branch (Option A — no transitional flag, no deprecation period). Since all phases ship together, there is no window where socket-mode channels need a runner kept alive. ## Memory Projection (64 GB container, post-refactor) Assumptions: idle TTL 30 min, ~5 % of agents active in a 30 min window, average hot runner ~30 MB (with sessions + MCP). | Total registered agents | Hot runners | Memory | |------|------|------| | 1000 | ~50 | ~1.5 GB | | 2000 | ~100 | ~3 GB | | 5000 | ~250 | ~7.5 GB | 64 GB easily supports 5000–10000 registered agents under this profile, plus headroom for connection pools, Node heap, and traffic bursts. ## Implementation Plan ### Prerequisite — MCP async loading ✅ shipped Lives in `apps/agent/src/mcp-client.ts`. Independently valuable — fixed "one bad MCP server stalls agent boot" and slow gateway startup. Required for sub-second cold hydration. 1. ✅ `connectAll()` is fire-and-forget; runner construction does not await MCP `connect()`. 2. ✅ Each client connects in the background; `getToolsets()` filters on `status === 'connected'` so the tool surface grows as connections complete. 3. ✅ `getStatus()` exposes per-server `'connecting' | 'connected' | 'disconnected' | 'error'` plus `lastError` / `connectedAt`. 4. ✅ Tool calls on a still-connecting server return `"MCP server … is still connecting. Try again in a moment."` ### Lazy-hydration branch One long-lived feature branch `feat/lazy-hydration` off `main` (after the MCP async PR is merged). All phases below land together; no partial merges. ### Phase 1 — `agents.status` + lazy hydration (HTTP / WS / webhook) ✅ shipped 1. Migration: add `agents.status` column (register in `_journal.json`). 2. Schema: update `packages/store/src/schema.ts`. 3. Store: add `getStatus` / `setStatus` to `agentStore` (or surface via existing `get`). 4. `AgentInstanceManager.getOrHydrate(agentId)` — if absent, look up DB; if `active`, start; cache `Promise` for single-flight. 5. Replace `resolveRunner` call sites in HTTP / WS / webhook handlers with `getOrHydrate`. 6. `PATCH /api/agents/:id { status }` endpoint; on transition to `disabled`, actively `instances.stop(agentId)` in-process. ### Phase 2 — Central cron scheduler ✅ shipped 1. `CentralScheduler` in gateway scans `schedules` every 10 s (`apps/gateway/src/central-scheduler.ts`). 2. On hit: `getOrHydrate(agentId)` → `runner.runScheduledJob(schedule, sessionId)` → mark row. 3. Per-runner `Scheduler` removed; `runScheduledJob` is the runner's only public scheduling surface. 4. Cron `next_run_at` is computed lazily — bootstrap pass on each tick handles rows where `next_run_at IS NULL`. Catchup across gateway restart is automatic (overdue rows are simply due). 5. Trade-off: cron precision drops to one tick interval (~10 s) — accepted for v1; in-process Cron timers can be re-added later if a specific schedule needs sub-second precision. ### Phase 3 — LRU eviction ✅ shipped 1. `AgentInstanceManager` tracks `lastActivityAt` per agent. `touch()` is called from `_doStart`, `getRunner` (read-touch), `getOrHydrate` cache hits, `dispatchWebhook`, and each WS message. 2. Eviction ticker scans hydrated runners every 60s; agents idle past `OPENHERMIT_EVICTION_TTL_MINUTES` (default 30) are stopped. 3. Skip-guards: agents with active channel handles (telegram/slack/discord polling), live WS connections, or non-zero `busy` counter are kept warm. 4. Long-running ops wrap themselves in `withBusy(agentId, fn)`; central scheduler does this around `runScheduledJob` so jobs that exceed the TTL aren't evicted mid-run. 5. `OPENHERMIT_EVICTION_TTL_MINUTES=0` disables eviction. ### Phase 4 — Channel connection pooling 1. ✅ Telegram polling loop moves to gateway-level pool (`apps/gateway/src/channel-pool.ts`, PR #29). 2. ✅ Slack Socket Mode client moves to gateway-level pool. 3. ✅ Discord client moves to gateway-level pool. 4. 🚫 **Out of scope.** A `(channel_kind, connection_id, conversation_id) → agent_id` routing table was on the original list to support multiple agents sharing one bot. In the actual product, each agent owns its own Telegram/Slack/Discord credentials (rows in `agent_channels`), so routing is 1:1 and already handled by `ChannelRegistry.register({apiKey, agentId})` in the pool. No use case in v1. 5. 🚫 **Out of scope.** Externalizing per-agent channel state to Postgres turned out to be unnecessary on inspection: - `chatSessions` / `channelSessions` are pure memoization — on cache miss the bridge falls back to `client.listSessions({metadata})` which already reads from the persisted `sessions` table (`apps/channels/{slack,discord,telegram}/src/bridge.ts`). - `lastEventIds` is the SSE dedup cursor, reset across runner restart via the `ready` / `nextEventId` frame (PR #29). - `pendingApprovals` (Telegram realtime) is valid only for the duration of one in-flight turn — that turn is in-memory by nature, so persisting the map is meaningless. - Async approvals already persist via `approval_requests.short_id` (PR #28). - `chatLocks` / `turnQueues` are per-conversation in-process mutexes; correct as in-memory under single-gateway. Multi-gateway HA is explicitly out of scope (see Open Questions §4) and would require its own design pass anyway. ### Phase 5 — Cleanup ✅ shipped (PR #30) 1. ✅ Deleted `autoStartAgents` config and the boot-time iteration in `index.ts`. 2. ✅ Updated `apps/gateway/README.md` and `docs/architecture.md`. (Other doc passes done as needed.) 3. ⏸ Formal end-to-end load test (100+ agents hydrating concurrently) not run; smoke-tested manually on test.openhermit.ai. ## Open Questions 1. **Discord bot deployment model** — per-agent independent bot, or multiple agents sharing one bot? Determines Discord scaling ceiling. 2. **Channel state backend** — Postgres (assumed default) vs Redis. Postgres preferred unless contention shows up in load testing. 3. **Branch strategy** — long feature branch will drift from `main`. Either freeze `main` to critical fixes only during the refactor, or rebase periodically. Need to pick one. 4. **Disable propagation across multiple gateways** — out of scope for v1 (single-gateway deployment); revisit when multi-gateway lands. ## Risks - **Cold-start latency** on hydration is dominated by AgentRunner construction and DB reads (sandbox provisioning is already lazy and unchanged). MCP async loading keeps this sub-second. Per-agent "warm" pinning can be added later if specific latency-sensitive agents need it. - **Channel state migration** is invasive — existing in-flight approvals and dedup cursors must be migrated, not just truncated. - **Long-lived feature branch** risks merge conflicts. Mitigation: rebase weekly, keep phases independently testable. - **Cron drift across restart** — central scheduler must implement a clear catchup policy (run-once-on-recovery vs skip-overdue) and document it.