ADR 0265: Priority block and row actions for the Host-owned turn queue
- Status: Accepted for implementation
- Date: 2026-09-15
- Amends: ADR 0213 (persisted Host-owned turn queue), ADR 0118 (renderer-owned queued prompts and turn-boundary stops)
Context
ADR 0213 moved the queue into host-core and gave "Send now" exactly one shape: prioritize rewrites the entry's position below the current minimum, so the entry jumps to the head of the whole queue. The renderer mirrored that as a single in-memory sendNowRequested flag cleared on every other row, which meant only one row could ever be waiting for the next boundary, and two "Send now" clicks resolved as "the last click wins".
The queued row also only offered Remove and Send now. Users could not correct a queued prompt without deleting it and retyping, and could not reorder the plain queue at all even though the Host already orders it by a durable position.
Decision
- A promotion block, ordered by click.
turn_queuegains a nullableprioritycolumn (schema v18). Promoting an entry writesCOALESCE(MAX(priority), 0) + 1inside the session, so the first promotion is delivered first and each later promotion queues behind it. Delivery order ispriority ASC, position ASCfor promoted entries followed byposition ASCfor the rest; the plain queue keeps its own order. - Promotion is one-way and idempotent-hostile. Promoting an entry that already carries a
priorityfails withCONFLICT/ALREADY_PRIORITIZEDinstead of silently moving it again.session.queuePrioritizekeeps moving optimistic mirror does the same. - Promotion fixes order, but does not prevent cancellation. A promoted row disables move up, move down, edit, and Send now. Its label is Waiting to send. Remove remains available until delivery, and the renderer waits for Host confirmation before removing it. Cancellation, promotion, reordering, and delivery reservation share session admission serialization. Runtime delivery runs outside that lock and reserves only its own input. Once an input is reserved or accepted as steering input, cancellation returns
CONFLICT; consuming an input in another turn must not be mistaken for an earlier successful cancellation. Refused steering retains the queued input for the next turn. There is no un-promote path. Acceptance is recorded before durable cleanup; failed cleanup emits an error and remains retryable without re-execution in the live Host. The queue retains its record when persistence fails. Acceptance is an in-memory guard, not a durable receipt: restart after failed cleanup retains the existing replay risk and does not provide exactly-once delivery. - A reorder operation for the plain queue.
session.queueReorderswaps one entry'spositionwith its adjacent non-prioritized neighbour in the requested direction, and reportsmoved: falsefor a missing entry, a promoted entry, or a block/queue edge. Promoted entries are never neighbours, so reordering can never cross into the priority block. The renderer mirrors one swap and never reaches the Host for a boundary no-op. - Edit returns the row to the composer. Editing is renderer-local: the row is removed (through the existing removal path) and its captured
ComposerDraftSnapshot— text plus inline file references — replaces the composer input through the existingcomposerPrefillchannel. The token-strippedcontentis never used to rebuild the prompt. The action is refused with a toast while the input is non-empty, and the emptiness check runs against the live editor read because the draft cache is not written per keystroke. - The promoted block is delivered into the active turn. Promotion uses steering so the parent handles user input while delegates keep working (issue #1607). An active
TaskWaityields to accepted steering; ordinary tools keep their normal boundaries. Immediate delivery accepts only the paired caller's own input with the active turn's permission mode. External Desktop turns use the stored session mode; module-admitted turns use their principal-specific ceiling. Other input retains turn-boundary delivery. When there is no active turn, the first promoted entry starts the turn and later promoted entries join it as steering input, in click order. Runtime refusals are retried within a bounded budget. Undelivered input remains queued for its own turn at the next boundary. An injected entry's own RACP turn is canceled: its input was delivered by another turn, and no client may be left believing it is still waiting. - The turn's owner is authoritative about its end. A runtime terminal event is not a reliable release: Main drops one that names a turn it no longer owns (
isStaleTerminalEvent), and an abort need not produce one at all. A turn left active inside the module holds its session's queue forever, which is how "Send now, then Stop" stranded a queued row. So the settlement reported byfinishTurncloses the turn in the module (marking it as the settlement reason, canceling its approvals and inputs) and lets the queue run, and the module's drain request is remembered while a pass is running instead of being dropped.
Consequences and validation
No breaking change to existing channels: agentQueuePrioritize / session.queuePrioritize keep their names and gain the block semantics, and agentQueueReorder / session.queueReorder are additive. Existing rows migrate with priority = NULL, so an upgraded database keeps its FIFO order and its eight-entry bound. Row-level unit coverage lives in turn_queue tests (promotion order, reorder, prioritized-row rejection, v16 upgrade), turn-queue.test.ts / agent-host.test.ts (block order, permissions), and composer-send-state.test.mjs (lock, reorder, and edit contracts). The rendered journeys are specified as E2E-QUEUE-promote-orders-delivery-by-click, E2E-QUEUE-reorder-moves-plain-neighbours, and E2E-QUEUE-edit-restores-draft-only-when-input-empty, and remain Draft until rendered E2E validation is performed.
