feat(decopilot): in-UI message queue (surface + cancel) + externalize attachments at POST#4155
Open
tlgimenes wants to merge 13 commits into
Open
feat(decopilot): in-UI message queue (surface + cancel) + externalize attachments at POST#4155tlgimenes wants to merge 13 commits into
tlgimenes wants to merge 13 commits into
Conversation
Extract cancelActiveThreadRun helper inside createDecopilotRoutes (closes over cancelBroadcast + runRegistry) and add cancelThreadGateHead call so the stop endpoint also cancels any PENDING gate head stranded across a deploy. Collapse the old 200-vs-202 split to a single 202 (frontend only checks res.ok/404). Add e2e spec decopilot-thread-queue.spec.ts with seedGate helper + "stop frees stuck PENDING head" case. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add GET /:org/decopilot/queue/:threadId that returns the thread's pending gate workflows (PENDING head + ENQUEUED tail) as ThreadQueueItem[]. Owner-only via validateThreadOwnership. E2E cases: list ordering + 403 for non-member. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… DBOS input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…p TODO Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…SSE refresh Address three UI remarks on the thread-queue panel (#4155): 1. The queue panel now lists only *waiting* (ENQUEUED) messages. The running/PENDING head is excluded — it's already rendered in the chat body, so showing it in the queue too was duplicative. 2. The composer is no longer disabled while a run is in progress. A draft sends even mid-run (the message enqueues behind the running gate; concurrency=1 serializes the thread), so users can stack follow-ups. New pure helper `resolveComposerAction({hasDraft,isStreaming, isRunInProgress})` → send | stop | disabled, unit-tested; the primary button flips Stop→Send the moment there's a draft. 3. The queue is now refreshed from SSE, not a 3s poll. Dropped `refetchInterval`; the query re-fetches via `KEYS.threadQueue` invalidation on send, on cancel, and on every `conn.status` run start/end edge (the SSE stream the chat already consumes). Verified live against a seeded stranded gate (1 PENDING head + 2 ENQUEUED): panel shows only the 2 queued, input editable mid-run, per-item cancel + refresh, and no recurring /queue/ polling in the network log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The queue panel was duplicating the current message: a just-sent message
sits ENQUEUED ("accepted and queued") for a window before it flips to
PENDING, and the previous filter only excluded PENDING — so the active
head leaked into the panel while also rendering in the chat body.
Define the active run as the *oldest non-terminal* gate workflow (PENDING
or ENQUEUED) and exclude it: new pure `selectWaitingQueueItems(items)`
sorts by enqueuedAt and drops the head. The current message keeps
rendering in the body as before; the panel shows only what comes next.
Verified live: a lone ENQUEUED head shows no panel (only the body), and a
head + one waiting item shows just the waiting one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…disabled input Reworks the queue UX per design feedback: - **Send renders in the body.** A message sent to an idle thread submits normally and renders in the thread's user-message place + streams. - **No disabled composer state, only a streaming state.** Tools, the microphone, and the mode pills are always enabled now; only the primary button reflects streaming (Send when there's a draft, Stop otherwise). - **Send while a run is active → the queue, not the body.** New `conn.enqueue()` POSTs the message to the gate quietly (no optimistic body row, no run-status change); the message is mirrored into a per-thread frontend queue and surfaces in the panel. It renders in the body only when its turn dequeues and streams. - **Frontend message queue.** New module-scoped per-thread store (`message-queue-store.ts`) with `useMessageQueue(threadId)` + `useMessageQueueActions()` hooks (enqueue / cancel / refresh). Seeded from the gate on mount, written optimistically on send, and re-synced on every SSE run start/end edge and on terminal `onFinish`. Replaces the React-Query `useThreadQueue` (deleted) and the `KEYS.threadQueue` key. `selectWaitingQueueItems` still excludes the active head so the running message never double-renders. Verified live (Codex-desktop thread): message renders in the body + streams; Tools/mic stay enabled mid-run; a message sent behind a running gate lands in the panel (not the body) and cancels cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
b7b8010 to
bba7ddb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two related changes that give users control over a thread's pending message queue and stop bloating the durable workflow record.
1. In-UI message queue (surface + cancel)
A thread's messages run one-at-a-time behind a per-thread DBOS
thread-gateworkflow (partition concurrency = 1). When a deploy strands the head gatePENDINGon a dead executor/version, it permanently holds the partition slot and every later message sitsENQUEUEDforever ("accepted and queued"), with no UI recourse. This moves that recovery into the user's hands:GET /:org/decopilot/queue/:threadId— lists the thread's pending gate workflows (running head + queued tail) with each message's text.POST /:org/decopilot/queue/:threadId/cancel/:workflowId— cancels one item; a running head also runs the full run-cancel teardown.PENDINGgate head (today an orphaned gate ignores the in-memory run cancel).The queue is read from
dbos.workflow_statusviaDBOS.listWorkflows(theworkflow_queuetable is empty in this DBOS version). Authz is owner-only (validateThreadOwnership) plus athread-run:{threadId}:prefix guard — a direct clone of the existingbgtool:cancel pattern.2. Externalize attachments at POST
The existing
uploadFilePartsmaterializer (base64data:→ shortmesh-storage:refs) ran only at dispatch — so the raw blobs were already serialized into the DBOS workflow input at POST. It now also runs at POST, boundingdbos.workflow_status.inputs. A pure idempotency guard keeps the still-present dispatch-time call from double-annotating;computeIdempotencyKeystays computed from the original messages so the workflowID is stable.Testing
thread-gate-queue.test.ts) + the attachment idempotency guard (file-materializer.test.ts, TDD).packages/mcp-utilsexists at the merge base).packages/e2e/tests/decopilot-thread-queue.spec.tsis authored and typechecks but was not executed locally (needs Postgres + NATS + dev server). CI is the first real run — see follow-ups.Reviewed
Per-task reviews + a final whole-branch review (no Critical/Important blockers; the attachment idempotency path and cancel-teardown preservation were traced and verified).
Follow-ups (deferred, flagged in code)
connectDevDband the e2e app server share oneDATABASE_URL(elsedbos.*asserts pass vacuously); (c) confirm the{"json":[ctx]}seed envelope parses viaDBOS.listWorkflows({loadInput:true})in SDK 4.21.6.thread_messagesat POST and pass only a reference into the workflow, with tombstone-on-cancel — making the queue list readthread_messagesand shrinking the DBOS input to a pointer. This PR's list endpoint is intentionally source-swappable for that.🤖 Generated with Claude Code
Summary by cubic
Adds an in-UI message queue for Decopilot threads with per-item cancel and “send while running” support; Stop also frees a stuck gate head. Attachments are externalized at POST so
dbos.workflow_status.inputsstays small.New Features
/:org/decopilot/queue/:threadIdreturns the thread’s running head and queued tail fromDBOS.listWorkflows; the UI shows only waiting items (excludes the active head)./:org/decopilot/queue/:threadId/cancel/:workflowIdcancels a queue item; if it’s the running head, it runs the full stop teardown. The Stop button now also cancels a strandedPENDINGgate head to unblock the queue.Performance
uploadFilePartsat POST to convertdata:blobs tomesh-storage:refs; idempotent via an annotation check and keeps the workflowID stable.Written for commit bba7ddb. Summary will update on new commits.