|
<div class="docs-content" data-render>
|
|
# 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.
|
|
</div>
|