feat(tui,app): queue user messages while the session is busy - #50
Merged
Conversation
Current behavior: editorCmp.send() checks IsSessionBusy and returns a
warning toast, leaving the typed text in the textarea but discarding the
submission. The user has to notice when the run finishes and re-press Enter
manually. Text is not lost; the submission is.
This change adds a per-session in-memory FIFO queue on app.App so that
every Enter press while the session is busy (or already has queued messages)
enqueues rather than rejects. The textarea is reset immediately — text moves
into the queue, not into /dev/null.
Design decisions
----------------
No-persist-before-delivery: queued messages are held in memory only. Writing
to the DB before delivery would make agent-loop compaction (~agent.go:945) and
the non-interactive reload (~agent.go:1244), both of which re-List from the DB,
sweep an undelivered message into the in-flight run non-deterministically.
Exclusivity unchanged: the drain worker calls agent.Run and treats ErrSessionBusy
as the sole retryable signal. The authoritative exclusivity mechanism remains the
atomic LoadOrStore inside RunWith. No check-then-act pattern is introduced.
FIFO routing: send() enqueues when QueueLen > 0 OR IsSessionBusy, so a
submission arriving in the idle window between two drain deliveries cannot
bypass already-queued messages via direct dispatch.
Drain errors: ErrSessionBusy is silently retried with 100 ms back-off. Any
other error is surfaced with attribution ("queued message could not be
delivered: ..."), the worker halts, and the remaining queue is preserved so
the user can discard it or trigger a fresh drain.
Delivery timing: messages are delivered after the current run completes, not
between tool calls. Tool results already occupy a user turn, so mid-run
injection produces consecutive same-role turns — combined by the direct
Anthropic API but historically rejected by Bedrock, and unverified on
VertexAI. Mid-run turn-boundary injection is a documented follow-up.
Cancel semantics: Esc/Ctrl+C cancels the in-flight run; the queue survives
by default (interrupting to redirect via a queued instruction is the common
case). ctrl+x is the explicit discard binding, shown in the queue banner.
Discard key ctrl+x: verified absent from editorMaps (enter/ctrl+s, ctrl+e),
DeleteKeyMaps (ctrl+r, esc, r), messageKeys (pgdown, pgup, ctrl+u, ctrl+d),
and the bubbles v2 textarea default KeyMap (ctrl+d is DeleteCharacterForward
there, not InsertNewline as earlier noted — but it also binds HalfPageDown in
messageKeys, making it doubly off-limits).
Components added/changed
------------------------
- internal/app/queue.go: QueuedMessage, DrainEvent, EnqueueMessage,
DequeueMessage, QueueLen, DiscardQueue, startDrainWorker, drainLoop,
ShutdownQueues, NewForTest, SetDrainNotifier.
- internal/app/app.go: ctx/queues/queueMu/queueCancels/queueWg/drainNotify
fields; ShutdownQueues called in Shutdown.
- internal/tui/components/chat/editor.go: send() rewritten with FIFO routing;
OpenEditor guard comment added.
- internal/tui/components/chat/list.go: queueBanner() affordance; DrainEvent
handling triggers re-render.
- internal/tui/page/chat.go: DrainEvent handling (error surfacing); ctrl+x
DiscardQueue binding; ErrSessionBusy suppressed in sendMessage;
CommandRunCustomMsg guard comment added.
- cmd/root.go: SetDrainNotifier wired after tea.NewProgram.
Tests
-----
- internal/app/queue_test.go: FIFO order, QueueLen, DiscardQueue, concurrent
race-detector coverage, attachments preserved, ShutdownQueues termination.
- internal/app/drain_test.go: FIFO delivery, ErrSessionBusy retry, non-busy
error halts/attributes/preserves queue/restarts on re-enqueue, worker
terminates after drain, context cancellation, FIFO across idle window,
error attribution format.
- internal/tui/components/chat/editor_busy_test.go: busy enqueues+resets+nil,
empty-while-busy no-op, idle direct dispatch, queue-non-empty idle enqueues
(FIFO), idle-path error returns non-nil cmd.
…nt preservation, audible drops, 429 enrichment
The bridge inbound channel (16-slot, never-drop) was already queuing messages;
this change closes four loss paths and a cross-session starvation bug that the
queue made reachable.
## ErrSessionBusy — message content was discarded (CVE of the change)
The chat-bridge spec incorrectly asserted ErrSessionBusy can never surface in
the bridge path. The session-run-exclusivity spec contradicts this: its scenario
'Session-run-exclusivity for bridge inbound' names the bridge dispatcher
explicitly as a caller that MUST handle ErrSessionBusy from cross-actor holders
(flow steps, cron sentinel locks, task auto-resume). The old code's response was
to discard the message and tell the user 'please resend' — a data-loss bug.
Fix: bounded retry (100 ms backoff, 5-minute budget); on budget expiry the
message is re-queued via the per-session overflow path, never dropped.
## Cross-session starvation — co-required with the retry fix
runInboundLoop is the single shared goroutine for the whole service. The old
pushInbound blocked when d.inbound (cap 16) was full. With the retry fix,
handleInbound keeps the run goroutine inside the retry loop, so d.inbound fills
faster; any push to a full channel from the shared loop then froze dispatch for
every session on every adapter.
Fix: pushInbound is now non-blocking. A full channel spills to a per-session
overflow slice (d.overflow, guarded by d.mu). run() drains overflow back into
d.inbound under d.mu after each handleInbound returns, preserving per-session
FIFO: the channel-then-overflow ordering invariant guarantees overflow items are
always older than new arrivals that reach pushInbound while the drain is in
flight.
## Three previously silent loss paths made audible
- No active agent: now replies to the sender instead of silently discarding
- Interactive-flow buffer drop-oldest: now notifies the evicted peer
- Shutdown with queued messages: close() drains d.overflow and d.inbound before
close(d.inbound) and logs one WARN per dropped message plus a per-session
summary count. In-chat notification is NOT attempted: Service.Stop() cancels
the context before tearDownDispatchers(), so adapter API calls would return
immediately. Durability across restart is a named non-goal; the WARN log is
the hard requirement.
## POST /router/inbound 429 enrichment
Bare 429 'retry' replaced with Retry-After: 1 header plus machine-readable JSON
body {error, retryAfterSeconds, dispatcherSaturated: true} so the orchestrator's
single-retry policy is actionable.
## Tests
- TestHandleInbound_BusyRetryPreservesContent: proves old code discarded (Run
called 1×, 'please resend' reply sent); with fix, called 3× and no reply.
- TestDispatch_NonBlockingPush_NoStarvation: proves old code blocked (2.5s
timeout firing); with fix, completes in O(1).
- TestOverflowFIFO, TestSessionSerializationInvariant, TestHandleInbound_NilAgentReplies,
TestBufferInbound_DropNotifiesEvictedPeer, TestShutdown_WarnsOnQueuedMessages,
TestRouterInbound_BackpressureReturns429 (updated).
…d + QueuedAcknowledger
Adds opt-in user-visible acknowledgement for messages queued behind an in-flight
agent run. When QueueAcknowledgementsEnabled is true the sender receives an
in-place-editable '⏳ Your message is queued' note that is edited on each retry
and resolved to '▶ Processing your message now…' the moment their run starts.
The bridge was already queueing messages (16-slot inbound channel, never-drop);
this change adds visibility for the sender during the wait.
## Design decisions
Short-wait threshold (Decision 3): the ack is suppressed when the blocking run
completes within 2 seconds to avoid a pointless flash for sub-second waits. The
threshold is a package-level var (busyAckThreshold) so tests override it without
sleeping.
In-place edit (Decision 4): all three adapters support edit primitives. No new
message is posted per position change; the single ack message is edited, keeping
the channel thread clean.
Per-sender delivery (spec): in multi-peer sessions the ack goes only to the
specific peer whose inbound was queued, not broadcast.
Sentinel position 0 = 'resolved': UpdateQueuedAck(ctx, peer, token, 0) edits
the message to the 'Processing' text, so callers don't need a separate Delete
primitive. Documented in the QueuedAcknowledger interface comment.
Adapter tokens:
- Telegram: string-encoded int message ID ('1234')
- Slack: channelID + '\x00' + ts (null-separated; neither field contains nulls)
- Mattermost: post ID string
## Config field (CLAUDE.md obligations fulfilled)
QueueAcknowledgementsEnabled bool (json: queueAcknowledgementsEnabled) on
bridge.Config. Default false — silent queuing as before.
1. Field added to internal/bridge/config.go
2. cmd/schema/main.go updated with full 'router' property block; opencode-schema.json
regenerated (74 insertions, no unrelated churn)
3. docs/bridge.md: queueAcknowledgementsEnabled row added to router fields table
4. internal/config/config_router_test.go: Viper round-trip test confirms the field
survives viper.ReadInConfig + viper.Unmarshal (pure json.Unmarshal passes but
Viper's case-fold would mangle camelCase keys differently in production)
## Tests
Config-gate proof (both directions, deterministic — no sleep):
- DISABLED: TestQueuedAck_ConfigGate_Disabled — zero SendQueuedAck/UpdateQueuedAck calls
- ENABLED: TestQueuedAck_ConfigGate_Enabled — SendQueuedAck called once, final
UpdateQueuedAck has position=0 (resolve)
TestHandleInbound_QueuedAckLifecycle: end-to-end lifecycle with busyAckThreshold=0;
asserts SendQueuedAck called once, UpdateQueuedAck called with position>0 then
position=0 on success.
TestBufferInbound_NoDrainWithoutQuestion (task 9.2): interactive-buffer FIFO
drain into the next Ask call; head of buffer ('first') auto-answers, remainder
('second','third') stays buffered, question not fanned out to adapter.
Per-adapter unit tests: Telegram queue_ack_test.go, Slack queue_ack_test.go,
Mattermost queue_ack_test.go — each verifies SendQueuedAck posts a message
returning the correct token format, and UpdateQueuedAck edits in-place with
position>0 and resolves with position=0.
Restart durability remains a named non-goal. Queued messages are in-memory;
shutdown loss is logged at WARN (Part 1). The visibility layer does not change
that non-goal.
…l, honest acks Seven correctness fixes from review of the message-queue work. Each one is covered by a test that was verified to fail when the fix is reverted. ## Silent message loss on the direct-dispatch path (tui/page/chat.go) sendMessage swallowed ErrSessionBusy and returned. Nothing retried it: the editor only reaches direct dispatch when QueueLen == 0 AND !IsSessionBusy, so the message was never enqueued — and the textarea had already been reset, so the submission was gone with no toast. The race is reachable through ordinary queue use: the drain worker dequeues the last message (QueueLen -> 0) but has not called Run yet (IsSessionBusy -> false), the user presses Enter, and whichever Run loses acquireSessionSlot gets ErrSessionBusy. proposal.md assumed this path was "retryable via the drain", but the drain worker calls ag.Run directly and never goes through sendMessage. It now enqueues instead of dropping. Non-busy errors still surface. ## Lost-wakeup race stalls the queue (app/queue.go) drainLoop used two critical sections: DequeueMessage (empty -> false), then a second Lock to delete(queueCancels, sessionID). An EnqueueMessage landing in between sees the still-registered cancel, declines to start a worker, and its message is stranded with nothing draining it — plus a QueueLen: 0 DrainEvent, so the banner under-reports. Reproduced by widening the window with a 5 ms sleep: 423/500 delivered, queueLen=77, workerRunning=false. Dequeue and deregistration now happen in one critical section (dequeueOrRelease). ## Queued-ack edit storm (bridge/service/dispatch.go) UpdateQueuedAck fired on every 100 ms retry with an unchanged position — up to ~3000 identical edits per queued message over the 5-minute budget. Telegram rejects an unchanged editMessageText with "message is not modified"; Slack chat.update and Mattermost UpdatePost are rate-limited. Now edits only when the rendered position actually changes. ## Ack falsely resolved when the run never started Both the budget-expiry branch and the non-busy-error branch called resolveQueueAck, which renders "▶ Processing your message now…". On expiry the message was re-queued, not processed; on error a failure reply lands right after. Both now leave the "⏳ queued" text in place. ## Ack re-sent on every re-queue cycle queueAckState was a handleInbound local, so each 5-minute cycle SENT a brand-new "⏳ queued" message and orphaned the previous one, never resolved — a session held for two hours accumulates ~24 dead acks. design.md:220 claims "the user sees the ack update on each cycle"; the code did not do that. A per-peer token memo on sessionDispatch (liveAcks) makes the token survive a re-queue; it is cleared on resolve and on a failed edit so a deleted message falls back to a fresh send. ## Send on a closed channel (bridge/service/dispatch.go) close() closed d.inbound outside d.mu and pushInbound had no stop check, so a push racing an unbind panics the dispatcher goroutine (recovered by launchSupervised, but the message is lost). Newly reachable from handleInbound's budget-expiry pushInbound. close(d.inbound) moved inside d.mu and pushInbound re-checks stop under the same mutex, dropping audibly instead. ## Platform I/O on the shared inbound loop (bridge/service/question.go) BufferInbound's eviction notice called replyToPeer synchronously on the single orchestrator-inbound-dispatch goroutine, so one slow platform call stalls inbound dispatch for every session and identity. Worst for the orchestrator-mediated external channel, whose Send is an HTTP round-trip back to the orchestrator with a 10 s timeout. Now fire-and-forget with recover, mirroring emitToolUpdate. Uses context.WithoutCancel(ctx) rather than r.svc.ctx — svc.ctx is only set in Service.Start and a nil ctx panics inside replyToPeer. ## TUI affordances (tui/components/chat/editor.go, tui/page/chat.go) - ctrl+e reported "Agent is working, please wait..." when the queue was non-empty but the session idle (drain halted on an error). The guard itself is correct — openEditor's result goes out via SendMsg -> sendMessage, which dispatches directly and would jump queued messages — so only the message changed: the queue-only case now says so and points at ctrl+x. - A drain worker halting for a session the user is not viewing produced no signal at all. A halt is terminal, the banner looks identical to a healthy mid-drain queue, and the event is never re-emitted, so the error is now surfaced for any session, prefixed with its id. ## Tests - app: dequeueOrRelease atomicity, enqueue-during-worker-exit stress - bridge/service: no redundant same-position edits, update on position change, no false resolve on run failure or budget expiry, ack survives a re-queue cycle; the eviction-notice test now polls for the async send - tui/page (first tests in this package): busy-race enqueues rather than drops, non-busy error still surfaces busyRetryBudget becomes a var so tests can shrink it, matching busyAckThreshold. No change to the /router/inbound wire contract, bridge.Inbound, or the 202/429 semantics — the orchestrator forwarder is untouched. liveAcks is per-dispatcher and keyed channel|identity|peerID, and the external adapter does not implement QueuedAcknowledger, so the ack changes are inert for orchestrator-mediated peers.
The drain notifier was wired as a bare `program.Send(e)`. `Program.Send` writes to an unbuffered channel that only the Bubble Tea event loop reads, and that loop is blocked inside `Model.Update` for the duration of the update. Both queue entry points — the editor's enqueue path (`EnqueueMessage`) and the ctrl+x discard path (`DiscardQueue`) — notify synchronously from `Update`, so submitting a message while the agent was busy blocked the update goroutine forever. Nothing could recover it: `Quit` is also a `Send`, so even the quit path was wedged. Forward DrainEvents through a buffered channel drained by a single goroutine (same shape as `setupSubscriptions`), so notification never blocks the caller and event order is preserved. On buffer overflow the event is handed to a goroutine rather than blocking, keeping halted-drain error events deliverable. Tests: forwarder never blocks a stalled consumer, preserves FIFO order, tolerates stop/idempotent stop, plus an end-to-end probe that notifies from inside `Update` on a real `tea.Program` (hangs on the old wiring).
Auto-approve was enough to make the question tool answer itself with the
first (recommended) option. That silently stole the decision from users
who enable auto-approve in the TUI purely to skip permission dialogs —
auto-approve is about tool permissions, not about deciding for the human.
Gate the short-circuit on a new session-scoped mark instead:
permission.Service.MarkUnattendedSession, set only where no surface could
answer a question —
- `opencode -p` headless runs (cmd/flow.go)
- flow steps (flow.Service.runStep); interactive steps are marked too,
their own session keeps the interactive marker the tool checks first
- cron jobs firing on a session nothing is watching, re-evaluated per
fire with the scheduler's existing TUI-active / bridge-bound predicate
so a blocking question cannot wedge the session's agent lock
IsUnattendedSession resolves through the LinkSession chain, so task-tool
subagents inherit their caller's verdict and a subagent asking inside a
flow step still auto-answers rather than hanging on a prompt nobody sees.
The mark is cleared when a human attaches after the fact: the bridge's
`/session <prefix>` switch and the TUI selecting a session.
TUI (with or without auto-approve), chat bridge and API sessions now all
get the real prompt.
Two follow-ups on the queue feature: Viewer (ctrl+g). The banner told users messages were queued but not WHAT was queued, and the queue is in-memory only, so there was no way to check before delivery. dialog.QueueDialog lists the queue in delivery order, reading App.QueuedMessages at render time so it tracks an ongoing drain instead of showing already-sent text. Previews are single-line, ANSI- stripped and truncated so a large paste cannot break the layout, and the dialog handles ctrl+x itself (it swallows key presses, so the chat page's binding never fires while it is open). ctrl+g was picked because it is not an alias of another key without the kitty keyboard protocol, unlike ctrl+m (enter) and ctrl+i (tab). Footer. The queue banner was rendered as an extra row between the working spinner and the help bar, but the chat view's height budget is viewport + 2 rows, so the container's MaxHeight silently clipped the help bar — and it stayed gone for the rest of the session. The banner and the help bar now share one row via footer(), truncated rather than wrapped, with the "esc to cancel" hint folded into the banner while a run is in flight.
Conflict in internal/tui/components/chat/editor.go: main's chat-editor wrapping fix (#49) moved textarea sizing out of View into syncTextareaHeight/promptColumnWidth, while this branch added the enqueue-on-submit path to send() and the queue hint to the busy-editor warning. Resolution keeps both: every branch that resets m.attachments — including the new enqueue path — calls syncTextareaHeight, and View no longer sizes the textarea.
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.
Problem
While the agent is running, pressing Enter in the TUI triggers the busy guard in
editorCmp.send(), which returned a warning toast and did nothing else. The typed text was preserved in the textarea — this is not data loss — but the submission itself was discarded. The user had to notice when the run finished and re-press Enter manually. Claude Code queues this input instead; users expect the same behaviour.What changed
Core queue (, )
A per-session in-memory FIFO queue lives on
app.App(queues map[string][]QueuedMessage). Whensend()fires while the session is busy (or already has queued messages), it callsapp.EnqueueMessage, resets the textarea, and returnsnil— no warning toast. A lazily-started drain worker goroutine picks up messages one at a time after the current run releases the slot.FIFO routing in
send()()The routing condition is
QueueLen(sessionID) > 0 || IsSessionBusy(sessionID). Direct dispatch is permitted only when both are false. Without theQueueLenbranch, a submission arriving in the idle window between two drain deliveries (queue non-empty, slot momentarily free) would bypass already-queued messages and cause a FIFO inversion.Drain worker lifecycle
ErrSessionBusyfromRunis the only swallowed error: the message is re-prepended at the head and the worker backs off 100 ms. The authoritative exclusivity mechanism remains the atomicLoadOrStoreinsideRunWith; no check-then-act pattern is introduced.app.Shutdown()viaShutdownQueues().TUI affordance (, )
A muted italic banner renders below the working spinner: "N messages queued — press ctrl+x to discard".
ctrl+xcallsapp.DiscardQueueand the banner disappears.ErrSessionBusyfrom the idle direct-dispatch path is suppressed (it means the worker raced; it is retryable and not an error the user needs to see).Drain notifier ()
app.SetDrainNotifieris called aftertea.NewProgramso drain workers can pushapp.DrainEventmessages into the TUI loop viaprogram.Send.Scope limit — delivery is after the current run, not between tool calls
Queued messages are delivered only after the current run completes, not between tool calls. Tool results already occupy a user turn, so mid-run injection produces consecutive same-role turns. The direct Anthropic API silently combines them, but Bedrock has historically rejected them outright and VertexAI behaviour is unverified. Each is a distinct correctness risk. Mid-run turn-boundary injection is a documented follow-up (see
design.md); this PR intentionally does not attempt it.Design rationale
No-persist-before-delivery. Queued messages are never written to the DB before delivery. Agent-loop compaction (
agent.go ~line 945) and the non-interactive reload (~line 1244) both re-Listmessages from the DB, so a persisted-but-undelivered message would be swept into the in-flight run non-deterministically.Cancel semantics. Esc/Ctrl+C cancels the in-flight run; the queue survives by design — the most common interruption pattern is "I noticed a mistake; redirect me" and the queued message IS that redirection.
ctrl+xis the explicit way to clear the queue.Discard key —
ctrl+xVerified absent from:
editorMaps— Send:enter/ctrl+s, OpenEditor:ctrl+eDeleteKeyMaps— AttachmentDeleteMode:ctrl+r, Escape:esc, DeleteAllAttachments:rmessageKeys— PageDown:pgdown, PageUp:pgup, HalfPageUp:ctrl+u, HalfPageDown:ctrl+dKeyMap—ctrl+disDeleteCharacterForwardthere (and alsoHalfPageDowninmessageKeys);ctrl+xis absent from all entriesVerification
make test— exit 0; 455 tests pass across 16 packagesgo test -race ./internal/llm/agent/— 246 tests includingsession_locks_test.go(run unmodified)go test -race ./internal/app/ ./internal/tui/components/chat/— 25 testsDeliberate-breakage check (a) — FIFO routing:
Changed
QueueLen > 0 || IsSessionBusy→IsSessionBusyinsend():Deliberate-breakage check (b) — drain error surfacing:
Swallowed non-busy error in
drainLoop:Both restored; all tests green.
Expected conflict with #49
PR #49 (
fix/chat-editor-input-wrapping) also modifiesinternal/tui/components/chat/editor.go'ssend(). It adds asyncTextareaHeight()call insidesend(); this PR rewrites the busy guard. Resolution: keep BOTH the enqueue routing from this PR and thesyncTextareaHeight()call from #49.Sibling PR: #49 (
fix/chat-editor-input-wrapping)Bridge queue visibility and loss-path fixes (
bridge-queue-visibility-and-loss-paths)Corrected premise
The bridge already had a 16-slot
d.inboundchannel with never-drop semantics. This change does not add a new queue. It closes four loss paths and a cross-session starvation bug that the existing queue made reachable.Four loss paths closed
1.
ErrSessionBusy→ message discarded (the critical bug)The
chat-bridgespec incorrectly assertedErrSessionBusycan never surface in the bridge path. Thesession-run-exclusivityspec contradicts this — its scenario "Session-run-exclusivity for bridge inbound" names the bridge dispatcher explicitly as a caller that MUST handle it from cross-actor holders (flow steps, cron sentinel locks, task auto-resume). The old code discarded the message and told the user "please resend".Fix: bounded retry (100 ms backoff, 5-minute budget); on budget expiry the message is re-queued via the per-session overflow path (see below), never dropped. Both the retry and the overflow fix had to ship together.
2. Cross-session starvation
runInboundLoopis the single shared goroutine for the whole service. The oldpushInboundblocked whend.inbound(cap 16) was full. With the retry fix in place,handleInboundkeeps the run goroutine in the retry loop longer, sod.inboundfills faster, and a full channel froze dispatch for every session on every adapter.Fix:
pushInboundis now non-blocking. A full channel spills to a per-sessiond.overflowslice (guarded byd.mu).run()drains overflow back intod.inboundunderd.muafter eachhandleInboundreturns, preserving FIFO: the channel-then-overflow ordering invariant guarantees overflow items are always older than new arrivals.3. Silent drops made audible
close()drains beforeclose(d.inbound)and logs one WARN per dropped message plus a per-session summary. In-chat notification is not attempted (context is cancelled by the timetearDownDispatchersruns). Durability across restart is a named non-goal.4. POST /router/inbound 429 enrichment
Bare
429 "retry"replaced withRetry-After: 1header plus machine-readable JSON body{error, retryAfterSeconds, dispatcherSaturated: true}so the orchestrator's single-retry policy is actionable.New config flag:
router.queueAcknowledgementsEnabled(defaultfalse)When enabled, the bridge sends an in-place-editable
⏳ queuedacknowledgement to a sender whose message is waiting behind an in-flight agent run. The ack is edited on each retry and resolved to▶ Processing your message now…when the run starts. A 2-second threshold suppresses the ack for sub-second waits.All three production adapters implement the new
bridge.QueuedAcknowledgerinterface with platform-native edit primitives (TelegramEditMessageText, SlackUpdateMessageContext, MattermostUpdatePost). No emoji-reaction primitive is used.opencode-schema.jsonregenerated (+74 lines, newrouterproperty block).docs/bridge.mdupdated. Viper round-trip test added.Still carrying the expected
send()conflict with PR #49.