fix(services): detach the turn's status callback when the turn ends - #264
Merged
Merged
Conversation
`GenerationService.generate` installs a progress callback on the engine and never removed it. `SynthesisAgent.set_status_callback` forwards straight to the chat client -- and `app_factory` builds exactly one `RoutingChatClient` for the process, while `engine_factory` builds a `SynthesisAgent` per turn. The per-turn callback therefore stayed on a process-wide object. It closes over `sink` and `snapshot`, so the finished turn's snapshot stayed referenced for the life of the process, attachments included -- up to the 24 MB a single message may carry, held long after the answer was delivered. It also misattributed progress. `generate_chat_title` builds a fresh engine and installs no callback of its own, so it runs against whatever the previous turn left behind: a model load during titling published a "loading_model" event against a job that had already completed. The install is now paired with a `finally` that detaches it, and the protocol documents `None` as meaning exactly that. The failure path matters as much as the success one -- a turn that raised used to leave its callback installed -- so both are covered. The diff looks large and is almost entirely indentation: `git diff -w` shows the eleven lines that actually changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dovvnloading
deleted the
fix/status-callback-does-not-outlive-its-turn
branch
September 7, 2026 16:11
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.
The problem
GenerationService.generateinstalls a progress callback on the engine and never removes it:SynthesisAgent.set_status_callbackforwards straight through to the chat client — and the lifetimes do not match:So a per-turn callback ends up parked on a process-wide object, with two consequences.
It pins the turn. The lambda closes over
sinkandsnapshot, so the finished turn's snapshot stays referenced for the life of the process — attachments included, up to the 24 MB a single message may carry, held long after the answer was delivered.It misattributes progress.
generate_chat_titlebuilds a fresh engine and installs no callback of its own, so it runs against whatever the previous turn left behind:A model load during titling therefore publishes a
loading_modelevent against a job that has already completed.The fix
The install is paired with a
finallythat detaches it, and the protocol documentsNoneas meaning exactly that.Verified directly against a stand-in for the shared client:
Verification
Two tests, because the failure path is the one that matters most — a turn that raised used to leave its callback installed, and that is precisely when a stale callback would misattribute the next runtime message:
ModelOperationErrorleaves none eitherBoth fail against the unfixed code. Each asserts a callback was installed before asserting it was removed, so neither can be satisfied by an engine that simply never received one.
python -m pytest -qpython -m mypypython -m ruff check backend tests tools main.py app_factory.pyRun on Python 3.14, one of the versions in the compatibility matrix.
Compatibility and rollback
set_status_callbacknow acceptsNoneon the protocol and the shipped double;LlamaCppChatClientalready did, so nothing downstream changes. Progress reporting during a turn is untouched — the callback is installed at the same point and removed only once the turn is over. No API contract, stored data, or migration. Reverting the commit restores the previous behaviour exactly.Limits
This makes the callback's lifetime match the turn's. It does not give
generate_chat_titleortranslate_textprogress reporting of their own — they still run without one, so a model load during titling now reports nothing rather than reporting it against the wrong job. Silence is the correct half of that trade here; wiring real progress into those calls is a feature.The retained-snapshot cost was bounded at one turn (each new turn replaced the previous callback), so this is a correctness and footprint fix rather than an unbounded leak.
🤖 Generated with Claude Code