mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* docs(scheduler): document tool_enabled, goal notices and goal-evaluation change Follow-up to #6229: - CONFIGURATION.md: add scheduler.tool_enabled to the example and notes. - CONFIGURATION.md, README (en/zh/pt): unmet goal runs send a goal-unmet notice instead of a completion notice, and three unmet runs add an auto-pause notice; the docs still said only success/failed notify. - CHANGELOG (en/zh): the #6229 entry now states the default-on goal evaluation change for scheduled/webhook/autonomous goal runs and that evaluator usage counts toward run usage. * feat(scheduled-tasks): show goal outcomes and end conditions in run history Follow-up to #6229. The API already returns these fields; the tasks page did not show them, so a met goal read as plain "Success", an unmet reason code rendered as a red execution error, and the goal-unmet IM notice pointed to a workspace showing neither objective nor verdict. - Task detail shows the per-run goal, max automatic runs and end time when a conversation-created task has them. - Goal runs show "Goal met" (or met relying on stated assumptions); unmet runs show a localized reason plus the host code in neutral styling. The run whose agent asked to stop the schedule is marked. - Runs without a goal, and failed runs, render as before. Read-only and frontend-only: no API change. * docs(changelog): add #6326 scheduled-task goal outcome entry * fix(scheduled-tasks): show readable unmet reasons instead of raw codes Unmet runs now show only the localized reason; the host code moves to the tooltip so it can still be matched with the IM notice. Codes without a label are still shown verbatim. * fix(scheduler): show readable goal reasons instead of internal codes Task detail now localizes the host-written last_error values from goal and stop finalization (agent stop, automatic pause, unmet reason codes) under a matching label, and Chinese copy is tightened. Goal-unmet and auto-pause IM notices state the reason in words instead of printing the raw code; unknown codes are still not forwarded. * docs(changelog): describe readable goal reasons in #6326 entry * test(scheduler): pin host goal strings with a cross-language contract Address review on #6326: - Add contracts/scheduled_goal_notes_contract.json listing the task last_error strings and unmet reason codes the tasks page and IM notices translate. A backend test checks finalization constants, blocker and stand-down codes against it; a frontend test checks every listed value has a label. - Move the two last_error strings in finalization.py into constants. - Frontend reason lookup strips the blocked: prefix like the IM map and now covers goal_not_met_yet, with en/zh labels. --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
214 lines
11 KiB
Python
214 lines
11 KiB
Python
"""Exercise task snapshots and notification commits through the real scheduler."""
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.scheduler.notification_delivery import render_notification_text
|
|
from app.scheduler.service import ScheduledTaskService
|
|
from deerflow.persistence.channel_connections import ChannelConnectionRepository
|
|
from deerflow.persistence.channel_connections.model import ChannelConnectionRow
|
|
from deerflow.persistence.notification_deliveries import NotificationDeliveryRepository, NotificationDeliveryRow
|
|
from deerflow.persistence.run.model import RunRow
|
|
from deerflow.persistence.scheduled_task_runs import ScheduledTaskRunRepository
|
|
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
|
|
from deerflow.runtime import RunStatus
|
|
|
|
|
|
@pytest.fixture
|
|
async def scheduler(tmp_path):
|
|
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
|
|
|
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'scheduler.db'}", sqlite_dir=str(tmp_path))
|
|
sf = get_session_factory()
|
|
tasks, occurrences = ScheduledTaskRepository(sf), ScheduledTaskRunRepository(sf)
|
|
deliveries, connections = NotificationDeliveryRepository(sf), ChannelConnectionRepository(sf)
|
|
launches = []
|
|
|
|
async def launch(**kwargs):
|
|
launches.append(kwargs)
|
|
run_id = f"run-{len(launches)}"
|
|
async with sf() as session:
|
|
session.add(RunRow(run_id=run_id, thread_id=kwargs["thread_id"], user_id=kwargs["owner_user_id"], status="running", metadata_json=kwargs["metadata"]))
|
|
await session.commit()
|
|
return {"run_id": run_id, "thread_id": kwargs["thread_id"]}
|
|
|
|
service = ScheduledTaskService(task_repo=tasks, task_run_repo=occurrences, launch_run=launch, poll_interval_seconds=1, lease_seconds=120, max_concurrent_runs=3, connection_repo=connections, notification_repo=deliveries)
|
|
try:
|
|
yield sf, tasks, occurrences, service, launches, deliveries
|
|
finally:
|
|
await close_engine()
|
|
|
|
|
|
async def create_task(tasks, **extra):
|
|
return await tasks.create(
|
|
task_id="task-a",
|
|
user_id="owner",
|
|
thread_id=None,
|
|
context_mode="fresh_thread_per_run",
|
|
assistant_id="lead_agent",
|
|
title="Meeting preparation",
|
|
prompt="Read the supplied materials and prepare the meeting.",
|
|
schedule_type="interval",
|
|
schedule_spec={"every_seconds": 3600},
|
|
timezone="UTC",
|
|
next_run_at=datetime.now(UTC),
|
|
origin_thread_id="origin",
|
|
**extra,
|
|
)
|
|
|
|
|
|
async def finish(sf, service, launch, result, *, verdict=None):
|
|
async with sf() as session:
|
|
row = await session.get(RunRow, result["run_id"])
|
|
row.status = "success"
|
|
row.goal_verdict = verdict
|
|
await session.commit()
|
|
record = SimpleNamespace(run_id=result["run_id"], user_id="owner", metadata=launch["metadata"], status=RunStatus.success, error=None, goal_verdict=verdict)
|
|
await service.handle_run_completion(record)
|
|
|
|
|
|
async def dispatch_scheduled(tasks, service):
|
|
now = datetime.now(UTC)
|
|
await tasks.update("task-a", user_id="owner", updates={"next_run_at": now})
|
|
claimed = await tasks.claim_due_tasks(now=now, lease_owner=service._lease_owner, lease_seconds=120, limit=1)
|
|
assert len(claimed) == 1
|
|
return await service.dispatch_task(claimed[0], now=now, trigger="scheduled")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_launch_snapshots_goal_and_explicit_task_notes(scheduler):
|
|
sf, tasks, _occurrences, service, launches, _deliveries = scheduler
|
|
task = await create_task(tasks, goal_objective="Meeting preparation is complete", standing_notes=["Use the develop branch"])
|
|
result = await service.dispatch_task(task, now=datetime.now(UTC), trigger="manual")
|
|
assert result["outcome"] == "launched"
|
|
assert "Use the develop branch" in launches[0]["prompt"]
|
|
assert launches[0]["metadata"]["scheduled_goal_objective"] == task["goal_objective"]
|
|
async with sf() as session:
|
|
rows = list((await session.scalars(select(NotificationDeliveryRow))).all())
|
|
assert rows == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_next_fresh_occurrence_carries_only_the_previous_executed_thread(scheduler):
|
|
sf, tasks, _occurrences, service, launches, _deliveries = scheduler
|
|
task = await create_task(tasks)
|
|
first = await service.dispatch_task(task, now=datetime.now(UTC), trigger="manual")
|
|
await finish(sf, service, launches[0], first)
|
|
current = await tasks.get("task-a", user_id="owner")
|
|
second = await service.dispatch_task(current, now=datetime.now(UTC), trigger="manual")
|
|
assert second["outcome"] == "launched"
|
|
assert launches[1]["metadata"]["scheduled_previous_thread_id"] == first["thread_id"]
|
|
assert second["thread_id"] != first["thread_id"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_auto_pause_and_notice_share_the_completion_transaction(scheduler, monkeypatch):
|
|
sf, tasks, _occurrences, service, launches, deliveries = scheduler
|
|
await create_task(tasks, goal_objective="Meeting preparation is complete")
|
|
async with sf() as session:
|
|
session.add(ChannelConnectionRow(id="owner-connection", owner_user_id="owner", provider="wecom", external_account_id="owner-wecom", status="connected"))
|
|
session.add(ChannelConnectionRow(id="foreign-connection", owner_user_id="foreign", provider="wecom", external_account_id="foreign-wecom", status="connected"))
|
|
await session.commit()
|
|
verdict = {"satisfied": False, "blocker": "needs_user_input", "reason": "Required materials are missing", "relied_on_assumption": False, "stand_down_reason": "blocked:needs_user_input"}
|
|
for _ in range(2):
|
|
result = await dispatch_scheduled(tasks, service)
|
|
await finish(sf, service, launches[-1], result, verdict=verdict)
|
|
result = await dispatch_scheduled(tasks, service)
|
|
original = deliveries.enqueue_in_session
|
|
|
|
async def fail_notice(*args, **kwargs):
|
|
if kwargs["event"] == "task_paused":
|
|
raise RuntimeError("outbox temporarily unavailable")
|
|
return await original(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(deliveries, "enqueue_in_session", fail_notice)
|
|
with pytest.raises(RuntimeError, match="outbox temporarily unavailable"):
|
|
await finish(sf, service, launches[-1], result, verdict=verdict)
|
|
assert (await tasks.get("task-a", user_id="owner"))["status"] == "enabled"
|
|
monkeypatch.setattr(deliveries, "enqueue_in_session", original)
|
|
await finish(sf, service, launches[-1], result, verdict=verdict)
|
|
assert (await tasks.get("task-a", user_id="owner"))["status"] == "paused"
|
|
async with sf() as session:
|
|
rows = list((await session.scalars(select(NotificationDeliveryRow))).all())
|
|
assert [row.event for row in rows].count("task_paused") == 1
|
|
assert {row.target for row in rows} == {"owner-wecom"}
|
|
|
|
|
|
def test_unmet_and_pause_notifications_have_distinct_safe_text():
|
|
unmet = render_notification_text({"event": "run_unmet", "task_id": "task-a", "payload": {"reason_code": "blocked:needs_user_input", "error": "private raw failure"}})
|
|
paused = render_notification_text({"event": "task_paused", "task_id": "task-a", "payload": {"reason_code": "consecutive_unmet"}})
|
|
assert "goal was not met" in unmet
|
|
assert "Reason: it needs your input." in unmet
|
|
assert "needs_user_input" not in unmet
|
|
assert "private raw failure" not in unmet
|
|
assert "automatically paused" in paused
|
|
assert "Reason: 3 scheduled runs in a row did not meet the goal." in paused
|
|
assert "consecutive_unmet" not in paused
|
|
assert "completed" not in unmet + paused
|
|
|
|
|
|
def test_assumption_marker_is_visible_without_forwarding_raw_error_text():
|
|
text = render_notification_text({"event": "run_completed", "task_id": "task-a", "payload": {"relied_on_assumption": True, "result_summary": "Report is ready"}})
|
|
assert "met, relying on stated assumptions" in text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_end_reached_at_atomic_admission_reports_completed(scheduler, monkeypatch):
|
|
_sf, tasks, _occurrences, service, launches, _deliveries = scheduler
|
|
await create_task(tasks)
|
|
now = datetime.now(UTC)
|
|
claimed = await tasks.claim_due_tasks(now=now, lease_owner=service._lease_owner, lease_seconds=120, limit=1)
|
|
assert len(claimed) == 1
|
|
# The preliminary check observed a live task; its deadline changes before
|
|
# occurrence admission takes the authoritative parent lock.
|
|
await tasks.update("task-a", user_id="owner", updates={"end_at": now - timedelta(seconds=1)})
|
|
|
|
async def preliminary_check(*_args, **_kwargs):
|
|
return False
|
|
|
|
monkeypatch.setattr(tasks, "complete_if_ended", preliminary_check)
|
|
result = await service.dispatch_task(claimed[0], now=now, trigger="scheduled")
|
|
assert result["outcome"] == "completed"
|
|
assert launches == []
|
|
assert (await tasks.get("task-a", user_id="owner"))["status"] == "completed"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("outcomes", [("external_wait",) * 3, ("goal_not_met_yet", "goal_not_met_yet", "external_wait", "goal_not_met_yet")])
|
|
async def test_external_wait_does_not_advance_unmet_streak(scheduler, outcomes):
|
|
sf, tasks, _occurrences, service, launches, _deliveries = scheduler
|
|
await create_task(tasks, goal_objective="report is ready")
|
|
for index, blocker in enumerate(outcomes):
|
|
result = await dispatch_scheduled(tasks, service)
|
|
verdict = {"satisfied": False, "blocker": blocker, "reason": "synthetic independent dependency", "stand_down_reason": f"blocked:{blocker}", "relied_on_assumption": False}
|
|
await finish(sf, service, launches[-1], result, verdict=verdict)
|
|
status = (await tasks.get("task-a", user_id="owner"))["status"]
|
|
assert status == ("paused" if index == 3 else "enabled")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("reason", "readable"),
|
|
[
|
|
("token_capped", "the token budget was reached"),
|
|
("no_durable_end_of_turn", "no final reply was saved"),
|
|
("thread_changed_after_evaluation", "the conversation changed during the goal check"),
|
|
("thread_changed_before_continuation", "the conversation changed during the goal check"),
|
|
("blocked:missing_evidence", "the goal check found evidence missing"),
|
|
("missing_evidence", "the goal check found evidence missing"),
|
|
("no_verdict", "no goal verdict was recorded"),
|
|
],
|
|
)
|
|
def test_known_host_stand_down_reasons_are_reported_readably(reason, readable):
|
|
text = render_notification_text({"event": "run_unmet", "task_id": "task-a", "payload": {"reason_code": reason}})
|
|
assert f"Reason: {readable}." in text
|
|
assert reason not in text
|
|
|
|
|
|
def test_unknown_reason_code_is_not_forwarded():
|
|
text = render_notification_text({"event": "run_unmet", "task_id": "task-a", "payload": {"reason_code": "provider said: secret"}})
|
|
assert "Reason: unknown." in text
|
|
assert "secret" not in text
|