* feat: add scheduled tasks MVP
* fix: harden scheduled task execution semantics
* feat(scheduled-tasks): preset-driven schedule form with timezone and live preview
Replace the raw cron input with a preset Select (hourly/daily/weekly/monthly/custom)
plus structured inputs (time picker, weekday toggles, day-of-month), datetime-local
for one-time tasks, a timezone selector defaulting to the browser timezone, and a
live human-readable preview. Reuses one ScheduledTaskScheduleInput for create and
edit; backend contract unchanged; zero new deps (pure Intl + DST-safe offset helpers).
* feat(scheduled-tasks): full-page i18n + recipe templates + E2E locale pin
Localize the rest of the scheduled-tasks page (filters, detail pane, actions,
edit form, run list, enum values) via t.scheduledTasks.* in en/zh. Add four
built-in recipe templates (GitHub Trending, news digest, issue triage, weekly
report) exposed as a chip row that pre-fills title + prompt + schedule. Pin
Playwright locale to en-US so E2E selectors stay stable against i18n. No backend
change, no new deps.
* fix(scheduled-tasks): idempotent 0003 migration, update head constants, future-date once test
Merge with main surfaced three CI failures:
- 0003_scheduled_tasks create_table collided with legacy test seeds that
build from full metadata; guard with inspector.has_table so the revision
no-ops when the table already exists (0004/0005 are already idempotent via
_helpers.py).
- persistence bootstrap concurrency/regression tests pinned HEAD to main's
0002_runs_token_usage; bump to the new head 0005_scheduled_task_thread_nullable.
- once-task router test used a fixed past run_at and tripped the
must-be-in-the-future validation; use a future date.
* address review: ok-check, 502 for trigger failure, mock fields, migration filename, doc fences
- fetchThreadScheduledTasks now checks response.ok like the other fetchers.
- trigger endpoint returns 502 (not 409) when dispatch fails outright, so
clients can distinguish a real conflict from a server-side failure.
- E2E mock normalizes scheduled-task objects with context_mode/last_thread_id
and nullable thread_id, matching the backend contract the UI renders against.
- Rename 0002_scheduled_tasks.py -> 0003_scheduled_tasks.py to match its
revision id (file was renamed in spirit already; filename now follows).
- CONFIGURATION.md: close the Tool Groups yaml fence and drop the stray fence
after the Scheduler notes so the sections render correctly.
* fix(scheduled-tasks): harden lease, poller, config, and frontend UX after review
* fix(scheduled-tasks): harden run lifecycle, overlap skip, non_interactive gating, and DST conversion after review
- defer a once task's terminal status to the run-completion hook; the task
stays running until the real outcome, and a startup sweep cancels once
tasks orphaned by a crash (launch-time 'completed' could stick forever)
- record interrupted runs as a distinct 'interrupted' run status with a
readable message; an interrupted once task ends 'cancelled', not 'failed'
- enforce overlap_policy=skip for fresh_thread_per_run via an active-run
pre-check (same-thread ConflictError can never fire across fresh threads)
- protect terminal run statuses from the late launch-path 'running' write
- honor context.non_interactive only for internally-authenticated callers;
arbitrary clients can no longer strip ask_clarification
- fix DST-stale timezone offset in zonedLocalToUtcIso by re-deriving the
offset at the resolved instant (once tasks fired an hour late around
spring-forward and the create->edit round-trip diverged)
- drop dead ScheduledTaskRunRepository.update_by_run_id; share one Gateway
API error helper between channels and scheduled-tasks frontends
* fix(scheduled-tasks): close review round-3 gaps in guards, concurrency, and API ergonomics
- scrub internal-only context keys (non_interactive) from the assembled run
config for non-internal callers: gating body.context alone left the same
key smuggle-able through the free-form body.config copied verbatim by
build_run_config
- guard update_after_launch with protect_terminal so the launch bookkeeping
write cannot clobber a once task already finalized by a fast-failing run's
completion hook (parent-row sibling of the run-row guard)
- reject a manual trigger while the task has an active run (409) instead of
launching a duplicate concurrent run on fresh_thread_per_run
- re-arm a terminal once task to enabled when PATCH pushes run_at into the
future; previously the endpoint returned 200 with a next_run_at that could
never be claimed
- make max_concurrent_runs a real global cap: each poll claims only into the
remaining budget of active (queued/running) scheduled runs
- paginate GET /scheduled-tasks/{id}/runs (limit<=200, offset) and push the
thread filter of /threads/{id}/scheduled-tasks into SQL
- stamp context.user_id on scheduler-launched runs, matching IM channels, so
user-scoped guardrail providers see the owning user
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
62 KiB
Scheduled Tasks MVP Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a first-class scheduled-task MVP for DeerFlow with durable backend scheduling, a workspace management page, run history, and real-path validation, limited to thread-attached agent runs with once and cron schedules.
Architecture: Add harness persistence models and repositories for scheduled tasks and task-run history, then add an app-layer scheduler service and REST API that reuse the existing run lifecycle. Build a dedicated frontend workspace page and thread-level entry point backed by typed React Query hooks. Validate through backend tests, frontend tests, Playwright, and a real browser smoke path.
Tech Stack: Python 3.12, FastAPI, SQLAlchemy, Alembic, pytest, Next.js 16, React 19, TypeScript 5.8, TanStack Query, Playwright, pnpm, uv.
Global Constraints
- The MVP supports only thread-attached agent runs; there is no text-only task type, no channel dispatch, and no GitHub dispatch.
- The MVP supports only
onceandcron; it must not addinterval. - Scheduled executions must reuse the normal DeerFlow run lifecycle rather than introducing a parallel agent execution path.
- Harness persistence code must not import
app.*. - Background scheduling remains opt-in through config and must default to disabled.
- Owner isolation must cover task list, task detail, task history, mutate, trigger, and delete.
- Completion claims require fresh verification evidence: backend tests, frontend tests, Playwright, and one real-path browser validation.
Task 1: Add scheduler config and persistence skeleton
Files:
- Create:
backend/packages/harness/deerflow/config/scheduler_config.py - Modify:
backend/packages/harness/deerflow/config/app_config.py - Modify:
backend/packages/harness/deerflow/config/reload_boundary.py - Modify:
backend/packages/harness/deerflow/persistence/models/__init__.py - Create:
backend/packages/harness/deerflow/persistence/scheduled_tasks/__init__.py - Create:
backend/packages/harness/deerflow/persistence/scheduled_tasks/model.py - Create:
backend/packages/harness/deerflow/persistence/scheduled_task_runs/__init__.py - Create:
backend/packages/harness/deerflow/persistence/scheduled_task_runs/model.py - Test:
backend/tests/test_scheduled_task_models.py
Interfaces:
-
Consumes: existing
AppConfig,Base, and ORM registration pattern. -
Produces:
SchedulerConfigwith fields:enabled: boolpoll_interval_seconds: intlease_seconds: intmax_concurrent_runs: intmin_once_delay_seconds: int
ScheduledTaskRowScheduledTaskRunRow
-
Step 1: Write the failing tests
Create backend/tests/test_scheduled_task_models.py:
from deerflow.config.app_config import AppConfig
from deerflow.persistence.models import ScheduledTaskRow, ScheduledTaskRunRow
def test_app_config_exposes_scheduler_section():
config = AppConfig.model_validate(
{
"models": [],
"sandbox": {"use": "local"},
}
)
assert config.scheduler.enabled is False
assert config.scheduler.poll_interval_seconds == 5
assert config.scheduler.lease_seconds == 120
def test_scheduled_task_models_registered():
assert ScheduledTaskRow.__tablename__ == "scheduled_tasks"
assert ScheduledTaskRunRow.__tablename__ == "scheduled_task_runs"
- Step 2: Run tests to verify they fail
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_models.py -v
Expected:
-
fail because
AppConfighas noscheduler -
fail because
ScheduledTaskRow/ScheduledTaskRunRowdo not exist -
Step 3: Implement minimal config and model skeleton
Create backend/packages/harness/deerflow/config/scheduler_config.py:
from pydantic import BaseModel, Field
class SchedulerConfig(BaseModel):
enabled: bool = Field(default=False)
poll_interval_seconds: int = Field(default=5, ge=1, le=300)
lease_seconds: int = Field(default=120, ge=5, le=3600)
max_concurrent_runs: int = Field(default=3, ge=1, le=32)
min_once_delay_seconds: int = Field(default=60, ge=1, le=86400)
Update backend/packages/harness/deerflow/config/app_config.py imports and fields:
from deerflow.config.scheduler_config import SchedulerConfig
Add field inside AppConfig:
scheduler: SchedulerConfig = Field(
default_factory=SchedulerConfig,
description="Scheduled task runtime configuration",
)
Create backend/packages/harness/deerflow/persistence/scheduled_tasks/model.py:
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class ScheduledTaskRow(Base):
__tablename__ = "scheduled_tasks"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), index=True)
thread_id: Mapped[str] = mapped_column(String(64), index=True)
assistant_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
title: Mapped[str] = mapped_column(String(255))
prompt: Mapped[str] = mapped_column(Text)
schedule_type: Mapped[str] = mapped_column(String(16))
schedule_spec: Mapped[dict] = mapped_column(JSON, default=dict)
timezone: Mapped[str] = mapped_column(String(64))
status: Mapped[str] = mapped_column(String(16), default="enabled", index=True)
overlap_policy: Mapped[str] = mapped_column(String(16), default="skip")
misfire_policy: Mapped[str] = mapped_column(String(16), default="run_once")
next_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True, nullable=True)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_run_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
lease_owner: Mapped[str | None] = mapped_column(String(128), nullable=True)
lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
run_count: Mapped[int] = mapped_column(Integer, default=0)
max_runs: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
Create backend/packages/harness/deerflow/persistence/scheduled_task_runs/model.py:
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class ScheduledTaskRunRow(Base):
__tablename__ = "scheduled_task_runs"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
task_id: Mapped[str] = mapped_column(String(64), index=True)
thread_id: Mapped[str] = mapped_column(String(64), index=True)
run_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True))
trigger: Mapped[str] = mapped_column(String(16))
status: Mapped[str] = mapped_column(String(16), index=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
Create __init__.py files:
from .model import ScheduledTaskRow
__all__ = ["ScheduledTaskRow"]
from .model import ScheduledTaskRunRow
__all__ = ["ScheduledTaskRunRow"]
Update backend/packages/harness/deerflow/persistence/models/__init__.py:
from deerflow.persistence.scheduled_task_runs.model import ScheduledTaskRunRow
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow
Append to __all__:
"ScheduledTaskRow",
"ScheduledTaskRunRow",
- Step 4: Run tests to verify they pass
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_models.py -v
Expected: PASS
- Step 5: Commit
git add backend/packages/harness/deerflow/config/scheduler_config.py \
backend/packages/harness/deerflow/config/app_config.py \
backend/packages/harness/deerflow/persistence/models/__init__.py \
backend/packages/harness/deerflow/persistence/scheduled_tasks/__init__.py \
backend/packages/harness/deerflow/persistence/scheduled_tasks/model.py \
backend/packages/harness/deerflow/persistence/scheduled_task_runs/__init__.py \
backend/packages/harness/deerflow/persistence/scheduled_task_runs/model.py \
backend/tests/test_scheduled_task_models.py
git commit -m "feat(scheduler): add scheduler config and scheduled task models"
Task 2: Add Alembic migration and repository CRUD
Files:
- Create:
backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py - Create:
backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py - Create:
backend/packages/harness/deerflow/persistence/migrations/versions/0002_scheduled_tasks.py - Modify:
backend/packages/harness/deerflow/persistence/scheduled_tasks/__init__.py - Modify:
backend/packages/harness/deerflow/persistence/scheduled_task_runs/__init__.py - Test:
backend/tests/test_scheduled_task_repository.py
Interfaces:
-
Consumes:
ScheduledTaskRow,ScheduledTaskRunRow, async session factory. -
Produces:
ScheduledTaskRepositoryScheduledTaskRunRepository- task CRUD API and owner-scoped listing
-
Step 1: Write the failing tests
Create backend/tests/test_scheduled_task_repository.py:
from datetime import UTC, datetime
import pytest
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
from deerflow.persistence.scheduled_task_runs import ScheduledTaskRunRepository
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
@pytest.mark.asyncio
async def test_scheduled_task_repository_create_and_list(tmp_path):
await init_engine_from_config({"backend": "sqlite", "sqlite_dir": str(tmp_path)})
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRepository(sf)
created = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id="thread-1",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize this thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="Asia/Shanghai",
next_run_at=datetime(2026, 7, 2, 1, 0, tzinfo=UTC),
)
assert created["id"] == "task-1"
listed = await repo.list_by_user("user-1")
assert [task["id"] for task in listed] == ["task-1"]
await close_engine()
@pytest.mark.asyncio
async def test_scheduled_task_run_repository_records_history(tmp_path):
await init_engine_from_config({"backend": "sqlite", "sqlite_dir": str(tmp_path)})
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRunRepository(sf)
row = await repo.create(
run_record_id="task-run-1",
task_id="task-1",
thread_id="thread-1",
scheduled_for=datetime(2026, 7, 2, 1, 0, tzinfo=UTC),
trigger="manual",
status="queued",
)
assert row["id"] == "task-run-1"
history = await repo.list_by_task("task-1")
assert [entry["id"] for entry in history] == ["task-run-1"]
await close_engine()
- Step 2: Run tests to verify they fail
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_repository.py -v
Expected:
-
fail because repositories do not exist and migration/table path is absent
-
Step 3: Implement repositories and migration
Create backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py:
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow
from deerflow.utils.time import coerce_iso
class ScheduledTaskRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: ScheduledTaskRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at", "next_run_at", "last_run_at", "lease_expires_at"):
if data.get(key) is not None:
data[key] = coerce_iso(data[key])
return data
async def create(
self,
*,
task_id: str,
user_id: str,
thread_id: str,
assistant_id: str | None,
title: str,
prompt: str,
schedule_type: str,
schedule_spec: dict[str, Any],
timezone: str,
next_run_at: datetime | None,
) -> dict[str, Any]:
row = ScheduledTaskRow(
id=task_id,
user_id=user_id,
thread_id=thread_id,
assistant_id=assistant_id,
title=title,
prompt=prompt,
schedule_type=schedule_type,
schedule_spec=schedule_spec,
timezone=timezone,
next_run_at=next_run_at,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def get(self, task_id: str, *, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ScheduledTaskRow, task_id)
if row is None or row.user_id != user_id:
return None
return self._row_to_dict(row)
async def list_by_user(self, user_id: str) -> list[dict[str, Any]]:
stmt = (
select(ScheduledTaskRow)
.where(ScheduledTaskRow.user_id == user_id)
.order_by(ScheduledTaskRow.created_at.desc(), ScheduledTaskRow.id.desc())
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(row) for row in result.scalars()]
Create backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py:
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.scheduled_task_runs.model import ScheduledTaskRunRow
from deerflow.utils.time import coerce_iso
class ScheduledTaskRunRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: ScheduledTaskRunRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("scheduled_for", "started_at", "finished_at", "created_at"):
if data.get(key) is not None:
data[key] = coerce_iso(data[key])
return data
async def create(
self,
*,
run_record_id: str,
task_id: str,
thread_id: str,
scheduled_for: datetime,
trigger: str,
status: str,
) -> dict[str, Any]:
row = ScheduledTaskRunRow(
id=run_record_id,
task_id=task_id,
thread_id=thread_id,
scheduled_for=scheduled_for,
trigger=trigger,
status=status,
created_at=datetime.now(UTC),
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def list_by_task(self, task_id: str) -> list[dict[str, Any]]:
stmt = (
select(ScheduledTaskRunRow)
.where(ScheduledTaskRunRow.task_id == task_id)
.order_by(ScheduledTaskRunRow.created_at.desc(), ScheduledTaskRunRow.id.desc())
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(row) for row in result.scalars()]
Update package exports:
from .model import ScheduledTaskRow
from .sql import ScheduledTaskRepository
__all__ = ["ScheduledTaskRow", "ScheduledTaskRepository"]
from .model import ScheduledTaskRunRow
from .sql import ScheduledTaskRunRepository
__all__ = ["ScheduledTaskRunRow", "ScheduledTaskRunRepository"]
Create backend/packages/harness/deerflow/persistence/migrations/versions/0002_scheduled_tasks.py with upgrade() / downgrade() creating:
op.create_table(
"scheduled_tasks",
...
)
op.create_index("ix_scheduled_tasks_user_id", "scheduled_tasks", ["user_id"])
op.create_index("ix_scheduled_tasks_thread_id", "scheduled_tasks", ["thread_id"])
op.create_index("ix_scheduled_tasks_status", "scheduled_tasks", ["status"])
op.create_index("ix_scheduled_tasks_next_run_at", "scheduled_tasks", ["next_run_at"])
op.create_table(
"scheduled_task_runs",
...
)
op.create_index("ix_scheduled_task_runs_task_id", "scheduled_task_runs", ["task_id"])
op.create_index("ix_scheduled_task_runs_thread_id", "scheduled_task_runs", ["thread_id"])
op.create_index("ix_scheduled_task_runs_status", "scheduled_task_runs", ["status"])
- Step 4: Run tests to verify they pass
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_repository.py tests/test_scheduled_task_models.py -v
Expected: PASS
- Step 5: Commit
git add backend/packages/harness/deerflow/persistence/scheduled_tasks/__init__.py \
backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py \
backend/packages/harness/deerflow/persistence/scheduled_task_runs/__init__.py \
backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py \
backend/packages/harness/deerflow/persistence/migrations/versions/0002_scheduled_tasks.py \
backend/tests/test_scheduled_task_repository.py
git commit -m "feat(scheduler): add scheduled task repositories and migration"
Task 3: Implement schedule parsing and next-run computation
Files:
- Create:
backend/packages/harness/deerflow/scheduler/__init__.py - Create:
backend/packages/harness/deerflow/scheduler/clock.py - Create:
backend/packages/harness/deerflow/scheduler/schedules.py - Test:
backend/tests/test_scheduled_task_schedules.py
Interfaces:
-
Produces:
validate_timezone(timezone: str) -> strnormalize_cron_expression(expr: str) -> strnext_run_at(schedule_type: str, schedule_spec: dict[str, object], timezone_name: str, *, now: datetime) -> datetime | Nonevalidate_once_time(...)
-
Step 1: Write the failing tests
Create backend/tests/test_scheduled_task_schedules.py:
from datetime import UTC, datetime
import pytest
from deerflow.scheduler.schedules import (
next_run_at,
normalize_cron_expression,
validate_timezone,
)
def test_validate_timezone_accepts_iana_name():
assert validate_timezone("Asia/Shanghai") == "Asia/Shanghai"
def test_validate_timezone_rejects_unknown_name():
with pytest.raises(ValueError):
validate_timezone("Mars/Base")
def test_normalize_cron_accepts_five_fields():
assert normalize_cron_expression("0 9 * * 1") == "0 9 * * 1"
def test_normalize_cron_rejects_seconds_field():
with pytest.raises(ValueError):
normalize_cron_expression("0 0 9 * * 1")
def test_next_run_at_for_once_returns_none_after_fire_time():
now = datetime(2026, 7, 2, 2, 0, tzinfo=UTC)
result = next_run_at(
"once",
{"run_at": "2026-07-02T01:00:00+00:00"},
"UTC",
now=now,
)
assert result is None
def test_next_run_at_for_cron_uses_timezone():
now = datetime(2026, 7, 1, 0, 30, tzinfo=UTC)
result = next_run_at(
"cron",
{"cron": "0 9 * * *"},
"Asia/Shanghai",
now=now,
)
assert result == datetime(2026, 7, 1, 1, 0, tzinfo=UTC)
- Step 2: Run tests to verify they fail
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_schedules.py -v
Expected: fail because deerflow.scheduler.schedules does not exist.
- Step 3: Implement
Create backend/packages/harness/deerflow/scheduler/schedules.py:
from __future__ import annotations
from datetime import UTC, datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from croniter import croniter
def validate_timezone(timezone_name: str) -> str:
try:
ZoneInfo(timezone_name)
except ZoneInfoNotFoundError as exc:
raise ValueError(f"Unknown timezone: {timezone_name}") from exc
return timezone_name
def normalize_cron_expression(expr: str) -> str:
parts = [part for part in expr.split() if part]
if len(parts) != 5:
raise ValueError("Cron expression must contain exactly 5 fields")
return " ".join(parts)
def next_run_at(
schedule_type: str,
schedule_spec: dict[str, object],
timezone_name: str,
*,
now: datetime,
) -> datetime | None:
validate_timezone(timezone_name)
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
if schedule_type == "once":
run_at_raw = schedule_spec.get("run_at")
if not isinstance(run_at_raw, str):
raise ValueError("once schedule requires run_at")
run_at = datetime.fromisoformat(run_at_raw)
if run_at.tzinfo is None:
run_at = run_at.replace(tzinfo=UTC)
return run_at if run_at > now else None
if schedule_type == "cron":
cron_expr = normalize_cron_expression(str(schedule_spec.get("cron", "")))
zone = ZoneInfo(timezone_name)
local_now = now.astimezone(zone)
next_local = croniter(cron_expr, local_now).get_next(datetime)
if next_local.tzinfo is None:
next_local = next_local.replace(tzinfo=zone)
return next_local.astimezone(UTC)
raise ValueError(f"Unsupported schedule_type: {schedule_type}")
Create backend/packages/harness/deerflow/scheduler/__init__.py:
from .schedules import next_run_at, normalize_cron_expression, validate_timezone
__all__ = ["next_run_at", "normalize_cron_expression", "validate_timezone"]
Create backend/packages/harness/deerflow/scheduler/clock.py:
from datetime import UTC, datetime
def utc_now() -> datetime:
return datetime.now(UTC)
- Step 4: Run tests to verify they pass
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_schedules.py -v
Expected: PASS
- Step 5: Commit
git add backend/packages/harness/deerflow/scheduler/__init__.py \
backend/packages/harness/deerflow/scheduler/clock.py \
backend/packages/harness/deerflow/scheduler/schedules.py \
backend/tests/test_scheduled_task_schedules.py
git commit -m "feat(scheduler): add schedule parsing and next-run computation"
Task 4: Add due-claim, lease management, and task-run status updates
Files:
- Modify:
backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py - Modify:
backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py - Test:
backend/tests/test_scheduled_task_claims.py
Interfaces:
-
Produces:
claim_due_tasks(...)release_lease(...)mark_execution_result(...)update_after_launch(...)ScheduledTaskRunRepository.update_status(...)
-
Step 1: Write the failing tests
Create backend/tests/test_scheduled_task_claims.py:
from datetime import UTC, datetime, timedelta
import pytest
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
@pytest.mark.asyncio
async def test_claim_due_tasks_claims_only_due_owned_rows(tmp_path):
await init_engine_from_config({"backend": "sqlite", "sqlite_dir": str(tmp_path)})
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRepository(sf)
due = datetime.now(UTC) - timedelta(minutes=1)
future = datetime.now(UTC) + timedelta(hours=1)
await repo.create(
task_id="due-1",
user_id="user-1",
thread_id="thread-1",
assistant_id="lead_agent",
title="Due",
prompt="Prompt",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=due,
)
await repo.create(
task_id="future-1",
user_id="user-1",
thread_id="thread-1",
assistant_id="lead_agent",
title="Future",
prompt="Prompt",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=future,
)
claimed = await repo.claim_due_tasks(
now=datetime.now(UTC),
lease_owner="worker-1",
lease_seconds=120,
limit=10,
)
assert [task["id"] for task in claimed] == ["due-1"]
await close_engine()
- Step 2: Run tests to verify they fail
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_claims.py -v
Expected: fail because claim_due_tasks does not exist.
- Step 3: Implement
Update backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py with:
from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, or_
Add methods:
async def claim_due_tasks(
self,
*,
now: datetime,
lease_owner: str,
lease_seconds: int,
limit: int,
) -> list[dict[str, Any]]:
lease_expires_at = now + timedelta(seconds=lease_seconds)
stmt = (
select(ScheduledTaskRow)
.where(
ScheduledTaskRow.status == "enabled",
ScheduledTaskRow.next_run_at.is_not(None),
ScheduledTaskRow.next_run_at <= now,
or_(
ScheduledTaskRow.lease_expires_at.is_(None),
ScheduledTaskRow.lease_expires_at < now,
),
)
.order_by(ScheduledTaskRow.next_run_at.asc(), ScheduledTaskRow.id.asc())
.limit(limit)
.with_for_update()
)
async with self._sf() as session:
result = await session.execute(stmt)
rows = list(result.scalars())
for row in rows:
row.lease_owner = lease_owner
row.lease_expires_at = lease_expires_at
row.status = "running"
row.updated_at = datetime.now(UTC)
await session.commit()
return [self._row_to_dict(row) for row in rows]
async def release_lease(self, task_id: str, *, user_id: str | None = None) -> None:
async with self._sf() as session:
row = await session.get(ScheduledTaskRow, task_id)
if row is None:
return
if user_id is not None and row.user_id != user_id:
return
row.lease_owner = None
row.lease_expires_at = None
row.updated_at = datetime.now(UTC)
await session.commit()
async def update_after_launch(
self,
task_id: str,
*,
status: str,
next_run_at: datetime | None,
last_run_at: datetime | None,
last_run_id: str | None,
last_error: str | None,
increment_run_count: bool,
) -> None:
async with self._sf() as session:
row = await session.get(ScheduledTaskRow, task_id)
if row is None:
return
row.status = status
row.next_run_at = next_run_at
row.last_run_at = last_run_at
row.last_run_id = last_run_id
row.last_error = last_error
if increment_run_count:
row.run_count += 1
row.lease_owner = None
row.lease_expires_at = None
row.updated_at = datetime.now(UTC)
await session.commit()
Update backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py:
async def update_status(
self,
run_record_id: str,
*,
status: str,
run_id: str | None = None,
error: str | None = None,
started_at: datetime | None = None,
finished_at: datetime | None = None,
) -> None:
async with self._sf() as session:
row = await session.get(ScheduledTaskRunRow, run_record_id)
if row is None:
return
row.status = status
row.run_id = run_id
row.error = error
if started_at is not None:
row.started_at = started_at
if finished_at is not None:
row.finished_at = finished_at
await session.commit()
- Step 4: Run tests to verify they pass
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_claims.py tests/test_scheduled_task_repository.py -v
Expected: PASS
- Step 5: Commit
git add backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py \
backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py \
backend/tests/test_scheduled_task_claims.py
git commit -m "feat(scheduler): add due-task claim and lease management"
Task 5: Add scheduler service and shared run-launch helper
Files:
- Create:
backend/app/scheduler/__init__.py - Create:
backend/app/scheduler/service.py - Modify:
backend/app/gateway/services.py - Modify:
backend/app/gateway/deps.py - Test:
backend/tests/test_scheduled_task_service.py
Interfaces:
-
Produces:
launch_scheduled_thread_run(...)ScheduledTaskServiceget_scheduled_task_service
-
Step 1: Write the failing tests
Create backend/tests/test_scheduled_task_service.py:
from datetime import UTC, datetime, timedelta
import pytest
from app.scheduler.service import ScheduledTaskService
class DummyTaskRepo:
def __init__(self, rows):
self.rows = rows
self.claimed = False
async def claim_due_tasks(self, **_kwargs):
if self.claimed:
return []
self.claimed = True
return self.rows
async def update_after_launch(self, *args, **kwargs):
self.updated = (args, kwargs)
class DummyRunRepo:
async def create(self, **kwargs):
self.created = kwargs
return {"id": kwargs["run_record_id"]}
async def update_status(self, run_record_id, **kwargs):
self.updated = (run_record_id, kwargs)
@pytest.mark.asyncio
async def test_service_claims_and_dispatches_due_task():
async def fake_launch(**kwargs):
return {"run_id": "run-1"}
task_repo = DummyTaskRepo(
[
{
"id": "task-1",
"thread_id": "thread-1",
"assistant_id": "lead_agent",
"prompt": "Summarize thread",
"schedule_type": "once",
"schedule_spec": {"run_at": "2026-07-02T01:00:00+00:00"},
"timezone": "UTC",
}
]
)
run_repo = DummyRunRepo()
service = ScheduledTaskService(
task_repo=task_repo,
task_run_repo=run_repo,
launch_run=fake_launch,
poll_interval_seconds=5,
lease_seconds=120,
max_concurrent_runs=3,
)
await service.run_once(now=datetime.now(UTC) + timedelta(days=1))
assert run_repo.created["task_id"] == "task-1"
assert run_repo.updated[1]["status"] == "success"
- Step 2: Run tests to verify they fail
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_service.py -v
Expected: fail because service module does not exist.
- Step 3: Implement
Add helper to backend/app/gateway/services.py:
async def launch_scheduled_thread_run(
*,
thread_id: str,
assistant_id: str | None,
prompt: str,
request: Request,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
body = SimpleNamespace(
assistant_id=assistant_id,
input={"messages": [{"role": "user", "content": prompt}]},
command=None,
metadata=metadata or {},
config=None,
context=None,
webhook=None,
checkpoint_id=None,
checkpoint=None,
interrupt_before=None,
interrupt_after=None,
stream_mode=None,
stream_subgraphs=False,
stream_resumable=None,
on_disconnect="continue",
on_completion="keep",
multitask_strategy="reject",
after_seconds=None,
if_not_exists="reject",
feedback_keys=None,
)
record = await start_run(
request,
thread_id,
body,
require_existing=True,
)
return {"run_id": record.run_id, "thread_id": record.thread_id}
Create backend/app/scheduler/service.py:
from __future__ import annotations
import asyncio
import socket
import uuid
from datetime import UTC, datetime
from typing import Any, Awaitable, Callable
from deerflow.scheduler.schedules import next_run_at
class ScheduledTaskService:
def __init__(
self,
*,
task_repo,
task_run_repo,
launch_run: Callable[..., Awaitable[dict[str, Any]]],
poll_interval_seconds: int,
lease_seconds: int,
max_concurrent_runs: int,
) -> None:
self._task_repo = task_repo
self._task_run_repo = task_run_repo
self._launch_run = launch_run
self._poll_interval_seconds = poll_interval_seconds
self._lease_seconds = lease_seconds
self._max_concurrent_runs = max_concurrent_runs
self._lease_owner = f"{socket.gethostname()}:{uuid.uuid4().hex}"
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
async def run_once(self, *, now: datetime) -> None:
claimed = await self._task_repo.claim_due_tasks(
now=now,
lease_owner=self._lease_owner,
lease_seconds=self._lease_seconds,
limit=self._max_concurrent_runs,
)
for task in claimed:
await self._dispatch_task(task, now=now)
async def _dispatch_task(self, task: dict[str, Any], *, now: datetime) -> None:
task_run_id = f"task-run-{uuid.uuid4().hex}"
await self._task_run_repo.create(
run_record_id=task_run_id,
task_id=task["id"],
thread_id=task["thread_id"],
scheduled_for=now,
trigger="scheduled",
status="queued",
)
try:
result = await self._launch_run(
thread_id=task["thread_id"],
assistant_id=task.get("assistant_id"),
prompt=task["prompt"],
)
next_at = next_run_at(
task["schedule_type"],
task["schedule_spec"],
task["timezone"],
now=now,
)
status = "completed" if task["schedule_type"] == "once" else "enabled"
await self._task_run_repo.update_status(
task_run_id,
status="success",
run_id=result["run_id"],
started_at=now,
finished_at=now,
)
await self._task_repo.update_after_launch(
task["id"],
status=status,
next_run_at=next_at,
last_run_at=now,
last_run_id=result["run_id"],
last_error=None,
increment_run_count=True,
)
except Exception as exc:
await self._task_run_repo.update_status(
task_run_id,
status="failed",
error=str(exc),
started_at=now,
finished_at=now,
)
await self._task_repo.update_after_launch(
task["id"],
status="failed" if task["schedule_type"] == "once" else "enabled",
next_run_at=next_run_at(task["schedule_type"], task["schedule_spec"], task["timezone"], now=now),
last_run_at=now,
last_run_id=None,
last_error=str(exc),
increment_run_count=False,
)
async def start(self) -> None:
if self._task is not None:
return
self._stop.clear()
self._task = asyncio.create_task(self._run_loop())
async def stop(self) -> None:
if self._task is None:
return
self._stop.set()
await self._task
self._task = None
async def _run_loop(self) -> None:
while not self._stop.is_set():
await self.run_once(now=datetime.now(UTC))
try:
await asyncio.wait_for(self._stop.wait(), timeout=self._poll_interval_seconds)
except TimeoutError:
continue
Create backend/app/scheduler/__init__.py:
from .service import ScheduledTaskService
__all__ = ["ScheduledTaskService"]
- Step 4: Run tests to verify they pass
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_service.py -v
Expected: PASS
- Step 5: Commit
git add backend/app/gateway/services.py \
backend/app/scheduler/__init__.py \
backend/app/scheduler/service.py \
backend/tests/test_scheduled_task_service.py
git commit -m "feat(scheduler): add scheduled task service and shared run launcher"
Task 6: Add scheduled-task API routes and app wiring
Files:
- Create:
backend/app/gateway/routers/scheduled_tasks.py - Modify:
backend/app/gateway/routers/__init__.py - Modify:
backend/app/gateway/app.py - Modify:
backend/app/gateway/deps.py - Test:
backend/tests/test_scheduled_task_router.py
Interfaces:
-
Produces REST endpoints:
- list/create/detail/update/pause/resume/trigger/delete/history/thread-list
-
Step 1: Write the failing tests
Create backend/tests/test_scheduled_task_router.py with a minimal FastAPI app mounting the new router and stubbing repo dependencies:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers import scheduled_tasks
def test_router_registers_list_endpoint():
app = FastAPI()
app.include_router(scheduled_tasks.router)
client = TestClient(app)
response = client.get("/api/scheduled-tasks")
assert response.status_code != 404
- Step 2: Run tests to verify they fail
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py -v
Expected: fail because router module does not exist.
- Step 3: Implement minimal router and wiring
Create backend/app/gateway/routers/scheduled_tasks.py with:
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
router = APIRouter(prefix="/api", tags=["scheduled-tasks"])
class ScheduledTaskCreateRequest(BaseModel):
thread_id: str
title: str = Field(min_length=1)
prompt: str = Field(min_length=1)
schedule_type: str
schedule_spec: dict[str, Any]
timezone: str
@router.get("/scheduled-tasks")
@require_permission("threads", "read")
async def list_scheduled_tasks(request: Request):
return []
Update router exports in backend/app/gateway/routers/__init__.py:
from . import scheduled_tasks
Mount router in backend/app/gateway/app.py import list and create_app() include list.
- Step 4: Run tests to verify they pass
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py -v
Expected: PASS
- Step 5: Commit
git add backend/app/gateway/routers/scheduled_tasks.py \
backend/app/gateway/routers/__init__.py \
backend/app/gateway/app.py \
backend/tests/test_scheduled_task_router.py
git commit -m "feat(scheduler): add scheduled task router skeleton"
Task 7: Flesh out router behavior, owner isolation, and manual trigger
Files:
- Modify:
backend/app/gateway/routers/scheduled_tasks.py - Modify:
backend/app/gateway/deps.py - Test:
backend/tests/test_scheduled_task_router.py
Interfaces:
-
Produces working route handlers and dependency accessors:
get_scheduled_task_repoget_scheduled_task_run_repoget_scheduled_task_service
-
Step 1: Extend the failing tests
Append to backend/tests/test_scheduled_task_router.py:
def test_router_registers_trigger_route():
app = FastAPI()
app.include_router(scheduled_tasks.router)
client = TestClient(app)
response = client.post("/api/scheduled-tasks/task-1/trigger")
assert response.status_code != 404
- Step 2: Run tests to verify they fail correctly
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py -v
Expected: fail because trigger route not implemented.
- Step 3: Implement routes
Add dependencies to backend/app/gateway/deps.py:
def get_scheduled_task_repo(request: Request):
val = getattr(request.app.state, "scheduled_task_repo", None)
if val is None:
raise HTTPException(status_code=503, detail="Scheduled task repo not available")
return val
def get_scheduled_task_run_repo(request: Request):
val = getattr(request.app.state, "scheduled_task_run_repo", None)
if val is None:
raise HTTPException(status_code=503, detail="Scheduled task run repo not available")
return val
def get_scheduled_task_service(request: Request):
val = getattr(request.app.state, "scheduled_task_service", None)
if val is None:
raise HTTPException(status_code=503, detail="Scheduled task service not available")
return val
Expand backend/app/gateway/routers/scheduled_tasks.py with route stubs:
@router.post("/scheduled-tasks")
@require_permission("threads", "write")
async def create_scheduled_task(request: Request, body: ScheduledTaskCreateRequest):
return body.model_dump()
@router.get("/scheduled-tasks/{task_id}")
@require_permission("threads", "read")
async def get_scheduled_task(task_id: str):
return {"id": task_id}
@router.patch("/scheduled-tasks/{task_id}")
@require_permission("threads", "write")
async def update_scheduled_task(task_id: str, request: Request, body: dict[str, Any]):
return {"id": task_id, **body}
@router.post("/scheduled-tasks/{task_id}/pause")
@require_permission("threads", "write")
async def pause_scheduled_task(task_id: str):
return {"id": task_id, "status": "paused"}
@router.post("/scheduled-tasks/{task_id}/resume")
@require_permission("threads", "write")
async def resume_scheduled_task(task_id: str):
return {"id": task_id, "status": "enabled"}
@router.post("/scheduled-tasks/{task_id}/trigger")
@require_permission("threads", "write")
async def trigger_scheduled_task(task_id: str):
return {"id": task_id, "triggered": True}
@router.delete("/scheduled-tasks/{task_id}")
@require_permission("threads", "write")
async def delete_scheduled_task(task_id: str):
return {"id": task_id, "deleted": True}
@router.get("/scheduled-tasks/{task_id}/runs")
@require_permission("threads", "read")
async def list_scheduled_task_runs(task_id: str):
return []
@router.get("/threads/{thread_id}/scheduled-tasks")
@require_permission("threads", "read", owner_check=True)
async def list_thread_scheduled_tasks(thread_id: str):
return []
- Step 4: Run tests to verify they pass
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py -v
Expected: PASS
- Step 5: Commit
git add backend/app/gateway/deps.py \
backend/app/gateway/routers/scheduled_tasks.py \
backend/tests/test_scheduled_task_router.py
git commit -m "feat(scheduler): add scheduled task route surface"
Task 8: Wire app state repositories and lifecycle start/stop
Files:
- Modify:
backend/app/gateway/deps.py - Modify:
backend/app/gateway/app.py - Test:
backend/tests/test_scheduled_task_lifecycle.py
Interfaces:
-
Produces app state members:
scheduled_task_reposcheduled_task_run_reposcheduled_task_service
-
Step 1: Write the failing tests
Create backend/tests/test_scheduled_task_lifecycle.py:
from app.gateway.app import create_app
def test_gateway_app_includes_scheduled_task_router():
app = create_app()
paths = {route.path for route in app.routes}
assert "/api/scheduled-tasks" in paths
- Step 2: Run tests to verify they fail
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_lifecycle.py -v
Expected: fail if route inclusion or lifecycle wiring is incomplete.
- Step 3: Implement
In backend/app/gateway/deps.py::langgraph_runtime, after thread_store creation:
if sf is not None:
from deerflow.persistence.scheduled_task_runs import ScheduledTaskRunRepository
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
app.state.scheduled_task_repo = ScheduledTaskRepository(sf)
app.state.scheduled_task_run_repo = ScheduledTaskRunRepository(sf)
else:
app.state.scheduled_task_repo = None
app.state.scheduled_task_run_repo = None
In backend/app/gateway/app.py::lifespan, after channel service startup:
scheduled_task_service = None
if getattr(startup_config.scheduler, "enabled", False):
from app.scheduler import ScheduledTaskService
from app.gateway.services import launch_scheduled_thread_run
if (
getattr(app.state, "scheduled_task_repo", None) is not None
and getattr(app.state, "scheduled_task_run_repo", None) is not None
):
scheduled_task_service = ScheduledTaskService(
task_repo=app.state.scheduled_task_repo,
task_run_repo=app.state.scheduled_task_run_repo,
launch_run=launch_scheduled_thread_run,
poll_interval_seconds=startup_config.scheduler.poll_interval_seconds,
lease_seconds=startup_config.scheduler.lease_seconds,
max_concurrent_runs=startup_config.scheduler.max_concurrent_runs,
)
app.state.scheduled_task_service = scheduled_task_service
await scheduled_task_service.start()
Before exiting lifespan shutdown:
if getattr(app.state, "scheduled_task_service", None) is not None:
try:
await app.state.scheduled_task_service.stop()
except Exception:
logger.exception("Failed to stop scheduled task service")
- Step 4: Run tests to verify they pass
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_lifecycle.py tests/test_scheduled_task_router.py -v
Expected: PASS
- Step 5: Commit
git add backend/app/gateway/deps.py backend/app/gateway/app.py backend/tests/test_scheduled_task_lifecycle.py
git commit -m "feat(scheduler): wire scheduled task repos and lifecycle"
Task 9: Build frontend scheduled-task API layer and page shell
Files:
- Create:
frontend/src/core/scheduled-tasks/types.ts - Create:
frontend/src/core/scheduled-tasks/api.ts - Create:
frontend/src/core/scheduled-tasks/hooks.ts - Create:
frontend/src/app/workspace/scheduled-tasks/page.tsx - Modify:
frontend/src/components/workspace/workspace-nav-chat-list.tsx - Modify:
frontend/src/core/i18n/locales/types.ts - Modify:
frontend/src/core/i18n/locales/en-US.ts - Modify:
frontend/src/core/i18n/locales/zh-CN.ts - Test:
frontend/tests/unit/core/scheduled-tasks/hooks.test.ts - Test:
frontend/tests/e2e/scheduled-tasks.spec.ts
Interfaces:
-
Produces:
ScheduledTaskScheduledTaskRunuseScheduledTasks/workspace/scheduled-tasks
-
Step 1: Write the failing frontend tests
Create frontend/tests/e2e/scheduled-tasks.spec.ts:
import { expect, test } from "@playwright/test";
test("scheduled tasks page is reachable from sidebar", async ({ page }) => {
await page.goto("/workspace/chats/new");
await page.getByRole("link", { name: /scheduled tasks/i }).click();
await page.waitForURL("**/workspace/scheduled-tasks");
await expect(page).toHaveURL(/workspace\/scheduled-tasks/);
});
Create frontend/tests/unit/core/scheduled-tasks/hooks.test.ts:
import { describe, expect, test } from "vitest";
import type { ScheduledTask } from "@/core/scheduled-tasks/types";
describe("scheduled task types", () => {
test("scheduled task shape supports status and next run", () => {
const task: ScheduledTask = {
id: "task-1",
thread_id: "thread-1",
title: "Daily summary",
prompt: "Summarize thread",
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "UTC",
status: "enabled",
next_run_at: "2026-07-02T01:00:00+00:00",
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00+00:00",
updated_at: "2026-07-01T00:00:00+00:00",
};
expect(task.status).toBe("enabled");
});
});
- Step 2: Run tests to verify they fail
Run:
cd frontend && pnpm test
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
Expected:
-
fail because scheduled-task modules and route do not exist
-
Step 3: Implement page shell and hooks
Create frontend/src/core/scheduled-tasks/types.ts:
export type ScheduledTask = {
id: string;
thread_id: string;
title: string;
prompt: string;
schedule_type: "once" | "cron";
schedule_spec: Record<string, unknown>;
timezone: string;
status: "enabled" | "paused" | "running" | "completed" | "failed" | "cancelled";
next_run_at: string | null;
last_run_at: string | null;
last_run_id: string | null;
last_error: string | null;
run_count: number;
created_at: string;
updated_at: string;
};
export type ScheduledTaskRun = {
id: string;
task_id: string;
thread_id: string;
run_id: string | null;
scheduled_for: string;
trigger: "scheduled" | "manual";
status: "queued" | "running" | "success" | "failed" | "skipped";
error: string | null;
started_at: string | null;
finished_at: string | null;
created_at: string;
};
Create frontend/src/core/scheduled-tasks/api.ts:
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import type { ScheduledTask, ScheduledTaskRun } from "./types";
export async function fetchScheduledTasks(): Promise<ScheduledTask[]> {
const response = await fetch(`${getBackendBaseURL()}/api/scheduled-tasks`);
return response.json();
}
export async function fetchThreadScheduledTasks(
threadId: string,
): Promise<ScheduledTask[]> {
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/scheduled-tasks`,
);
return response.json();
}
export async function fetchScheduledTaskRuns(
taskId: string,
): Promise<ScheduledTaskRun[]> {
const response = await fetch(
`${getBackendBaseURL()}/api/scheduled-tasks/${encodeURIComponent(taskId)}/runs`,
);
return response.json();
}
Create frontend/src/core/scheduled-tasks/hooks.ts:
import { useQuery } from "@tanstack/react-query";
import {
fetchScheduledTaskRuns,
fetchScheduledTasks,
fetchThreadScheduledTasks,
} from "./api";
export function useScheduledTasks() {
return useQuery({
queryKey: ["scheduled-tasks"],
queryFn: fetchScheduledTasks,
});
}
export function useThreadScheduledTasks(threadId: string | null | undefined) {
return useQuery({
queryKey: ["scheduled-tasks", "thread", threadId],
queryFn: () => fetchThreadScheduledTasks(threadId ?? ""),
enabled: Boolean(threadId),
});
}
export function useScheduledTaskRuns(taskId: string | null | undefined) {
return useQuery({
queryKey: ["scheduled-tasks", "runs", taskId],
queryFn: () => fetchScheduledTaskRuns(taskId ?? ""),
enabled: Boolean(taskId),
});
}
Create frontend/src/app/workspace/scheduled-tasks/page.tsx:
"use client";
import { useEffect } from "react";
import { WorkspaceBody, WorkspaceContainer, WorkspaceHeader } from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import { useScheduledTasks } from "@/core/scheduled-tasks/hooks";
export default function ScheduledTasksPage() {
const { t } = useI18n();
const { data } = useScheduledTasks();
useEffect(() => {
document.title = `${t.sidebar.scheduledTasks} - ${t.pages.appName}`;
}, [t.pages.appName, t.sidebar.scheduledTasks]);
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="mx-auto flex w-full max-w-(--container-width-md) flex-col gap-4 p-6">
<h1 className="text-2xl font-semibold">{t.sidebar.scheduledTasks}</h1>
<div data-testid="scheduled-task-list">
{(data ?? []).map((task) => (
<div key={task.id}>{task.title}</div>
))}
</div>
</div>
</WorkspaceBody>
</WorkspaceContainer>
);
}
Update sidebar types and translations with scheduledTasks.
Update frontend/src/components/workspace/workspace-nav-chat-list.tsx to add a link:
import { CalendarClock, BotIcon, MessagesSquare } from "lucide-react";
Add menu item:
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/workspace/scheduled-tasks")}
asChild
>
<Link
className="text-muted-foreground"
href="/workspace/scheduled-tasks"
>
<CalendarClock />
<span>{t.sidebar.scheduledTasks}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
- Step 4: Run tests to verify they pass
Run:
cd frontend && pnpm test
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
Expected: PASS
- Step 5: Commit
git add frontend/src/core/scheduled-tasks/types.ts \
frontend/src/core/scheduled-tasks/api.ts \
frontend/src/core/scheduled-tasks/hooks.ts \
frontend/src/app/workspace/scheduled-tasks/page.tsx \
frontend/src/components/workspace/workspace-nav-chat-list.tsx \
frontend/src/core/i18n/locales/types.ts \
frontend/src/core/i18n/locales/en-US.ts \
frontend/src/core/i18n/locales/zh-CN.ts \
frontend/tests/unit/core/scheduled-tasks/hooks.test.ts \
frontend/tests/e2e/scheduled-tasks.spec.ts
git commit -m "feat(scheduler): add scheduled tasks workspace page shell"
Task 10: Add thread-level scheduled-task entry point
Files:
- Modify:
frontend/src/app/workspace/chats/[thread_id]/page.tsx - Create:
frontend/src/components/workspace/thread-scheduled-tasks-link.tsx - Test:
frontend/tests/e2e/scheduled-tasks.spec.ts
Interfaces:
-
Produces a thread-scoped link to
/workspace/scheduled-tasks?thread_id=<id> -
Step 1: Extend the failing E2E test
Append to frontend/tests/e2e/scheduled-tasks.spec.ts:
test("thread page links to filtered scheduled tasks", async ({ page }) => {
await page.goto("/workspace/chats/new");
await page.goto("/workspace/chats/thread-1");
await page.getByRole("link", { name: /scheduled tasks/i }).click();
await page.waitForURL(/thread_id=thread-1/);
});
- Step 2: Run test to verify it fails
Run:
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
Expected: fail because thread page has no such link.
- Step 3: Implement
Create frontend/src/components/workspace/thread-scheduled-tasks-link.tsx:
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useI18n } from "@/core/i18n/hooks";
export function ThreadScheduledTasksLink({ threadId }: { threadId: string }) {
const { t } = useI18n();
return (
<Button variant="outline" size="sm" asChild>
<Link href={`/workspace/scheduled-tasks?thread_id=${encodeURIComponent(threadId)}`}>
{t.sidebar.scheduledTasks}
</Link>
</Button>
);
}
Import and render it in frontend/src/app/workspace/chats/[thread_id]/page.tsx near the header controls using the current threadId.
- Step 4: Run tests to verify they pass
Run:
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
Expected: PASS
- Step 5: Commit
git add frontend/src/components/workspace/thread-scheduled-tasks-link.tsx \
frontend/src/app/workspace/chats/[thread_id]/page.tsx \
frontend/tests/e2e/scheduled-tasks.spec.ts
git commit -m "feat(scheduler): add thread-level scheduled task entry point"
Task 11: Flesh out frontend page interactions and backend contract
Files:
- Modify:
backend/app/gateway/routers/scheduled_tasks.py - Modify:
frontend/src/core/scheduled-tasks/api.ts - Modify:
frontend/src/core/scheduled-tasks/hooks.ts - Modify:
frontend/src/app/workspace/scheduled-tasks/page.tsx - Test:
backend/tests/test_scheduled_task_router.py - Test:
frontend/tests/unit/core/scheduled-tasks/hooks.test.ts - Test:
frontend/tests/e2e/scheduled-tasks.spec.ts
Interfaces:
-
Produces working list/detail/create/update/pause/resume/trigger/delete flows
-
Step 1: Extend failing tests for CRUD and actions
Add backend tests that assert response shapes and route presence for:
- create
- history
- pause/resume
- delete
Add Playwright interactions for:
-
create modal/form submit
-
pause/resume action buttons
-
trigger action
-
delete action
-
Step 2: Run tests to verify they fail
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py -v
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
Expected: fail because current page shell and route stubs do not implement these behaviors.
- Step 3: Implement minimal full flow
Backend:
- validate payloads
- read/write through repositories
- manual trigger delegates to scheduled task service or shared launch helper
- list history from
ScheduledTaskRunRepository
Frontend:
-
add create form
-
add selected-task detail panel
-
add action buttons and mutation hooks
-
refresh list/history on success
-
Step 4: Run tests to verify they pass
Run:
cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py tests/test_scheduled_task_service.py tests/test_scheduled_task_repository.py tests/test_scheduled_task_claims.py tests/test_scheduled_task_schedules.py -v
cd frontend && pnpm test
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
Expected: PASS
- Step 5: Commit
git add backend/app/gateway/routers/scheduled_tasks.py \
frontend/src/core/scheduled-tasks/api.ts \
frontend/src/core/scheduled-tasks/hooks.ts \
frontend/src/app/workspace/scheduled-tasks/page.tsx \
backend/tests/test_scheduled_task_router.py \
frontend/tests/unit/core/scheduled-tasks/hooks.test.ts \
frontend/tests/e2e/scheduled-tasks.spec.ts
git commit -m "feat(scheduler): implement scheduled task CRUD and UI actions"
Task 12: Real-path validation, docs sync, and final verification
Files:
- Modify:
README.md - Modify:
AGENTS.md - Modify:
backend/AGENTS.md - Modify:
backend/docs/CONFIGURATION.md - Test: no new file required; use existing verification commands
Interfaces:
-
Produces updated docs and final evidence package
-
Step 1: Update docs
Add concise documentation for:
-
what scheduled tasks MVP supports
-
how to enable scheduler in config
-
where users manage tasks in the workspace
-
what is intentionally unsupported in MVP
-
Step 2: Run backend verification
Run:
cd backend && PYTHONPATH=. uv run pytest \
tests/test_scheduled_task_models.py \
tests/test_scheduled_task_repository.py \
tests/test_scheduled_task_schedules.py \
tests/test_scheduled_task_claims.py \
tests/test_scheduled_task_service.py \
tests/test_scheduled_task_router.py \
tests/test_scheduled_task_lifecycle.py -v
Expected: all PASS
- Step 3: Run frontend verification
Run:
cd frontend && pnpm test
cd frontend && pnpm check
cd frontend && pnpm test:e2e scheduled-tasks.spec.ts
Expected: all PASS
- Step 4: Run real-path browser validation
Run:
make dev
Then verify in a real browser:
-
create a one-time scheduled task due soon
-
observe list row and task detail
-
observe task trigger and resulting DeerFlow run
-
verify final status/result is reflected in the management page
-
Step 5: Commit
git add README.md AGENTS.md backend/AGENTS.md backend/docs/CONFIGURATION.md
git commit -m "docs(scheduler): document scheduled tasks MVP"
Task 13: Independent verifier pass before PR
Files:
- No code changes required; verifier may request fixes in touched files.
Interfaces:
-
Produces fail-loud verification report covering:
- skipped paths
- warnings
- not-yet-verified areas
-
Step 1: Dispatch a fresh verifier
Give the verifier only:
-
goal contract
-
changed files
-
exact verification commands
-
Step 2: Address verifier findings
If findings exist:
-
write failing test
-
confirm fail
-
implement fix
-
rerun relevant verification
-
Step 3: Run final full verification
Run:
cd backend && PYTHONPATH=. uv run pytest
cd frontend && pnpm check
cd frontend && pnpm test
cd frontend && pnpm test:e2e
Expected: pass or clearly documented existing unrelated failures
- Step 4: Prepare PR
PR body must include:
- goal contract
- test evidence
- real browser validation evidence
- verifier fail-loud report
- explicit skipped/not-verified items if any