Skip to content

Commit 08679ea

Browse files
sorryhyunclaude
andcommitted
fix: read the per-turn notification queue so Codex turns stop hanging
The openai-codex SDK routes every turn-scoped notification into a dedicated queue and drops it into a pending buffer when the turn is not registered; only unscoped events reach the global queue. `next_notification()` -- which in the old SDK was the whole stream -- now returns just those global leftovers, so our loop blocked forever while the turn's deltas, items and turn/completed piled up unread. Same method name, a narrower contract: the rename-only migration sailed straight past it. Register the turn and drain its queue, as the SDK's own TurnHandle.stream() does. Registration replays anything buffered between turn/start returning and the register call, so no early event is lost. Also route notifications back through loop.call_soon_threadsafe: the worker thread was calling asyncio.Queue.put_nowait directly, which is not thread-safe. Verified with a live turn against codex 0.144.3: 6 events, reply "PONG". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 79cfc64 commit 08679ea

1 file changed

Lines changed: 31 additions & 25 deletions

File tree

backend/providers/codex/app_server_instance.py

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -304,9 +304,15 @@ async def start_turn(
304304
# Queue for passing notifications from sync thread to async
305305
queue: asyncio.Queue[Optional[Notification]] = asyncio.Queue()
306306
turn_error: List[Optional[Exception]] = [None]
307+
loop = asyncio.get_event_loop()
307308

308309
def _run_turn_sync():
309310
"""Run the turn in a sync thread, collecting notifications."""
311+
312+
def emit(item: Optional[Notification]) -> None:
313+
# asyncio.Queue is not thread-safe; hand items over on the loop thread.
314+
loop.call_soon_threadsafe(queue.put_nowait, item)
315+
310316
try:
311317
client = self._client
312318
if client is None:
@@ -316,39 +322,39 @@ def _run_turn_sync():
316322
started = client.turn_start(thread_id, input_items, params=turn_params)
317323
turn_id = started.turn.id
318324

319-
# Put turn started event
320-
turn_started_notif = Notification(
321-
method="turn/started",
322-
payload=UnknownNotification(params={"turnId": turn_id, "threadId": thread_id}),
323-
)
324-
queue.put_nowait(turn_started_notif)
325-
326-
# Stream notifications until turn completes
327-
while True:
328-
try:
329-
notification = client.next_notification()
330-
queue.put_nowait(notification)
325+
# Everything this turn emits is routed to a dedicated queue, NOT the
326+
# global one `next_notification()` drains — without registering, the
327+
# events pile up unread and the turn never completes. Registering also
328+
# replays whatever arrived between turn/start returning and this call.
329+
client.register_turn_notifications(turn_id)
330+
try:
331+
# Put turn started event
332+
emit(
333+
Notification(
334+
method="turn/started",
335+
payload=UnknownNotification(params={"turnId": turn_id, "threadId": thread_id}),
336+
)
337+
)
331338

332-
# Check for turn completion
339+
# Stream notifications until turn completes. The queue carries only
340+
# this turn's events, so any turn/completed is ours.
341+
while True:
342+
try:
343+
notification = client.next_turn_notification(turn_id)
344+
except TransportClosedError:
345+
break
346+
emit(notification)
333347
if notification.method == "turn/completed":
334-
if isinstance(notification.payload, TurnCompletedNotification):
335-
if notification.payload.turn.id == turn_id:
336-
break
337-
else:
338-
break
339-
except TransportClosedError:
340-
break
341-
except Exception as e:
342-
turn_error[0] = e
343-
break
348+
break
349+
finally:
350+
client.unregister_turn_notifications(turn_id)
344351
except Exception as e:
345352
turn_error[0] = e
346353
finally:
347354
# Signal completion
348-
queue.put_nowait(None)
355+
emit(None)
349356

350357
# Run the sync turn in a background thread
351-
loop = asyncio.get_event_loop()
352358
turn_future = loop.run_in_executor(None, _run_turn_sync)
353359

354360
try:

0 commit comments

Comments
 (0)