fix(runtime): yield once in RunJournal.flush() so cross-thread events survive on Python 3.13+ (#6565)

Since CPython gh-105836 (backported to 3.13) an awaited executor future can
complete without a loop iteration when the worker finished before the future
was chained, so a middleware event a worker handed to the owner loop with
call_soon_threadsafe may still be pending when flush() starts. The run-end
flush() -> _detach_runtime_dependencies() sequence then dropped it. Yield to
the loop once before draining and re-check _closed.

Fixes #6564

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
sisyfyZhang 2026-10-09 21:40:59 +11:00 committed by GitHub
parent 7ff13bad45
commit 259e1d1377
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 13 additions and 1 deletions

View File

@ -100,7 +100,10 @@ producers (currently slash-skill activation via `asyncio.to_thread`) schedule
journal mutation directly onto its owning event loop; they never mutate or
flush `RunJournal._buffer` from the worker thread. The task-tool subagent proxy
rejects a loop that differs from the journal owner, so its close fence always
drains the only scheduling hop.
drains the only scheduling hop. `flush()` yields to the loop once before
draining: since Python 3.13 an awaited executor future can complete without a
loop iteration, so a hop queued by a worker that already returned may still be
pending when flush starts.
The persisted projection accepts
only framework-defined error/action values and strict booleans (using null for
invalid values) from the producer-supplied tool stamp; tool content, args,

View File

@ -1352,6 +1352,15 @@ class RunJournal(BaseCallbackHandler):
async def flush(self) -> None:
"""Force flush remaining buffer. Called in worker's finally block."""
if self._closed:
return
# Events recorded from worker threads reach this loop through
# call_soon_threadsafe (see record_middleware). Since Python 3.13 an
# awaited run_in_executor()/to_thread() may complete without yielding
# to the loop when the worker finished before its future was chained,
# so such callbacks can still be queued when flush() starts. Yield once
# so they land in the buffer instead of being dropped after detach.
await asyncio.sleep(0)
if self._closed:
return
self._explicit_flush_in_progress = True