Repository navigation
Conversation
… is full After Ctrl-Z and `fg`, the classic CLI reads the frames its socket buffered in one burst that never yields to the event loop. The 4,096-frame client queue filled before the pump that drains it had a turn, so the reader treated a ready consumer as stalled: the turn was reported as "outcome is unknown", and the next prompt failed while the socket closed. A full queue now parks the reader until the consumer makes room. Only a consumer that takes no event for EVENT_STALL_TIMEOUT (30 s, the owner's own send deadline) is failed, as before, and the queue keeps its bound. The wait uses asyncio.timeout, not wait_for, which on Python 3.11 can swallow a close that lands as the put completes. The Kanban worker client is the other site: kanban.run subscribes its socket to the card's session, but it only polls receipts, so a card whose turn streamed more than 4,096 events stopped its reader and the next receipt never arrived. It now discards the events it never reads. Refs NousResearch#134889 Fixes NousResearch#140597
Author
|
Landed via #140968 (merged as |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
The problem. Suspend the classic CLI with Ctrl-Z while a long answer streams in many small pieces, then
fg. The CLI printsGateway disconnected; turn outcome is unknown…although the gateway is healthy and the turn completes there. The next prompt fails withsent 1000 (OK); no close frame receivedand never reaches the model; only the one after it works. The same queue also stops a Kanban worker whose card's turn streams more than 4,096 events.Why.
asyncio.Queue(maxsize=4096).GatewayClient._readadds each one withput_nowaitand treatsQueueFullas a lost connection: it fails pending requests and queues the "outcome is unknown" error.fg, the frames the socket buffered while the process was stopped come back one after another, and the reader'sasync fornever yields to the event loop while frames are buffered. The CLI's pump (GatewayConnection._pump), which moves frames to the agent thread and never blocks, is ready but gets no turn. The queue fills within milliseconds and the reader fails a consumer that was never slow.kanban.runsubscribes the Kanban worker's socket to the card's session (run_taskcallsconnection.resume), but the worker only pollsprompt.receiptand never reads its events. A card whose turn streams more than 4,096 events fills the queue, the reader stops, and the next receipt never arrives.The fix. One invariant: a client's own event backlog never fails its turn.
put_nowaitfinds the queue full, the reader now waits for room (async with asyncio.timeout(EVENT_STALL_TIMEOUT): await self.events.put(frame)). The pump empties the queue on its next turn and reading resumes. Only a consumer that takes no event forEVENT_STALL_TIMEOUT(30 s) is failed, with the same error and cleanup as before.Design choice.
_WS_DISPATCH_QUEUE_MAXintui_gateway/ws.py: "A full queue parks the reader"). A bigger queue only moves the threshold: the live repro streamed 150,000 deltas. An unbounded one gives up the memory bound.max_queue(frames already parsed from the last read stay in memory), so the rest of the backlog stays in the kernel and in the owner's bounded per-viewer fanout. If that fills, the owner sends itssession.replay_gapnotice: the gateway-side path that fix(gateway): re-attach after session.replay_gap instead of failing the turn #140389 handles._WS_SEND_DEADLINE_S): a viewer that can't take a frame for that long is gone. The connection can end sooner: with socket reads paused, the client's 20 s keepalive ping can time out. Either way pending requests end, with their own timeout or the usual disconnect error.asyncio.timeout, notwait_for. On Python 3.11,wait_forreturns normally when it is cancelled just as the innerputcompletes, so a client closing at that moment would wait on the open socket forever.await asyncio.sleep(0)beforeput_nowait): that would fix the CLI's pump, which drains everything in one turn. It wouldn't fix a consumer that awaits per frame, such as the ACP adapter writing each update to the editor; a short write stall would still fail its turn.run_task); the worker is the side that ignores the events, so it discards them.Before / after:
fgsession.replay_gapRelated Issue
Fixes #140597
Refs #134889
Overlap:
session.replay_gap): no file overlap. It covers the owner dropping a slow viewer; this PR covers the client dropping itself first, which fix(gateway): re-attach after session.replay_gap instead of failing the turn #140389 lists under Known limits ("Client queue"). They are independent and complementary: with both, a resumed CLI reads its backlog, and if the owner's fanout overflowed meanwhile, the gap re-attaches. This commit applies cleanly on fix(gateway): re-attach after session.replay_gap instead of failing the turn #140389's head (6529f8e1de). Its tests plus fix(gateway): re-attach after session.replay_gap instead of failing the turn #140389's replay-gap and reconnect tests and the Kanban worker tests pass together (9 files, 112 passed), and the combined build passed the live retest below.Type of Change
Changes Made
hermes_cli/gateway_client.py:EVENT_STALL_TIMEOUT(30 s);GatewayClient._readwaits for room when the event queue is full, underasyncio.timeout, and treats a wait that times out like the oldQueueFull.hermes_cli/kanban_worker_client.py:rundiscards the socket's events (_discard) while_submit_claimpolls the card's receipt.tests/hermes_cli/test_gateway_client_backlog.py(new):test_backlog_larger_than_the_client_queue_reaches_the_cli_intact: the realGatewayConnectionpump over aGatewayClientwhose socket returns 6,000 buffered frames without yielding, as a read afterfgdoes, thenmessage.complete. Every delta reaches the CLI's queue in order, with no loss notice;test_kanban_worker_settles_a_card_whose_turn_outstreams_the_client_queue: the real worker client over a realGatewayClient;kanban.runis followed by 6,000 events, and the next receipt poll finds the card terminal;test_a_consumer_that_stops_draining_is_still_failed_with_the_queue_bounded: with nothing draining, the reader still fails the pending request with the "outcome is unknown" error, and the queue holds its 4,096-frame bound with the error last;test_closing_as_a_waiting_frame_lands_still_ends_the_reader: a close that lands just as the consumer makes room still ends the reader.tests/hermes_cli/test_kanban_worker_owner_wait.py,tests/agent/test_kanban_turn_recovery.py: their fake clients get theeventsqueue a real one has.How to Test
mainat991f34bcbe, the backlog and Kanban tests fail. The backlog test's loss notice arrives after 4,095 deltas, and the first delta is gone (dropped to make room for the notice). The Kanban test times out waiting for a receipt the stopped reader never delivers. The bound and close tests pass onmain, which has no wait. The close test fails on Python 3.11 withwait_forin place ofasyncio.timeout: a standalone run of the same steps on 3.11.15 left the close waiting.maintoo, on this host:test_kanban_db.py::test_infrastructure_spawn_refusal_never_charges_the_card(the dispatcher's spawn accounting).scripts/check --base 991f34bcbe: 12 checks ok; code health 0 blocking, 0 advisory.Independent review: an independent review round and a final review-and-fix pass. The round's one P1 is fixed above with a regression test: on Python 3.11,
wait_forcan swallow a close that lands as theputcompletes, so__aexit__could wait forever. The round's audit of every event consumer also found the Kanban worker site, fixed above. The final pass found no further P1/P2. It reran the 3.11 race and timeout probes on Python 3.11 to 3.14, and corrected a few claims in this description.Known limits (each as on
main):GatewayConnection.events) is unbounded, as onmain; this PR adds no buffer.mainthat happens only in short windows outside any drain loop: the CLI's and the ACP adapter's re-attach after a reconnect, before their pumps start, and the A2A forward's receipt re-poll. fix(gateway): re-attach after session.replay_gap instead of failing the turn #140389's chat view and ACP adapter await the re-attach inside their drain loops; more than 4,096 events arriving ahead of its answer would hold it until the bound (not seen in the combined live run).Live retest (17 Oct).
What was run: a real gateway daemon and the real classic CLI (
hermes --cli) in tmux, in a throwawayHOMEandHERMES_HOME, against a local fake OpenAI-compatible model. The reply streamed 40 slow deltas, then 150,000 fast deltas (one character each, with a numbered line marker every 100th). The driver pressed Ctrl-Z after the slow deltas, kept the CLI stopped for about 14 s, ranfg, then sent a second prompt.Results:
main(991f34bcbe): the client queue overflowed 13 ms afterfg, and the CLI printed "turn outcome is unknown". The second prompt failed withsent 1000 (OK); no close frame receivedand never reached the model. The owner logged its fanout overflow 1.2 s afterfg, after the client had stopped reading.033482fe10): no loss; the reader waited 26 times while it read the backlog. The answer shows once and complete: all 1,500 numbered lines, unique and in order, with the slow prefix once. The owner logged the turn complete (160,650 chars) with no fanout overflow. The second prompt reached the model within the driver's 0.1 s poll, and its reply showed.6529f8e1de):fgthe client queue filled four times while the backlog was read, and the reader waited each time. Then the owner'ssession.replay_gaparrived, fix(gateway): re-attach after session.replay_gap instead of failing the turn #140389 re-attached, and the turn settled from its receipt with the exact-prefix continuation (no[output resumed]). The second prompt answered. The pane's scrollback kept the last 1,874 lines, contiguous and each once.Still needs a live retest:
GatewayClient);hermes chat --format stream-jsonwith a backlog over 4,096 frames. They share the changed reader, but only the classic CLI was run live.Checklist
Code
pytest tests/ -qand all tests pass. Not ticked: I ran the related suites above, not the whole tree; CI is the full runpython scripts/checkpassesDocumentation & Housekeeping
cli-config.yaml.example: N/ACONTRIBUTING.md/AGENTS.md: N/A