fix(gateway): coalesce concurrent process completions - #71898
fix(gateway): coalesce concurrent process completions#71898yuzilongleif-collab wants to merge 4 commits into
Conversation
Related: #70319 also targets completion flood for #70300, but its current diff batches the post-turn watch-queue drain. This PR batches standard completions at the watcher delivery seam, with retry and lifecycle-dedupe coverage; the approaches are complementary rather than duplicates. |
|
Really solid implementation — the batching at the watcher delivery seam with Future-based retry/dedupe is exactly the right pattern. The 100ms route-scoped batch, flush-during-delivery handling, and duplicate-primary fallback are all well thought out. I've opened #72675 which builds directly on your approach and adds:
The watcher-path batching follows the same Future-based pattern you established — just with the additions above. Would be great to get your review on the combined approach! |
|
@yuzilongleif-collab — wanted to drop a quick thank-you. Your review on #70319 was the key insight: standard completions don't flow through the post-turn drain, they're owned by Your #71898 also established the right pattern — Future-based batching at the watcher seam, route-scoped keys, dedup/retry preservation, flush-during-delivery handling. #72675 follows that same architecture, just extended to also cover Solid collaboration — your debugging saved us from a silent failure. Thanks! |
|
Thanks — I really appreciate the thoughtful follow-up and the clear attribution. I’m glad tracing the production watcher path caught that disconnect before it shipped. The way #72675 extends the same batching model across watcher-owned completions and post-turn watch events makes sense, while preserving the important delivery, retry, and dedupe invariants. The threshold-based early flush and aggregate summaries also look like a strong continuation of the approach. Great collaboration — and thanks again for taking the time to connect the work so clearly. |
Coalesce multiple background process completions and watch events that share the same gateway route so the agent receives one synthetic turn instead of one turn per process (NousResearch#70300). Two-pronged approach, each at the correct ownership seam: 1. Standard completions: batched at _run_process_watcher() via _enqueue_process_completion_notification() with a short (100ms) window and threshold-based early flush (5+ entries = immediate). Single completions pass through with zero extra latency. 2. watch_match / watch_disabled events: coalesced at the post-turn drain via _coalesce_and_inject_watch_events(), grouped by type and session_key. Output format includes: - Per-process status with visual indicators (✅/❌) - Exit codes, elapsed time, reason - Aggregate summary (N succeeded, M failed) - Bounded output (10 detailed results, 800-char tails) Edge cases covered: - Flush during delivery schedules next batch - Duplicate primary tries next batch identity - Formatter failure resolves all waiters with False - Lazy init for tests using object.__new__ - None-safe batch key and type handling Closes NousResearch#70300 Supersedes NousResearch#70319 and NousResearch#71898 Co-authored-by: yuzilongleif-collab <235949691+yuzilongleif-collab@users.noreply.github.com>
|
Thanks for tracing the standard-completion path to the watcher seam; that premise is correct on current main. Problems
Suggested changes
This is an automated hermes-sweeper review. |
4bc3f2c to
caa3981
Compare
|
Addressed the requested flush-task lifecycle/cancellation gaps in What changed:
Verification:
The new deterministic tests cover cancellation inside the real fan-in wait, cancellation while adapter delivery is blocked, shutdown ordering before disconnect, overlapping flushes for one route, successful task-reference cleanup, and the documented 10-entry summary bound. |
SummaryFive PRs address #70300. #70319 batches an unreachable post-turn path; #71898 batches at the verified watcher-owned completion seam and now owns cancellation/shutdown lifecycle; #72675 broadens that approach but retains reviewed correctness defects; and #73427/#73469 pursue a larger registry redesign, with #73427 superseded and #73469 still carrying blocking state-management defects. Related pull requests
Duplicates#71898, #72675, and #73469 are competing implementations of the same watcher-seam completion coalescing; #72675 adds post-turn watch-event scope, while #73469 replaces batching state with a registry. #73427 is the closed, superseded predecessor of #73469; #70319 seeks the same outcome but is not functionally equivalent because it modifies the wrong path. Suggested consolidationKeep #71898 open with a salvage path: obtain an independent re-review of Complex graphflowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
I70300(["issue #70300 (open)"])
subgraph Dup71898 ["PRs duplicating each other"]
P71898["PR #71898 (open)"]
P72675["PR #72675 (open)"]
P73427["PR #73427 (closed)"]
P73469["PR #73469 (open)"]
end
P71898 -->|best fix| I70300
class I70300 open
class P71898 open
class P72675 open
class P73427 closed
class P73469 open
class P71898 best
class P73469 best
class P71898 target
click I70300 "https://github.com/NousResearch/hermes-agent/issues/70300"
click P71898 "https://github.com/NousResearch/hermes-agent/pull/71898"
click P72675 "https://github.com/NousResearch/hermes-agent/pull/72675"
click P73427 "https://github.com/NousResearch/hermes-agent/pull/73427"
click P73469 "https://github.com/NousResearch/hermes-agent/pull/73469"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label). Cross-PR triage: Reviewed 5 pull requests and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 221 kB of PR diffs, 30 kB of issue/PR text, 28 kB of discussion (28 comments), 10 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
handnewb
left a comment
There was a problem hiding this comment.
Independent re-review of caa398106, which the 2026-08-03 triage names as the remaining salvage gate on this PR. I am the author of #72675 and #73469, so I have an obvious interest here — I have tried to write this as the review I would want on my own branch, and the two defects below are ones I would want raised against mine.
Short version: the lifecycle work is correct, and better than what I had in #72675 on three counts. Two real defects, one of them a message-delivery boundary.
What is right
Cancellation is retryable in both windows. The except asyncio.CancelledError sets delivered = False, recovers entries if the batch had not yet detached, and re-raises; the finally then resolves every waiter with False, which is the path _run_process_watcher retries. This is the defect the sweeper found in #72675, where CancelledError bypassed except Exception but still reached a finally that mapped delivered=None through _bool_to_disposition to DROP_UNROUTABLE. This branch does not have that hole.
Flush-task ownership is properly scoped. _cancel_process_completion_batch_tasks() drains _completion_notification_batch_flush_tasks and nothing else, with one gather(..., return_exceptions=True). Both #72675 and #73469 originally selected from _background_tasks and cancelled startup-resume and supervised watcher tasks before adapter teardown. This branch never had that bug.
The shutdown gate is something neither of mine had. _completion_notification_batches_stopping is checked in _enqueue_process_completion_notification, so a completion arriving mid-teardown cannot schedule a new flush behind the drain. The orphan sweep afterwards — resolving any queue that has no live flush task, then clearing — closes the window I left open.
Per-attribute hasattr guards. Worth pointing out explicitly, because #72675 put five attributes behind one guard on _completion_notification_batches, so a runner that pre-initialised some of them skipped the rest and raised AttributeError: _completion_notification_batch_max. This branch guards each independently and does not have that trap.
Detach-before-delivery is right, and the duplicate-primary loop that tries the next batch identity so a fresh sibling is not discarded with a duplicate is the correct behaviour.
Defect 1 — the routing key cannot distinguish None from ""
return tuple(str(evt.get(field) or "") for field in (
"session_key", "platform", "chat_type", "chat_id", "thread_id", "user_id",
))str(None or "") and str("" or "") are both "". Two events whose routes differ only in that one field is absent on one and empty on the other therefore produce an identical key and coalesce.
The consequence is not a wrong-looking message, it is a lost one. Delivery iterates candidates and breaks on the first non-None result:
for _text, candidate_evt, _future in entries:
delivered = await self._deliver_completion_notification(synth_text, candidate_evt)
if delivered is not None:
breakSo one route receives the consolidated text and the other route's waiter is resolved True while its chat receives nothing. Silent, and it looks like success in the ledger.
#72675 handles this with a sentinel rather than or "":
_SENTINEL = "\x00"
return tuple(
_SENTINEL if evt.get(field) is None else str(evt.get(field))
for field in (...)
)Any distinct marker works; the requirement is only that absent and empty do not converge. It is a two-line change and I would rather see it here than argue for my PR over it.
Defect 2 — the coalesced formatter emits unredacted output
output = str(evt.get("output") or "").strip()
if len(output) > 800:
output = f"[… truncated …]\n{output[-800:]}"evt["output"] comes from _run_process_watcher, where it is passed through redact_terminal_output() without force=True. agent/redact.py returns the text unmodified when security.redact_secrets is disabled, so with that setting off this formatter appends up to 800 raw characters per process into a synthetic turn that goes to the chat platform.
The docstring says "several redacted completions", which is true only when the setting is on.
Two ways to close it, not mutually exclusive:
- #73547 adds the unconditional gateway floor to
_outbeforecompletion_evtis built, which makesevt["output"]force-redacted at the source and this formatter safe. That PR issalvageability: highand independent of the consolidation — it is effectively a safety dependency of this branch, which is an argument for landing it regardless of which coalescing PR wins. - Defence in depth in the formatter itself:
output = _redact_gateway_user_facing_secrets(output)before truncating. Note the order — redacting after slicing can leave a credential fragment the pattern no longer matches.
Two smaller notes
No cap on batch size. _completion_notification_batches[key] grows without bound inside the window. #72675 caps it at 50 with the overflow counted in the summary. Probably an edge case rather than a real risk at the observed rates, but the formatter already bounds rendering at 10 entries, so bounding the queue is consistent with that.
Watch events are untouched. watch_match / watch_disabled are forwarded individually by _drain_gateway_watch_events() and have the same flood shape for a different reason. That is out of scope here and I am not suggesting it be added — I am noting it so the issue is not treated as fully closed when this merges. If #72675 closes as a duplicate, I will resubmit that scope separately, with the same sentinel fix, since the version in #72675 groups on session_key alone and is worse than what is described above.
On the consolidation
For the record, since I am the competing author: I have no objection to this branch being the one that lands. The lifecycle handling here is cleaner than mine, and the two defects above are contained. If it helps, I can open a PR against this branch with the sentinel and the formatter redaction rather than leaving them as review comments.
Method: read gateway/run.py at caa398106. _completion_notification_batch_key, _flush_process_completion_batch, _cancel_process_completion_batch_tasks and _enqueue_process_completion_notification read in full. Defect 1 is a property of the expression, not an observed failure; Defect 2 is traced from the redact_terminal_output call signature in _run_process_watcher and agent/redact.py's disabled-path early return, not measured on a live gateway. I have not run this branch's tests.
|
Thanks for the careful review. I pushed Addressed: defence-in-depth redaction at the coalesced formatter
Two regressions were added with
Verification: Challenged: missing vs empty optional route fieldsI did not add a missing-value sentinel to thread_id=str(evt.get("thread_id") or "").strip() or None
user_id=str(evt.get("user_id") or "").strip() or NoneThat is the normalization used by The inverse mismatch is fail-safe: values such as Safety boundary with #73547This commit adds defence in depth specifically at the multi-completion formatter. #73547 remains the broader source/direct-egress safety dependency for single completions and direct background-process notifications, so it should still land before or alongside this PR. I have left the suggested batch-size cap as a non-blocking follow-up rather than expanding this correctness fix. |
…e turn (#70300) The async-delegation watcher drained the completion queue as a batch but then delivered each event as its own synthetic turn, flooding the session when a fan-out of background subagents finished together. Builds on the per-process completion batching salvaged from PR #71898 (thanks @yuzilongleif-collab) which coalesces concurrent _run_process_watcher completions behind a short per-route fan-in window. This commit adds the async-delegation half: group the drained batch by full routing key (session_key + parent_session_id + platform/chat/thread/ user) and inject ONE consolidated turn per group. Durable-ack handling stays honest: sibling rows are claimed up front via claim_event_delivery; rows another consumer owns are excluded from the consolidated text (no double-delivery); sibling claims are acknowledged only after adapter acceptance and released (still pending) on failure. Events for different sessions never coalesce, and a single-event group rides the existing per-event path unchanged (latency and text identical). Tests: 3 same-tick events -> exactly one adapter.handle_message carrying all 3 results with all 3 durable rows delivered; 2 sessions -> 2 turns; single-event path unchanged; failed batch releases claims and retries; foreign-claimed sibling excluded and left pending.
|
Thanks @yuzilongleif-collab — your per-route completion fan-in landed in PR #85938 (rebase-merged) with all three of your commits and authorship preserved via cherry-pick, extended with async-delegation batch grouping on top. Closing this original; #70300 is fixed largely by your work. |
Summary
notify_on_completeprocess completions for the same gateway route into one synthetic turnwatch_match, and async-delegation ownership isolatedFixes #70300.
Root cause
Each
_run_process_watcher()directly called_deliver_completion_notification(). When several terminal sessions completed together, each watcher independently injected an internal message throughadapter.handle_message(), producing one agent turn per process.Standard process completions are owned by the per-process watcher path; the post-turn gateway drain only returns
watch_matchevents. Coalescing after that drain therefore cannot affect the production completion path.Implementation
_run_process_watcher()delivery seamA single completion still uses its original pre-redacted rich notification; only concurrent same-route completions take the aggregate format.
Tests
New regression coverage proves:
_run_process_watcher()tasks produce one synthetic turnConcurrency/race-focused tests were also repeated 20 times without failure.
Static checks:
All passed on Ubuntu Linux with Python 3.11. Type diagnostics introduced by this patch: 0 (the touched-file baseline had 145 existing diagnostics; patched tree had 144).
Compatibility notes
watch_matchdelivery paths are intentionally unchangedRelated prior attempt: #70319. This PR places batching on the per-process watcher ownership path and adds end-to-end watcher coverage.