From 259e1d1377bc09779081262673e558a6ff9d2a58 Mon Sep 17 00:00:00 2001 From: sisyfyZhang Date: Fri, 9 Oct 2026 21:40:59 +1100 Subject: [PATCH] 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 --- backend/packages/harness/deerflow/runtime/AGENTS.md | 5 ++++- backend/packages/harness/deerflow/runtime/journal.py | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/packages/harness/deerflow/runtime/AGENTS.md b/backend/packages/harness/deerflow/runtime/AGENTS.md index baccfa8ea6..dc491e0806 100644 --- a/backend/packages/harness/deerflow/runtime/AGENTS.md +++ b/backend/packages/harness/deerflow/runtime/AGENTS.md @@ -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, diff --git a/backend/packages/harness/deerflow/runtime/journal.py b/backend/packages/harness/deerflow/runtime/journal.py index ae5076d7ea..9fbae74a53 100644 --- a/backend/packages/harness/deerflow/runtime/journal.py +++ b/backend/packages/harness/deerflow/runtime/journal.py @@ -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