🐢 fix: Back Off Waiting Completion Wake-ups and Keep Their Trace Out of Requests - #16349
Merged
Merged
Conversation
9 tasks
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.
Summary
Background tool and subagent completion wake-ups re-read the database every five seconds for as long as they wait, and on a busy deployment they wait a long time. Since #15350 (Wake Agents on Background Tool Completion), every backgrounded call pre-registers its completion delivery at dispatch. The delivery engine claims it, finds the result not ready or the parent generation still busy, defers it, and repeats. Each cycle is a claim, a lease check, a generation-state read and a defer, every five seconds, for the whole time a tool runs, a turn continues, or an approval pause waits for a person. On the demo deployment, one user's waiting wake-ups sustained up to 32 delivery operations per second: 801,915
AgentTriggerDeliveryoperations for 1,091 deliveries in 40 hours, about 735 per delivery. v0.8.7 has none of this, so it is a regression in the v0.8.8 release line.The resolvers already asked for a one-second
retryAfteron these deferrals, but the engine never read it. A readiness deferral (deferWithoutAttempt) always waited the fixed five-second default, so the waiting cadence could not be tuned from the producer side at all.This PR makes waiting cheap. A waiting delivery backs off by a tenth of its age, between the existing five-second floor and a configurable cap (60 s by default). The engine honors a readiness
retryAfterwhenever it asks for longer than that floor, and never shorter, so queued turns and every other deferral keep their cadence.It also stops the delivery loop from hijacking request traces. The engine starts at boot but is woken from inside request handlers, and claim passes and poll timers inherited that request's async context. Every later tick belonged to that request forever: one chat request's trace on the demo carried 207,147 delivery operations over 6.6 hours. Claim passes and their timers now run under the root context.
Delivering a waiting result the moment it becomes ready (expediting on a durable result or a settled turn) is left to #16339, which builds on this. Until then, a result can wait up to one backoff interval after its turn ends.
How it works
A deferral whose parent has settled and is only finishing terminal persistence keeps the short re-check, since that clears within moments. The cap is
endpoints.agents.eventDriven.idlePolling.completionWaitMaxIntervalMs(default 60000, 5000–300000), next to the existing idle-polling caps. A new non-sparse{ user, status, availableAt }index onagenttriggerdeliveriesserves user-scoped reads of waiting deliveries. The collection's only existinguserindex is sparse onactorActionAdmittedAtand can't.Type of change
Testing
Verified against a local LibreChat build under concurrent load, measured from delivery rows in MongoDB and per-span delivery operations in ClickStack (ClickHouse Cloud), with the same run on
devand on this branch. Eight conversations each dispatch four background tasks (20–40 s) through a deterministic MCP fixture, then run a 5-minute foreground task, so 32 finished results wait behind busy turns:devAgentTriggerDeliveryoperations, whole runThe saving grows with waiting time, because the interval is a tenth of a delivery's age. In a separate run where four results waited behind a 12-minute turn,
devheld about 295 delivery operations a minute for the whole wait, while this branch fell to about 30 (mostly the engine's idle ticks) and its largest trace was 51 operations instead of 4,112. On the demo, waits ran 16 minutes at the median and up to 5.8 hours, where the fixed five-second cycle cost the most. The longer claim delay after a busy turn is the trade this PR makes on its own; #16339 removes it by expediting waiting results the moment they become ready.Both runs logged
JobPredecessorMismatchError(12 ondev, 29 here). This is an existing race when several wake-ups for one conversation start their turns at the same moment. The losing dispatch is deferred without consuming an attempt, and every result still delivered on its first attempt.Tested environments/configuration:
USE_REDIS=true)eventDriven.completionWakeups,durableReceipts,coalescing,actorMailboxopenai/gpt-6-luna; deterministic MCP fixtureslow_task(seconds, label)over streamable HTTPAutomated tests:
backoff.spec.ts; waiting-age backoff and the configured cap for running, paused and persisting parents inbackgroundCompletionWakeup.spec.tsandsubagentCompletionWakeup.spec.tsengine.spec.ts: longer readiness requests are honored, shorter ones floored at the default; claim passes and poll timers run under the root contextservice.delivery.spec.ts: the configured cap is exposed;config.spec.ts: defaults and boundstriggerDelivery.spec.ts(mongodb-memory-server): the new index exists and is not sparsepackages/apisrc/agentsandsrc/streamsuites;npx tsc --noEmitinpackages/api,packages/data-schemasandpackages/data-providerScreenshots / recordings
No user-facing change.
Risk / compatibility
Waiting deliveries re-check less often: a result that becomes ready while its delivery is backed off waits up to one interval (at most the cap, 60 s by default) instead of at most 5 s. Operators can lower
completionWaitMaxIntervalMsto trade database load for latency. Readiness deferrals that ask for less than five seconds, including every queued-turn deferral, keep exactly the cadence they have today. The new index is created at startup byensureAgentTriggerDeliveryIndexes, like the others. No stored-data migration.Checklist