mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-12 12:37:32 +00:00
fix(agent): cancel orphaned tool task when SSE client disconnects mid-call (#5106)
stream_agent_loop's per-tool drain loop had no cleanup path for early generator close. Starlette throws GeneratorExit into the generator at whatever await point it's suspended on when the SSE client disconnects (aclose()) - here that's 'await _progress_q.get()' inside the drain loop, before the final 'await _tool_task' line ever runs. The task, which wraps execute_tool_block, was left running unawaited and uncancelled. For bash/python tools this orphans the underlying subprocess: subprocess_tools.py already has correct CancelledError handling that kills the child process, but only runs if the task is actually cancelled. A client disconnecting mid long-running command left that subprocess running server-side for its full duration with nothing left to reap it. Wrap the drain loop in try/finally: on early exit, cancel _tool_task (if not already done) and await it so the existing subprocess-kill path runs. Adds a regression test that drives the real stream_agent_loop with a fake tool handler, closes the generator mid tool-call (mirroring what Starlette does on disconnect), and asserts the handler observed cancellation immediately - not merely via asyncio.run()'s own end-of-run task cleanup, which would mask the bug. Fixes #5105
This commit is contained in:
+25
-10
@@ -4013,16 +4013,31 @@ async def stream_agent_loop(
|
||||
await _progress_q.put(None)
|
||||
|
||||
_tool_task = asyncio.create_task(_run_tool())
|
||||
# Drain progress events as they arrive — block until the
|
||||
# next event OR the tool finishes (sentinel = None).
|
||||
while True:
|
||||
evt = await _progress_q.get()
|
||||
if evt is None:
|
||||
break
|
||||
yield (
|
||||
f'data: {json.dumps({"type": "tool_progress", "tool": block.tool_type, "round": round_num, **evt})}\n\n'
|
||||
)
|
||||
desc, result = await _tool_task
|
||||
try:
|
||||
# Drain progress events as they arrive — block until the
|
||||
# next event OR the tool finishes (sentinel = None).
|
||||
while True:
|
||||
evt = await _progress_q.get()
|
||||
if evt is None:
|
||||
break
|
||||
yield (
|
||||
f'data: {json.dumps({"type": "tool_progress", "tool": block.tool_type, "round": round_num, **evt})}\n\n'
|
||||
)
|
||||
desc, result = await _tool_task
|
||||
finally:
|
||||
# If the SSE client disconnects (or this generator is
|
||||
# otherwise closed) while we're awaiting a progress event
|
||||
# above, GeneratorExit is thrown in right here and the
|
||||
# `await _tool_task` on the line above never runs — the
|
||||
# task (and any subprocess execute_tool_block spawned for
|
||||
# bash/python tools) would otherwise keep running
|
||||
# orphaned with nothing left to await or cancel it.
|
||||
if not _tool_task.done():
|
||||
_tool_task.cancel()
|
||||
try:
|
||||
await _tool_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
# A skill the model just loaded can prescribe tools that weren't
|
||||
# RAG-selected this turn (declared via requires_toolsets in its
|
||||
|
||||
Reference in New Issue
Block a user