Skip to content

Commit 017dc06

Browse files
committed
Merge remote-tracking branch 'origin/main' into cynthiawang/agx1-618-2a-task-stream-lifecycle-metrics
# Conflicts: # agentex/src/domain/use_cases/streams_use_case.py
2 parents 830cbcb + c729b12 commit 017dc06

6 files changed

Lines changed: 299 additions & 29 deletions

File tree

agentex-ui/app/api/agentex/[...path]/route.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,26 @@ async function proxy(
3434

3535
const method = req.method.toUpperCase();
3636
const hasBody = method !== 'GET' && method !== 'HEAD';
37-
const upstream = await fetch(target, {
38-
method,
39-
headers,
40-
body: hasBody ? req.body : undefined,
41-
redirect: 'manual',
42-
// @ts-expect-error `duplex` is required to stream a request body (undici)
43-
duplex: 'half',
44-
});
37+
let upstream: Response;
38+
try {
39+
upstream = await fetch(target, {
40+
method,
41+
headers,
42+
body: hasBody ? req.body : undefined,
43+
redirect: 'manual',
44+
// A client disconnect must tear down the upstream request; undici otherwise holds it
45+
// open and the upstream handler keeps running.
46+
signal: req.signal,
47+
// @ts-expect-error `duplex` is required to stream a request body (undici)
48+
duplex: 'half',
49+
});
50+
} catch (error) {
51+
// The disconnect above rejects the fetch with the signal's abort reason itself, so identity —
52+
// not `error.name` — separates it from a transport failure that coincides with a disconnect.
53+
// Next's reason is a `ResponseAborted`, so an `AbortError` check would never fire.
54+
if (error === req.signal.reason) return new Response(null, { status: 499 });
55+
throw error;
56+
}
4557

4658
// Pass the upstream body through unbuffered so SSE / streaming responses work.
4759
const resHeaders = new Headers(upstream.headers);

agentex/src/domain/entities/tasks.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ class TaskStatus(str, Enum):
2929
DELETED = "DELETED"
3030

3131

32+
# Canonical status partition (state machine + SSE termination).
33+
# Non-terminal: RUNNING or INTERRUPTED (resumable); terminal is the rest.
34+
# New statuses are terminal unless added to the non-terminal set.
35+
NON_TERMINAL_TASK_STATUSES = frozenset({TaskStatus.RUNNING, TaskStatus.INTERRUPTED})
36+
TERMINAL_TASK_STATUSES = frozenset(TaskStatus) - NON_TERMINAL_TASK_STATUSES
37+
38+
3239
class TaskEntity(BaseModel):
3340
id: str = Field(
3441
...,

agentex/src/domain/services/task_service.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,9 @@ async def forward_task_to_acp(
166166
async def fail_task(self, task: TaskEntity, reason: str) -> None:
167167
task.status = TaskStatus.FAILED
168168
task.status_reason = reason
169-
await self.task_repository.update(task)
169+
# Publish task_updated so streaming viewers see the failure and end.
170+
# Every terminal write must emit; SSE termination relies on it.
171+
await self.update_task(task)
170172

171173
async def get_task(
172174
self,
@@ -374,7 +376,11 @@ async def cancel_task(
374376
new_status=TaskStatus.CANCELED,
375377
status_reason="Task canceled by user",
376378
)
377-
return updated if updated is not None else await self.task_repository.get(id=task.id)
379+
return (
380+
updated
381+
if updated is not None
382+
else await self.task_repository.get(id=task.id)
383+
)
378384

379385
async def interrupt_task(
380386
self, agent: AgentEntity, task: TaskEntity, acp_url: str

agentex/src/domain/use_cases/streams_use_case.py

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@
55
from fastapi import Depends
66
from pydantic import ValidationError
77

8+
from src.adapters.crud_store.exceptions import ItemDoesNotExist
89
from src.adapters.streams.adapter_redis import DRedisStreamRepository
910
from src.api.schemas.task_stream_events import TaskStreamEvent
1011
from src.config.dependencies import DEnvironmentVariables
1112
from src.domain.entities.task_stream_events import (
1213
TaskStreamConnectedEventEntity,
1314
TaskStreamErrorEventEntity,
1415
TaskStreamEventEntity,
16+
TaskStreamTaskUpdatedEventEntity,
1517
convert_task_stream_event_to_entity,
1618
)
19+
from src.domain.entities.tasks import TERMINAL_TASK_STATUSES
1720
from src.domain.services.task_service import DAgentTaskService
1821
from src.utils.logging import make_logger
1922
from src.utils.stream_metrics import (
@@ -128,10 +131,23 @@ async def stream_task_events(
128131
# congested relay fall behind far enough that those deltas land
129132
# before the snapshot and are never read. Snapshotting first resolves
130133
# to "0-0" (stream is empty until the client sends), so we read from
131-
# the beginning.
134+
# the beginning. Reading the cursor before the status check also
135+
# catches a task that goes terminal while we connect.
132136
last_id = await self.stream_repository.get_stream_tail_id(stream_topic)
137+
task = await self.task_service.get_task(id=task_id)
133138
# Send initial connection data
134139
yield f"data: {TaskStreamConnectedEventEntity(type='connected', taskId=task_id).model_dump_json()}\n\n"
140+
# Already terminal: replay buffered events and end (late connect).
141+
if task.status in TERMINAL_TASK_STATUSES:
142+
async for _id, data in self.read_messages(
143+
topic=stream_topic, last_id="0"
144+
):
145+
yield f"data: {data.model_dump_json()}\n\n"
146+
await asyncio.sleep(0.02)
147+
logger.info(
148+
f"Ending SSE stream for task {task_id}: already terminal at connect"
149+
)
150+
return
135151
now = asyncio.get_running_loop().time()
136152
# last_message_time drives the keepalive ping and is reset by pings;
137153
# last_event_time tracks only real data pushes (a ping is not a data
@@ -154,9 +170,33 @@ async def stream_task_events(
154170
# client's read fails on each cycle; without backoff this turns into a
155171
# log-ingestion firehose (one failure per client per cycle, ~once/sec).
156172
consecutive_errors = 0
173+
last_status_check = last_message_time
157174
# Application-level control loop
158175
while True:
159176
try:
177+
# Authoritative status recheck on an interval. Runs at the
178+
# TOP of every iteration — even after a read failure/backoff —
179+
# so a terminal task ends even if its event publish was lost
180+
# or Redis reads keep erroring.
181+
current_time = asyncio.get_running_loop().time()
182+
if current_time - last_status_check >= ping_interval:
183+
last_status_check = current_time
184+
try:
185+
task = await self.task_service.get_task(id=task_id)
186+
except ItemDoesNotExist:
187+
# Row permanently gone (e.g. retention) — end, don't retry.
188+
logger.info(
189+
f"Ending SSE stream for task {task_id}: "
190+
"task no longer exists"
191+
)
192+
return
193+
if task.status in TERMINAL_TASK_STATUSES:
194+
logger.info(
195+
f"Ending SSE stream for task {task_id}: "
196+
"terminal on status recheck"
197+
)
198+
return
199+
160200
# Process yielded messages one by one
161201
message_generator = self.read_messages(
162202
topic=stream_topic, last_id=last_id
@@ -173,29 +213,38 @@ async def stream_task_events(
173213
last_message_time = now
174214
last_event_time = now
175215
stalled = False
216+
# Terminal event is the last one — end here.
217+
if (
218+
isinstance(data, TaskStreamTaskUpdatedEventEntity)
219+
and data.task is not None
220+
and data.task.status in TERMINAL_TASK_STATUSES
221+
):
222+
logger.info(
223+
f"Ending SSE stream for task {task_id}: received "
224+
"a terminal task_updated event"
225+
)
226+
return
176227
await asyncio.sleep(0.02)
177228

178229
# A read cycle completed without raising — the stream is
179230
# healthy again, so reset the backoff/error counter.
180231
consecutive_errors = 0
181232

182-
# If we didn't get any messages, add a small pause
183-
# to prevent tight loops and send keepalive ping if needed
233+
# Idle: send keepalive ping so proxies don't reap us. Use a
234+
# fresh timestamp — the read above blocks up to timeout_ms, so
235+
# the loop-top current_time would be stale for ping timing.
184236
if message_count == 0:
185-
current_time = asyncio.get_running_loop().time()
237+
now = asyncio.get_running_loop().time()
186238
# No data event pushed for the stall window: count the
187239
# onset once. Keepalive pings deliberately do not reset
188240
# last_event_time, so a persistently quiet stream is
189241
# visible even though the connection stays alive.
190-
if (
191-
current_time - last_event_time >= stall_threshold
192-
and not stalled
193-
):
242+
if now - last_event_time >= stall_threshold and not stalled:
194243
stalled = True
195244
record_stream_stall()
196-
if current_time - last_message_time >= ping_interval:
245+
if now - last_message_time >= ping_interval:
197246
yield ":ping\n\n"
198-
last_message_time = current_time
247+
last_message_time = now
199248
await asyncio.sleep(0.1)
200249
else:
201250
# Small pause between batches
@@ -238,6 +287,7 @@ async def stream_task_events(
238287
)
239288
yield f"data: {TaskStreamErrorEventEntity(type='error', message=str(e)).model_dump_json()}\n\n"
240289
finally:
290+
# Don't delete the shared topic; the TTL reclaims it.
241291
logger.info(f"SSE stream for task {task_id} has ended")
242292
# Only close what we actually opened, so the active gauge is never
243293
# decremented for a stream that never incremented it.
@@ -246,7 +296,6 @@ async def stream_task_events(
246296
outcome,
247297
asyncio.get_running_loop().time() - stream_start_time,
248298
)
249-
await self.cleanup_stream(stream_topic)
250299

251300

252301
DStreamsUseCase = Annotated[StreamsUseCase, Depends(StreamsUseCase)]

agentex/src/domain/use_cases/tasks_use_case.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
from fastapi import Depends
44

55
from src.adapters.crud_store.exceptions import ItemDoesNotExist
6-
from src.domain.entities.tasks import TaskEntity, TaskRelationships, TaskStatus
6+
from src.domain.entities.tasks import (
7+
NON_TERMINAL_TASK_STATUSES,
8+
TaskEntity,
9+
TaskRelationships,
10+
TaskStatus,
11+
)
712
from src.domain.exceptions import ClientError
813
from src.domain.services.task_service import DAgentTaskService
914
from src.utils.logging import make_logger
@@ -132,10 +137,8 @@ async def update_mutable_fields_on_task(
132137

133138
return task_entity
134139

135-
# Non-terminal statuses a task can be transitioned to a terminal status from.
136-
# RUNNING is the normal case; INTERRUPTED is also valid so an interrupted
137-
# (paused, still-continuable) task can still be canceled/completed/etc later.
138-
_TERMINAL_TRANSITION_SOURCES = (TaskStatus.RUNNING, TaskStatus.INTERRUPTED)
140+
# Statuses a task can transition to terminal from (the non-terminal set).
141+
_TERMINAL_TRANSITION_SOURCES = NON_TERMINAL_TASK_STATUSES
139142

140143
async def _transition_to_terminal(
141144
self,

0 commit comments

Comments
 (0)