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
This commit is contained in:
Bohdan Triapitsyn
2026-09-04 14:08:08 +03:00
parent 8afec51480
commit 07fa83cc72
33 changed files with 2256 additions and 197 deletions
+1 -1
View File
@@ -284,7 +284,7 @@ Rules:
2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct.
3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls.
4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected.
5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime.
5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime. Outside VS Code the queue itself is server-owned (`packages/web/server/lib/message-queue/`): the UI hands the server the captured send configuration at queue time and the server delivers on idle; the composer only sends a queued message itself after taking it back from the server (`takeForSend`). See the `messageQueueStore.ts` section in `stores/DOCUMENTATION.md`.
6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation.
8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
@@ -3,7 +3,7 @@ import type { Todo } from '@opencode-ai/sdk/v2/client';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { createChatDraftIdentity, readChatDraft, writeChatDraft } from '@/lib/chatDraftPersistence';
import { createMessageQueueTarget, useMessageQueueStore } from '@/stores/messageQueueStore';
import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore } from '@/stores/messageQueueStore';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useTodosPersistStore } from '@/stores/useTodosPersistStore';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
@@ -25,8 +25,14 @@ describe('cleanupPersistedSessionState', () => {
const runtimeKey = getRuntimeKey();
const deleted = createMessageQueueTarget('session-1', '/repo-a', runtimeKey)!;
const retained = createMessageQueueTarget('session-1', '/repo-b', runtimeKey)!;
useMessageQueueStore.getState().addToQueue(deleted, { content: 'delete' });
useMessageQueueStore.getState().addToQueue(retained, { content: 'retain' });
// Outside VS Code the queue is a projection of the server's; seed it the
// way a server snapshot would, and expect only the projection to go.
useMessageQueueStore.setState({
queuedMessages: {
[getMessageQueueKey(deleted)]: [{ id: 'q-delete', content: 'delete', createdAt: 1 }],
[getMessageQueueKey(retained)]: [{ id: 'q-retain', content: 'retain', createdAt: 1 }],
},
});
useTodosPersistStore.getState().setSessionTodos('/repo-a', 'session-1', [todo]);
useTodosPersistStore.getState().setSessionTodos('/repo-b', 'session-1', [todo]);
const deletedDraft = createChatDraftIdentity(runtimeKey, '/repo-a', 'session-1')!;
@@ -1,6 +1,6 @@
import { getRuntimeKey } from '@/lib/runtime-switch';
import { clearChatDraft, createChatDraftIdentity } from '@/lib/chatDraftPersistence';
import { createMessageQueueTarget, useMessageQueueStore } from '@/stores/messageQueueStore';
import { createMessageQueueTarget, isServerOwnedMessageQueue, useMessageQueueStore } from '@/stores/messageQueueStore';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useTodosPersistStore } from '@/stores/useTodosPersistStore';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
@@ -14,7 +14,12 @@ export const cleanupPersistedSessionState = (identity: {
if (identity.runtimeKey !== getRuntimeKey() || !identity.directory || identity.directory === 'global' || !identity.sessionId) return;
const queueTarget = createMessageQueueTarget(identity.sessionId, identity.directory, identity.runtimeKey);
if (queueTarget) useMessageQueueStore.getState().clearQueue(queueTarget);
if (queueTarget) {
// A server-owned queue drops the deleted session itself; only the local
// projection needs to go. VS Code owns its queue and clears it here.
if (isServerOwnedMessageQueue()) useMessageQueueStore.getState().forgetQueue(queueTarget);
else useMessageQueueStore.getState().clearQueue(queueTarget);
}
useTodosPersistStore.getState().clearSessionTodos(identity.runtimeKey, identity.directory, identity.sessionId);
useSessionFoldersStore.getState().removeSessionEverywhere(identity.runtimeKey, identity.sessionId);
useInlineCommentDraftStore.getState().clearSessionDrafts(identity.runtimeKey, identity.directory, identity.sessionId);
+7
View File
@@ -44,6 +44,7 @@ import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./recon
import { messagesBefore } from "./message-ordering"
import { opencodeClient } from "@/lib/opencode/client"
import { usePermissionStore } from "@/stores/permissionStore"
import { applyMessageQueueUpdatedEvent, useMessageQueueStore } from "@/stores/messageQueueStore"
import {
processVSCodePermissionAutoAccept,
processVSCodeReconciledPermissionAutoAccept,
@@ -1572,6 +1573,11 @@ export function handleEvent(
batch?: DirectoryEventBatch,
globalEffectsAlreadyApplied = false,
) {
if ((payload as { type?: unknown }).type === "openchamber:message-queue.updated") {
applyMessageQueueUpdatedEvent(payload, expectedRuntimeKey)
return
}
if ((payload as { type?: unknown }).type === "openchamber:permission-auto-accept.updated") {
const properties = (payload as unknown as { properties?: unknown }).properties
if (properties && typeof properties === "object") {
@@ -2226,6 +2232,7 @@ export function SyncProvider(props: {
// Configure child store manager
useEffect(() => {
void usePermissionStore.getState().hydrate().catch(() => undefined)
void useMessageQueueStore.getState().hydrate().catch(() => undefined)
}, [props.sdk])
useEffect(() => {