mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 19:16:17 +00:00
* fix(subagents): drain owned batch stop across cancellation * fix(subagents): preserve cancellation across stop failures --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
178 lines
6.1 KiB
Python
178 lines
6.1 KiB
Python
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from deerflow.config.subagent_batches_config import SubagentBatchesConfig
|
|
from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig
|
|
from deerflow.subagents import SubagentRuntime
|
|
|
|
|
|
def test_runtime_rejects_batch_repository_without_enabled_batch_config() -> None:
|
|
with pytest.raises(ValueError, match="batch_config.enabled"):
|
|
SubagentRuntime(
|
|
SubagentRuntimeConfig(),
|
|
batch_repository=MagicMock(),
|
|
batch_config=SubagentBatchesConfig(enabled=False),
|
|
)
|
|
|
|
|
|
def test_runtime_rejects_batch_repository_without_app_config_snapshot() -> None:
|
|
with pytest.raises(ValueError, match="explicit app_config snapshot"):
|
|
SubagentRuntime(
|
|
SubagentRuntimeConfig(),
|
|
batch_repository=MagicMock(),
|
|
batch_config=SubagentBatchesConfig(enabled=True),
|
|
)
|
|
|
|
|
|
def test_runtime_uses_one_caller_owned_app_config_snapshot() -> None:
|
|
app_config = SimpleNamespace(
|
|
subagent_runtime=SubagentRuntimeConfig(max_running=11),
|
|
subagents=SimpleNamespace(max_total_per_run=14),
|
|
subagent_batches=SubagentBatchesConfig(enabled=False),
|
|
)
|
|
|
|
runtime = SubagentRuntime.from_app_config(app_config)
|
|
|
|
assert runtime.config.max_running == 11
|
|
assert runtime.max_total_per_run == 14
|
|
assert runtime.app_config is app_config
|
|
assert runtime.batch_submitter is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_owns_batch_worker_lifecycle_and_shared_capacity() -> None:
|
|
service = MagicMock()
|
|
service.start = AsyncMock()
|
|
service.stop = AsyncMock()
|
|
repository = MagicMock()
|
|
app_config = MagicMock()
|
|
|
|
with patch(
|
|
"deerflow.subagents.batch_service.SubagentBatchService",
|
|
return_value=service,
|
|
) as service_type:
|
|
runtime = SubagentRuntime(
|
|
SubagentRuntimeConfig(max_running=9),
|
|
batch_repository=repository,
|
|
batch_config=SubagentBatchesConfig(enabled=True),
|
|
app_config=app_config,
|
|
)
|
|
assert runtime.batch_submitter is None
|
|
|
|
async with runtime:
|
|
assert runtime.batch_submitter is service
|
|
|
|
assert runtime.batch_submitter is None
|
|
|
|
service_type.assert_called_once_with(
|
|
repository=repository,
|
|
config=runtime.batch_config,
|
|
runtime_config=runtime.config,
|
|
app_config=app_config,
|
|
execution_capacity=runtime.execution_capacity,
|
|
)
|
|
service.start.assert_awaited_once_with()
|
|
service.stop.assert_awaited_once_with()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_stop_drains_owned_batch_worker_across_repeated_cancellation() -> None:
|
|
stop_started = asyncio.Event()
|
|
allow_stop = asyncio.Event()
|
|
|
|
async def blocking_stop() -> None:
|
|
stop_started.set()
|
|
await allow_stop.wait()
|
|
|
|
service = MagicMock()
|
|
service.start = AsyncMock()
|
|
service.stop = AsyncMock(side_effect=blocking_stop)
|
|
repository = MagicMock()
|
|
app_config = MagicMock()
|
|
|
|
with patch(
|
|
"deerflow.subagents.batch_service.SubagentBatchService",
|
|
return_value=service,
|
|
):
|
|
runtime = SubagentRuntime(
|
|
SubagentRuntimeConfig(max_running=1),
|
|
batch_repository=repository,
|
|
batch_config=SubagentBatchesConfig(enabled=True),
|
|
app_config=app_config,
|
|
)
|
|
await runtime.start()
|
|
|
|
stop_task = asyncio.create_task(runtime.stop())
|
|
await asyncio.wait_for(stop_started.wait(), timeout=1)
|
|
|
|
stop_task.cancel()
|
|
for _ in range(10):
|
|
await asyncio.sleep(0)
|
|
|
|
assert runtime.batch_submitter is None
|
|
assert not stop_task.done(), "runtime stop released ownership after the first cancellation"
|
|
|
|
stop_task.cancel()
|
|
for _ in range(10):
|
|
await asyncio.sleep(0)
|
|
assert not stop_task.done(), "runtime stop released ownership after repeated cancellation"
|
|
|
|
allow_stop.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await stop_task
|
|
|
|
service.stop.assert_awaited_once_with()
|
|
assert runtime.batch_submitter is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("cancel_caller", [False, True])
|
|
@pytest.mark.parametrize("stop_error_type", [RuntimeError, asyncio.CancelledError])
|
|
async def test_runtime_stop_preserves_caller_cancellation_when_service_fails(cancel_caller: bool, stop_error_type: type[BaseException]) -> None:
|
|
stop_error = stop_error_type("service shutdown failed")
|
|
stop_started = asyncio.Event()
|
|
allow_stop = asyncio.Event()
|
|
|
|
async def failing_stop() -> None:
|
|
stop_started.set()
|
|
await allow_stop.wait()
|
|
raise stop_error
|
|
|
|
service = MagicMock()
|
|
service.start = AsyncMock()
|
|
service.stop = AsyncMock(side_effect=failing_stop)
|
|
with patch("deerflow.subagents.batch_service.SubagentBatchService", return_value=service):
|
|
runtime = SubagentRuntime(
|
|
batch_repository=MagicMock(),
|
|
batch_config=SubagentBatchesConfig(enabled=True),
|
|
app_config=MagicMock(),
|
|
)
|
|
await runtime.start()
|
|
stop_task = asyncio.create_task(runtime.stop())
|
|
try:
|
|
await asyncio.wait_for(stop_started.wait(), timeout=1)
|
|
if cancel_caller:
|
|
stop_task.cancel("first caller cancellation")
|
|
await asyncio.sleep(0)
|
|
stop_task.cancel("second caller cancellation")
|
|
await asyncio.sleep(0)
|
|
assert not stop_task.done()
|
|
allow_stop.set()
|
|
|
|
expected_error = asyncio.CancelledError if cancel_caller else type(stop_error)
|
|
with pytest.raises(expected_error) as raised:
|
|
await stop_task
|
|
if cancel_caller:
|
|
assert raised.value.args == ("first caller cancellation",)
|
|
assert raised.value.__cause__ is stop_error
|
|
else:
|
|
assert raised.value is stop_error
|
|
service.stop.assert_awaited_once_with()
|
|
assert runtime.batch_submitter is None
|
|
finally:
|
|
allow_stop.set()
|
|
await asyncio.gather(stop_task, return_exceptions=True)
|