feat: queued follow-up messages in composer (#58) #287

Merged
dries merged 2 commits from feat/queued-follow-up-messages into main 2026-07-12 01:34:24 +02:00
Owner

Closes #58.

Server-side, shared, race-free follow-up message queue. Prompts submitted while a session is mid-turn are queued and auto-sent one per turn when the session goes idle.

Design

  • Server-side, not frontend — the queue lives in state.db (migration v21), shared across every connected client and surviving a client moving machines. A frontend-only queue would show phantom messages to other clients watching the same session.
  • Race-free by construction — enqueue is unconditional (no busy check → no check-then-act race); flush is the sole send gate, serialized per session and driven by the existing session.idle SSE edge. One message drains per idle edge, oldest first (one follow-up per turn).
  • Remote-transparent — the queue row carries the compound platform id, so flush routes back over gRPC for remote sessions.

What's included

Backend

  • internal/queuesvc — enqueue / flush (per-session lock) + List/Remove/Move.
  • internal/state — v21 queued_message table + CRUD (append, head, list, delete, transactional reorder).
  • Composer POST /message enqueues; session.idle flushes. Loops/MCP still send directly (programmatic, not composer).
  • REST: GET/DELETE /api/session/{id}/queue{/qmid}, POST .../move (ownership-scoped).
  • ocman.queue.updated broadcast carrying the session's full list.

Frontend

  • useMessageQueue renders the list purely from server state (initial fetch + queue.updated broadcasts).
  • Composer renders the queue under the footer with remove / reorder controls, via a dedicated QueuedMessages leaf component.

Reliability & live-update fixes

Follow-up round hardening the live-update path so the list stays correct without a refresh:

  • http.Flusher on the status-recorder middleware — the wrapper implemented http.Hijacker (for WebSockets) but not http.Flusher, so the global /api/events SSE handler failed its w.(http.Flusher) check and returned before subscribing. No client ever subscribed → broadcasts reached zero subscribers → the UI only updated on refresh. (Per-session /api/session/{id}/events worked only because it bypasses that path.) This was the root cause of "new queued messages don't appear until refresh."
  • Reliable delivery — queue.updated is coalesced in the broadcast hub: on a full subscriber buffer the latest snapshot is parked (last-write-wins) instead of dropped, so the freshest list is never lost under load.
  • Load-on-reconnect — useMessageQueue reloads from the endpoint on every SSE (re)connect (mirroring the conversation SSE), with a monotonic seq guard so a slow reload can't clobber a fresher broadcast.
  • No spurious clears — the hook only resets the list on an actual session change, not on every effect re-run (e.g. platform resolving), which previously wiped a just-shown enqueue.
  • Blocking composer send — the composer disables its send controls until the POST completes, so the broadcast has landed by the time it re-enables; no optimistic queue state needed.
  • Sweep backstop fix — a force-queued message is no longer marked drained at enqueue time, which had disarmed the Sweep and could strand a message whose session.idle edge never arrived.
  • Dropped a vite proxy proxyRes data listener that put SSE responses into flowing mode and consumed the bytes.

Tests

  • Go: state CRUD, queuesvc (order, one-per-idle, send-error-retry, ownership guards, notify-on-every-mutation), server route + SSE-wire integration, broadcast coalescing, statusRecorder Flush/Hijack delegation.
  • Frontend: useGlobalEvents queue routing, useMessageQueue (broadcast apply, reconnect reload, seq guard, effect-rerun survival), QueuedMessages component.
  • All green: go test, go vet, platform-branching check, tsc -b, pnpm lint (0 errors), pnpm test (1480 pass), coverage ratchet ✅.
Closes #58. Server-side, shared, race-free follow-up message queue. Prompts submitted while a session is mid-turn are queued and auto-sent one per turn when the session goes idle. ## Design - **Server-side, not frontend** — the queue lives in `state.db` (migration v21), shared across every connected client and surviving a client moving machines. A frontend-only queue would show phantom messages to other clients watching the same session. - **Race-free by construction** — enqueue is unconditional (no busy check → no check-then-act race); flush is the sole send gate, serialized per session and driven by the existing `session.idle` SSE edge. One message drains per idle edge, oldest first (one follow-up per turn). - **Remote-transparent** — the queue row carries the compound platform id, so flush routes back over gRPC for remote sessions. ## What's included **Backend** - `internal/queuesvc` — enqueue / flush (per-session lock) + List/Remove/Move. - `internal/state` — v21 `queued_message` table + CRUD (append, head, list, delete, transactional reorder). - Composer `POST /message` enqueues; `session.idle` flushes. Loops/MCP still send directly (programmatic, not composer). - REST: GET/DELETE `/api/session/{id}/queue{/qmid}`, POST `.../move` (ownership-scoped). - `ocman.queue.updated` broadcast carrying the session's full list. **Frontend** - `useMessageQueue` renders the list purely from server state (initial fetch + `queue.updated` broadcasts). - Composer renders the queue under the footer with remove / reorder controls, via a dedicated `QueuedMessages` leaf component. ## Reliability & live-update fixes Follow-up round hardening the live-update path so the list stays correct without a refresh: - **`http.Flusher` on the status-recorder middleware** — the wrapper implemented `http.Hijacker` (for WebSockets) but not `http.Flusher`, so the global `/api/events` SSE handler failed its `w.(http.Flusher)` check and returned before subscribing. No client ever subscribed → broadcasts reached zero subscribers → the UI only updated on refresh. (Per-session `/api/session/{id}/events` worked only because it bypasses that path.) This was the root cause of "new queued messages don't appear until refresh." - **Reliable delivery** — `queue.updated` is coalesced in the broadcast hub: on a full subscriber buffer the latest snapshot is parked (last-write-wins) instead of dropped, so the freshest list is never lost under load. - **Load-on-reconnect** — `useMessageQueue` reloads from the endpoint on every SSE (re)connect (mirroring the conversation SSE), with a monotonic seq guard so a slow reload can't clobber a fresher broadcast. - **No spurious clears** — the hook only resets the list on an actual session change, not on every effect re-run (e.g. `platform` resolving), which previously wiped a just-shown enqueue. - **Blocking composer send** — the composer disables its send controls until the POST completes, so the broadcast has landed by the time it re-enables; no optimistic queue state needed. - **Sweep backstop fix** — a force-queued message is no longer marked drained at enqueue time, which had disarmed the Sweep and could strand a message whose `session.idle` edge never arrived. - Dropped a vite proxy `proxyRes` data listener that put SSE responses into flowing mode and consumed the bytes. ## Tests - Go: state CRUD, queuesvc (order, one-per-idle, send-error-retry, ownership guards, notify-on-every-mutation), server route + SSE-wire integration, broadcast coalescing, `statusRecorder` Flush/Hijack delegation. - Frontend: `useGlobalEvents` queue routing, `useMessageQueue` (broadcast apply, reconnect reload, seq guard, effect-rerun survival), `QueuedMessages` component. - All green: `go test`, `go vet`, platform-branching check, `tsc -b`, `pnpm lint` (0 errors), `pnpm test` (1480 pass), coverage ratchet ✅.

Coverage ratchet: ✅ pass

Suite Baseline This PR Δ
go 72.10% 72.40% +0.30 ✅
frontend 62.24% 62.39% +0.15 ✅

Tolerance: -0.1%. Baseline stored on gh-pages.

<!-- coverage-ratchet --> ### Coverage ratchet: ✅ pass | Suite | Baseline | This PR | Δ | | |---|---|---|---|---| | go | 72.10% | 72.40% | +0.30 | ✅ | | frontend | 62.24% | 62.39% | +0.15 | ✅ | _Tolerance: -0.1%. Baseline stored on `gh-pages`._
dries changed title from feat: queued follow-up messages in composer (#58) to WIP: feat: queued follow-up messages in composer (#58) 2026-07-11 22:43:16 +02:00
dries changed title from WIP: feat: queued follow-up messages in composer (#58) to feat: queued follow-up messages in composer (#58) 2026-07-11 22:59:12 +02:00
dries force-pushed feat/queued-follow-up-messages from 103d6648b1
Some checks failed
CI / Build Desktop (macOS arm64) (pull_request) Successful in 1m16s
CI / Coverage Ratchet (pull_request) Has been cancelled
CI / Frontend (pull_request) Has been cancelled
CI / Backend (pull_request) Has been cancelled
CI / Playwright E2E (pull_request) Has been cancelled
CI / Build (pull_request) Has been cancelled
CI / Semantic Tag (pull_request) Has been cancelled
to 72a8d2659b
All checks were successful
CI / Build Desktop (macOS arm64) (pull_request) Successful in 1m11s
CI / Frontend (pull_request) Successful in 5m19s
CI / Backend (pull_request) Successful in 7m4s
CI / Coverage Ratchet (pull_request) Successful in 9m15s
CI / Build (pull_request) Successful in 3m35s
CI / Playwright E2E (pull_request) Successful in 6m1s
CI / Semantic Tag (pull_request) Has been skipped
CI / Build Desktop (macOS arm64) (push) Successful in 1m5s
CI / Frontend (push) Successful in 5m41s
CI / Backend (push) Successful in 7m26s
CI / Coverage Ratchet (push) Successful in 9m54s
CI / Build (push) Successful in 3m34s
CI / Playwright E2E (push) Successful in 6m0s
CI / Semantic Tag (push) Successful in 7s
Release / Build Frontend (push) Successful in 1m3s
Release / Build Desktop (macOS arm64) (push) Successful in 2m14s
Release / Build darwin/arm64 (push) Successful in 3m21s
Release / Build darwin/amd64 (push) Successful in 4m39s
Release / Build linux/amd64 (push) Successful in 4m41s
Release / Build linux/arm64 (push) Successful in 1m47s
Release / Publish Release (push) Successful in 36s
2026-07-12 01:12:51 +02:00
Compare
dries merged commit 72a8d2659b into main 2026-07-12 01:34:24 +02:00
dries deleted branch feat/queued-follow-up-messages 2026-07-12 01:34:24 +02:00
Sign in to join this conversation.
No description provided.