feat(agent): accept messageMetadata on agent.queueMessage - #1783
Conversation
`agent.queueMessage` dropped the caller's `messageMetadata`, so a user answer routed through the explicit queue path (e.g. while the asker was busy) lost its `question_answers` tag and never resolved the pending question set on drain. - Router: read `messageMetadata`, strip the reserved sender-attribution fields (same user-origin front door as `agent.sendMessage`), reject a non-object value with `-32602`; `null`/omitted reads as absent. - Thread `Option<Value>` through `WorkspaceApi::agent_queue_message`, the `Services` impl and `agent_queue_message_op` into `enqueue_message`, so the entry carries it (result `queuedMessage`, `agent.getQueue`) and the drain-time persist writes it onto the user row. - Tests: unit test for the op capturing the tag on the entry; WSS e2e `queued_answer_via_queue_message_clears_marker_over_wss` proving a queued tagged answer clears the pending marker on drain and that a non-object `messageMetadata` is `-32602`.
🤖 Augment PR SummarySummary: This PR preserves caller-supplied message metadata for explicit queued agent messages. Changes:
Tests: Adds service coverage for queued metadata and a WSS regression covering a queued 🤖 Was this summary useful? React with 👍 or 👎 |
Every drain arm published the shrunk `agent:queue:updated` BEFORE the drained entry's user row was persisted (and before the `question_answers` intake cleared the pending marker). In that window a client saw marker set + no queued tagged entry + no tagged row, so the Q&A wizard flashed back. - Split the drain-time publish: the durable `agent_queue` write-through (`persist_queue_snapshot`) still runs at dequeue time (crash semantics unchanged); the event itself now goes out via the new `publish_queue_updated_after_drain_persist` only after `persist_user` succeeds — after the user-row `agent:message` and, for a tagged answer, the marker-clearing `agent:updated`. Applied to `try_drain_queue`, both worker single-entry arms, the batch flush (`prepare_flush_turn`, ONE shrunk snapshot after all rows) and `send_queued_message_now`. - Persist failure keeps the fail-closed path: no shrunk snapshot is ever published; the front requeue republishes the restored queue as before. - e2e: `queued_answer_via_queue_message_clears_marker_over_wss` now asserts the wire order (user-row `agent:message` and `agent:updated` marker clear before the `agent:queue:updated` that no longer lists the entry); it fails against the previous order.
Drain ordering contract (commit a37531e)FE review found that every drain arm published the shrunk Change. The drain-time publish is split: the durable New wire order per drained entry: Checked for dependents on the old order. Tests.
Protocol docs (monorepo workspace branch): |
With the drain ordering reorder a client briefly sees the persisted user row AND the still-listed queue entry, and nothing linked them: the drain arms mint a fresh row id and batch-flush echoes all share the head turnId. - New `stamp_queued_message_id`: every drained entry's `messageMetadata` gains `queueInfo.queuedMessageId` = the entry's own id, next to the existing `queuedAt`/`waitedMs`/`batchId` stamps. Threshold-independent (a sub-threshold drain now carries a `queueInfo` holding only this key), always rewritten to the entry delivering now, skipped for `persisted: true` requeues and non-object metadata. Applied on all three drain arms (try_drain_queue, both worker arms, the batch flush loop) and on `agent.sendQueuedMessageNow` for parity. - The user-row `agent:message` echo lifts the stamp as an additive `queuedMessageId?` field (omitted when absent) so the FE can match the row to the queue entry from the event alone. The row id is unchanged (still freshly minted; never the entry id). - Tests: unit coverage for the stamp; the queued-answer WSS e2e asserts the stamp on the echo and on the persisted row (and that the row id differs from the entry id); the sub-threshold lifecycle e2e now expects a queueInfo carrying only queuedMessageId.
Drain identity link (commit f499f57)With the drain reorder the FE briefly sees the persisted user row AND the still-listed queue entry; nothing linked them (drain arms mint a fresh row id; batch-flush echoes share the head Change. New The user-row FE matching: Tests.
Protocol docs (monorepo workspace branch): |
…elope over WSS
Review follow-ups on the agent.queueMessage messageMetadata front door:
- New WSS e2e `queue_message_strips_forged_sender_attribution_over_wss`:
a wire caller queues an entry whose messageMetadata names a sibling
agent as fromAgentId/fromAgentName. The router strips both (ordinary
keys kept) on the RPC result, agent.getQueue and the MCP
ws.agent.getQueue view; the named sibling's ws.agent.removeQueuedMessage
is rejected by the ownership guard ("another sender") and the entry
stays queued; the user-facing agent.removeQueuedMessage removes it.
- The invalid-messageMetadata rejection now asserts the full JSON-RPC
error envelope (jsonrpc "2.0", echoed id, no result member, -32602 with
a string message) via a shared `assert_invalid_params_envelope` helper.
… persist The §6.5 drain-ordering barrier held for the drain arm's OWN shrunk `agent:queue:updated`, but an unrelated concurrent mutation (enqueue / edit / remove of another entry) publishing between dequeue and the user-row persist could still emit a snapshot with the in-flight entry already gone. `DrainingGuard` registers popped entries in a `draining_queue_entries` overlay that `queue_snapshot` lists ahead of the live queue (deduped by id / turnId against hand-backs and failure requeues). Every drain arm (worker loop, slot-race re-claim, batch flush, `sendQueuedMessageNow`, archived user-origin drain) pops through a `*_draining` variant and drops the guard right before its settled publish; hand-back / parked / abort paths retire the entry at scope exit, so no ghost outlives its arm. Unit coverage (`draining_overlay_tests`): overlay semantics in isolation, merged-guard retirement, and two real-drain regressions with the arm parked in the persist retry backoff while unrelated mutations publish — settled path (no snapshot omits the entry before its row echo) and rollback path (no flash, no ghost, restored order).
`queueInfo` is daemon-reserved: an absent / null / non-object value is now replaced by a fresh object carrying the drain identity link instead of being left alone, so EVERY drained user row (and its `agent:message` echo) names its queue entry. A non-object `messageMetadata` reaching a drain arm (the wire path already rejects it with -32602) is replaced the same way. Tests: - unit: `non_object_queue_info_is_replaced_by_the_link` (inverts the former carve-out). - WSS flush suite: the combined-turn case now asserts both flushed rows carry DISTINCT `queueInfo.queuedMessageId`s in queue order next to the shared `batchId`, each row's `agent:message` echo lifts the same id, and the direct-send kick-off row/echo carry none.
The no-`AgentManager` fallback `agent_send_queued_message_now_op` was the one `agent.sendQueuedMessageNow` path still publishing the shrunk `agent:queue:updated` BEFORE persisting the drained user row, contradicting the §6.5 "every publisher" contract. It now follows the runtime path: pop through `take_queued_message_draining` (entry stays listed in every snapshot), durable `persist_queue_snapshot` at dequeue time, user row persist + `agent:message` echo + answer intake, then drop the guard and `publish_queue_updated_after_drain_persist`. Persist failure keeps the fail-closed requeue (no shrunk snapshot). The row also gains the `queueInfo.queuedMessageId` identity link (`stamp_queued_message_id` is now `pub(crate)`), so every queue-drained row names its entry on this path too. Test: `agent_send_queued_message_now_publishes_shrunk_queue_after_row_persist` asserts the wire order (row echo before the first snapshot omitting the entry, no earlier snapshot omits it), the echo/row identity link, and an empty queue after; it fails against the previous order.
…er one gate publish_queue_updated_for took a caller-captured queue Vec and awaited the write-through persist before publishing it, so a publisher suspended on the persist gate could emit a pre-enqueue snapshot that omitted an entry which had since been enqueued and started draining (or re-list an already drained one). Route every publisher through publish_queue_event, which now reads queue_snapshot (overlay included) immediately before the bus write under a new agent_queue_publish_gate, so publication order equals snapshot order and no caller hands in a stale Vec. Tests: deterministic stale-publish regression (publisher parked on the persist gate across an enqueue + draining pop); the two mid-drain overlay tests now synchronize on the entry actually reaching the draining registry instead of a fixed 250ms sleep (under load the arm had not popped yet, the batch flush took both entries, and the edit failed "Queued message not found").
panghy
left a comment
There was a problem hiding this comment.
Verification Summary
APPROVED — High confidence, scoped to the intentd task and reviewed head 6cbbcc9b54a9fbe74b67210f0363cc893aeb38c0. This is verification approval, not human merge authorization.
Acceptance criteria
- VERIFIED: queueMessage forwards object metadata to result/getQueue and drained rows; omitted/null compatibility, reserved sender stripping, and full -32602 JSON-RPC error envelope covered.
- VERIFIED: tagged queued answers clear pendingQuestionsMessageId on drain; processing/row echo/marker clear precede the settled shrunk snapshot.
- VERIFIED: concurrent publishers retain draining entries and cannot publish stale caller snapshots: all queue events read the current overlay-inclusive snapshot inside the shared gate held through publication. Deterministic pre-enqueue/persist-gate and real-drain settled/rollback regressions pass.
- VERIFIED: queuedMessageId is stamped on every fresh single/worker/batch/send-now drain, including non-object queueInfo normalization; row/echo links and distinct batch entry identities are covered. Already-persisted requeues remain exempt as specified.
- VERIFIED: docs commit 1fa2bc947488442ce97f83cec242a207d1717710 and PR body correctly distinguish normal-drain fresh row IDs from send-now entry IDs. All review threads resolved; conventional headlines checked.
Independent final-head checks
- cargo fmt --check: PASS
- cargo clippy --workspace --all-targets -- -D warnings: PASS
- Targeted pending-questions, services-queue, WSS flush, resume-order, and turn-correlation suites: 22/22 PASS, no skips.
- Selected services queue/drain/flush/stamp/send-now/migration units: 274/274 PASS with --retries 0 (4046 outside-filter tests skipped). Includes the new stale publisher regression and the formerly flaky real-drain regression; no retry.
- make docs-check: PASS (4 docs). Both diff checks: PASS.
- Final-head fast PR CI: SUCCESS, including CI Gate; 0 unresolved review threads.
Explicitly accepted gate split
Coordinator accepts independent targeted tests + green fast PR CI for this review approval. Fast PR CI is not a full-suite pass: coverage-e2e/all are intentionally skipped on pull_request and run on merge_group. The coordinator separately requires the implementor local full nextest run (fail-fast disabled, known-unrelated host flakes isolated) before requesting human merge permission. Merge-group coverage gates landing and ejects on failure. Earlier local MCP probe timeout and pipeline exit-0 masking are recorded limitations, not passing full-suite evidence.
No reviewer code edits. No merge, auto-merge, or merge-queue action taken.
Summary
agent.queueMessagedropped the caller'smessageMetadata, so a user answer routed through the explicit queue path lost itsquestion_answerstag and never resolved the pending question set on drain. This PR makes the method carrymessageMetadata, and closes the two drain-time gaps review surfaced around it: the shrunkagent:queue:updatedwas published before the drained row persisted (Q&A wizard flashed back), and nothing linked a persisted drained row to the queue entry it came from.Workspace: Intent
agne-tq-2("Dismiss agent Q&A when answer is queued") — intentd half of the cross-component fix; the FE counterpart (intent-hq/cloudlands-fe#2308) routes queued answers through this param and keys onqueueInfo.queuedMessageId.Changes
1.
messageMetadataonagent.queueMessagecrates/intent-transport/src/router.rs): theagent.queueMessagearm readsmessageMetadata, strips the reservedfromAgentId/fromAgentNameattribution fields (same user-origin front door asagent.sendMessage), and rejects a non-object value with-32602(messageMetadata must be an object).null/ omitted reads as absent.WorkspaceApi::agent_queue_message(intent-core/src/traits.rs), theServicesimpl (intent-services/src/lib.rs) andagent_queue_message_op(intent-services/src/agent_ops.rs) takemessage_metadata: Option<Value>and pass it intoenqueue_message, so the entry carries it (resultqueuedMessage,agent.getQueue) and the drain-time persist writes it onto the user row — the answer intake then resolves the pending marker exactly as a direct taggedagent.sendMessagedoes.messageMetadata?already existed on theQueuedMessageshape; it was only ever populated by daemon-internal enqueues.2. Drain ordering: shrunk
agent:queue:updatedafter the row persists (§6.5)question_answersintake cleared the pending marker). The durableagent_queuewrite-through still runs at dequeue time (crash semantics unchanged); the event now goes out viapublish_queue_updated_after_drain_persistonly afterpersist_usersucceeds — after the user-rowagent:messageand, for a tagged answer, the marker-clearingagent:updated. Applied totry_drain_queue, both worker single-entry arms, the batch flush (ONE shrunk snapshot after all rows) andsend_queued_message_now. Persist failure keeps the fail-closed path: no shrunk snapshot, the front requeue republishes the restored queue.DrainingGuardregisters popped entries in adraining_queue_entriesoverlay thatqueue_snapshotlists ahead of the live queue (deduped by id / turnId against hand-backs and failure requeues). Every drain arm pops through a*_drainingvariant and drops the guard right before its settled publish; hand-back / parked / abort paths retire the entry at scope exit.AgentManageragent_send_queued_message_now_op(the oneagent.sendQueuedMessageNowpath outside the manager) followed the old order; it now pops throughtake_queued_message_draining, persists the durable shrink at dequeue, persists the row + echo + answer intake, then drops the guard and publishes viapublish_queue_updated_after_drain_persist. Fail-closed requeue unchanged. No documented exception is needed — the contract text ("every drain arm andagent.sendQueuedMessageNow, every publisher") holds as written.publish_queue_updated_fortook a caller-capturedVecand awaited the write-through persist before publishing it, so a publisher parked on the persist gate could emit a pre-enqueue snapshot omitting an entry that had since been enqueued and started draining (or re-list an already-drained one). Every publisher (publish_queue_updated[_for],publish_queue_updated_after_drain_persist, the poisoned-queue migration) now ends inpublish_queue_event, which readsqueue_snapshot(overlay included) immediately before the bus write under a newagent_queue_publish_gate; theVecparameter is gone, so no caller can hand in a stale snapshot. Publication order equals snapshot order.3. Drain identity link:
queueInfo.queuedMessageIdstamp_queued_message_id: every drained entry'smessageMetadatagainsqueueInfo.queuedMessageId= the entry's own id, next to the existingqueuedAt/waitedMs/batchIdstamps. Threshold-independent, always rewritten to the entry delivering now (a requeue re-drained under a fresh id re-links), skipped only forpersisted: truerequeues.queueInfois daemon-reserved: an absent / null / non-object value is replaced by a fresh object carrying the link, so EVERY drained row names its entry. Applied on all drain arms and on bothagent.sendQueuedMessageNowpaths (runtime and store-only fallback).agent:messageecho lifts the stamp as an additivequeuedMessageId?field (omitted when absent). Row ids are unchanged: freshly minted on the three drain arms (never the entry id — there the stamp is the only link), and equal to the entry id on bothagent.sendQueuedMessageNowpaths as before (runtime and store-only fallback persist under the entry id, somessageId == queuedMessageIdthere).Tests
intent-services):queue_message_captures_message_metadata_on_entry; stamp coverage incl.non_object_queue_info_is_replaced_by_the_link;draining_overlay_tests— overlay semantics, merged-guard retirement, and two real-drain regressions with the arm parked in the persist retry backoff while unrelated mutations publish (settled path: no snapshot omits the entry before its row echo; rollback path: no flash, no ghost, restored order — both synchronize on the entry actually reaching the draining registry, not a fixed sleep);publisher_suspended_across_persist_never_emits_a_pre_enqueue_snapshot— deterministic stale-publish regression (publisher parked on the persist gate across an enqueue + draining pop; fails against the caller-capturedVec);agent_send_queued_message_now_publishes_shrunk_queue_after_row_persist— store-only fallback wire order (row echo before the first snapshot omitting the entry, no earlier snapshot omits it) plus the row/echo identity link; fails against the previous order.e2e_wss_pending_questions.rs:queued_answer_via_queue_message_clears_marker_over_wss— tag lands on the entry (result +getQueue), wire order asserted (user-rowagent:messageandagent:updatedmarker clear BEFORE theagent:queue:updatedthat no longer lists the entry — fails against the previous order), persisted row and echo carryqueuedMessageId(row id ≠ entry id), queue empties.queue_message_strips_forged_sender_attribution_over_wss— forgedfromAgentId/fromAgentNamestripped on the result,agent.getQueueand the MCPws.agent.getQueueview; the named sibling'sws.agent.removeQueuedMessageis rejected by the ownership guard and the entry stays queued; user-facingagent.removeQueuedMessageremoves it.messageMetadataasserts the full JSON-RPC error envelope (jsonrpc"2.0", echoed id, noresult,-32602with a string message).e2e_wss_flush_queued_messages.rs: the combined-turn flush asserts both flushed rows carry DISTINCTqueueInfo.queuedMessageIds in queue order next to the sharedbatchId, each row's echo lifts the same id, and the kick-off row/echo carry none.e2e_wss_agent_lifecycle.rs: the sub-threshold drain now expects aqueueInfoholding onlyqueuedMessageId.Verification (latest push)
cargo fmt --check— cleancargo clippy --workspace --all-targets -- -D warnings— cleancargo nextest run -p intent-services -E "test(/stamp/) or test(/queue_info/) or test(/queued_message_id/) or test(/drain/)"— 88/88 passedcargo nextest run -p intentd --test e2e_wss_flush_queued_messages --test e2e_wss_agent_lifecycle -E "test(/flush/) or test(/queued_message_metadata/) or test(/drain/)"— 9/9 passedcargo nextest run -p intentd --test e2e_wss_pending_questions— 8/8 passedcargo nextest run -p intent-services -E "test(/send_queued_message_now/) or test(/send_now/) or test(/draining/) or test(/stamp/) or test(/queued_message_id/)"— 67/67 passedcargo nextest run -p intent-services -p intentd -E "... sendQueuedMessageNow / force_send / last_activity / uds+wss queue ..."— 56/56 passedProtocol docs
Monorepo
docs/protocol/methods/agents.md(agent.queueMessagerow,queueInfo.queuedMessageId),docs/protocol/06-events.md(§6.5 drain ordering: the shrunk snapshot follows the row persist, from every publisher) anddocs/protocol/versioning.md("Also within 9.11" additive entries) are updated in intent-hq/intent#4689; presence-detected additive fields, no version bump.