fix(agentex): end SSE subscriptions on terminal task / vanished topic; stop deleting shared stream - #385
Merged
Conversation
deepthi-rao-scale
force-pushed
the
fix/agentex-sse-zombie-subscription-leak
branch
2 times, most recently
from
July 29, 2026 18:35
2846998 to
2eed3af
Compare
deepthi-rao-scale
marked this pull request as ready for review
July 30, 2026 20:23
rpatel-scale
approved these changes
Jul 30, 2026
deepthi-rao-scale
force-pushed
the
fix/agentex-sse-zombie-subscription-leak
branch
from
July 31, 2026 15:58
143ac4d to
6716712
Compare
…ing Redis connections Task event SSE subscriptions had no terminal condition: stream_task_events ran a `while True` loop that only exited on client disconnect or a fatal error. Once a task finished, its producer stopped writing but the reader kept blocking on the Redis stream (XREAD) forever, pinning a connection from the shared per-process pool. Because stream keys carry a sliding TTL, a finished task's key eventually expires while readers keep blocking on it — a permanent zombie. Accumulated zombies exhaust the pool, which is shared with the readiness probe, so /readyz fails while the dependency-free /healthz stays 200: the pod goes Unready and is never restarted. Termination now has three independent, deterministic checks: - Connect-time: read task status once at connect (after snapshotting the cursor, so a racing terminal event still lands after the cursor). If already terminal, replay buffered events and end — handles late connects. - In-stream: end on a terminal task_updated event (the last event a task emits). - Periodic authoritative recheck: at the top of every loop iteration, on an interval, re-read task status and end if terminal. Runs busy or idle and even after a read failure/backoff, so a dropped/lost terminal event or a failing read cannot keep the stream open. A hard-deleted task (ItemDoesNotExist) is treated as terminal; other lookup errors fall through to transient retry. Also: - AgentTaskService.fail_task now publishes task_updated via update_task — it was the only terminal write that didn't emit, which could strand a live viewer. - Drop the per-subscriber cleanup_stream in `finally`: the topic is shared by all viewers, so deleting it on one exit broke the others. The sliding TTL reclaims. - Canonical NON_TERMINAL/TERMINAL task-status sets on the TaskStatus entity, referenced by both the status state machine and SSE termination. Adds integration regression tests: event-driven termination, late-connect termination, a running task surviving a reclaimed key, and shared-stream survival on disconnect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
deepthi-rao-scale
force-pushed
the
fix/agentex-sse-zombie-subscription-leak
branch
from
July 31, 2026 16:25
6716712 to
85dfe6b
Compare
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 was broken
When you open a task in the UI, the backend keeps a live stream open to push updates. That stream had no way to end itself — it only stopped if the browser disconnected.
So when a task finished, the backend just kept listening forever, holding one Redis connection each time. These pile up until the pool is empty, at which point the pod can't serve requests (and its health check fails, so it sits stuck until a manual restart).
A second bug: when one viewer left, the code deleted the task's shared event stream — yanking it out from under everyone else watching the same task.
The fix
task_updatedevent when a task finishes, so the stream now closes as soon as it sees a terminal status.Testing
Two integration regression tests (stream self-ends on terminal task; one viewer disconnecting doesn't delete the shared stream).
Verified live (A/B against
main) — same steps both times: create a RUNNING task → openGET /tasks/{id}/stream→ complete the task, while watching Redisblocked_clients.mainXREAD-looping; kept sending:ping15s+ after completion and only ended when the client was force-disconnectedblocked_clientsstayed1— released only on client disconnect (zombie)task_updatedand returns immediately (Ending SSE stream …: received a terminal task_updated event)blocked_clients1 → 0instantly, no client disconnect neededOn
mainthe reader loggedReading messages from Redis stream …every ~2s indefinitely after the task was done; on this branch it stops the moment the terminal event arrives. Same reproduction the incident described, and the connection is released instead of pinned.Related: the UI-side half of this leak is #383.
🤖 Generated with Claude Code
Greptile Summary
This PR updates task SSE subscription lifecycle handling.
Confidence Score: 4/5
The PR should not merge until recoverable FAILED tasks no longer terminate subscriptions that must observe their subsequent RUNNING updates.
The stream returns upon receiving any FAILED task update, while the task service intentionally supports forwarding that same FAILED task again and publishing a RUNNING recovery update; viewers whose subscriptions closed on FAILED cannot receive that recovery or subsequent events.
Files Needing Attention: agentex/src/domain/entities/tasks.py, agentex/src/domain/use_cases/streams_use_case.py, agentex/src/domain/services/task_service.py
Important Files Changed
Sequence Diagram
sequenceDiagram participant Client participant SSE as StreamsUseCase participant DB as Task Store participant Redis Client->>SSE: Subscribe to task stream SSE->>Redis: Snapshot stream cursor SSE->>DB: Read authoritative task state alt Task already terminal SSE->>Redis: Replay buffered events SSE-->>Client: Events, then close else Task is non-terminal loop Until terminal, missing, or disconnect SSE->>Redis: Read new events alt Terminal task_updated received SSE-->>Client: Terminal event SSE-->>Client: Close stream else Status-check interval elapsed SSE->>DB: Recheck task alt Terminal or missing SSE-->>Client: Close stream end end end end Note over SSE,Redis: Subscriber exit does not delete the shared streamReviews (14): Last reviewed commit: "fix(agentex): terminate SSE task streams..." | Re-trigger Greptile