Fix #1925: wait-loop defaults to stream tip so it blocks for NEW events - #1933
Conversation
Before: cursor-less `/messages/wait` scanned from stream ID 0-0, so once any matching event existed, every `wait-loop` invocation returned instantly with the same already-seen message instead of blocking. In the observed documenter trace on issue-1882, this caused ~27s of tight-loop foreground calls all returning the same stale CONSENSUS_CONFIRMED before the agent fell back to a background TaskOutput workaround. After: the wait endpoint passes `from_tip=True` when no `since_id` is supplied. Redis backend uses XREAD `$` (only entries added after the call); in-memory backend snapshots `len(messages)` at call entry under the lock. Explicit `since_id` disables `from_tip` so callers that want cursor-passing (zero-drop send→wait semantics) can opt in. Also updates STAY ALIVE preambles and `agent-wait-patterns.md` to document the new-events-only default and the `--since <id>` escape hatch for callers that need to include events fired in the send→wait window.
There was a problem hiding this comment.
Solid fix for #1925. The root cause analysis is correct — cursor-less waits scanning from 0-0 / index 0 caused repeated calls to re-match stale events instead of blocking for new ones. The fix is well-designed: from_tip snaps the starting cursor to the stream tip at call entry in both backends, and the preconditions (from_tip AND NOT since_id AND wait > 0) correctly protect the edge cases.
What I verified
- In-memory backend:
tip_indexcaptured underself._lockin the fast-path block, then used stably in the blocking loop's_filterclosure. Race-free becauseadd_messagemust acquire the same lock, andcv.wait()releases/reacquires it correctly. - Redis backend:
start_id = "$"only enters the XREAD path (which accepts$), never the XRANGE path (which doesn't). Cursor advancement after the first XREAD correctly replaces$with a concretelast_sid, so subsequent iterations use real stream IDs. wait=0safety:use_tipis False whenwait=0in both backends. This avoids the footgun where$+ XRANGE would fail and where from_tip + non-blocking would always return empty.since_idoverride:since_idbeing set disablesfrom_tipin both backends, preserving the cursor-passing escape hatch for zero-drop semantics.- Endpoint wiring:
from_tip=since_id is Noneatroutes/messages.py:422correctly derives the flag from the query parameter presence. - Prompt updates: All three stay-alive preambles (two in
_build_brc_preamble, one in_build_agent_prompt) are updated consistently. - Test coverage: Strong — both backends have dedicated
TestFromTipSemantics/TestRedisFromTipSemanticsclasses; endpoint tests rewritten to inject messages via background threads (correct for the new event-driven contract); dedicated regression test (test_wait_ignores_pre_existing_messages) and cursor-opt-in test (test_wait_honors_explicit_since_id).
Non-blocking suggestions
1. Redis from_tip docstring omits wait > 0 precondition (redis_message_store.py:192-193)
The in-memory backend's docstring correctly documents all three conditions:
from_tip: If True AND ``since_id`` is not set AND ``wait > 0``, snap ...
But the Redis docstring says only:
from_tip: If True AND ``since_id`` is not set, start the read at ...
The code at line 203 does check wait > 0. Consider aligning the docstring with the in-memory backend's for consistency — someone reading only the Redis implementation would miss that from_tip is silently ignored on the non-blocking path.
2. Misleading comment about precedence (message_store.py:231-232)
# from_tip takes precedence over since_id (since_id is unset when we enter the tip branch; guard above).
"Takes precedence" suggests a conflict resolution, but use_tip and since_id are mutually exclusive by construction (use_tip = from_tip and not since_id and wait > 0). A more accurate phrasing: "from_tip branch: since_id is guaranteed unset here (guard above ensures mutual exclusion)."
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Redis from_tip docstring now includes the `wait > 0` precondition, matching the in-memory backend's documentation. - Replace misleading "takes precedence" comment with accurate phrasing that reflects the mutual exclusion enforced by the guard.
There was a problem hiding this comment.
Agreed. Both suggestions addressed in 3ce433e:
- Redis
from_tipdocstring now includes thewait > 0precondition, matching the in-memory backend's documentation. - Replaced "takes precedence" comment with accurate phrasing reflecting the mutual exclusion enforced by the guard.
— Authored by egg
There was a problem hiding this comment.
Re-review of changes since d826360 (1 new commit: 3ce433e).
Both suggestions from the previous review are correctly addressed:
-
Redis
from_tipdocstring (redis_message_store.py:191): Now includes thewait > 0precondition, matching the in-memory backend's docstring verbatim. -
Misleading "takes precedence" comment (
message_store.py:231-232): Replaced with accurate phrasing — "from_tip branch: since_id is guaranteed unset here (guard above ensures mutual exclusion)" — which correctly describes theuse_tip = from_tip and not since_id and wait > 0guard at line 227.
No other files were modified. No new issues.
— Authored by egg
|
egg review completed. View run logs 2 previous review(s) hidden. |
…ater] (#1936) Update orchestrator-cli.md command table to show [--since <id>] for message wait and message wait-loop, and note the new-events-only default (stream-tip cursor) introduced by #1925. The agent-wait-patterns.md zero-drop pattern added in #1933 explicitly uses --since with wait-loop, but the CLI reference table did not show that flag for these commands. Triggered by: #1933 Authored-by: egg Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
…ater] (#1936) Update orchestrator-cli.md command table to show [--since <id>] for message wait and message wait-loop, and note the new-events-only default (stream-tip cursor) introduced by #1925. The agent-wait-patterns.md zero-drop pattern added in #1933 explicitly uses --since with wait-loop, but the CLI reference table did not show that flag for these commands. Triggered by: #1933 Authored-by: egg Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
Summary
Fixes #1925.
egg-orch message wait-loopwas returning already-seen messages immediately because the/messages/waitendpoint's cursor-less path scanned from stream ID0-0, so XREAD/XRANGE returned the first matching event in the stream instead of blocking for a new one./messages/waitnow passesfrom_tip=Truewhen nosince_idis supplied. The store snaps the starting cursor to the stream tip at call entry so only events added after the call can unblock the wait.start_id = "$"(XREAD's native "tip at call time" sentinel).len(messages)under the lock on entry.since_iddisablesfrom_tipso callers that need zero-drop cursor-passing (race-safety across a send→wait boundary) can opt in.routes/pipelines.pyanddocs/reference/agent-wait-patterns.mdnow call out the new-events-only default and describe the--since <id>escape hatch.Root cause
Traced in
orchestrator/redis_message_store.py:195(nosince_id→start_id = \"0-0\") andorchestrator/message_store.py:248-257(fast path filters withsince_id=None→ matches full history). Eachwait-loopinvocation re-scanned history and matched the first still-matching event, so repeated calls kept returning the same stale CONSENSUS_CONFIRMED. The existing test attest_messages.py:1054encoded this broken behaviour as "expected"; it's been rewritten to exercise the correct event-driven semantics.Test plan
orchestrator/tests/test_messages.py::TestWaitEndpoint— existing tests rewritten to inject the match via a background thread (new semantics). Addedtest_wait_ignores_pre_existing_messages(egg-orch message wait-loopreturns already-seen messages immediately instead of blocking for new events #1925 regression) andtest_wait_honors_explicit_since_id(cursor-passing opt-in).orchestrator/tests/test_message_store.py::TestFromTipSemantics— pre-existing ignored, post-call unblocks,wait=0degrades safely, explicitsince_idwins.orchestrator/tests/test_redis_message_store.py::TestRedisFromTipSemantics— same, with fakeredis; verifies$vs0-0start_id selection.test_concurrent_integration.pyproducer/reviewer stay-alive assertions still pass (canonical--forlists unchanged).wait-loopblocks on the bus instead of returning instantly.Related
egg-orch message wait-loopreturns already-seen messages immediately instead of blocking for new events #1925