Files
openchamber/packages/web/server/lib/message-queue/DOCUMENTATION.md
T
Bohdan Triapitsyn 58dfc789a2 fix(queue): clear the queue card after the last queued message is sent
When the server delivered the last item of a session's queue it deleted
the session entry and broadcast the update with an empty directory. The
UI keys its projection by directory, could not build the key, and dropped
the event, so the delivered message stayed in the "Queued messages" card
until the next full hydration.

The server now remembers a session's directory beyond the emptying of its
queue so that broadcast names it. The UI additionally treats an empty
session without a directory as "this session's queue is done" for every
projection keyed under that session id, which covers clients talking to
an older server.

Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
2026-09-05 11:21:39 +03:00

8.0 KiB

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.jscreateMessageQueueRuntime(...) (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, file mentions resolved); defaults to content
  agentMention?,  // delivered as an `agent` part
  attachments: [{ id, filename, mimeType, size, source, serverPath?, dataUrl }],
  context: [      // what the composer had attached, in send order
    { kind: 'context', text, metadata, instructions? },  // a draft chip or linked issue/PR; metadata is the UI's structured payload
    { kind: 'instruction', text },                       // derived from the text (skill instruction)
    { kind: 'synthetic', text },                         // handed to the composer by another surface
  ],
  sendConfig: { providerID, modelID, agent?, variant? }   // required
}

The server is a courier for context: it validates the shape (a kind it knows, a metadata object on context entries) and delivers each entry as a synthetic text part, an entry's instructions going out as its own part just before it and its metadata riding the part verbatim so the timeline renders the context block back. The payload inside metadata is the UI's contract (lib/messages/contextParts.ts), parsed by the UI on the way back.

parseQueuedItemInput rejects anything the server could not deliver later (no text, attachments, or context; missing model; malformed attachment or context entry). Public snapshots and broadcasts strip the payloads — attachment dataUrl (megabytes of base64) and context (a PR diff, say) — so they do 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, the captured context, pending project knowledge (sessionKnowledgeRuntime.resolvePendingForSession, synthetic, recorded as delivered only after the prompt is accepted), then the agent mention. The command path sends files and captured context as parts. 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. The session in that payload always names its directory, including the broadcast that removes the last item: the UI keys its projection by directory, and a broadcast without one left the delivered message on screen (a session's directory is remembered until the session is deleted or evicted).

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.