fix(gateway): coalesce concurrent process completions and watch events - #72675
fix(gateway): coalesce concurrent process completions and watch events#72675handnewb wants to merge 8 commits into
Conversation
Related: #71898 already coalesces standard completion deliveries at the watcher seam. This PR also batches post-turn watch events and adds threshold/format behavior, so the patches need maintainer consolidation rather than a duplicate designation. |
yuzilongleif-collab
left a comment
There was a problem hiding this comment.
Thanks for consolidating these paths — coalescing both standard completions and post-turn watch events is the right direction for #70300. I reviewed exact HEAD a205be392f36d270357d76ae30afd5f058d5abc5, ran the two changed test modules plus tests/tools/test_process_registry.py (195 passed), and then added deterministic fault probes. The existing tests are green, but a few advertised/runtime properties still escape them.
Two of the lifecycle points below are inherited from our own #71898, so this is not criticism of the consolidation itself; we should fix them in whichever patch is ultimately retained.
1. Must fix: the flush task is outside the gateway lifecycle, and None can silently consume a completion
_enqueue_process_completion_notification() launches _flush_process_completion_batch() with a bare asyncio.create_task(). The task is kept only in _completion_notification_batch_tasks, not in the gateway's shutdown-owned task set. The flush pops/clears the batch, propagates the Optional[bool] returned by _deliver_completion_notification(), and the watcher retries only on literal False; None exits the watcher.
This creates a reachable adapter-disconnect/reconnect window. A deterministic probe on this HEAD enqueued one completion, removed its adapter during a 50 ms window, and produced:
enqueue_result=None
identity_delivered=False
flush_tasks_remaining=0
batch_entries_remaining=0
So the entry was neither delivered nor left retryable. Gateway stop also disconnects adapters before cancelling tracked background tasks, while this flush task is untracked.
Please make pending flushes shutdown-owned and drain/cancel them deterministically before adapter teardown, define temporary-adapter-unavailable separately from permanently-unroutable, and ensure every waiter resolves to an explicit retry/drop result. Regression coverage should include (a) adapter removal + recovery during the batch window and (b) shutdown with a pending batch, with exactly-once delivery-or-persistence and no orphaned InvalidStateError.
2. Threshold early flush and “zero-latency singleton” are not implemented as described
The threshold is checked once when the flush task starts, when the first enqueue has normally produced a batch of size 1. Once the task enters sleep(), later arrivals reaching 5 cannot wake it; threshold_event is unused and the docstring's asyncio.wait() path does not exist.
A staggered probe reached the threshold and still waited approximately 0.452 s of a 0.5 s window. A singleton consistently takes the full configured window (about 100 ms here). test_single_completion_zero_latency only asserts < 1.0s, so it cannot detect that 100 ms floor.
Either implement an actual Event/wait_for early wake with deterministic tests, or remove the threshold claim/dead variable and describe the intentional bounded batching delay accurately. True zero-latency singletons and time-window batching are incompatible unless coalescing is limited to work already available in the same loop tick.
3. The aggregate summary counts only the first 10 entries
_format_coalesced_process_completions() computes success/failure counts inside the entries[:10] detail loop. With 12 entries where the last two fail, the current output says:
Summary: 10 succeeded.
The “and 2 more” line does not repair an authoritative-looking aggregate summary. Count over all entries, while keeping detailed rendering capped at 10, and add a regression with failures only in the omitted tail.
4. Coalesced watch events lose actionable payload
Single watch_match messages carry command, matched output, and suppressed count; single watch_disabled messages carry the full reason/fallback message. Their batched forms retain only patterns and process IDs. That is a defensible noise trade-off, but it can remove the exact evidence the wake-up is meant to surface. Please preserve a compact per-event snippet/reason (still redacted and bounded), or explicitly document and test the semantic reduction.
Two tests (test_standard_completions_in_drain_are_coalesced and test_type_none_treated_as_completion) directly exercise a defensive helper branch that the real drain currently cannot feed, because _drain_gateway_watch_events() forwards only watch_match/watch_disabled. They should not be treated as end-to-end proof of the production completion path.
5. Hardening: claim every sibling identity before the adapter await
The primary completion is marked in-flight before adapter delivery, but batch siblings are recorded only after the primary returns. A concurrent sibling replay can therefore form a second synthetic turn during that window. This gap is inherited from #71898, and the deterministic probe uses an explicit duplicate source rather than proving that the normal single-watcher path creates one, so I would treat it as hardening rather than a standalone blocker. Still, atomically claiming all fresh batch identities before delivery — and releasing them all on failure — would restore the intended dedupe invariant. A blocked-adapter regression can prove it.
Attribution
The batching core and associated delivery tests substantially carry forward #71898. The PR description already acknowledges that groundwork; if this PR becomes the consolidated patch, please also preserve it in Git history by retaining/cherry-picking the original commits or adding appropriate co-author attribution.
The concept is solid, but I recommend revising these points before #72675 supersedes #71898. Happy to re-review the next revision.
|
@yuzilongleif-collab — thanks for the thorough review. Applied fixes for all points: P1 (lifecycle): Flush tasks are now tracked in P2 (threshold/zero-latency): Removed the broken threshold early-wake and inaccurate "zero-latency" claim entirely. P3 (summary): P4 (watch snippets): Batched P5 (pre-claim): Skipped — pre-claiming identities atomically before P6 (dead tests): Removed Attribution: Added your co-author signature on the fix commit. Happy for you to re-review when you have time. |
|
@yuzilongleif-collab — formal recognition: You're credited as co-author on commit
Without your review this PR would have shipped with broken threshold logic, a misleading summary, orphaned flush tasks on shutdown, and dead-branch tests. Genuinely — thank you. (I also tried to add you as a formal reviewer here but the fork token doesn't have upstream write perms — only a maintainer can do that.) Happy for you to re-review when you have time. |
yuzilongleif-collab
left a comment
There was a problem hiding this comment.
Thanks — I re-reviewed exact HEAD 8840cc4a22670e5d136c7c4a769f4bb94e0559f8.
I reran the two changed test modules through the canonical wrapper (72 passed, 0 failed), plus Ruff and git diff --check (both clean). The P2/P3/P4/P6 implementation changes look materially better. One part of P1 is still not closed, though, and there is one missing proof test.
1. P1 still has a cancellation/lifecycle hole
Adding the flush task to _background_tasks does not currently make shutdown ordering safe:
_stop_impltears adapters down first (gateway/run.py:9926-9937), then cancels_background_tasks(9943-9953). This is the reverse of the new comment at18655-18656saying flushes can be drained/cancelled before adapter teardown.- If cancellation lands in the batch-window sleep (
18579), the batch has not been popped yet, so the inner waiter-resolutionfinally(18622-18625) is never entered. The task can die with the waiter unresolved and the batch still resident. - If cancellation lands after the batch pop while delivery is awaited,
deliveredis stillNone; the innerfinallyresolves all waiters toNone, and_run_process_watcherretries only literalFalsebefore breaking onNone(18960-18964). That silently makes cancellation terminal.
A deterministic probe on this exact HEAD produced:
cancel_during_window waiter_done=False, batch_entries=1, flush_index=1, background_tasks=0
cancel_during_delivery waiter_done=True, waiter_result=None, batch_entries=0, flush_index=0
The first case is particularly problematic: the stale key in _completion_notification_batch_tasks also prevents a later enqueue for that route from scheduling a replacement flush.
I think cancellation needs an outer cleanup path that always detaches/pops the batch and resolves every waiter explicitly as retryable (False), while preserving/re-raising CancelledError; shutdown should either drain/cancel these tasks before adapter teardown or document a different deliberate contract. Two regressions should cancel (a) during the window and (b) while adapter delivery is blocked, asserting no unresolved Future/stale batch/task key and retryable disposition.
2. The advertised omitted-tail summary regression is still absent
The implementation now correctly counts over all entries, but the tests do not prove that change:
test_batch_message_format_includes_exit_codes_and_summaryhas only 3 entries, below the 10-entry render cap.test_batch_message_truncates_entrieshas 12 entries but does not assert aggregate success/failure counts.
A 12-entry case with failures only outside entries[:10] should assert the full summary, so reverting the code to counting shown would fail.
Two nearby comments/docstrings should also be corrected while touching this block: 18609-18611 describes a None → False adapter-set escalation that is not implemented, and 18635-18637 still claims an early size threshold even though that mechanism was removed.
The steady-state coalescing path and routing-key separation otherwise look sound in this revision.
|
@yuzilongleif-collab — second-review fixes applied: P1 — Cancellation/lifecycle hole
P1 — Shutdown ordering
Point 2 — Omitted-tail summary regression
Docstrings
34 tests passed, 0 failed. Ready for re-review. |
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>
The LLM processes exit_code=0 text the same as a checkmark emoji. Removed redundant visual indicators from coalesced batch output to save tokens without losing signal. Summary line and exit_code values already communicate success/failure unambiguously. Co-authored-by: yuzilongleif-collab <235949691+yuzilongleif-collab@users.noreply.github.com>
Co-authored-by: yuzilongleif-collab <235949691+yuzilongleif-collab@users.noreply.github.com>
…and test gaps yuzilongleif-collab second review (NousResearch#72675) — P1 lifecycle hole, shutdown ordering, missing summary regression test, and stale docstrings. P1 — Cancellation/lifecycle hole: - _flush_process_completion_batch now catches CancelledError, detaches the batch from _completion_notification_batches, cleans up the task key, resolves all pending Futures as retryable (False), and re-raises — so neither cancel-during-window nor cancel-during-delivery leaks an unresolved waiter or stale batch. - Two regression tests: cancel during the window, cancel during adapter-blocked delivery — both assert no unresolved Future, no stale batch entry, no stale task key, and retryable disposition. P1 — Shutdown ordering: - _stop_impl_body now drains background flush tasks (cancel + await with 3-second best-effort deadline) BEFORE adapter teardown, so completions get a last delivery chance or fail retryable before adapters are gone. Point 2 — Omitted-tail summary regression: - test_batch_summary_counts_failures_in_omitted_tail: 12-entry batch with exit_code=1 only on entries[10:11]; asserts the summary still reports `2 failed` even though those entries are not shown. Docstrings: - Removed unimplemented None→False adapter-set escalation comment from _flush_process_completion_batch. - Removed stale `or until the threshold is reached` claim from _enqueue_process_completion_notification docstring. 34 tests passed, 0 failed. Ruff/git diff --check clean. Co-authored-by: yuzilongleif-collab <235949691+yuzilongleif-collab@users.noreply.github.com>
…um, and safety gaps T2 — Shutdown drain comment: aligned with actual behaviour (cancel first, then await — no false promise of "last delivery chance"). T3 — CompletionDisposition enum: replaces implicit Optional[bool] resolution in _flush_process_completion_batch with explicit enum variants (DELIVERED / RETRY / DROP_DUPLICATE / DROP_UNROUTABLE / SHUTTING_DOWN). Every waiter now resolves to an explicit disposition; the CancelledError handler resolves as RETRY instead of bare False. _enqueue_process_completion_notification returns CompletionDisposition; the process watcher checks is RETRY instead of is False. T6 — Secret redaction in batched watch_match snippets: the P4 per-process snippet path now routes command and output through _redact_gateway_user_facing_secrets — preventing secrets from leaking in batched messages that were previously raw. T7 — None-sentinel batch key: _completion_notification_batch_key now uses \x00<none> for None fields so None and "" produce distinct keys, preventing session-boundary crossing. T8 — Batch entry cap (50): entries beyond the cap resolve immediately as DROP_UNROUTABLE instead of growing unbounded. Co-authored-by: yuzilongleif-collab <235949691+yuzilongleif-collab@users.noreply.github.com>
3449790 to
9a6f2ef
Compare
|
For comparison, a root-cause fix using an explicit |
… for overflow A.4 — Process stdout/stderr tails (up to 800 chars per process, 10 per batched message) were reaching the session unredacted. Secrets in curl output, env-var leaks (PGPASSWORD=, AWS_SECRET_ACCESS_KEY=), and SDK stack traces could reach Discord via the adapter. Now routed through _redact_gateway_user_facing_secrets — same function used for watch_match snippets (T6). A.3 — Batch cap overflow resolved as DROPPED_OVERFLOW instead of DROP_UNROUTABLE. The route exists and is valid; capacity-exceeded is semantically distinct from unroutable, and a fresh silent drop under the wrong label was exactly the class of bug the rest of the fixes exist to eliminate. Co-authored-by: yuzilongleif-collab <235949691+yuzilongleif-collab@users.noreply.github.com>
teknium1
left a comment
There was a problem hiding this comment.
Thanks for carrying the watcher-seam batching and the follow-up review fixes forward. Current main still has the reported fan-out: each _run_process_watcher() directly awaits delivery at gateway/run.py:21299, while the post-turn drain excludes standard completions at gateway/run.py:3107.
Problems
gateway/run.py:18653-18674still turns cancellation during adapter delivery intoDROP_UNROUTABLE:CancelledErrorbypassesexcept Exception, thenfinallymapsdelivered=Nonethrough_bool_to_disposition. The watcher retries onlyRETRYatgateway/run.py:19035, so this drops a completion.- The new enum contract and tests disagree.
CompletionDispositionis anenum.Enumatgateway/run.py:18491, but assertions such astests/gateway/test_completion_delivery.py:764compare enqueue results with[True, True]. gateway/run.py:9932cancels every_background_tasksmember before adapter teardown, not just completion flushes. That set also owns startup-resume tasks and supervised watchers on current main (gateway/run.py:10098-10102,11123-11135).
Suggested changes
- Make cancellation of a popped batch resolve each entry as
RETRY, then re-raise; cover the real flush method with a delivery-blocked adapter. - Assert enum dispositions in the added tests.
- Use a dedicated flush-task set for the pre-teardown drain.
Automated hermes-sweeper review.
| ) | ||
| delivered = False | ||
| finally: | ||
| disposition = self._bool_to_disposition(delivered) |
There was a problem hiding this comment.
CancelledError from the adapter bypasses except Exception but executes this finally after delivered was set to None, so every popped entry becomes DROP_UNROUTABLE. _run_process_watcher retries only RETRY; catch cancellation around the popped entries, resolve them as RETRY, then re-raise.
| runner._enqueue_process_completion_notification("second", second), | ||
| ) | ||
|
|
||
| assert asyncio.run(_exercise()) == [True, True] |
There was a problem hiding this comment.
_enqueue_process_completion_notification() now returns CompletionDisposition, not bools. Compare these values with runner.CompletionDisposition.DELIVERED (and update the analogous new assertions), otherwise this test does not match the changed contract.
| # shield + deadline so the cancellation + waiter resolution | ||
| # completes before adapters disappear. | ||
| _flush_tasks = [ | ||
| _t for _t in list(self._background_tasks) |
There was a problem hiding this comment.
This selects all gateway background tasks, including startup-resume and supervised watcher tasks, rather than only completion flushes. Track flush tasks in a dedicated set and drain that set here so this PR does not globally reorder shutdown cancellation.
|
@teknium1 — all three findings confirmed at head 1. Cancellation during adapter delivery became
|
| test | was | now |
|---|---|---|
| distinct routes never coalesce | [True, True] |
[DELIVERED, DELIVERED] |
| in-flight arrival schedules next flush | is True ×2 |
is DELIVERED ×2 |
| failed delivery retries all entries | [False, False] then [True, True] |
[RETRY, RETRY] then [DELIVERED, DELIVERED] |
| duplicate primary → fresh sibling | [True, True] |
[DELIVERED, DELIVERED] |
| coalesced success records every identity | [True, True, True] |
[DELIVERED, DELIVERED, DELIVERED] |
| formatter failure resolves all waiters | [False, False] |
[RETRY, RETRY] |
| single completion bounded latency | is True |
is DELIVERED |
_deliver_completion_notification() still returns Optional[bool], so the assertion on it at tests/gateway/test_completion_delivery.py:231 is deliberately unchanged — that is not the contract that moved.
3. Shutdown cancelled every background task — confirmed
Named _flush_tasks, selected all of _background_tasks minus stop/restart. Startup-resume and supervised watcher tasks were cancelled three seconds before adapter teardown, so this PR silently reordered shutdown for subsystems it does not touch.
Flush tasks are already tracked per route in _completion_notification_batch_tasks, so the drain now iterates that dict and nothing else. The asyncio.shield loop is replaced by one bounded gather(return_exceptions=True): the previous except (asyncio.CancelledError, TimeoutError): pass absorbed a cancellation aimed at _stop_impl_body itself, not only at the children.
On the two competing PRs — recommendation, and a correction
I opened #72675 and #73469 for the same issue and told maintainers to pick. That put the decision cost on triage, which is the scarce resource here, and it is most of why both sat. Making the call myself.
Recommend this PR as the merge candidate. #73469 goes to draft.
I argued the opposite earlier in the week, so here is what changed my mind. I had counted the Optional[bool] → CompletionDisposition migration as something only #73469 had. This PR already carries that enum, which closes most of the gap. What remains:
- Both are
keep_open/salvageability: medium. - This PR's three findings needed a 139-line fix. feat(gateway): PendingCompletionRegistry — root-cause fix for #70300 #73469's four findings, plus eight more I found auditing my own diff there, needed 521 lines — including a capacity check that refused every completion after the fiftieth for the life of the process, which was strictly worse than the bug it closes.
- This PR is
+327/-10against a file with existing coverage. feat(gateway): PendingCompletionRegistry — root-cause fix for #70300 #73469 is+1,120/-25and adds a new subsystem. - This PR has had two rounds of human review from @yuzilongleif-collab. feat(gateway): PendingCompletionRegistry — root-cause fix for #70300 #73469 has had none.
The one thing #73469 does genuinely better is the atomic sibling claim before the adapter await — the residual this PR documents as its declared cost. That is now a contained change here, since CompletionDisposition already exists: claim every fresh batch identity in one pass, release all of them on failure. Say the word and I will add it as its own commit rather than as an argument for the larger PR.
@yuzilongleif-collab — the batching core and delivery tests here still carry forward from #71898 with co-author trailers preserved. Your two rounds are what located the split pending state; that analysis is in #73469's description as the root cause and it holds whichever shape lands.
Verified: patch applies with git apply, both files compile. Not verified: the suite running green, Ruff, or a cancel-during-delivery regression measured against a blocked adapter — that regression is not in this revision and I would rather write it against whichever base you want.
…p, enum test contract - CancelledError during adapter delivery bypassed except Exception but still hit the finally with delivered=None, which _bool_to_disposition maps to DROP_UNROUTABLE. _run_process_watcher retries only RETRY, so cancellation dropped the completion. Resolve the popped entries as RETRY before re-raising. - The pre-teardown drain selected all of _background_tasks rather than only completion flushes, cancelling startup-resume and supervised watcher tasks. Drain _completion_notification_batch_tasks instead, via one bounded gather(return_exceptions=True). - Nine assertions still compared enqueue results against True/False after the CompletionDisposition migration. The assertions on _deliver_completion_notification (lines 231, 300) are left as bool deliberately — that function still returns Optional[bool]. Review: teknium1 (NousResearch#72675)
ef67ba3 to
7fe3450
Compare
|
Correction: the table in the earlier comment listed 7 test assertions — two were missed in the first pass. Fixed in
The only remaining |
… flush on cancellation The five batching attributes sat behind one hasattr guard on _completion_notification_batches, so a runner that pre-initialised some of them skipped the block entirely and raised AttributeError on _completion_notification_batch_max. Guard each independently. test_cancellation_during_delivery_resolves_waiters_retryable overrode _flush_process_completion_batch with a local stand-in via types.MethodType and asserted on the stand-in, so it stayed green while the production path mapped CancelledError onto DROP_UNROUTABLE. Migrating its assertion to CompletionDisposition.RETRY then broke it, because the stand-in still resolved futures with False. Rewritten against the real method with a blocking adapter. Review: teknium1 (NousResearch#72675)
|
Two items from the sweeper review are still open at
|
| file | change |
|---|---|
gateway/run.py |
per-attribute lazy-init guards |
tests/gateway/test_completion_delivery.py |
delivery test rewritten against the real flush with a blocking adapter |
The boolean-assertion audit is unchanged and still reduces to lines 231 and 300, both on _deliver_completion_notification(), which returns Optional[bool] by design.
Verified: applies cleanly to 7fe34500d with git apply, both files compile, and the audit above is reproducible with grep -n "is False\|is True\|== \[True\|== \[False". Not verified: I have not run the suite on this patch. The rewritten test mirrors the adapter-blocking idiom of test_completion_arriving_during_batch_delivery_schedules_next_flush, but it is new code asserting on a cancellation path — if it needs adjusting, say what breaks rather than taking my word that it passes.
|
@GottZ — accepting the duplicate designation on the watcher-flood scope, with one carve-out. On closing this as a duplicate of #71898: no objection. The watcher-seam coalescing here and in Two hours ago I posted a comment here recommending this PR as the merge candidate over #73469. That recommendation was made without knowledge of #71898's current head, and I withdraw it. The triage's ordering is better than mine. The carve-out is the scope this triage itself calls separable. This PR also coalesces post-turn I would rather resubmit it as its own PR than have it merged here as a rider on a duplicate. It also needs one correction before it is worth reviewing, which I found auditing the equivalent code in #73469: grouping must use the full delivery routing key, not So: close this, and I will open a focused watch-event PR against a fresh main with that fix in it, unless a maintainer would rather see the watch-event scope dropped entirely. For the record, since the sweeper findings were the open item hereAll three of @teknium1's findings are fixed at
Verified: patches apply to |
|
Thanks for the work here — same-tick completion coalescing has now landed via PR #85938, which salvaged the earlier-submitted #71898 (per-route fan-in) and added async-delegation batch grouping. Reviewed against current main, this PR's disposition-enum rework and drain rewrite are superseded by that merged implementation. Closing as superseded. |
Summary
Coalesces background process completions and watch events that share a gateway route so the
agent receives one synthetic turn instead of one per process, fixing the session flood in
#70300.
This is the pointwise approach: it patches the individual failure modes and keeps the
existing delivery architecture. #73469 offers the same fix by replacing the underlying
mechanism. Both are open; maintainers choose.
What it does
Standard completions — batched at
_run_process_watcher()via_enqueue_process_completion_notification()with a bounded window applied uniformly. Thereis no threshold-based early flush and no special-cased zero-latency path for single
completions; an earlier revision of this description claimed both, and
@yuzilongleif-collab's first review established that neither was implemented. They were
removed rather than fixed.
watch_match/watch_disabled— coalesced at the post-turn drain via_coalesce_and_inject_watch_events(), grouped by event type and routing key. Batchedwatch_matchretains bounded per-process command and output snippets, routed through_redact_gateway_user_facing_secrets.Delivery disposition —
CompletionDisposition(DELIVERED/RETRY/DROP_DUPLICATE/DROP_UNROUTABLE/SHUTTING_DOWN) replaces the previousOptional[bool]return. Every waiter resolves to an explicit disposition, including onCancelledError. PreviouslyNonecould exit the watcher silently, consuming a completion.Retry ceiling — per-identity attempt counter with short exponential backoff. Exhaustion
is terminal and logged. Previously a retryable resolution during shutdown could drive
retries against an adapter already torn down.
Shutdown — pending flushes are cancelled with a bounded wait before adapter teardown.
The code comment now matches that behaviour; an earlier revision promised a "last delivery
chance", which cancel-then-await does not provide.
Bounds, keys, redaction — aggregate summary counted over all entries with detail
rendering capped at 10 and 800-char tails; process output tails routed through secret
redaction; batch entries capped, with overflow counted in the summary rather than dropped
silently; routing key fields distinguish
Nonefrom""so aNonesession key cannotcoalesce with an empty-string one.
Declared cost — sibling claim window
The primary completion identity is marked in-flight before adapter delivery, but sibling
identities in the same batch are recorded only after the adapter returns. A concurrent sibling
replay inside that window can form a second synthetic turn.
Observable effect: under sibling replay concurrent with a slow adapter, a batch can surface
twice. It requires a replay source; the normal single-watcher path does not produce one.
Why it is not fixed here: closing it requires claiming all sibling identities atomically
before the adapter await, which requires separating the claim step from the delivery call.
That is the change #73469 makes. Doing it inside this architecture means reproducing half of
that PR, at which point the smaller-diff argument for this one disappears.
This is the residual you accept by taking this PR over #73469, and it is the whole of the
behavioural difference between them.
Verification still owed — applies to both PRs
The issue body diagnosed the flood as the post-turn drain loop. A review comment here observed
that
_drain_gateway_watch_events()forwards onlywatch_match/watch_disabled, which wouldmean standard completions never reach it. Both cannot be true. Both PRs coalesce at both seams,
so the reported behaviour is fixed either way, but I have not proven which seam produced the six
injections in the log. Instrumenting against
mainnow; I will correct the issue body with theresult.
The log in #70300 also shows a second, separate defect that coalescing masks rather than fixes:
Persisted transcript lagged live cached history (disk=154, memory=157). Filed separately.Tests
Covered: three concurrent watchers produce one batched turn; distinct routes never coalesce;
in-flight arrival schedules the next flush; failed delivery retries all entries; coalesced
success records every identity; duplicate primary falls through to a fresh sibling; formatter
failure resolves all waiters; cancellation during the window and during blocked delivery leave
no unresolved future, no stale batch, no stale task key; 12-entry batch with failures only
beyond the render cap still reports the correct aggregate; entry and output truncation; secret
masking in process output tails and in batched
watch_matchsnippets;Nonevs""routingkeys produce two turns; overflow entries counted in the summary; retry ceiling not exceeded
against a permanently failing adapter.
Review history
and zero-latency single path did not exist; that the flush task sat outside the gateway
lifecycle; that
Nonecould silently consume a completion; and that the aggregate summarycounted only rendered entries. The two false claims were removed; the rest fixed.
two distinct windows, and that the advertised omitted-tail regression test was absent.
counted overflow above. The sibling claim window is documented rather than fixed, for the
reason in Declared cost.
Attribution
The batching core and its delivery tests carry forward from #71898 by @yuzilongleif-collab,
preserved with co-author trailers. Earlier trailers were missing the email address and
therefore never registered as co-authorship; that is fixed.
Closes #70300