Files
openchamber/packages/web/server/lib/message-queue/DOCUMENTATION.md
T
Bohdan Triapitsyn 07fa83cc72 feat(queue): deliver queued messages from the server
Messages queued while a session is busy used to live in the browser tab and
were sent by that tab once the session went idle, so closing the tab (or
losing the connection) stranded them. The web server now owns the queue:
it persists to <data-dir>/message-queue.json, watches session.status on the
global event hub, re-verifies idleness against OpenCode before sending, and
delivers the head of the queue via prompt_async (or /command for slash
commands) with the model, agent, variant, attachments, and agent mention
captured at queue time. Failed sends stay queued and retry with backoff; a
user abort holds delivery briefly; every change is broadcast so all clients
see one queue.

The shared UI store becomes a projection of the server queue outside VS
Code (hydrate on connect, apply broadcasts, optimistic mutations settled on
the server's copy, one-time upload of locally queued messages from older
builds). Edit / send-now take the full message back from the server. A
UI-driven auto-review run asks the server to hold that session's queue.
VS Code keeps its local queue and foreground auto-send.

Claude-Session: https://claude.ai/code/session_01HB9wdLQoZX2vfyDjwv6Rso
2026-09-04 14:08:08 +03:00

136 lines
6.7 KiB
Markdown

# Message Queue
## Purpose
Owns the messages a user queued while a session was busy, and sends them the
moment the session goes idle. The queue lives in the web server so a closed
tab, a locked phone, or a dropped connection no longer strands it. Structural
template: `permission-auto-accept` — the server is authoritative, the shared UI
renders a projection, and VS Code (which has no server of its own) keeps its
UI-side queue and foreground auto-send hook.
## Files
- `runtime.js``createMessageQueueRuntime(...)` (state, persistence,
dispatch loop, event handling) and `registerMessageQueueRoutes(app, runtime)`.
- `runtime.test.js` — delivery, idleness gates, retries, holds, persistence,
concurrency with in-flight sends, slash commands, and project knowledge.
Wiring: created in `server/index.js` after the global event hub and the
session-knowledge runtime; routes registered in
`opencode/feature-routes-runtime.js` (before the generic OpenCode proxy) with
JSON bodies enabled in `opencode/core-routes.js`; stopped by
`opencode/shutdown-runtime.js`.
## Item
An item is what the UI would have sent itself, captured at queue time so the
send never re-resolves mutable UI state:
```
{
id, createdAt,
content, // raw text for display and editing
text, // text to deliver (agent mention stripped); defaults to content
agentMention?, // delivered as an `agent` part
attachments: [{ id, filename, mimeType, size, source, serverPath?, dataUrl }],
sendConfig: { providerID, modelID, agent?, variant? } // required
}
```
`parseQueuedItemInput` rejects anything the server could not deliver later
(no text and no attachments, missing model, malformed attachment). Public
snapshots and broadcasts strip `dataUrl` from attachments — payloads can be
megabytes of base64 and must not ride every update; the only way to get them
back is a `take`.
## Persistence
`<data-dir>/message-queue.json` (`OPENCHAMBER_DATA_DIR` or
`~/.config/openchamber`): `{ version, revision, sessions: { [sessionId]:
{ directory, items } } }`, written atomically (temp file + rename) through a
serialized write chain. A missing file is an empty queue. A malformed file is
a failure, not an empty queue: it is moved aside as
`message-queue.json.corrupt-<timestamp>` before the runtime starts empty, so
the next write cannot overwrite the user's data. A failed read leaves writes
disabled until a later load succeeds. `revision` is a global monotonic counter
bumped on every mutation; clients use it to reject stale snapshots.
In-memory only, deliberately: the in-flight item (`sendingId`), retry
backoff, abort timestamps, and holds. A restart has no in-flight sends; a
persisted "sending" flag would strand a message forever.
## Delivery loop
1. `start()` subscribes to the global upstream hub and loads the file; on
load and on every hub `connect` it arms every session that has items.
2. `session.status` for a queued session: `idle` arms a short quiet timer
(500 ms, coalescing the burst around a turn boundary), `busy`/`retry`
clears it. A `message.updated` for a completed assistant reply arms as
well, so a missed idle event cannot strand the queue. `session.deleted`
drops the session's queue. An assistant `MessageAbortedError` records an
abort.
3. `tick(sessionId)` bails when the queue is empty, an item is in flight, or
the session is held. It re-arms after a 2 s post-abort hold (the UI's
old behavior: a stop is not immediately followed by the next prompt) or
while the head item is in retry backoff.
4. Idleness is re-verified against OpenCode before sending, because
`prompt_async` into a running turn steers into it instead of starting the
next one: `GET /session/status` must not list the session as busy/retry,
and the trailing message must not be an unfinished assistant reply (the
status map only lists busy sessions, so a missed busy event leaves no
entry while a turn still streams). A failed fetch is unknown, never idle:
the tick re-arms with backoff.
5. The head is marked in flight (broadcast), then sent:
- text starting with `/` that names a command in OpenCode's `/command`
list (skills included) goes to `POST /session/:id/command` with the
captured model, agent, variant, and file parts;
- otherwise `POST /session/:id/prompt_async` with the parts in the same
order a UI send uses: text, files, pending project knowledge
(`sessionKnowledgeRuntime.resolvePendingForSession`, synthetic, recorded
as delivered only after the prompt is accepted), then the agent mention.
Success removes the item, persists, broadcasts, and marks the user
message sent for notifications. Failure keeps the item, backs off
2 s → 60 s (doubling per consecutive failure of that item), and re-arms.
6. The next item goes out after the next busy → idle cycle.
## Holds
Auto-review is driven from the UI and bounces the original session through
idle between iterations; the UI tells the server to hold that session's queue
(`PUT .../hold { held: true, ttlMs? }`) while a run is going and releases it
when the run ends. A hold expires on its own (default 5 min, cap 10 min)
because the UI that asserted it may be gone; the UI re-asserts it every two
minutes while the run continues. Releasing arms a dispatch.
## Routes (`/api/message-queue`)
Normal authenticated OpenChamber runtime routes; never on browser URL-token
allowlists.
| Route | Purpose |
|---|---|
| `GET /api/message-queue` | Full snapshot `{ revision, sessions[] }` |
| `POST .../sessions/:id/items` | Append `{ directory, item }`; returns `{ revision, session, itemId }` and arms a dispatch (the session may already be idle) |
| `DELETE .../sessions/:id/items/:itemId` | Remove; `409` while that item is in flight |
| `POST .../sessions/:id/items/:itemId/take` | Remove and return the full item (payloads included); `404`/`409` |
| `POST .../sessions/:id/take` | Remove and return every item not in flight, in order |
| `PUT .../sessions/:id/order` | `{ itemIds }` must be a complete permutation |
| `DELETE .../sessions/:id` | Clear; the in-flight item stays |
| `PUT .../sessions/:id/hold` | `{ held, ttlMs? }` |
Every mutation broadcasts `openchamber:message-queue.updated` with
`{ revision, session }` to all connected clients (SSE and WS), so several
devices on one server see one queue.
Limits: 20 items per session, 50 sessions (oldest evicted, never one with an
item in flight), 200k characters of content; attachment payloads are bounded
by the route family's 50 MB JSON limit.
## UI ownership
`packages/ui/src/stores/messageQueueStore.ts` is the projection: see its
section in `packages/ui/src/stores/DOCUMENTATION.md`. VS Code intentionally
does not use this module; with all OpenChamber webviews closed, queued
messages there are not delivered.