fix: deliver stranded follow-ups and surface message-queue metadata - #197
fix: deliver stranded follow-ups and surface message-queue metadata#197OchnikBartek wants to merge 2 commits into
Conversation
Coverage Report for CI Build 30820356581Coverage remained the same at 100.0%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
DEENUU1
left a comment
There was a problem hiding this comment.
Nice work — a careful, well-scoped take on the two fixes Douwe flagged plus the queue bound, and it deliberately stays out of the still-open injection-API decision. The discard_follow_up(keep=...) split for external-vs-typed on cancel is exactly what the reporter asked for, the sanitizer is a sensible defensive choice, and the tests are the right level of rigour — verifying they fail against the unmodified chat.py and calling out the fragile _notify_degraded_mcp seam in a comment.
One thing I'd like your read on before this goes in: the new idle drain in the finally and the goal-loop scheduling at chat.py:1268 don't coordinate, so a goal turn can schedule two runs. Details inline. Everything else is fine.
Smaller, non-blocking: external steering still gets dropped on cancel with only a local notify (chat.py:1318), while external follow-ups now survive. Defensible — steering means "before the next LLM call" and there isn't one after a cancel — but it's the same silent-to-the-sender drop you're fixing for follow-ups, on the monitor/urgent path. Worth a sentence in the docs so a bridge author knows steering-on-cancel is best-effort.
|
|
||
| # A message queued after the post-run drain still saw the run as | ||
| # active, so it landed as a follow-up nothing else will deliver. | ||
| if not _follow_up_scheduled: |
There was a problem hiding this comment.
Here's the case I'm unsure about: a run that finishes normally with a goal active. The goal continuation is scheduled back at chat.py:1268 — but only because _follow_up_scheduled was still False there. Then a message strands in the window this block covers, so this drain fires too, sets _follow_up_scheduled = True locally (too late — the goal already read the old value) and schedules _run_agent.
Now both _continue_goal and _run_agent(stranded_text) are on call_later. _continue_goal runs first, awaits the evaluator, and during that await _run_agent(stranded) starts a turn; when the evaluator returns unmet, _continue_goal calls _run_agent again and clobbers app.agent_task. Two concurrent turns streaming into the same widgets.
It's narrow — needs an active goal plus a message landing in that sub-second window — but a bridge is exactly what lands messages there. Simplest guard I see: skip this drain when a goal continuation is already pending, or move the goal decision into the finally next to this drain so one place owns "schedule the next turn." Did you already rule this out?
Summary
Three fixes to the message queue that an event-driven producer hits and a human
typist rarely does: a follow-up queued as a run ends is no longer stranded, the
sourcein a message'smetadatanow reaches the model, the logs and the span,and the queue is bounded so a refused submission fails loudly instead of piling
up. The public injection surface asked for in the issue is deliberately not part
of this change.
Related Issue
Refs #181.
Added
MessageQueue.discard_follow_up(keep=...)— removes pending follow-ups,sparing the ones a predicate keeps, so a caller can prune what a cancelled run
made stale without dropping the rest.
queued_source()— returns a message's sanitizedmetadata["source"]label,reduced to
\w,.,:,-and truncated to 32 characters because it isinterpolated into the prompt label the model reads.
QueueFullErrorandMessageQueue(max_pending=...), defaulting toDEFAULT_MAX_PENDING(100 per priority);Noneremoves the cap.sourcelabels, backpressure and cancellation pruning indocs/advanced/message-queue.md;queued_sourceandQueueFullErrorindocs/api/message-queue.md.Changed
_agent_stream_workerinapps/cli/screens/chat.pynow drains follow-ups inits
finallyblock. A message enqueued after the post-run drain still saw therun as active, so it landed as a follow-up that nothing else would deliver —
invisible until some later run happened to end.
carrying a
sourcesurvive and start a fresh turn. Previously every pendingfollow-up was discarded.
format_steering/format_follow_upnow render the source:[steering via slack] …,[follow-up via jira] …, and- [via monitor] …perline in a batch. A message with no
sourceis formatted exactly as before, soa locally typed follow-up still reaches the transcript verbatim.
MessageQueueCapability.before_model_requestandrun_with_queuelog eachdelivered batch and set
pydantic_deep.message_queue.{steering,follow_up}.{count,sources}on the enclosing span.
steer()/follow_up()route through a shared_put()that enforces the capand logs the enqueue; both can now raise
QueueFullError.>>/follow-up submit path catchesQueueFullErrorand reports it asa notification instead of failing the message handler.
pydantic_deep/features/monitoring/toolset.pycatchesQueueFullErrorand logs it —MonitorManager._emitswallows sink exceptions,so the batch would otherwise vanish silently.
Testing
tests/test_tui.py::TestQueueDrainWhenIdle— three tests covering thestranded follow-up, the cancelled-run split between external and typed
follow-ups, and the full-queue notification. Verified they fail against the
unmodified
chat.py: all three fail, all three pass with the change.tests/test_message_queue.pyfor the capacity bound,discard_follow_up,queued_sourcesanitizing/truncation, the source labels,and end-to-end label delivery through the capability and
run_with_queue.tests/test_monitoring.py::TestMonitorToolset::test_full_queue_drops_the_batch_with_a_warning.make test— 2828 passed, coverage 100.00%.make typecheck— 0 errors;make typecheck-mypy— clean across 210 files;ruff check/ruff format --checkclean;make docsbuilds with no warnings.Notes for Reviewers
survive a cancellation — is answered here as yes, with the discriminator
being the presence of
metadata["source"]: no source means typed in the TUI.It is a two-line change in the
finallyblock if you want it the other way.enqueues more than that without the agent consuming;
max_pending=Nonerestoresthe old unbounded behaviour.
test_follow_up_arriving_after_the_post_run_drain_still_runslands its messageby patching
_notify_degraded_mcp, the last call before the idle drain. That isthe only deterministic seam inside the window; if the order in
finallyischanged the test silently stops covering the race. It is called out in a comment
in the test.
discard_follow_upandqueued_sourceare library-level and reusable whichever surface is chosen,which is why the cancellation logic lives in
pydantic_deep/rather than inapps/cli.