# Architecture and sessions How Devii is wired at runtime: the hub, per-owner sessions, the websocket, persistence, and the multi-worker model. See also [Devii internals](/docs/devii-internals.html). ## Owner model Every session is keyed by an **owner-and-channel** tuple `(owner_kind, owner_id, channel)`: - `user` / user uid - a signed-in DevPlace account. The agent authenticates to this instance with that user's API key, so it operates the user's own account. - `guest` / `devii_guest` cookie - an anonymous sandbox session. - `channel` - an independent conversation thread: `main` (the floating terminal), `docs` (the in-page Docii documentation assistant, selected with `?channel=docs`), or `telegram`. The channel splits only the conversation thread; tasks, lessons, behavior, virtual tools, and the 24h quota stay owner-scoped and shared across a given owner's channels. Owner scoping is the spine of Devii: conversations, tasks, lessons, the usage ledger, and the audit log are all filtered by owner, so nothing is shared between accounts. ## DeviiHub `DeviiHub` holds the process-wide state: one `DeviiSession` per owner-and-channel (`get_or_create`), the shared `LLMClient`, the `ConversationStore`/`UsageLedger`/`TurnAudit`, and housekeeping (`gc_idle`, ledger prune). For a signed-in user it builds a `PlatformClient` with the user's API key and lazily **rehydrates** the saved conversation; a guest gets an unauthenticated client with no persistence. ## DeviiSession A session owns the agent, dispatcher, scheduler, and a **set** of websockets. Key behaviors: - **Multi-tab broadcast.** Every frame (trace, reply, task, status) goes to all of an owner's connected sockets, so the same content shows on every tab, window, and device. - **History snapshot.** On connect, the session sends a `history` frame so a new tab renders the prior conversation; live frames then stream. - **Turn serialization.** A per-session lock serializes turns; a tab joining mid-turn still receives live frames. - **Reload-resilient browser calls.** Avatar and client tool requests round-trip to the browser; if the page reloads mid-request, the call waits for reconnection and re-sends, so a turn survives a refresh. ## Persistence and rehydration After each turn the session writes the (compacted) message list to `devii_conversations` (users only). On reconnect (or after a restart) the saved messages are loaded back into the agent, so a signed-in conversation survives reloads and reboots. Guests are never persisted. See [Data and persistence](/docs/devii-data.html). ## Autonomous scheduler A session's scheduler ticks once a second and runs any due task in `devii_tasks` through the session executor (the result is broadcast to connected tabs and buffered when none are). The scheduler is no longer tied to an open websocket: it is started by `ensure_scheduler_started()`, which runs both on `attach` and from the lock-owner `DeviiService` housekeeping pass. That pass scans `devii_tasks` for every signed-in owner with an enabled pending task, resolves the user (API key, timezone, admin flag), and recreates a headless session whose scheduler runs the task. So a queued reminder fires after a server restart even if the user never reopens Devii, and an idle session that still owns a pending task is not garbage-collected between ticks. Guest tasks are in-memory and run only while the guest stays connected. When a task created as a reminder finishes, the owner also receives a `reminder` in-app notification and live toast. Timezone for the conversion to UTC comes from the browser (sent on connect, persisted to `users.timezone`) and is surfaced to the agent in a `# CURRENT TIME` block. ## Multi-worker model `make prod` runs multiple uvicorn workers, but only one holds the background-service lock. Because hubs are per-process, `/devii/ws` is served **only** by the lock-owning worker (`service_manager.owns_lock()`); other workers close the socket with code 4013 (a silent fast-retry in the private range) and the auto-reconnecting client lands on the owner, while the disabled-service path closes with the standard 1013. This guarantees a single hub, one broadcast source, and one cost tracker per user. The 24h spend cap is always read from the ledger DB, so it stays correct regardless. See [Multi-worker and concurrency](/docs/production-concurrency.html) for the underlying shared service-lock model. ## CLI parity The `devii` console script reuses the same agent, tools, and stores. When it runs against the same database, it resolves the logged-in account from its API key and uses that owner, so the CLI shares the same per-account lesson memory and tasks as the web session. See [Configuration and CLI](/docs/devii-config.html). ## Shared browser session The web terminal and the browser are one session. The websocket resolves its owner from the browser's `session` cookie at connect, so the terminal is always whoever the browser is logged in as. When the agent logs in via the terminal, it mints a real platform session and hands the token to the browser through a single-use `GET /devii/adopt` redirect (the `session` cookie is httpOnly, so only an HTTP endpoint can set it); on logout the browser is sent to `/auth/logout`. Both are full navigations, so the page refreshes and the terminal reconnects as the new identity. In the other direction, a login or logout in the browser (including another tab) is detected by a focus check against `/devii/session` and triggers a reload, keeping both sides in lock-step. ## Stopping a turn: stop and reset Each user message runs as its own cancellable asyncio task (`spawn_turn`), tracked in `_turns`. The websocket receive loop keeps reading while a turn runs, so an interrupt is honoured immediately even mid-task. Typing `stop` or `reset` (or sending a `stop` / `reset` frame) is intercepted server-side in the websocket loop before anything else, so the interrupt is authoritative and works regardless of the client. - `stop` cancels the running turn and keeps the conversation. - `reset` cancels the running turn and clears the conversation. Cancellation is bulletproof by construction. Tool calls run as awaited coroutines (the react loop gathers them; sub-agents are awaited inline), so cancelling the turn task propagates `CancelledError` through every await with no orphaned work. `cancel_turns` also increments a `_turn_epoch`; a turn only emits its reply or saves the conversation while its captured epoch still matches the session's, so a turn that briefly survives cancellation, or finishes the instant after an interrupt, can never deliver output or persist a half-finished message list afterward (`_persist_history` runs only on the matching-epoch, non-cancelled path). Spend is accounted separately: `_record_spend` always writes the usage delta actually billed to the ledger, even for a cancelled turn, so an interrupted turn is counted against the 24h cap rather than forgiven. `stop` then repairs the kept history (`_repair_history` trims any trailing assistant tool-call block whose tool responses are incomplete) so the next turn always starts from a valid conversation.