feat(harness,console): push queued-message events, drop the status poll (MOT-3837) - #452
Conversation
…-3837) Messages and subagent notifications arriving while a turn streams now land in a durable per-session queue (harness_queue state scope) instead of being appended mid-stream or racing the in-flight generation. The loop drains the queue at the start of the next step, in arrival order, under the deterministic entry ids the rows were queued with, so redelivery is idempotent. - harness::send / harness::inject enqueue instead of appending while a turn is Running; parked (awaiting_functions) turns still append immediately. - Steering now treats any non-Custom queued row like a trailing user message, closing a gap where a custom-role message merged into a running turn could be silently dropped if the final step made no function calls. - finalize_* drains best-effort on completion/failure/cancellation so a message that arrives as the turn dies is never stranded. - harness::status now reports the session's queued rows.
harness::react already fell back to the registering (owner) session when a join's downstream spec omitted `session_id` — a fan-in result landed as a turn in the chat that wired it instead of a detached child nobody reads. That fallback (`join_delivery_session`) was only ever invoked from the join-satisfaction path, though: a plain, single-predecessor reaction (no join) with no `session_id` pin dispatched the raw, unresolved spec, so `harness::spawn` minted a fresh anonymous session instead. Reproduced live: a pipeline stage registered a turn-completed reaction with no session_id, explicitly intending its result to land back in the registering session — instead it was stranded in an orphaned `s_...` session nobody was watching. Resolve the owner fallback once, before the join/non-join split, so both dispatch paths share it. Renamed join_delivery_session -> reaction_delivery_session to reflect the now-general use.
Unlocks the composer during streaming instead of just showing a stop button: a send while the turn is live queues the message on the harness (no second stream loop) and shows it as a draft chip above the composer until its drained row lands in the transcript. The strip also polls harness::status so it reflects server-side queue rows from other tabs and subagent/subscription notifications, not just this tab's own drafts.
Surfaces the notify/react bindings a session has registered via the harness's engine::register_trigger intercept as a strip above the composer: unregister inline, or open a detail dialog (fires on, delivers to, lifetime, subscription id, raw config/metadata). When enough triggers form a spawn chain or join group, renders them as a staged workflow view instead of a flat list, grouping join predecessors under one unit and leveling spawn -> watch edges so dependencies are visible at a glance.
applyCatalogModelFallback assigned the catalog's first model to every conversation with a null model once the catalog loaded, including sessions discovered from the engine (sub-agents, other surfaces) that never had a console-side model choice to begin with — so a sub-agent running e.g. claude-sonnet-5 would display whatever model happened to be first in the picker. Only console drafts get the catalog default now; a discovered session's null model is left alone. ChatView derives the display model from the transcript instead: when conversation.model is null, effectiveModel falls back to the model the latest assistant reply actually used, resolved against the catalog's composite ids so the header and picker still preselect correctly. Also feeds handleSubmit and the context-window estimate, so steering a discovered session inherits its real model instead of hitting "select a model".
…ll (MOT-3837) The console's queued strip polled harness::status every 2.5s while a step streamed to see rows parked by other tabs and subagent notifications. The harness now emits a harness::message-queued trigger after a message parks in the mid-turn queue; the console binds it (same pattern as harness::turn-completed) and refetches the queue once per event. The payload is a pointer, not the message, so the refetch stays idempotent under at-least-once delivery.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
Next review available in: 45 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (2)
📒 Files selected for processing (50)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
skill-check — worker0 verified, 41 skipped (no docs/).
Four for four. Nicely done. |
The registered-triggers strip now collapses to a count header
("N triggers registered · M stages") and expands on click. The bare
↓ divider between workflow stages now names the dependency — "after
<session> completes" — derived from the sessions the stage's units
watch, so chained triggers read as an explicit pipeline.
harness::react bindings previously read "spawns sub-agent" in the row and dumped the raw spec JSON in the dialog. Now: - rows and join headers show the reaction's model; hovering a react row previews its task - the dialog gets model (+provider), spawns-into (target session or the owner chat), and join (waits-for / fires-as / re-arms) rows, plus the task prompt as its own readable section; the JSON block shrinks to the true leftovers (spawn options) - react bindings stamp __once, not once — the console now reads both, so a once-reaction's lifetime no longer shows "until unregistered"
Post-mortem of a stalled state-coordinated pipeline (sub-agents spawned
without state::set finished politely, leaving every once-reaction armed
on keys nothing could write). Three guards:
1. Agent prompts (harness default/cli + all six provider identities):
a denied required function means the task FAILED — report it as the
first line, never bury it under deliverable-looking output.
2. Same prompts: children run fail-closed from a read-mostly baseline —
grant whatever the task requires via options.functions.allow; plus a
final-checklist item to verify every armed reaction has a producer
that can actually produce the watched key/event.
3. Console triggers strip: state bindings now show their watched key
and whether it exists ("on wiki-pipeline/summary — not written
yet"), probed via state::get while the strip is expanded, so a
pipeline armed on an unwritable key is diagnosable at a glance.
A reaction delivered into a chat (harness::react → spawn into the owner
session) appended its task as a bare user entry, so the console showed
the machine-sent prompt as '$ you' — as if the human typed it.
Mirror the notify pattern end to end: the harness stamps react-fired
task appends with origin { reaction: true, subscription_id? } and an
e_react_ entry id (reads carry no origin; the prefix is the read-path
fallback), and the console maps either signal to a reaction flag that
renders as '⚡ trigger · reaction task', left-aligned, instead of a
user bubble. reactive_depth gates the stamp — only harness::react sets
it, and the dispatch path clears model-supplied values, so a real user
message can never be mislabeled.
# Conflicts: # console/web/src/components/chat/Composer.tsx # harness/src/functions/send.rs
|
Merged Two real conflicts, both resolved by hand:
Verified post-merge: |
…d JSON The <event>/<inputs> block harness::react appends to a reaction task rendered as mangled inline prose (escaped JSON mid-markdown). The entry mapper now splits the trailing block off the task text: the task renders as clean markdown, and the payload becomes a collapsed details row — 'firing event · reviewer-cr7k2 · completed · show json' — expanding to pretty-printed, syntax-highlighted JSON (join inputs show predecessor keys instead). Unparseable payloads render raw rather than disappearing.
A message queued mid-stream (MOT-3837) is committed to the server queue immediately, so editing it means pulling it back out — otherwise the old version still drains and you get a duplicate. Add that primitive and the composer ergonomics: - harness::unqueue (trusted, off the agent catalog): removes a still- parked queued row by its client-visible entry_id (resolved to the internal row id for deletion). Best-effort — an already-drained row is removed:false. - backend.removeQueued → harness::unqueue. - LexicalShell: Up-arrow in an EMPTY editor recalls a message for editing (gated on empty so an in-progress draft is never clobbered; defers to an open typeahead). Loads the returned text, caret at end. - ChatView: recall the most recent queued draft — drop the local draft, remove the server row (surfacing a warn notice if removal fails, since the strip also re-shows it), hand text + attachments to the composer. Only wired when the backend can remove queued rows (recall without removal would double-deliver). A '↑ to edit' hint sits under the strip. Attachments are display-only (content rides the text's #file mentions), so recalling them is safe. Registered off-catalog; the golden catalog test is untouched.
Turns the destructive one-shot recall into non-destructive browsing so
Down can cycle back. ↑ goes older (from a blank composer, enters at the
newest); ↓ goes newer (past the newest, exits to a blank draft). The
message leaves the queue only when the edit is submitted (harness::unqueue
via onCommitEdit) — browsing never removes anything.
- queue-history.ts: pure nextHistoryTarget(list, browseId, current, dir)
with the cursor + pristine-gate logic, unit-tested (enter / older /
newer / clamp / exit / edited-protection).
- Pristine gate: navigation only fires when the editor is blank or holds
the browsed message unedited, so an in-progress edit and caret moves
within a real edit are never clobbered — no Lexical caret-boundary
probing needed.
- LexicalShell: HistoryNavPlugin binds ↑ and ↓ (was ↑-only), defers to an
open typeahead, loads the returned text ('' clears to a live draft).
- The queued strip highlights the message being edited ('editing', accent)
and the hint switches to '↑/↓ cycle · enter saves · ↓ past newest cancels'.
Submitting an edit re-queues the text at the tail (remove + re-add);
preserving queue position would need an in-place update — deferred.
…tion Editing a queued message used to remove + re-queue, which moved it to the tail of the delivery order. Update it in place instead. - harness::edit_queued (trusted, off-catalog): find the row by entry_id and rewrite its message, keeping the same internal id — so re-writing the same session_id:id state key overwrites in place, preserving queued_at (position), entry_id, and origin. Emits harness::message-queued so other tabs refetch the new content. - backend.editQueued rebuilds the message exactly as a send does (string sugar, or structured with #file() expansions) via an extracted buildMessageInput. - Composer: submitting a browsed message now saves it in place (onEditQueued) rather than sending a new one; submitting an emptied composer removes it (onEditQueued(id, null) → harness::unqueue). ChatView updates the draft optimistically and re-expands mentions for the server write. - Strip hint: 'enter saves in place · empty + enter removes'. The prior remove-then-resend path (and the tail reorder it caused) is gone; browsing an unedited message and hitting enter is now a no-op move.
While a queued message is being edited it lives only in the composer now — hidden from the queue strip instead of shown in both places (strip row + text input). It reappears at its position when saved in place (edit_queued), or when you cycle away to another message. Pure display filter: the ↑/↓ cycle source and the save-in-place / remove behavior are unchanged. Claude-Session: https://claude.ai/code/session_015tUsXKB3QY6udtEWPiQAA1
The catch handlers swallowed the underlying error and showed a generic "could not remove/save the queued message" warning, hiding the actual cause — most notably a stale harness binary that predates harness::unqueue / harness::edit_queued, which the engine rejects as an unknown function. Include the error text so the notice self-diagnoses instead of looking like a silent failure. Claude-Session: https://claude.ai/code/session_015tUsXKB3QY6udtEWPiQAA1
The main merge (f9fae14) bumped llm-router to 1.0.5 (Cargo.toml + llm-router/Cargo.lock) but left these two providers' lockfiles pinned at 1.0.4; a build regenerated them to match the declared version. Lockfile sync only, no code change. Claude-Session: https://claude.ai/code/session_015tUsXKB3QY6udtEWPiQAA1
Drops this branch's edits to tech-specs/2026-06-agentic/harness.md (the MOT-3837 queue + harness::message-queued trigger doc additions), reverting the file to origin/main so this PR no longer touches the published spec site. Claude-Session: https://claude.ai/code/session_015tUsXKB3QY6udtEWPiQAA1
CI clippy (--all-targets, -D warnings, rust 1.96) flagged `let rows = vec![...]` in a test that only iterates it. A plain `cargo clippy` run skips test targets, so it slipped through locally. Use an array literal; behaviour unchanged. Claude-Session: https://claude.ai/code/session_015tUsXKB3QY6udtEWPiQAA1
… drop the console trigger poll (#775) * (MOT-4401) feat(harness,console): push trigger-binding change events, drop the console trigger poll Every open console chat tab polled harness::triggers::list on a 5s interval (each call a full-scope harness::state::list on the state worker). Replace the poll with a harness::triggers-changed doorbell — the same pattern #452 used to kill the status poll: - harness: 5th event trigger type; {session_id, timestamp} emitted from the four BindingStore CAS primitives (reserve, attach_trigger_id, claim_fire, delete_if_unchanged), so every mutation path incl. the out-of-turn ones (expiry sweep, GC, other-tab unregister, deferred release) rings from one layer. - console: onTriggersChanged backend hook (shape of onQueuedMessage); ChatView swaps the interval for initial fetch + doorbell subscription + visibilitychange catch-up. Always-on for the selected conversation, unlike the streaming-gated queued subscription. Version skew is graceful both ways: an old harness parks the unknown trigger-type registration (console degrades to mount/visibility/post- unregister refetches); an old console keeps polling a new harness. * (MOT-4401) fix(console): coalesce trigger refetches, close subscription gaps Review fixes on #775: - serialRefresh: doorbells arrive at-least-once and burst on rapid fires; refetches now serialize behind one in-flight list read with a single trailing rerun, so an older snapshot can never resolve after and overwrite a newer one. reset() discards in-flight responses on conversation switch/unmount (also fixes the pre-existing late-write into a switched conversation). - ChatView subscribes to the doorbell BEFORE the initial snapshot, so a mutation in the setup gap rings instead of being missed. - realOnTriggersChanged reseeds on every 'connected' transition: the SDK replays trigger registrations on reconnect but outage-window doorbells are gone (same repair TracesV2 uses). The reseed is ordered after the SDK's synchronous registration replay, so it also covers the first connect.
What
Removes the console's 2.5s
harness::statuspoll for the queued-messages strip and replaces it with a push signal.harness::message-queuedtrigger type (third sibling ofharness::turn-started/turn-completed, same binding config and fan-out), emitted after a message parks in the mid-turnharness_queue. Payload is a pointer (session_id,entry_id,queued_at), not the message.turn-events-live.tsgainsstartQueuedEventsSubscription;ChatViewdrops thesetIntervaland refetchesharness::status → queuedonce per event (plus one catch-up fetch on stream start). Refetch-on-signal keeps the strip correct under at-least-once delivery.harness.md§ Trigger types emitted.Stacking note
feat/message-queue(MOT-3837, the queue itself) exists only locally, so this PR bases onmainand includes those commits — my change is the last commit (f7a9b35). Land the queue work first, then this rebases to just the one commit.Related tickets
Because
feat/message-queuerides along locally, this branch bundles the queue and a batch of unrelated console/harness work. Split into focused tickets for tracking:functions::infotab, richtriggers::info,register_triggerwhen→then).Checks
cargo test(harness): 189 passed;cargo fmt --check+clippycleantsc -b --noEmitclean;vitestturn-events-live 7/7 (2 new cases for the queued binding)ChatView.tsxcome from the base branchNot done (deliberate)
ChatViewstays — there's no register/unregister event to bind to today.