deer-flow/backend/tests/test_scheduler_agent_service.py
Totoro cdbcc0d9e9
feat(scheduled-tasks): show goal outcomes and end conditions in run history (#6326)
* 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>
2026-10-05 21:56:04 +08:00

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