mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-08 03:30:34 +00:00
* fix(subagents): terminalize a background cancel that beat the loop request_cancel_background_task() calls Future.cancel() on the future handed back by _submit_to_isolated_loop_in_context(). That call only wins while the coroutine has not been picked up yet; when it does win, run_with_timeout never starts, so no one writes a terminal status and no one opens the cleanup fence. The registry entry then stayed PENDING forever: task_tool polled its full budget for a result that could not arrive, and cleanup_background_task() refused to drop a non-terminal entry. Publish CANCELLED from the future's own completion callback when the coroutine body never ran. A cancellation that did reach a running coroutine keeps its existing owner -- the future reports cancelled() for that case as well, which is why the callback checks a start marker instead of cancelled() alone. * fix(subagents): publish a pre-start cancel from the isolated loop `Future.cancel()` on the future returned by `_submit_to_isolated_loop_in_context()` fires `forget_future` in the calling thread, but the loop keeps owning the coroutine until its first step runs. A cancel that lands in that window read the start marker as clear and published CANCELLED — opening the cleanup fence — while the worker could still take its capacity slot. Hand the decision to the isolated loop and re-check the marker there, which is the only place serialized against that first step. A loop that is gone cannot start the coroutine, so the inline fallback keeps the original guarantee. * test(subagents): accept both terminal owners in the task-created cancel window Task.__init__ queues the first step inside the task factory, so the step can run before the deferred pre-start decision that was handed to the loop. When it does, the coroutine's own CancelledError branch legitimately publishes "Cancelled by user"; the harness had pinned the pre-start message instead. Assert the invariants both owners share -- terminal cancelled, fence opened only after the loop settled the body, cleanup removes the entry -- and let either error message pass. * fix(subagents): refuse to start a cancelled execution before taking a slot A cancel that wins the race against the loop is decided on the loop, one turn after the submission callback queues the wrapper's first step. That turn is not a guarantee: the wrapper could still start, create the execution task, and take a capacity slot after the terminal status and the cleanup fence had already been published from that decision. request_cancel_background_task sets cancel_event before it cancels the future, so the wrapper can treat that event as the startup half of the handshake and decline to create the execution task. The fence then cannot be outrun by a worker that acquires capacity afterwards. * test(subagents): hold the cancel window open with an event barrier The factory blocked the loop for a fixed 200 ms and then resumed on its own. A caller delayed longer than that -- a loaded CI worker is enough -- let execution start before the cancel landed, and `assert not execution_ran.is_set()` failed even though cancellation was handled correctly. Block the factory on an event instead and release it from the test's `finally` after the cancel request and the closed-fence assertions, so the window stays open exactly as long as the test needs it. --------- Co-authored-by: gouyu-hou <213296172+gouyu-hou@users.noreply.github.com>