Skip to content

Commit 71f6b9d

Browse files
fix(agentex): snapshot stream cursor before reading task status at connect
Close a connect-time race that could leave a zombie stream. A terminal transition writes the DB before it XADDs its task_updated event, and get_task reads the DB while get_stream_tail_id sets the cursor the live loop reads after. Previously status was read before the tail snapshot: if the task went terminal in that gap, the status read said RUNNING (stale) so the connect-time terminal branch didn't fire, and the tail snapshot then landed on the just-published terminal event, which the live loop reads past — blocking forever. Snapshot the tail first, then read status. A non-terminal status read now guarantees any racing terminal event lands after the cursor, so the live loop is guaranteed to surface it. Pure reorder of two reads; no behavior change beyond eliminating the race. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 65a0af3 commit 71f6b9d

1 file changed

Lines changed: 12 additions & 6 deletions

File tree

agentex/src/domain/use_cases/streams_use_case.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,18 +103,24 @@ async def stream_task_events(
103103
task_id = task.id
104104

105105
stream_topic = get_task_event_stream_topic(task_id=task_id)
106+
# Snapshot the read cursor BEFORE reading task status (and before yielding
107+
# "connected"). Ordering is load-bearing for two reasons:
108+
# 1. A terminal transition writes the DB *before* it XADDs its
109+
# task_updated event. Snapshotting the tail first means that if the
110+
# status read below still says non-terminal, the terminal event (if
111+
# one is racing in) is guaranteed to land *after* this cursor, so the
112+
# live loop will read it — no missed-terminal zombie.
113+
# 2. "connected" is the client's cue to send its message, which makes
114+
# the agent start XADD-ing deltas; snapshotting after that yield
115+
# could let the first deltas advance the tail and be skipped.
116+
# Resolves to "0-0" (from the beginning) when the stream is empty.
117+
last_id = await self.stream_repository.get_stream_tail_id(stream_topic)
106118
# Authoritative connect-time check. If the task is ALREADY terminal, any
107119
# terminal event sits behind the live tail and the read loop below would
108120
# never surface it — so replay whatever is buffered and end here. This
109121
# handles the late-connect case deterministically (no polling needed);
110122
# the in-loop check below handles a task that finishes while streaming.
111123
task = await self.task_service.get_task(id=task_id)
112-
# Snapshot the read cursor BEFORE yielding "connected". "connected" is the
113-
# client's cue to send its message, which makes the agent start XADD-ing
114-
# deltas; snapshotting after the yield could let those first deltas advance
115-
# the tail and be skipped. Resolves to "0-0" (from the beginning) when the
116-
# stream is empty. Only used by the live path below.
117-
last_id = await self.stream_repository.get_stream_tail_id(stream_topic)
118124
# Send initial connection data
119125
yield f"data: {TaskStreamConnectedEventEntity(type='connected', taskId=task_id).model_dump_json()}\n\n"
120126
if task.status in TERMINAL_TASK_STATUSES:

0 commit comments

Comments
 (0)