mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-07 19:20:34 +00:00
* feat(subagents): continue native batches and honor item dependencies * docs: link native batch follow-up changelog entries * test(migrations): pin isolated history-seed upgrade to 0050 * fix(subagents): preserve follow-up scope, blockers and fair scheduling * test(memory): freeze capacity evaluation time * fix(subagents): guard follow-up admission and retain run controls * fix(subagents): enforce runs:cancel on tool cancellation of follow-up batches * style(tests): format test_batch_followup_host with ruff * fix(subagents): drop the admission checkpoint check and keep SDK cancel compatible The checkpoint check inside run admission waited on the checkpoint saver's lock while holding the SQLite write lock, which could make another chat's checkpoint write fail. Remove it; dispatch still checks the conversation before admission. A follow-up waiting for an unanswered question now keeps its claim until the lease expires instead of rereading the conversation every poll. Cancel passes the caller's run authority only to submitters that take it, so existing SDK submitters keep the two-argument call. Document that retrying a prerequisite by hand does not requeue its blocked items, and list 0052 as the migration head. --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
151 lines
5.9 KiB
Python
151 lines
5.9 KiB
Python
"""Native batch dependencies use committed outcomes without occupying worker slots."""
|
|
|
|
import os
|
|
from datetime import UTC, datetime
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy.schema import DropSchema
|
|
|
|
from deerflow.config.database_config import DatabaseConfig
|
|
from deerflow.persistence.engine import close_engine, get_engine, get_session_factory, init_engine_from_config
|
|
from deerflow.persistence.subagent_batches import SubagentBatchRepository
|
|
|
|
|
|
@pytest_asyncio.fixture(params=["sqlite", "postgres"])
|
|
async def repo(tmp_path, request):
|
|
config = DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path))
|
|
if request.param == "postgres":
|
|
uri = os.environ.get("TEST_POSTGRES_URI")
|
|
if not uri:
|
|
pytest.skip("requires TEST_POSTGRES_URI")
|
|
config = DatabaseConfig(backend="postgres", postgres_url=uri, postgres_schema="native_batch_" + uuid4().hex)
|
|
await init_engine_from_config(config)
|
|
try:
|
|
yield SubagentBatchRepository(get_session_factory())
|
|
finally:
|
|
if config.backend == "postgres":
|
|
async with get_engine().begin() as connection:
|
|
await connection.execute(DropSchema(config.postgres_schema, cascade=True))
|
|
await close_engine()
|
|
|
|
|
|
async def create(repo, items, **overrides):
|
|
values = dict(
|
|
batch_id="batch",
|
|
user_id="owner",
|
|
thread_id="thread",
|
|
run_id="run",
|
|
tool_call_id="call",
|
|
submission_key="run:call",
|
|
title="Compare source files",
|
|
subagent_type="general-purpose",
|
|
items=items,
|
|
max_live_items=3,
|
|
max_running_items=3,
|
|
max_attempts=1,
|
|
execution_spec={},
|
|
)
|
|
return await repo.create_batch(**(values | overrides))
|
|
|
|
|
|
async def claim(repo):
|
|
return await repo.claim_items(now=datetime.now(UTC), lease_owner="worker", lease_seconds=120, limit=3)
|
|
|
|
|
|
async def finish(repo, item, *, succeeded=True, stop_reason=None, **overrides):
|
|
values = dict(
|
|
lease_owner="worker",
|
|
succeeded=succeeded,
|
|
result="A useful result" if succeeded else None,
|
|
result_preview="A useful result" if succeeded else None,
|
|
result_truncated=False,
|
|
error=None if succeeded else "Source unavailable",
|
|
stop_reason=stop_reason,
|
|
token_usage=None,
|
|
model_name="local-test",
|
|
completed_at=datetime.now(UTC),
|
|
)
|
|
return await repo.finalize_item(item["id"], **(values | overrides))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ready_dependency_starts_before_unrelated_item_finishes_and_survives_reopen(repo):
|
|
await create(
|
|
repo,
|
|
[
|
|
{"key": "d", "prompt": "Combine A and B", "depends_on": ["a", "b"]},
|
|
{"key": "a", "prompt": "Read sales"},
|
|
{"key": "b", "prompt": "Read risks"},
|
|
{"key": "c", "prompt": "Wait for independent appendix"},
|
|
],
|
|
)
|
|
running = {item["item_key"]: item for item in await claim(repo)}
|
|
assert set(running) == {"a", "b", "c"}
|
|
await finish(repo, running["a"])
|
|
assert await claim(repo) == []
|
|
await finish(repo, running["b"])
|
|
reopened = SubagentBatchRepository(get_session_factory())
|
|
ready = await claim(reopened)
|
|
assert [item["item_key"] for item in ready] == ["d"]
|
|
rows = await reopened.list_items("batch", user_id="owner")
|
|
assert next(item for item in rows if item["item_key"] == "c")["status"] == "leased"
|
|
assert next(item for item in rows if item["item_key"] == "d")["attempt"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_prerequisite_blocks_descendants_but_keeps_independent_work(repo):
|
|
await create(
|
|
repo,
|
|
[
|
|
{"key": "a", "prompt": "Missing source"},
|
|
{"key": "d", "prompt": "Requires A", "depends_on": ["a"]},
|
|
{"key": "e", "prompt": "Requires D", "depends_on": ["d"]},
|
|
{"key": "c", "prompt": "Independent work"},
|
|
],
|
|
)
|
|
running = {item["item_key"]: item for item in await claim(repo)}
|
|
assert set(running) == {"a", "c"}
|
|
await finish(repo, running["a"], succeeded=False)
|
|
assert await claim(repo) == []
|
|
await finish(repo, running["c"])
|
|
batch = await repo.get_batch("batch", user_id="owner")
|
|
assert batch["counts"]["failed"] == 3
|
|
assert batch["counts"]["succeeded"] == 1
|
|
rows = await repo.list_items("batch", user_id="owner")
|
|
assert all(item["attempt"] == 0 for item in rows if item["item_key"] in {"d", "e"})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("dependencies", [["missing"], ["a"], ["b"]])
|
|
async def test_invalid_dependencies_are_rejected_before_any_batch_is_saved(repo, dependencies):
|
|
items = [{"key": "a", "prompt": "A", "depends_on": dependencies}, {"key": "b", "prompt": "B", "depends_on": ["a"]}]
|
|
with pytest.raises(ValueError):
|
|
await create(repo, items)
|
|
assert await repo.get_batch("batch", user_id="owner") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_batch_does_not_release_waiting_dependencies(repo):
|
|
await create(repo, [{"key": "a", "prompt": "A"}, {"key": "b", "prompt": "B", "depends_on": ["a"]}])
|
|
running = await claim(repo)
|
|
assert len(running) == 1
|
|
await repo.cancel_batch("batch", user_id="owner")
|
|
await finish(repo, running[0])
|
|
assert await claim(repo) == []
|
|
assert (await repo.get_batch("batch", user_id="owner"))["status"] == "cancelled"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capped_attempt_can_retry_before_releasing_its_dependent(repo):
|
|
await create(repo, [{"key": "a", "prompt": "A"}, {"key": "b", "prompt": "Use A", "depends_on": ["a"]}], max_attempts=3)
|
|
first = (await claim(repo))[0]
|
|
await finish(repo, first, succeeded=False, stop_reason="turn_capped", error="No usable partial result")
|
|
retry = (await claim(repo))[0]
|
|
assert retry["item_key"] == "a" and retry["attempt"] == 2
|
|
await finish(repo, retry)
|
|
ready = await claim(repo)
|
|
assert [item["item_key"] for item in ready] == ["b"]
|
|
assert ready[0]["attempt"] == 1
|