diff --git a/AGENTS.md b/AGENTS.md index a5f86bd89..1b40cc84c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,10 @@ servers + skills). Both real files are gitignored and may be edited at runtime v Gateway API. Config schema and resolution order are documented in [backend/AGENTS.md](backend/AGENTS.md). +Scheduled-task note: +- The scheduled-task MVP adds a workspace page at `/workspace/scheduled-tasks` plus a background scheduler service gated by `config.yaml -> scheduler.enabled`. +- Scheduled background runs are intentionally non-interactive: they execute through the normal run lifecycle, but the lead-agent toolset excludes `ask_clarification` when `context.non_interactive=true`. The key is honored only for internally-authenticated callers (the scheduler launch path); client-supplied `context.non_interactive` is dropped. + ## Commands: Root vs. Module **Root `make` targets drive the whole stack** (run from the repo root): diff --git a/README.md b/README.md index 833e02c38..cab20ed94 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ DeerFlow has newly integrated the intelligent search and crawling toolset indepe - [Long-Term Memory](#long-term-memory) - [Recommended Models](#recommended-models) - [Embedded Python Client](#embedded-python-client) + - [Scheduled Tasks](#scheduled-tasks) - [Terminal Workbench (TUI)](#terminal-workbench-tui) - [Documentation](#documentation) - [⚠️ Security Notice](#️-security-notice) @@ -767,6 +768,29 @@ client.clear_goal("thread-1") All dict-returning methods are validated against Gateway Pydantic response models in CI (`TestGatewayConformance`), ensuring the embedded client stays in sync with the HTTP API schemas. See `backend/packages/harness/deerflow/client.py` for full API documentation. +## Scheduled Tasks + +DeerFlow now includes a first-class scheduled-task MVP in the workspace. + +Current MVP capabilities: + +- Manage tasks at `/workspace/scheduled-tasks` +- Choose whether each scheduled task reuses a thread or creates a fresh thread per run +- Support `once` and `cron` schedules +- Run background scheduled executions as non-interactive DeerFlow runs (`ask_clarification` is not exposed there) +- Use `skip` overlap behavior for due cron executions that collide with an active run on the same reused thread +- Pause, resume, trigger, inspect history, and delete tasks +- Execute scheduled work through the normal DeerFlow run lifecycle + +Current MVP limits: + +- No conversation-created `schedule_task` tool yet +- No text-only notification jobs +- No channel or GitHub dispatch targets +- No `interval` schedule type in this first cut + +Enable background polling with `config.yaml -> scheduler.enabled`. Manual trigger uses the same scheduled-task resource and execution path. + ## Terminal Workbench (TUI) `deerflow` is a terminal-native workbench for people who live in the shell. It runs **embedded** over `DeerFlowClient` — no Gateway, frontend, nginx, or Docker required — while honoring the same `config.yaml`, checkpointer, skills, memory, MCP, and sandbox settings as the rest of DeerFlow. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 6f73e8a05..3b44afa36 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -14,6 +14,7 @@ DeerFlow is a LangGraph-based AI super agent system with a full-stack architectu **Runtime**: - `make dev`, Docker dev, and production all run the agent runtime in Gateway via `RunManager` + `run_agent()` + `StreamBridge` (`packages/harness/deerflow/runtime/`). Nginx exposes that runtime at `/api/langgraph/*` and rewrites it to Gateway's native `/api/*` routers. +- Scheduled-task executions must reuse that same Gateway run lifecycle. The scheduler may decide *when* work runs, but it must dispatch through the existing run path rather than introducing a parallel execution stack. **Project Structure**: ``` @@ -367,6 +368,9 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti 4. **Subagent tool** (if enabled): - `task` - Delegate to subagent (description, prompt, subagent_type) +Scheduled-task runtime note: +- Scheduled background runs set `context.non_interactive=true` and therefore exclude `ask_clarification` from the lead-agent tool list. This keeps scheduler-triggered runs from stalling on human confirmation mid-execution. `non_interactive` is an internal-only context key: it is merged from `body.context` only when the request authenticated as the process-internal user (the scheduler path), never from arbitrary HTTP/IM clients. + **Community tools** (`packages/harness/deerflow/community/`): optional integrations, each in its own subpackage and wired through `config.yaml`. Documented examples: - `tavily/` - Web search (5 results default) and web fetch (4KB limit) - `jina_ai/` - Web fetch via Jina reader API with readability extraction diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 1880383c4..85db668b5 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -24,6 +24,7 @@ from app.gateway.routers import ( memory, models, runs, + scheduled_tasks, skills, suggestions, thread_runs, @@ -234,6 +235,25 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: except Exception: logger.exception("No IM channels configured or channel service failed to start") + try: + from app.gateway.services import launch_scheduled_thread_run + from app.scheduler import ScheduledTaskService + + 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=lambda **kwargs: launch_scheduled_thread_run(app=app, **kwargs), + 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 + if startup_config.scheduler.enabled: + await scheduled_task_service.start() + except Exception: + logger.exception("Failed to initialize scheduled task service") + yield try: @@ -257,6 +277,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: except Exception: logger.exception("Failed to stop channel service") + 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") + logger.info("Shutting down API Gateway") @@ -405,6 +431,9 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for # Thread cleanup API is mounted at /api/threads/{thread_id} app.include_router(threads.router) + # Scheduled tasks API is mounted at /api/scheduled-tasks + app.include_router(scheduled_tasks.router) + # Agents API is mounted at /api/agents app.include_router(agents.router) diff --git a/backend/app/gateway/deps.py b/backend/app/gateway/deps.py index 28a8a01f0..c3d935696 100644 --- a/backend/app/gateway/deps.py +++ b/backend/app/gateway/deps.py @@ -238,6 +238,17 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen from deerflow.persistence.thread_meta import make_thread_store app.state.thread_store = make_thread_store(sf, app.state.store) + 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 # Run event store. The store and the matching ``run_events_config`` are # both frozen at startup so ``get_run_context`` does not combine a @@ -316,6 +327,27 @@ def get_thread_store(request: Request) -> ThreadMetaStore: return val +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 + + def get_run_context(request: Request) -> RunContext: """Build a :class:`RunContext` from ``app.state`` singletons. @@ -333,6 +365,7 @@ def get_run_context(request: Request) -> RunContext: run_events_config=getattr(request.app.state, "run_events_config", None), thread_store=get_thread_store(request), app_config=get_config(), + on_run_completed=getattr(request.app.state, "scheduled_task_service", None).handle_run_completion if getattr(request.app.state, "scheduled_task_service", None) is not None else None, ) diff --git a/backend/app/gateway/routers/__init__.py b/backend/app/gateway/routers/__init__.py index c5f67a396..b271a1228 100644 --- a/backend/app/gateway/routers/__init__.py +++ b/backend/app/gateway/routers/__init__.py @@ -1,3 +1,25 @@ -from . import artifacts, assistants_compat, mcp, models, skills, suggestions, thread_runs, threads, uploads +from . import ( + artifacts, + assistants_compat, + mcp, + models, + scheduled_tasks, + skills, + suggestions, + thread_runs, + threads, + uploads, +) -__all__ = ["artifacts", "assistants_compat", "mcp", "models", "skills", "suggestions", "threads", "thread_runs", "uploads"] +__all__ = [ + "artifacts", + "assistants_compat", + "mcp", + "models", + "scheduled_tasks", + "skills", + "suggestions", + "threads", + "thread_runs", + "uploads", +] diff --git a/backend/app/gateway/routers/scheduled_tasks.py b/backend/app/gateway/routers/scheduled_tasks.py new file mode 100644 index 000000000..35cd74bf1 --- /dev/null +++ b/backend/app/gateway/routers/scheduled_tasks.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import uuid +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 +from app.gateway.deps import ( + get_config, + get_optional_user_from_request, + get_scheduled_task_repo, + get_scheduled_task_run_repo, + get_scheduled_task_service, + get_thread_store, +) +from deerflow.scheduler.schedules import ( + next_run_at as compute_next_run_at, +) +from deerflow.scheduler.schedules import ( + normalize_cron_expression, + validate_timezone, +) + +router = APIRouter(prefix="/api", tags=["scheduled-tasks"]) + + +def _ensure_task_mutable(task: dict[str, Any]) -> None: + if task.get("status") == "running": + raise HTTPException( + status_code=409, + detail="Scheduled task is currently running; retry after the active execution finishes", + ) + + +class ScheduledTaskCreateRequest(BaseModel): + thread_id: str | None = None + context_mode: str = "fresh_thread_per_run" + title: str = Field(min_length=1) + prompt: str = Field(min_length=1) + schedule_type: str + schedule_spec: dict[str, Any] + timezone: str + + +class ScheduledTaskUpdateRequest(BaseModel): + context_mode: str | None = None + thread_id: str | None = None + title: str | None = Field(default=None, min_length=1) + prompt: str | None = Field(default=None, min_length=1) + schedule_spec: dict[str, Any] | None = None + timezone: str | None = None + + +@router.get("/scheduled-tasks") +@require_permission("threads", "read") +async def list_scheduled_tasks(request: Request): + repo = get_scheduled_task_repo(request) + user = await get_optional_user_from_request(request) + if user is None: + return [] + return await repo.list_by_user(str(user.id)) + + +@router.post("/scheduled-tasks") +@require_permission("threads", "write") +async def create_scheduled_task(request: Request, body: ScheduledTaskCreateRequest): + config = get_config() + repo = get_scheduled_task_repo(request) + thread_store = get_thread_store(request) + user = await get_optional_user_from_request(request) + if user is None: + raise HTTPException(status_code=401, detail="Authentication required") + if body.context_mode not in {"fresh_thread_per_run", "reuse_thread"}: + raise HTTPException(status_code=422, detail="Unsupported context_mode") + if body.context_mode == "reuse_thread": + if not body.thread_id: + raise HTTPException(status_code=422, detail="reuse_thread requires thread_id") + if not await thread_store.check_access(body.thread_id, str(user.id), require_existing=True): + raise HTTPException(status_code=404, detail="Thread not found") + if body.schedule_type not in {"once", "cron"}: + raise HTTPException(status_code=422, detail="Unsupported schedule_type") + + schedule_spec = dict(body.schedule_spec) + try: + validate_timezone(body.timezone) + if body.schedule_type == "cron": + raw_cron = schedule_spec.get("cron") + if not isinstance(raw_cron, str): + raise HTTPException(status_code=422, detail="cron schedule requires schedule_spec.cron") + schedule_spec["cron"] = normalize_cron_expression(raw_cron) + next_run_at = compute_next_run_at( + body.schedule_type, + schedule_spec, + body.timezone, + now=datetime.now(UTC), + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + if body.schedule_type == "once" and next_run_at is None: + raise HTTPException(status_code=422, detail="once schedule must be in the future") + if body.schedule_type == "once" and next_run_at is not None and (next_run_at - datetime.now(UTC)).total_seconds() < config.scheduler.min_once_delay_seconds: + raise HTTPException( + status_code=422, + detail=(f"once schedule must be at least {config.scheduler.min_once_delay_seconds} seconds in the future"), + ) + + return await repo.create( + task_id=f"task-{uuid.uuid4().hex}", + user_id=str(user.id), + thread_id=body.thread_id, + context_mode=body.context_mode, + assistant_id="lead_agent", + title=body.title, + prompt=body.prompt, + schedule_type=body.schedule_type, + schedule_spec=schedule_spec, + timezone=body.timezone, + next_run_at=next_run_at, + ) + + +@router.get("/scheduled-tasks/{task_id}") +@require_permission("threads", "read") +async def get_scheduled_task(task_id: str, request: Request): + repo = get_scheduled_task_repo(request) + user = await get_optional_user_from_request(request) + if user is None: + raise HTTPException(status_code=401, detail="Authentication required") + task = await repo.get(task_id, user_id=str(user.id)) + if task is None: + raise HTTPException(status_code=404, detail="Scheduled task not found") + return task + + +@router.patch("/scheduled-tasks/{task_id}") +@require_permission("threads", "write") +async def update_scheduled_task(task_id: str, request: Request, body: ScheduledTaskUpdateRequest): + config = get_config() + repo = get_scheduled_task_repo(request) + user = await get_optional_user_from_request(request) + if user is None: + raise HTTPException(status_code=401, detail="Authentication required") + existing = await repo.get(task_id, user_id=str(user.id)) + if existing is None: + raise HTTPException(status_code=404, detail="Scheduled task not found") + _ensure_task_mutable(existing) + + updates = body.model_dump(exclude_none=True) + if "context_mode" in updates: + if updates["context_mode"] not in {"fresh_thread_per_run", "reuse_thread"}: + raise HTTPException(status_code=422, detail="Unsupported context_mode") + effective_context_mode = str(updates.get("context_mode", existing["context_mode"])) + effective_thread_id = updates.get("thread_id", existing.get("thread_id")) + if effective_context_mode == "reuse_thread": + if not effective_thread_id: + raise HTTPException(status_code=422, detail="reuse_thread requires thread_id") + thread_store = get_thread_store(request) + if not await thread_store.check_access(str(effective_thread_id), str(user.id), require_existing=True): + raise HTTPException(status_code=404, detail="Thread not found") + elif effective_context_mode == "fresh_thread_per_run": + updates["thread_id"] = None + if "timezone" in updates: + try: + validate_timezone(str(updates["timezone"])) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + if "schedule_spec" in updates or "timezone" in updates: + schedule_spec = dict(existing["schedule_spec"]) + if "schedule_spec" in updates and isinstance(updates["schedule_spec"], dict): + schedule_spec = dict(updates["schedule_spec"]) + timezone = str(updates.get("timezone", existing["timezone"])) + try: + if existing["schedule_type"] == "cron": + raw_cron = schedule_spec.get("cron") + if not isinstance(raw_cron, str): + raise HTTPException( + status_code=422, + detail="cron schedule requires schedule_spec.cron", + ) + schedule_spec["cron"] = normalize_cron_expression(raw_cron) + next_run_at = compute_next_run_at( + existing["schedule_type"], + schedule_spec, + timezone, + now=datetime.now(UTC), + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + if existing["schedule_type"] == "once" and next_run_at is None: + raise HTTPException(status_code=422, detail="once schedule must be in the future") + if existing["schedule_type"] == "once" and next_run_at is not None and (next_run_at - datetime.now(UTC)).total_seconds() < config.scheduler.min_once_delay_seconds: + raise HTTPException( + status_code=422, + detail=(f"once schedule must be at least {config.scheduler.min_once_delay_seconds} seconds in the future"), + ) + updates["schedule_spec"] = schedule_spec + updates["next_run_at"] = next_run_at + # A terminal task (completed/failed/cancelled) whose schedule was just + # pushed into the future must be re-armed: claim_due_tasks only admits + # "enabled" rows, so leaving the terminal status would return 200 with + # a next_run_at that silently never fires. + if next_run_at is not None and existing["status"] in {"completed", "failed", "cancelled"}: + updates["status"] = "enabled" + + updated = await repo.update( + task_id, + user_id=str(user.id), + updates=updates, + ) + return updated + + +@router.post("/scheduled-tasks/{task_id}/pause") +@require_permission("threads", "write") +async def pause_scheduled_task(task_id: str, request: Request): + repo = get_scheduled_task_repo(request) + user = await get_optional_user_from_request(request) + if user is None: + raise HTTPException(status_code=401, detail="Authentication required") + existing = await repo.get(task_id, user_id=str(user.id)) + if existing is None: + raise HTTPException(status_code=404, detail="Scheduled task not found") + _ensure_task_mutable(existing) + updated = await repo.update(task_id, user_id=str(user.id), updates={"status": "paused"}) + if updated is None: + raise HTTPException(status_code=404, detail="Scheduled task not found") + return updated + + +@router.post("/scheduled-tasks/{task_id}/resume") +@require_permission("threads", "write") +async def resume_scheduled_task(task_id: str, request: Request): + repo = get_scheduled_task_repo(request) + user = await get_optional_user_from_request(request) + if user is None: + raise HTTPException(status_code=401, detail="Authentication required") + existing = await repo.get(task_id, user_id=str(user.id)) + if existing is None: + raise HTTPException(status_code=404, detail="Scheduled task not found") + _ensure_task_mutable(existing) + updated = await repo.update(task_id, user_id=str(user.id), updates={"status": "enabled"}) + if updated is None: + raise HTTPException(status_code=404, detail="Scheduled task not found") + return updated + + +@router.post("/scheduled-tasks/{task_id}/trigger") +@require_permission("threads", "write") +async def trigger_scheduled_task(task_id: str, request: Request): + repo = get_scheduled_task_repo(request) + service = get_scheduled_task_service(request) + user = await get_optional_user_from_request(request) + if user is None: + raise HTTPException(status_code=401, detail="Authentication required") + task = await repo.get(task_id, user_id=str(user.id)) + if task is None: + raise HTTPException(status_code=404, detail="Scheduled task not found") + result = await service.dispatch_task(task, now=datetime.now(UTC), trigger="manual") + if result["outcome"] == "conflict": + raise HTTPException(status_code=409, detail=result["error"] or "Scheduled task trigger conflicted with an active run") + if result["outcome"] == "failed": + raise HTTPException(status_code=502, detail=result["error"] or "Scheduled task trigger failed") + return {"id": task_id, "triggered": True} + + +@router.delete("/scheduled-tasks/{task_id}") +@require_permission("threads", "write") +async def delete_scheduled_task(task_id: str, request: Request): + repo = get_scheduled_task_repo(request) + user = await get_optional_user_from_request(request) + if user is None: + raise HTTPException(status_code=401, detail="Authentication required") + deleted = await repo.delete(task_id, user_id=str(user.id)) + if not deleted: + raise HTTPException(status_code=404, detail="Scheduled task not found") + return {"id": task_id, "deleted": deleted} + + +@router.get("/scheduled-tasks/{task_id}/runs") +@require_permission("threads", "read") +async def list_scheduled_task_runs( + task_id: str, + request: Request, + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +): + task_repo = get_scheduled_task_repo(request) + run_repo = get_scheduled_task_run_repo(request) + user = await get_optional_user_from_request(request) + if user is None: + raise HTTPException(status_code=401, detail="Authentication required") + task = await task_repo.get(task_id, user_id=str(user.id)) + if task is None: + raise HTTPException(status_code=404, detail="Scheduled task not found") + return await run_repo.list_by_task(task_id, limit=limit, offset=offset) + + +@router.get("/threads/{thread_id}/scheduled-tasks") +@require_permission("threads", "read", owner_check=True) +async def list_thread_scheduled_tasks(thread_id: str, request: Request): + repo = get_scheduled_task_repo(request) + user = await get_optional_user_from_request(request) + if user is None: + raise HTTPException(status_code=401, detail="Authentication required") + return await repo.list_by_user_and_thread(str(user.id), thread_id) diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index 277de1eb3..8e0d644fe 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -20,8 +20,14 @@ from langchain_core.messages import BaseMessage from langchain_core.messages.utils import convert_to_messages from langgraph.types import Command +from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL from app.gateway.deps import get_checkpointer, get_run_context, get_run_manager, get_stream_bridge -from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE, get_trusted_internal_owner_user_id +from app.gateway.internal_auth import ( + INTERNAL_OWNER_USER_ID_HEADER_NAME, + INTERNAL_SYSTEM_ROLE, + get_internal_user, + get_trusted_internal_owner_user_id, +) from app.gateway.utils import sanitize_log_param from deerflow.config.app_config import get_app_config from deerflow.runtime import ( @@ -169,8 +175,28 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset( } ) +# Keys honored only for internally-authenticated callers (the scheduler path). +# ``non_interactive`` strips ``ask_clarification`` from the lead-agent toolset; +# arbitrary HTTP/IM clients must not be able to force autonomous execution. +_INTERNAL_ONLY_CONTEXT_KEYS: frozenset[str] = frozenset({"non_interactive"}) -def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, Any] | None) -> None: + +def strip_internal_context_keys(config: dict[str, Any]) -> None: + """Drop internal-only keys a non-internal caller smuggled into the run config. + + Gating :func:`merge_run_context_overrides` is not enough on its own: + ``build_run_config`` copies a client-supplied ``body.config['context']`` / + ``body.config['configurable']`` verbatim, so the same keys must be scrubbed + from both sections after the config is assembled. + """ + for section in ("context", "configurable"): + value = config.get(section) + if isinstance(value, dict): + for key in _INTERNAL_ONLY_CONTEXT_KEYS: + value.pop(key, None) + + +def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, Any] | None, *, internal: bool = False) -> None: """Merge whitelisted keys from ``body.context`` into both ``config['configurable']`` and ``config['context']`` so they are visible to legacy configurable readers and to LangGraph ``ToolRuntime.context`` consumers (e.g. the ``setup_agent`` tool — @@ -181,12 +207,18 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An ``body.context`` keep it on ``ToolRuntime.context``. It is merged with ``setdefault`` so a server-authenticated id stamped by :func:`inject_authenticated_user_context` always wins over the client-supplied one. + + ``internal=True`` (the request authenticated as the process-internal user, + e.g. the scheduler's ``launch_scheduled_thread_run``) additionally honors + :data:`_INTERNAL_ONLY_CONTEXT_KEYS`; those keys are dropped from client + requests. """ if not context: return configurable = config.setdefault("configurable", {}) runtime_context = config.setdefault("context", {}) - for key in _CONTEXT_CONFIGURABLE_KEYS: + keys = _CONTEXT_CONFIGURABLE_KEYS | _INTERNAL_ONLY_CONTEXT_KEYS if internal else _CONTEXT_CONFIGURABLE_KEYS + for key in keys: if key in context: if isinstance(configurable, dict): configurable.setdefault(key, context[key]) @@ -566,7 +598,12 @@ async def start_run( # The ``context`` field is a custom extension for the langgraph-compat layer # that carries agent configuration (model_name, thinking_enabled, etc.). # Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored. - merge_run_context_overrides(config, getattr(body, "context", None)) + is_internal_caller = getattr(getattr(request, "state", None), "auth_source", None) == AUTH_SOURCE_INTERNAL + merge_run_context_overrides(config, getattr(body, "context", None), internal=is_internal_caller) + if not is_internal_caller: + # ``body.config`` is free-form and copied verbatim by + # ``build_run_config``; scrub internal-only keys smuggled there. + strip_internal_context_keys(config) inject_authenticated_user_context(config, request) stream_modes = normalize_stream_modes(body.stream_mode) @@ -598,6 +635,61 @@ async def start_run( reset_current_user(owner_context_token) +async def launch_scheduled_thread_run( + *, + thread_id: str, + assistant_id: str | None, + prompt: str, + request: Request | None = None, + app: Any | None = None, + owner_user_id: str | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + if request is None: + if app is None: + raise ValueError("launch_scheduled_thread_run requires request or app") + request = SimpleNamespace( + app=app, + headers=({INTERNAL_OWNER_USER_ID_HEADER_NAME: owner_user_id} if owner_user_id else {}), + state=SimpleNamespace( + user=get_internal_user(), + auth_source=AUTH_SOURCE_INTERNAL, + ), + cookies={}, + ) + # SimpleNamespace stands in for the Pydantic run-request body that the + # HTTP path parses. If start_run gains a new body.* attribute that it reads + # directly, add the matching field here so the scheduler path stays in sync. + body = SimpleNamespace( + assistant_id=assistant_id, + input={"messages": [{"role": "user", "content": prompt}]}, + command=None, + metadata=metadata or {}, + config=None, + # ``user_id`` mirrors what IM channels put in ``body.context`` so + # runtime-context consumers without a ContextVar fallback (e.g. + # user-scoped GuardrailMiddleware providers) see the owning user; + # ``inject_authenticated_user_context`` skips the internal user. + context=({"non_interactive": True, "user_id": owner_user_id} if owner_user_id else {"non_interactive": True}), + 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(body, thread_id, request) + return {"run_id": record.run_id, "thread_id": record.thread_id} + + async def sse_consumer( bridge: StreamBridge, record: RunRecord, diff --git a/backend/app/scheduler/__init__.py b/backend/app/scheduler/__init__.py new file mode 100644 index 000000000..a3c331940 --- /dev/null +++ b/backend/app/scheduler/__init__.py @@ -0,0 +1,3 @@ +from .service import ScheduledTaskService + +__all__ = ["ScheduledTaskService"] diff --git a/backend/app/scheduler/service.py b/backend/app/scheduler/service.py new file mode 100644 index 000000000..b294c8957 --- /dev/null +++ b/backend/app/scheduler/service.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import asyncio +import logging +import socket +import uuid +from datetime import UTC, datetime +from typing import Any, Literal + +from fastapi import HTTPException + +from deerflow.runtime import ConflictError, RunRecord +from deerflow.scheduler.schedules import next_run_at + +logger = logging.getLogger(__name__) + + +class ScheduledTaskService: + def __init__( + self, + *, + task_repo, + task_run_repo, + launch_run, + 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: + # ``max_concurrent_runs`` is a global cap on active scheduled runs, not + # just a per-poll claim batch: long runs accumulate across poll cycles, + # so each cycle only claims into the remaining budget. + active = await self._task_run_repo.count_active_runs() + budget = self._max_concurrent_runs - active + if budget <= 0: + return + claimed = await self._task_repo.claim_due_tasks( + now=now, + lease_owner=self._lease_owner, + lease_seconds=self._lease_seconds, + limit=budget, + ) + for task in claimed: + await self.dispatch_task(task, now=now, trigger="scheduled") + + @staticmethod + def _is_overlap_conflict(exc: Exception) -> bool: + if isinstance(exc, ConflictError): + return True + return isinstance(exc, HTTPException) and exc.status_code == 409 + + @staticmethod + def _task_status_for_failure(task: dict[str, Any], *, trigger: str) -> str: + if trigger == "manual": + # A failed manual trigger must not consume the task's scheduled + # future: a `once` task with run_at still ahead would otherwise be + # flipped to "failed" and never claimed again. + return task.get("status") or "enabled" + if task["schedule_type"] == "once": + return "failed" + return "enabled" + + @staticmethod + def _task_status_for_skip(task: dict[str, Any]) -> str: + if task["schedule_type"] == "once": + # The single occurrence was lost to an overlapping run; "completed" + # would claim an execution that never happened. + return "failed" + return "enabled" + + async def dispatch_task( + self, + task: dict[str, Any], + *, + now: datetime, + trigger: str, + ) -> dict[str, Any]: + execution_thread_id = task.get("thread_id") + if task.get("context_mode") == "fresh_thread_per_run" or not execution_thread_id: + execution_thread_id = str(uuid.uuid4()) + # "skip" must hold for fresh-thread runs too, where every run gets a new + # thread and the same-thread multitask ConflictError below can never + # fire. Checked before creating this dispatch's own run row so the row + # does not count itself as the active run. A manual trigger against an + # active run is rejected outright (409 at the router) instead of being + # recorded as a skipped occurrence — nothing was scheduled to happen. + skip_error: str | None = None + if task.get("overlap_policy", "skip") == "skip" and await self._task_run_repo.has_active_runs(task["id"]): + if trigger == "manual": + return { + "outcome": "conflict", + "task_run_id": None, + "run_id": None, + "thread_id": execution_thread_id, + "error": "task already has an active run", + } + skip_error = "skipped: a previous run of this task is still active" + 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=execution_thread_id, + scheduled_for=now, + trigger=trigger, + status="queued", + ) + if skip_error is not None: + return await self._finalize_skip(task, task_run_id=task_run_id, thread_id=execution_thread_id, now=now, error=skip_error) + try: + result = await self._launch_run( + thread_id=execution_thread_id, + assistant_id=task.get("assistant_id"), + prompt=task["prompt"], + owner_user_id=task.get("user_id"), + metadata={ + "scheduled_task_id": task["id"], + "scheduled_task_run_id": task_run_id, + "scheduled_trigger": trigger, + }, + ) + next_at = next_run_at( + task["schedule_type"], + task["schedule_spec"], + task["timezone"], + now=now, + ) + if task["schedule_type"] == "once": + # Stay "running" until handle_run_completion sees the real + # terminal outcome; declaring "completed" at launch would stick + # if the run fails or the process dies (startup reconciliation + # is cancel_stuck_once_tasks). + task_status = "running" + elif trigger == "manual" and task.get("status") == "paused": + task_status = "paused" + else: + task_status = "enabled" + await self._task_run_repo.update_status( + task_run_id, + status="running", + run_id=result["run_id"], + started_at=now, + # A fast-failing run can reach handle_run_completion before this + # write resumes; never clobber its terminal status. + protect_terminal=True, + ) + await self._task_repo.update_after_launch( + task["id"], + status=task_status, + next_run_at=next_at, + last_run_at=now, + last_run_id=result["run_id"], + last_thread_id=result["thread_id"], + last_error=None, + increment_run_count=True, + # Same race as the run-row write above: a fast-failing run's + # completion hook may have already finalized a `once` task. + protect_terminal=True, + ) + return { + "outcome": "launched", + "task_run_id": task_run_id, + "run_id": result["run_id"], + "thread_id": result["thread_id"], + "error": None, + } + except Exception as exc: + next_at = next_run_at( + task["schedule_type"], + task["schedule_spec"], + task["timezone"], + now=now, + ) + if self._is_overlap_conflict(exc) and trigger == "scheduled" and task.get("overlap_policy", "skip") == "skip": + return await self._finalize_skip(task, task_run_id=task_run_id, thread_id=execution_thread_id, now=now, error=str(exc)) + + task_status = self._task_status_for_failure(task, trigger=trigger) + 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=task_status, + next_run_at=next_at, + last_run_at=now, + last_run_id=None, + last_thread_id=execution_thread_id, + last_error=str(exc), + increment_run_count=False, + ) + return { + "outcome": "conflict" if self._is_overlap_conflict(exc) else "failed", + "task_run_id": task_run_id, + "run_id": None, + "thread_id": execution_thread_id, + "error": str(exc), + } + + async def _finalize_skip( + self, + task: dict[str, Any], + *, + task_run_id: str, + thread_id: str, + now: datetime, + error: str, + ) -> dict[str, Any]: + next_at = next_run_at( + task["schedule_type"], + task["schedule_spec"], + task["timezone"], + now=now, + ) + await self._task_run_repo.update_status( + task_run_id, + status="skipped", + error=error, + started_at=now, + finished_at=now, + ) + await self._task_repo.update_after_launch( + task["id"], + status=self._task_status_for_skip(task), + next_run_at=next_at, + last_run_at=task.get("last_run_at"), + last_run_id=task.get("last_run_id"), + last_thread_id=task.get("last_thread_id"), + last_error=error if task["schedule_type"] == "once" else None, + increment_run_count=False, + ) + return { + "outcome": "skipped", + "task_run_id": task_run_id, + "run_id": None, + "thread_id": thread_id, + "error": error, + } + + async def handle_run_completion(self, record: RunRecord) -> None: + metadata = record.metadata or {} + task_id = metadata.get("scheduled_task_id") + task_run_id = metadata.get("scheduled_task_run_id") + user_id = record.user_id + if not isinstance(task_id, str) or not isinstance(task_run_id, str) or not user_id: + return + + terminal_status: Literal["success", "failed", "interrupted"] | None + if record.status.value == "success": + terminal_status = "success" + error = None + elif record.status.value == "interrupted": + # Distinct from "failed": an interrupt (user cancel, same-thread + # takeover) carries no error and is not an execution failure. + terminal_status = "interrupted" + error = record.error or "run was interrupted before completion" + elif record.status.value in {"error", "timeout"}: + terminal_status = "failed" + error = record.error + else: + terminal_status = None + error = record.error + if terminal_status is None: + return + + await self._task_run_repo.update_status( + task_run_id, + status=terminal_status, + run_id=record.run_id, + error=error, + finished_at=datetime.now(UTC), + ) + + task = await self._task_repo.get(task_id, user_id=user_id) + if task is None: + return + + updates: dict[str, Any] = {"last_error": error} + if task["schedule_type"] == "once": + # The single occurrence is consumed either way (the run did launch, + # so re-arming risks duplicate side effects), but an interrupt ends + # as "cancelled", not "failed". + if terminal_status == "success": + updates["status"] = "completed" + elif terminal_status == "interrupted": + updates["status"] = "cancelled" + else: + updates["status"] = "failed" + await self._task_repo.update(task_id, user_id=user_id, updates=updates) + + async def start(self) -> None: + if self._task is not None: + return + restart_error = "interrupted: gateway restarted before the run reached a terminal state" + try: + stale = await self._task_run_repo.mark_stale_active_runs(error=restart_error) + if stale: + logger.warning("Marked %d stale scheduled task run(s) as interrupted after restart", stale) + except Exception: + logger.exception("Failed to sweep stale scheduled task runs at startup") + try: + # The run rows above are only half the story: a launched `once` + # task is parked in "running" until the (now dead) completion hook + # would have finalized it, so reconcile the parent rows too. + stuck = await self._task_repo.cancel_stuck_once_tasks(error=restart_error) + if stuck: + logger.warning("Cancelled %d stuck once task(s) after restart", stuck) + except Exception: + logger.exception("Failed to reconcile stuck once tasks at startup") + 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(): + try: + await self.run_once(now=datetime.now(UTC)) + except Exception: + # A transient DB error (e.g. SQLite "database is locked") must + # not kill the poller task for the rest of the process life. + logger.exception("Scheduled task poll failed; retrying next interval") + try: + await asyncio.wait_for( + self._stop.wait(), + timeout=self._poll_interval_seconds, + ) + except TimeoutError: + continue diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index b89ea6920..b7d4a5c5e 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -220,6 +220,30 @@ tool_groups: - name: bash # Shell command execution ``` +### Scheduler + +The scheduled-task MVP adds a scheduler section to `config.yaml`: + +```yaml +scheduler: + enabled: false + poll_interval_seconds: 5 + lease_seconds: 120 + max_concurrent_runs: 3 + min_once_delay_seconds: 60 +``` + +Notes: + +- `enabled: false` keeps background polling off by default. +- `max_concurrent_runs` is a global cap on active scheduled runs (queued/running run rows); each poll cycle claims only into the remaining budget, so long runs accumulating across cycles cannot exceed it. +- All scheduler fields are restart-required; edits need a Gateway restart. +- Multi-worker deployments (`GATEWAY_WORKERS > 1`) must use the Postgres database backend. SQLite silently ignores row-level locks, so multiple workers can double-fire the same task. +- The MVP supports thread reuse and fresh-thread-per-run execution modes. +- The MVP supports only `once` and `cron`. +- Manual trigger uses the same scheduled-task resource and run lifecycle. +- Scheduled task definitions and task-run history are persisted in the application database. + ### Tools Configure specific tools available to the agent: diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index f777f2412..4abb1a3ca 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -50,6 +50,7 @@ from deerflow.tracing import build_tracing_callbacks logger = logging.getLogger(__name__) _BOOTSTRAP_SKILL_NAMES = {"bootstrap"} +_NON_INTERACTIVE_DISABLED_TOOL_NAMES = frozenset({"ask_clarification"}) def _get_runtime_config(config: RunnableConfig) -> dict: @@ -449,6 +450,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): subagent_enabled = cfg.get("subagent_enabled", False) max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3) is_bootstrap = cfg.get("is_bootstrap", False) + non_interactive = bool(cfg.get("non_interactive", False)) agent_name = validate_agent_name(cfg.get("agent_name")) agent_config = load_agent_config(agent_name) if not is_bootstrap else None @@ -516,6 +518,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): # remains deterministic before the custom agent's own config exists. raw_tools = get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=resolved_app_config) + [setup_agent] filtered = filter_tools_by_skill_allowed_tools(raw_tools, skills_for_tool_policy, always_allowed_tool_names=SKILL_LOADING_TOOL_NAMES) + if non_interactive: + filtered = [tool for tool in filtered if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES] final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled) return create_agent( model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False), @@ -545,6 +549,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): # Default lead agent (unchanged behavior) raw_tools = get_available_tools(model_name=model_name, groups=agent_config.tool_groups if agent_config else None, subagent_enabled=subagent_enabled, app_config=resolved_app_config) filtered = filter_tools_by_skill_allowed_tools(raw_tools + extra_tools, skills_for_tool_policy, always_allowed_tool_names=SKILL_LOADING_TOOL_NAMES) + if non_interactive: + filtered = [tool for tool in filtered if tool.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES] final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled) return create_agent( model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False), diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index e7ebfeb5d..217d1df2f 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -27,6 +27,7 @@ from deerflow.config.run_events_config import RunEventsConfig from deerflow.config.runtime_paths import existing_project_file from deerflow.config.safety_finish_reason_config import SafetyFinishReasonConfig from deerflow.config.sandbox_config import SandboxConfig +from deerflow.config.scheduler_config import SchedulerConfig from deerflow.config.skill_evolution_config import SkillEvolutionConfig from deerflow.config.skills_config import SkillsConfig from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict @@ -191,6 +192,13 @@ class AppConfig(BaseModel): field_doc="Run-event store backend (memory for dev, db for production queries, jsonl for lightweight single-node persistence).", ), ) + scheduler: SchedulerConfig = Field( + default_factory=SchedulerConfig, + description=format_field_description( + "scheduler", + field_doc="Scheduled task runtime configuration (background poller for one-time and cron agent runs).", + ), + ) checkpointer: CheckpointerConfig | None = Field( default=None, description=format_field_description( diff --git a/backend/packages/harness/deerflow/config/reload_boundary.py b/backend/packages/harness/deerflow/config/reload_boundary.py index f5d362495..3080a1c2a 100644 --- a/backend/packages/harness/deerflow/config/reload_boundary.py +++ b/backend/packages/harness/deerflow/config/reload_boundary.py @@ -64,6 +64,10 @@ STARTUP_ONLY_FIELDS: dict[str, str] = { "channel_connections": ( "start_channel_service() wires the connection repository and channel workers once at startup, and the channel-connections router caches the merged provider config on app.state; channel_connections.* edits need a restart." ), + "scheduler": ( + "ScheduledTaskService is constructed and started once during Gateway lifespan startup; enabled, poll_interval_seconds, lease_seconds, " + "and max_concurrent_runs are captured into the service instance and the background poller task is not rebuilt on config.yaml edits." + ), } diff --git a/backend/packages/harness/deerflow/config/scheduler_config.py b/backend/packages/harness/deerflow/config/scheduler_config.py new file mode 100644 index 000000000..dfc0bb207 --- /dev/null +++ b/backend/packages/harness/deerflow/config/scheduler_config.py @@ -0,0 +1,9 @@ +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) diff --git a/backend/packages/harness/deerflow/persistence/migrations/versions/0003_scheduled_tasks.py b/backend/packages/harness/deerflow/persistence/migrations/versions/0003_scheduled_tasks.py new file mode 100644 index 000000000..46a774d62 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/migrations/versions/0003_scheduled_tasks.py @@ -0,0 +1,94 @@ +"""scheduled tasks. + +Revision ID: 0003_scheduled_tasks +Revises: 0002_runs_token_usage +Create Date: 2026-07-01 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0003_scheduled_tasks" +down_revision: str | Sequence[str] | None = "0002_runs_token_usage" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if inspector.has_table("scheduled_tasks"): + # Idempotent: a DB whose full-metadata create_all already provisioned + # both scheduled-task tables (e.g. legacy test seeds) must not have them + # re-created here. + return + op.create_table( + "scheduled_tasks", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.String(length=64), nullable=False), + sa.Column("thread_id", sa.String(length=64), nullable=True), + sa.Column("context_mode", sa.String(length=32), nullable=False), + sa.Column("assistant_id", sa.String(length=128), nullable=True), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("prompt", sa.Text(), nullable=False), + sa.Column("schedule_type", sa.String(length=16), nullable=False), + sa.Column("schedule_spec", sa.JSON(), nullable=False), + sa.Column("timezone", sa.String(length=64), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("overlap_policy", sa.String(length=16), nullable=False), + sa.Column("next_run_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_run_id", sa.String(length=64), nullable=True), + sa.Column("last_thread_id", sa.String(length=64), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("lease_owner", sa.String(length=128), nullable=True), + sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("run_count", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("scheduled_tasks", schema=None) as batch_op: + batch_op.create_index("ix_scheduled_tasks_user_id", ["user_id"], unique=False) + batch_op.create_index("ix_scheduled_tasks_thread_id", ["thread_id"], unique=False) + batch_op.create_index("ix_scheduled_tasks_status", ["status"], unique=False) + batch_op.create_index("ix_scheduled_tasks_next_run_at", ["next_run_at"], unique=False) + + op.create_table( + "scheduled_task_runs", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("task_id", sa.String(length=64), nullable=False), + sa.Column("thread_id", sa.String(length=64), nullable=False), + sa.Column("run_id", sa.String(length=64), nullable=True), + sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=False), + sa.Column("trigger", sa.String(length=16), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("error", sa.Text(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("scheduled_task_runs", schema=None) as batch_op: + batch_op.create_index("ix_scheduled_task_runs_task_id", ["task_id"], unique=False) + batch_op.create_index("ix_scheduled_task_runs_thread_id", ["thread_id"], unique=False) + batch_op.create_index("ix_scheduled_task_runs_status", ["status"], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table("scheduled_task_runs", schema=None) as batch_op: + batch_op.drop_index("ix_scheduled_task_runs_status") + batch_op.drop_index("ix_scheduled_task_runs_thread_id") + batch_op.drop_index("ix_scheduled_task_runs_task_id") + op.drop_table("scheduled_task_runs") + + with op.batch_alter_table("scheduled_tasks", schema=None) as batch_op: + batch_op.drop_index("ix_scheduled_tasks_next_run_at") + batch_op.drop_index("ix_scheduled_tasks_status") + batch_op.drop_index("ix_scheduled_tasks_thread_id") + batch_op.drop_index("ix_scheduled_tasks_user_id") + op.drop_table("scheduled_tasks") diff --git a/backend/packages/harness/deerflow/persistence/models/__init__.py b/backend/packages/harness/deerflow/persistence/models/__init__.py index 40445f373..986ad5db1 100644 --- a/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -23,6 +23,8 @@ from deerflow.persistence.channel_connections.model import ( from deerflow.persistence.feedback.model import FeedbackRow from deerflow.persistence.models.run_event import RunEventRow from deerflow.persistence.run.model import RunRow +from deerflow.persistence.scheduled_task_runs.model import ScheduledTaskRunRow +from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow from deerflow.persistence.thread_meta.model import ThreadMetaRow from deerflow.persistence.user.model import UserRow @@ -34,6 +36,8 @@ __all__ = [ "FeedbackRow", "RunEventRow", "RunRow", + "ScheduledTaskRow", + "ScheduledTaskRunRow", "ThreadMetaRow", "UserRow", ] diff --git a/backend/packages/harness/deerflow/persistence/scheduled_task_runs/__init__.py b/backend/packages/harness/deerflow/persistence/scheduled_task_runs/__init__.py new file mode 100644 index 000000000..3f9edfbf0 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/scheduled_task_runs/__init__.py @@ -0,0 +1,4 @@ +from .model import ScheduledTaskRunRow +from .sql import ScheduledTaskRunRepository + +__all__ = ["ScheduledTaskRunRow", "ScheduledTaskRunRepository"] diff --git a/backend/packages/harness/deerflow/persistence/scheduled_task_runs/model.py b/backend/packages/harness/deerflow/persistence/scheduled_task_runs/model.py new file mode 100644 index 000000000..dd165521e --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/scheduled_task_runs/model.py @@ -0,0 +1,24 @@ +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)) diff --git a/backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py b/backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py new file mode 100644 index 000000000..4f32e90c2 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/scheduled_task_runs/sql.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import func, 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 + +TERMINAL_RUN_STATUSES: frozenset[str] = frozenset({"success", "failed", "skipped", "interrupted"}) +ACTIVE_RUN_STATUSES: tuple[str, ...] = ("queued", "running") + + +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, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]: + stmt = ( + select(ScheduledTaskRunRow) + .where(ScheduledTaskRunRow.task_id == task_id) + .order_by( + ScheduledTaskRunRow.created_at.desc(), + ScheduledTaskRunRow.id.desc(), + ) + .limit(limit) + .offset(offset) + ) + async with self._sf() as session: + result = await session.execute(stmt) + return [self._row_to_dict(row) for row in result.scalars()] + + async def count_active_runs(self) -> int: + """Global count of queued/running rows, used to bound cross-task concurrency.""" + stmt = select(func.count()).select_from(ScheduledTaskRunRow).where(ScheduledTaskRunRow.status.in_(ACTIVE_RUN_STATUSES)) + async with self._sf() as session: + result = await session.execute(stmt) + return int(result.scalar() or 0) + + 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, + protect_terminal: bool = False, + ) -> None: + async with self._sf() as session: + row = await session.get(ScheduledTaskRunRow, run_record_id) + if row is None: + return + if protect_terminal and row.status in TERMINAL_RUN_STATUSES: + # The launch-path "running" write lost the race against the + # completion hook; keep the terminal status/error and only + # backfill bookkeeping the completion write could not know. + if row.run_id is None and run_id is not None: + row.run_id = run_id + if row.started_at is None and started_at is not None: + row.started_at = started_at + await session.commit() + 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() + + async def has_active_runs(self, task_id: str) -> bool: + stmt = ( + select(ScheduledTaskRunRow.id) + .where( + ScheduledTaskRunRow.task_id == task_id, + ScheduledTaskRunRow.status.in_(ACTIVE_RUN_STATUSES), + ) + .limit(1) + ) + async with self._sf() as session: + result = await session.execute(stmt) + return result.scalars().first() is not None + + async def mark_stale_active_runs(self, *, error: str) -> int: + """Fail-fast bookkeeping for runs orphaned by a process crash. + + Agent runs execute in-process, so any ``queued``/``running`` row found + at scheduler startup belongs to a run whose process is gone. Only valid + under the MVP's single-scheduler-instance assumption. + """ + stmt = select(ScheduledTaskRunRow).where(ScheduledTaskRunRow.status.in_(ACTIVE_RUN_STATUSES)) + now = datetime.now(UTC) + async with self._sf() as session: + result = await session.execute(stmt) + rows = list(result.scalars()) + for row in rows: + row.status = "interrupted" + row.error = error + row.finished_at = now + await session.commit() + return len(rows) diff --git a/backend/packages/harness/deerflow/persistence/scheduled_tasks/__init__.py b/backend/packages/harness/deerflow/persistence/scheduled_tasks/__init__.py new file mode 100644 index 000000000..18b6ae7fe --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/scheduled_tasks/__init__.py @@ -0,0 +1,4 @@ +from .model import ScheduledTaskRow +from .sql import ScheduledTaskRepository + +__all__ = ["ScheduledTaskRow", "ScheduledTaskRepository"] diff --git a/backend/packages/harness/deerflow/persistence/scheduled_tasks/model.py b/backend/packages/harness/deerflow/persistence/scheduled_tasks/model.py new file mode 100644 index 000000000..1fad973fc --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/scheduled_tasks/model.py @@ -0,0 +1,39 @@ +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 | None] = mapped_column(String(64), index=True, nullable=True) + context_mode: Mapped[str] = mapped_column(String(32), default="fresh_thread_per_run") + 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") + 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_thread_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) + 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), + ) diff --git a/backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py b/backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py new file mode 100644 index 000000000..7f05f12bd --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/scheduled_tasks/sql.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow +from deerflow.utils.time import coerce_iso + +TERMINAL_TASK_STATUSES: frozenset[str] = frozenset({"completed", "failed", "cancelled"}) + + +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 | None, + context_mode: 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]: + now = datetime.now(UTC) + row = ScheduledTaskRow( + id=task_id, + user_id=user_id, + thread_id=thread_id, + context_mode=context_mode, + 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=now, + updated_at=now, + ) + 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()] + + async def update( + self, + task_id: str, + *, + user_id: str, + updates: dict[str, Any], + ) -> 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 + for key, value in updates.items(): + if hasattr(row, key): + setattr(row, key, value) + row.updated_at = datetime.now(UTC) + await session.commit() + await session.refresh(row) + return self._row_to_dict(row) + + async def delete(self, task_id: str, *, user_id: str) -> bool: + async with self._sf() as session: + row = await session.get(ScheduledTaskRow, task_id) + if row is None or row.user_id != user_id: + return False + await session.delete(row) + await session.commit() + return True + + 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.next_run_at.is_not(None), + ScheduledTaskRow.next_run_at <= now, + or_( + and_( + ScheduledTaskRow.status == "enabled", + or_( + ScheduledTaskRow.lease_expires_at.is_(None), + ScheduledTaskRow.lease_expires_at < now, + ), + ), + # A task stuck in "running" with an expired lease means the + # claiming process died between claim and dispatch; it must + # stay reclaimable or the task is dead forever. + and_( + ScheduledTaskRow.status == "running", + ScheduledTaskRow.lease_expires_at.is_not(None), + ScheduledTaskRow.lease_expires_at < now, + ), + ), + ) + .order_by(ScheduledTaskRow.next_run_at.asc(), ScheduledTaskRow.id.asc()) + .limit(limit) + .with_for_update(skip_locked=True) + ) + 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 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_thread_id: str | None, + last_error: str | None, + increment_run_count: bool, + protect_terminal: bool = False, + ) -> None: + async with self._sf() as session: + row = await session.get(ScheduledTaskRow, task_id) + if row is None: + return + if protect_terminal and row.status in TERMINAL_TASK_STATUSES: + # A fast-failing run can reach handle_run_completion (which + # finalizes a `once` task) before this launch-path write + # commits; keep the hook's status/error and only record the + # launch bookkeeping. + pass + else: + row.status = status + row.last_error = last_error + row.next_run_at = next_run_at + row.last_run_at = last_run_at + row.last_run_id = last_run_id + row.last_thread_id = last_thread_id + 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() + + async def list_by_user_and_thread(self, user_id: str, thread_id: str) -> list[dict[str, Any]]: + stmt = ( + select(ScheduledTaskRow) + .where( + ScheduledTaskRow.user_id == user_id, + ScheduledTaskRow.thread_id == thread_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()] + + async def cancel_stuck_once_tasks(self, *, error: str) -> int: + """Reconcile ``once`` tasks orphaned in ``running`` by a process crash. + + A launched ``once`` task stays ``running`` until the in-process + completion hook moves it to a terminal status; its lease was cleared at + launch, so the claim query's expired-lease reclaim branch never sees + it. After a crash the hook is gone and the task would be stuck forever. + Tasks still holding a lease are left alone — they were claimed but not + launched, and expired-lease reclaim recovers them safely. + """ + stmt = select(ScheduledTaskRow).where( + ScheduledTaskRow.schedule_type == "once", + ScheduledTaskRow.status == "running", + ScheduledTaskRow.lease_expires_at.is_(None), + ) + async with self._sf() as session: + result = await session.execute(stmt) + rows = list(result.scalars()) + now = datetime.now(UTC) + for row in rows: + row.status = "cancelled" + row.last_error = error + row.updated_at = now + await session.commit() + return len(rows) diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py index 7f09482c1..a62035ac0 100644 --- a/backend/packages/harness/deerflow/runtime/runs/worker.py +++ b/backend/packages/harness/deerflow/runtime/runs/worker.py @@ -106,6 +106,7 @@ class RunContext: run_events_config: Any | None = field(default=None) thread_store: Any | None = field(default=None) app_config: AppConfig | None = field(default=None) + on_run_completed: Any | None = field(default=None) def _install_runtime_context(config: dict, runtime_context: dict[str, Any]) -> None: @@ -588,6 +589,11 @@ async def run_agent( except Exception: logger.debug("Failed to update thread_meta status for %s (non-fatal)", thread_id) + if ctx.on_run_completed is not None: + try: + await ctx.on_run_completed(record) + except Exception: + logger.warning("Run completion hook failed for %s (non-fatal)", run_id, exc_info=True) if record.finalizing: await run_manager.set_finalizing(run_id, False) diff --git a/backend/packages/harness/deerflow/scheduler/__init__.py b/backend/packages/harness/deerflow/scheduler/__init__.py new file mode 100644 index 000000000..b2262f3e8 --- /dev/null +++ b/backend/packages/harness/deerflow/scheduler/__init__.py @@ -0,0 +1,3 @@ +from .schedules import next_run_at, normalize_cron_expression, validate_timezone + +__all__ = ["next_run_at", "normalize_cron_expression", "validate_timezone"] diff --git a/backend/packages/harness/deerflow/scheduler/schedules.py b/backend/packages/harness/deerflow/scheduler/schedules.py new file mode 100644 index 000000000..8509c3523 --- /dev/null +++ b/backend/packages/harness/deerflow/scheduler/schedules.py @@ -0,0 +1,55 @@ +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: + # A naive run_at means "wall-clock time in the task's declared + # timezone", matching how cron schedules interpret it. + run_at = run_at.replace(tzinfo=ZoneInfo(timezone_name)) + 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}") diff --git a/backend/packages/harness/pyproject.toml b/backend/packages/harness/pyproject.toml index bf2ece44e..8b6d6e6e6 100644 --- a/backend/packages/harness/pyproject.toml +++ b/backend/packages/harness/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.12" dependencies = [ "agent-client-protocol>=0.4.0", "agent-sandbox>=0.0.19", + "croniter>=6.0.0", "dotenv>=0.9.9", "exa-py>=1.0.0", "httpx>=0.28.0", diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py index 120c4bdfc..523b58c79 100644 --- a/backend/tests/test_gateway_services.py +++ b/backend/tests/test_gateway_services.py @@ -416,6 +416,29 @@ def test_build_run_config_dual_write_matches_merge_run_context_overrides_shape() assert via_assistant_id["context"]["agent_name"] == via_context["context"]["agent_name"] +def test_non_interactive_context_override_is_internal_only(): + """Client-supplied ``non_interactive`` must be dropped: it strips the + ``ask_clarification`` tool, so only the internal scheduler path may set it.""" + from app.gateway.services import build_run_config, merge_run_context_overrides + + config = build_run_config("thread-1", None, None) + merge_run_context_overrides(config, {"non_interactive": True}) + + assert "non_interactive" not in config["configurable"] + assert "non_interactive" not in config["context"] + + +def test_non_interactive_context_override_honored_for_internal_caller(): + from app.gateway.services import build_run_config, merge_run_context_overrides + + config = build_run_config("thread-1", None, None) + merge_run_context_overrides(config, {"non_interactive": True, "model_name": "gpt"}, internal=True) + + assert config["configurable"]["non_interactive"] is True + assert config["context"]["non_interactive"] is True + assert config["configurable"]["model_name"] == "gpt" + + # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- @@ -851,6 +874,41 @@ def test_start_run_uses_internal_owner_header_for_persistence(_stub_app_config): assert task_context["user_id"] == "owner-1" +def test_launch_scheduled_thread_run_marks_context_non_interactive(_stub_app_config): + import asyncio + from types import SimpleNamespace + from unittest.mock import patch + + from app.gateway.services import launch_scheduled_thread_run + + async def _scenario(): + captured: dict[str, object] = {} + + async def fake_start_run(body, thread_id, request): + captured["thread_id"] = thread_id + captured["context"] = body.context + captured["metadata"] = body.metadata + return SimpleNamespace(run_id="run-1", thread_id=thread_id) + + with patch("app.gateway.services.start_run", side_effect=fake_start_run): + result = await launch_scheduled_thread_run( + thread_id="thread-scheduled", + assistant_id="lead_agent", + prompt="Run in background", + app=SimpleNamespace(state=SimpleNamespace()), + owner_user_id="user-1", + metadata={"scheduled_task_id": "task-1"}, + ) + return captured, result + + captured, result = asyncio.run(_scenario()) + + assert captured["thread_id"] == "thread-scheduled" + assert captured["context"] == {"non_interactive": True, "user_id": "user-1"} + assert captured["metadata"] == {"scheduled_task_id": "task-1"} + assert result == {"run_id": "run-1", "thread_id": "thread-scheduled"} + + # --------------------------------------------------------------------------- # build_run_config — context / configurable precedence (LangGraph >= 0.6.0) # --------------------------------------------------------------------------- @@ -965,3 +1023,19 @@ def test_build_run_config_no_request_config(): config = build_run_config("thread-abc", None, None) assert config["configurable"] == {"thread_id": "thread-abc"} assert "context" not in config + + +def test_strip_internal_context_keys_scrubs_config_smuggled_non_interactive(): + """A non-internal client must not force ``non_interactive`` via the free-form + ``body.config`` either — ``build_run_config`` copies ``config.context`` and + ``config.configurable`` verbatim, so the assembled config gets scrubbed.""" + from app.gateway.services import build_run_config, strip_internal_context_keys + + via_context = build_run_config("thread-1", {"context": {"non_interactive": True, "model_name": "gpt"}}, None) + strip_internal_context_keys(via_context) + assert "non_interactive" not in via_context["context"] + assert via_context["context"]["model_name"] == "gpt" + + via_configurable = build_run_config("thread-1", {"configurable": {"non_interactive": True}}, None) + strip_internal_context_keys(via_configurable) + assert "non_interactive" not in via_configurable["configurable"] diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index 2ced54408..c8e0fdc4f 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -288,6 +288,40 @@ def test_make_lead_agent_reads_runtime_options_from_context(monkeypatch): assert result["model"] is not None +def test_make_lead_agent_filters_clarification_tool_for_non_interactive_runs(monkeypatch): + app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)]) + + import deerflow.tools as tools_module + + def _named_tool(name: str): + tool = MagicMock() + tool.name = name + return tool + + monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config) + monkeypatch.setattr( + tools_module, + "get_available_tools", + lambda **kwargs: [_named_tool("ask_clarification"), _named_tool("bash")], + ) + monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda config, model_name, agent_name=None, **kwargs: []) + monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: object()) + monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs) + + result = lead_agent_module.make_lead_agent( + { + "context": { + "model_name": "safe-model", + "thinking_enabled": False, + "subagent_enabled": False, + "non_interactive": True, + } + } + ) + + assert [tool.name for tool in result["tools"]] == ["bash"] + + def test_make_lead_agent_rejects_invalid_bootstrap_agent_name(monkeypatch): app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)]) diff --git a/backend/tests/test_persistence_bootstrap.py b/backend/tests/test_persistence_bootstrap.py index 8d3a1a67e..439702510 100644 --- a/backend/tests/test_persistence_bootstrap.py +++ b/backend/tests/test_persistence_bootstrap.py @@ -47,7 +47,7 @@ from deerflow.persistence.migrations._helpers import _normalize_default asyncio_test = pytest.mark.asyncio -HEAD = "0002_runs_token_usage" +HEAD = "0003_scheduled_tasks" BASELINE = "0001_baseline" diff --git a/backend/tests/test_persistence_bootstrap_concurrency.py b/backend/tests/test_persistence_bootstrap_concurrency.py index e3c0a3b64..23a00e06c 100644 --- a/backend/tests/test_persistence_bootstrap_concurrency.py +++ b/backend/tests/test_persistence_bootstrap_concurrency.py @@ -28,7 +28,7 @@ from deerflow.persistence.bootstrap import bootstrap_schema pytestmark = pytest.mark.asyncio -HEAD = "0002_runs_token_usage" +HEAD = "0003_scheduled_tasks" def _url(tmp_path: Path) -> str: diff --git a/backend/tests/test_persistence_bootstrap_regression.py b/backend/tests/test_persistence_bootstrap_regression.py index 546311b13..a9d8c47c2 100644 --- a/backend/tests/test_persistence_bootstrap_regression.py +++ b/backend/tests/test_persistence_bootstrap_regression.py @@ -76,7 +76,7 @@ async def test_legacy_database_recovers_token_usage_column(tmp_path: Path) -> No cols = {row[1] for row in raw.execute("PRAGMA table_info(runs)").fetchall()} assert "token_usage_by_model" in cols version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone() - assert version_row[0] == "0002_runs_token_usage" + assert version_row[0] == "0003_scheduled_tasks" # And the read path that originally 500'd must now succeed. sf = get_session_factory() @@ -116,6 +116,6 @@ async def test_legacy_database_with_manual_alter_still_bootstraps(tmp_path: Path # No duplicate column -- list, not set, to catch dupes. assert cols.count("token_usage_by_model") == 1 version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone() - assert version_row[0] == "0002_runs_token_usage" + assert version_row[0] == "0003_scheduled_tasks" finally: await close_engine() diff --git a/backend/tests/test_scheduled_task_claims.py b/backend/tests/test_scheduled_task_claims.py new file mode 100644 index 000000000..83db44628 --- /dev/null +++ b/backend/tests/test_scheduled_task_claims.py @@ -0,0 +1,152 @@ +from datetime import UTC, datetime, timedelta + +import pytest + +from deerflow.config.database_config import DatabaseConfig +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_rows(tmp_path): + await init_engine_from_config(DatabaseConfig(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", + context_mode="reuse_thread", + 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", + context_mode="reuse_thread", + 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() + + +@pytest.mark.asyncio +async def test_claim_reclaims_task_stuck_in_running_with_expired_lease(tmp_path): + """A task whose claiming process died mid-dispatch must stay reclaimable. + + Regression for the lease dead-end bug: claim flips status to ``running``, + and the old claim query only selected ``status == 'enabled'``, so a crash + between claim and dispatch left the task permanently un-triggerable. + """ + await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path))) + sf = get_session_factory() + assert sf is not None + repo = ScheduledTaskRepository(sf) + + now = datetime.now(UTC) + due = now - timedelta(minutes=5) + + await repo.create( + task_id="stuck-1", + user_id="user-1", + thread_id="thread-1", + context_mode="reuse_thread", + assistant_id="lead_agent", + title="Stuck", + prompt="Prompt", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=due, + ) + + first_claim = await repo.claim_due_tasks( + now=now, + lease_owner="dead-worker", + lease_seconds=60, + limit=10, + ) + assert first_claim[0]["id"] == "stuck-1" + assert first_claim[0]["status"] == "running" + + # Simulate the claiming process dying: lease expires, status stays "running". + expired_now = now + timedelta(seconds=120) + reclaimed = await repo.claim_due_tasks( + now=expired_now, + lease_owner="new-worker", + lease_seconds=60, + limit=10, + ) + assert [task["id"] for task in reclaimed] == ["stuck-1"] + assert reclaimed[0]["lease_owner"] == "new-worker" + + await close_engine() + + +@pytest.mark.asyncio +async def test_claim_skips_task_with_active_lease(tmp_path): + """A task whose lease has not expired must not be reclaimed.""" + await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path))) + sf = get_session_factory() + assert sf is not None + repo = ScheduledTaskRepository(sf) + + now = datetime.now(UTC) + due = now - timedelta(minutes=5) + + await repo.create( + task_id="active-1", + user_id="user-1", + thread_id="thread-1", + context_mode="reuse_thread", + assistant_id="lead_agent", + title="Active", + prompt="Prompt", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=due, + ) + + await repo.claim_due_tasks( + now=now, + lease_owner="worker-1", + lease_seconds=300, + limit=10, + ) + + # Lease still valid — second claim within the same process must not re-grab it. + reclaimed = await repo.claim_due_tasks( + now=now + timedelta(seconds=10), + lease_owner="worker-2", + lease_seconds=300, + limit=10, + ) + assert reclaimed == [] + + await close_engine() diff --git a/backend/tests/test_scheduled_task_lifecycle.py b/backend/tests/test_scheduled_task_lifecycle.py new file mode 100644 index 000000000..06583e0dd --- /dev/null +++ b/backend/tests/test_scheduled_task_lifecycle.py @@ -0,0 +1,7 @@ +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 diff --git a/backend/tests/test_scheduled_task_models.py b/backend/tests/test_scheduled_task_models.py new file mode 100644 index 000000000..c4f7b1f4b --- /dev/null +++ b/backend/tests/test_scheduled_task_models.py @@ -0,0 +1,19 @@ +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" diff --git a/backend/tests/test_scheduled_task_repository.py b/backend/tests/test_scheduled_task_repository.py new file mode 100644 index 000000000..77b5a0710 --- /dev/null +++ b/backend/tests/test_scheduled_task_repository.py @@ -0,0 +1,318 @@ +from datetime import UTC, datetime + +import pytest + +from deerflow.config.database_config import DatabaseConfig +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(DatabaseConfig(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", + context_mode="reuse_thread", + 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(DatabaseConfig(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() + + +@pytest.mark.asyncio +async def test_mark_stale_active_runs_fails_orphaned_runs(tmp_path): + """Runs stuck in queued/running after a process crash are swept to interrupted.""" + await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path))) + sf = get_session_factory() + assert sf is not None + + repo = ScheduledTaskRunRepository(sf) + await repo.create( + run_record_id="task-run-queued", + task_id="task-1", + thread_id="thread-1", + scheduled_for=datetime(2026, 7, 2, 1, 0, tzinfo=UTC), + trigger="scheduled", + status="queued", + ) + await repo.create( + run_record_id="task-run-running", + task_id="task-1", + thread_id="thread-1", + scheduled_for=datetime(2026, 7, 2, 1, 0, tzinfo=UTC), + trigger="scheduled", + status="running", + ) + await repo.create( + run_record_id="task-run-success", + task_id="task-1", + thread_id="thread-1", + scheduled_for=datetime(2026, 7, 2, 1, 0, tzinfo=UTC), + trigger="scheduled", + status="success", + ) + + swept = await repo.mark_stale_active_runs(error="interrupted: gateway restarted") + assert swept == 2 + + history = await repo.list_by_task("task-1") + by_id = {entry["id"]: entry for entry in history} + assert by_id["task-run-queued"]["status"] == "interrupted" + assert by_id["task-run-running"]["status"] == "interrupted" + assert by_id["task-run-success"]["status"] == "success" + + await close_engine() + + +@pytest.mark.asyncio +async def test_update_status_protect_terminal_keeps_completion_result(tmp_path): + """The launch-path "running" write must not clobber a terminal status + already committed by the completion hook (launch/completion race).""" + await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path))) + sf = get_session_factory() + assert sf is not None + + repo = ScheduledTaskRunRepository(sf) + await repo.create( + run_record_id="task-run-race", + task_id="task-1", + thread_id="thread-1", + scheduled_for=datetime(2026, 7, 2, 1, 0, tzinfo=UTC), + trigger="scheduled", + status="queued", + ) + # Completion hook wins the race and commits the terminal state first. + await repo.update_status("task-run-race", status="failed", run_id="run-1", error="boom", finished_at=datetime(2026, 7, 2, 1, 1, tzinfo=UTC)) + # Late launch-path write: keeps terminal status/error, backfills started_at. + await repo.update_status("task-run-race", status="running", run_id="run-1", started_at=datetime(2026, 7, 2, 1, 0, tzinfo=UTC), protect_terminal=True) + + entry = (await repo.list_by_task("task-1"))[0] + assert entry["status"] == "failed" + assert entry["error"] == "boom" + assert entry["started_at"] is not None + + await close_engine() + + +@pytest.mark.asyncio +async def test_has_active_runs_sees_only_queued_and_running(tmp_path): + await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path))) + sf = get_session_factory() + assert sf is not None + + repo = ScheduledTaskRunRepository(sf) + assert await repo.has_active_runs("task-1") is False + await repo.create( + run_record_id="task-run-active", + task_id="task-1", + thread_id="thread-1", + scheduled_for=datetime(2026, 7, 2, 1, 0, tzinfo=UTC), + trigger="scheduled", + status="running", + ) + assert await repo.has_active_runs("task-1") is True + await repo.update_status("task-run-active", status="success", run_id="run-1") + assert await repo.has_active_runs("task-1") is False + + await close_engine() + + +@pytest.mark.asyncio +async def test_cancel_stuck_once_tasks_reconciles_orphaned_running(tmp_path): + """Launched (lease cleared) once tasks stuck in running are cancelled at + startup; leased ones are left for expired-lease reclaim.""" + await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path))) + sf = get_session_factory() + assert sf is not None + + repo = ScheduledTaskRepository(sf) + for task_id, schedule_type, status in ( + ("task-once-stuck", "once", "running"), + ("task-once-done", "once", "completed"), + ("task-cron-running", "cron", "running"), + ): + await repo.create( + task_id=task_id, + user_id="user-1", + thread_id=None, + context_mode="fresh_thread_per_run", + assistant_id="lead_agent", + title=task_id, + prompt="p", + schedule_type=schedule_type, + schedule_spec={"run_at": "2026-07-02T01:00:00+00:00"} if schedule_type == "once" else {"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=None, + ) + await repo.update(task_id, user_id="user-1", updates={"status": status}) + # A claimed-but-not-launched once task still holds its lease: keep it. + await repo.create( + task_id="task-once-leased", + user_id="user-1", + thread_id=None, + context_mode="fresh_thread_per_run", + assistant_id="lead_agent", + title="task-once-leased", + prompt="p", + schedule_type="once", + schedule_spec={"run_at": "2026-07-02T01:00:00+00:00"}, + timezone="UTC", + next_run_at=datetime(2026, 7, 2, 1, 0, tzinfo=UTC), + ) + await repo.update("task-once-leased", user_id="user-1", updates={"status": "running", "lease_expires_at": datetime(2026, 7, 2, 1, 2, tzinfo=UTC)}) + + cancelled = await repo.cancel_stuck_once_tasks(error="interrupted: gateway restarted") + assert cancelled == 1 + + by_id = {t["id"]: t for t in await repo.list_by_user("user-1")} + assert by_id["task-once-stuck"]["status"] == "cancelled" + assert by_id["task-once-stuck"]["last_error"] == "interrupted: gateway restarted" + assert by_id["task-once-done"]["status"] == "completed" + assert by_id["task-cron-running"]["status"] == "running" + assert by_id["task-once-leased"]["status"] == "running" + + await close_engine() + + +@pytest.mark.asyncio +async def test_update_after_launch_protect_terminal_keeps_hook_result(tmp_path): + """The launch-path bookkeeping write must not clobber a terminal task + status committed first by the completion hook (fast-failing run).""" + await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path))) + sf = get_session_factory() + assert sf is not None + + repo = ScheduledTaskRepository(sf) + await repo.create( + task_id="task-race", + user_id="user-1", + thread_id=None, + context_mode="fresh_thread_per_run", + assistant_id="lead_agent", + title="task-race", + prompt="p", + schedule_type="once", + schedule_spec={"run_at": "2026-07-02T01:00:00+00:00"}, + timezone="UTC", + next_run_at=datetime(2026, 7, 2, 1, 0, tzinfo=UTC), + ) + # Completion hook wins the race: task finalized as failed. + await repo.update("task-race", user_id="user-1", updates={"status": "failed", "last_error": "boom"}) + # Late launch-path write with protection keeps the hook's outcome. + await repo.update_after_launch( + "task-race", + status="running", + next_run_at=None, + last_run_at=datetime(2026, 7, 2, 1, 0, tzinfo=UTC), + last_run_id="run-1", + last_thread_id="thread-1", + last_error=None, + increment_run_count=True, + protect_terminal=True, + ) + + task = await repo.get("task-race", user_id="user-1") + assert task is not None + assert task["status"] == "failed" + assert task["last_error"] == "boom" + # Launch bookkeeping still recorded. + assert task["last_run_id"] == "run-1" + assert task["run_count"] == 1 + + await close_engine() + + +@pytest.mark.asyncio +async def test_list_by_task_paginates(tmp_path): + await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path))) + sf = get_session_factory() + assert sf is not None + + repo = ScheduledTaskRunRepository(sf) + for i in range(5): + await repo.create( + run_record_id=f"task-run-{i}", + task_id="task-1", + thread_id="thread-1", + scheduled_for=datetime(2026, 7, 2, 1, i, tzinfo=UTC), + trigger="scheduled", + status="success", + ) + + assert await repo.count_active_runs() == 0 + page1 = await repo.list_by_task("task-1", limit=2) + page2 = await repo.list_by_task("task-1", limit=2, offset=2) + assert len(page1) == 2 + assert len(page2) == 2 + assert {e["id"] for e in page1}.isdisjoint({e["id"] for e in page2}) + + await close_engine() + + +@pytest.mark.asyncio +async def test_list_by_user_and_thread_filters_in_sql(tmp_path): + await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path))) + sf = get_session_factory() + assert sf is not None + + repo = ScheduledTaskRepository(sf) + for task_id, thread_id in (("task-a", "thread-1"), ("task-b", "thread-2"), ("task-c", "thread-1")): + await repo.create( + task_id=task_id, + user_id="user-1", + thread_id=thread_id, + context_mode="reuse_thread", + assistant_id="lead_agent", + title=task_id, + prompt="p", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=None, + ) + + listed = await repo.list_by_user_and_thread("user-1", "thread-1") + assert sorted(t["id"] for t in listed) == ["task-a", "task-c"] + assert await repo.list_by_user_and_thread("user-2", "thread-1") == [] + + await close_engine() diff --git a/backend/tests/test_scheduled_task_router.py b/backend/tests/test_scheduled_task_router.py new file mode 100644 index 000000000..4c28161c1 --- /dev/null +++ b/backend/tests/test_scheduled_task_router.py @@ -0,0 +1,38 @@ +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 + + +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 + + +def test_router_registers_create_route(): + app = FastAPI() + app.include_router(scheduled_tasks.router) + client = TestClient(app) + response = client.post( + "/api/scheduled-tasks", + json={ + "thread_id": "thread-1", + "title": "Daily summary", + "prompt": "Summarize thread", + "schedule_type": "cron", + "schedule_spec": {"cron": "0 9 * * *"}, + "timezone": "UTC", + }, + ) + assert response.status_code != 404 diff --git a/backend/tests/test_scheduled_task_router_behavior.py b/backend/tests/test_scheduled_task_router_behavior.py new file mode 100644 index 000000000..70e516752 --- /dev/null +++ b/backend/tests/test_scheduled_task_router_behavior.py @@ -0,0 +1,652 @@ +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.gateway.routers import scheduled_tasks + + +class _Repo: + def __init__(self) -> None: + self.created = [] + self.items = {} + + async def list_by_user(self, user_id: str): + return [item for item in self.items.values() if item["user_id"] == user_id] + + async def list_by_user_and_thread(self, user_id: str, thread_id: str): + return [item for item in self.items.values() if item["user_id"] == user_id and item["thread_id"] == thread_id] + + async def create(self, **kwargs): + item = { + "id": kwargs["task_id"], + "user_id": kwargs["user_id"], + "thread_id": kwargs["thread_id"], + "context_mode": kwargs["context_mode"], + "title": kwargs["title"], + "prompt": kwargs["prompt"], + "schedule_type": kwargs["schedule_type"], + "schedule_spec": kwargs["schedule_spec"], + "timezone": kwargs["timezone"], + "status": "enabled", + "next_run_at": kwargs["next_run_at"], + } + self.items[item["id"]] = item + self.created.append(item) + return item + + async def get(self, task_id: str, *, user_id: str): + item = self.items.get(task_id) + if item is None or item["user_id"] != user_id: + return None + return item + + async def update(self, task_id: str, *, user_id: str, updates): + item = await self.get(task_id, user_id=user_id) + if item is None: + return None + item.update(updates) + return item + + async def delete(self, task_id: str, *, user_id: str): + item = await self.get(task_id, user_id=user_id) + if item is None: + return False + self.items.pop(task_id, None) + return True + + async def list_by_task(self, task_id: str): + return [] + + +class _Service: + def __init__(self) -> None: + self.calls = [] + self.result = {"outcome": "launched"} + + async def dispatch_task(self, task, *, now, trigger): + self.calls.append((task, now, trigger)) + return self.result + + +class _RunStore: + def __init__(self, runs): + self.runs = runs + + async def get(self, run_id: str, *, user_id: str): + run = self.runs.get(run_id) + if run is None or run.get("user_id") != user_id: + return None + return run + + +class _Config: + def __init__(self, min_once_delay_seconds: int = 60) -> None: + self.scheduler = SimpleNamespace(min_once_delay_seconds=min_once_delay_seconds) + + +@pytest.mark.asyncio +async def test_create_scheduled_task_uses_repo(): + repo = _Repo() + request = SimpleNamespace() + body = scheduled_tasks.ScheduledTaskCreateRequest( + thread_id="thread-1", + title="Daily summary", + prompt="Summarize thread", + schedule_type="once", + schedule_spec={"run_at": "2027-01-01T01:00:00+00:00"}, + timezone="UTC", + ) + + user = SimpleNamespace(id="user-1") + thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True)) + config = _Config() + + old_repo = scheduled_tasks.get_scheduled_task_repo + old_thread_store = scheduled_tasks.get_thread_store + old_config = scheduled_tasks.get_config + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_thread_store = lambda _request: thread_store + scheduled_tasks.get_config = lambda: config + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + + created = await scheduled_tasks.create_scheduled_task.__wrapped__( + request=request, + body=body, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_thread_store = old_thread_store + scheduled_tasks.get_config = old_config + scheduled_tasks.get_optional_user_from_request = old_user + + assert created["title"] == "Daily summary" + assert created["user_id"] == "user-1" + assert created["next_run_at"] == datetime(2027, 1, 1, 1, 0, tzinfo=UTC) + + +@pytest.mark.asyncio +async def test_create_fresh_thread_task_does_not_require_thread_id(): + repo = _Repo() + request = SimpleNamespace() + body = scheduled_tasks.ScheduledTaskCreateRequest( + context_mode="fresh_thread_per_run", + thread_id=None, + title="Fresh task", + prompt="Run in fresh thread", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + ) + + user = SimpleNamespace(id="user-1") + thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True)) + config = _Config() + + old_repo = scheduled_tasks.get_scheduled_task_repo + old_thread_store = scheduled_tasks.get_thread_store + old_config = scheduled_tasks.get_config + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_thread_store = lambda _request: thread_store + scheduled_tasks.get_config = lambda: config + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + + created = await scheduled_tasks.create_scheduled_task.__wrapped__( + request=request, + body=body, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_thread_store = old_thread_store + scheduled_tasks.get_config = old_config + scheduled_tasks.get_optional_user_from_request = old_user + + assert created["context_mode"] == "fresh_thread_per_run" + assert created["thread_id"] is None + + +@pytest.mark.asyncio +async def test_trigger_scheduled_task_dispatches_manual_run(): + repo = _Repo() + service = _Service() + task = await repo.create( + task_id="task-1", + user_id="user-1", + thread_id="thread-1", + context_mode="reuse_thread", + assistant_id="lead_agent", + title="Daily summary", + prompt="Summarize thread", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=None, + ) + request = SimpleNamespace() + user = SimpleNamespace(id="user-1") + + old_repo = scheduled_tasks.get_scheduled_task_repo + old_service = scheduled_tasks.get_scheduled_task_service + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_scheduled_task_service = lambda _request: service + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + + result = await scheduled_tasks.trigger_scheduled_task.__wrapped__( + task_id=task["id"], + request=request, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_scheduled_task_service = old_service + scheduled_tasks.get_optional_user_from_request = old_user + + assert result == {"id": "task-1", "triggered": True} + assert len(service.calls) == 1 + assert service.calls[0][2] == "manual" + + +@pytest.mark.asyncio +async def test_trigger_scheduled_task_returns_conflict_when_dispatch_conflicts(): + repo = _Repo() + service = _Service() + service.result = {"outcome": "conflict", "error": "Thread thread-1 already has an active run"} + task = await repo.create( + task_id="task-1", + user_id="user-1", + thread_id="thread-1", + context_mode="reuse_thread", + assistant_id="lead_agent", + title="Daily summary", + prompt="Summarize thread", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=None, + ) + request = SimpleNamespace() + user = SimpleNamespace(id="user-1") + + old_repo = scheduled_tasks.get_scheduled_task_repo + old_service = scheduled_tasks.get_scheduled_task_service + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_scheduled_task_service = lambda _request: service + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + + with pytest.raises(Exception) as exc_info: + await scheduled_tasks.trigger_scheduled_task.__wrapped__( + task_id=task["id"], + request=request, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_scheduled_task_service = old_service + scheduled_tasks.get_optional_user_from_request = old_user + + assert "already has an active run" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_update_scheduled_task_writes_repo(): + repo = _Repo() + task = await repo.create( + task_id="task-1", + user_id="user-1", + thread_id="thread-1", + context_mode="reuse_thread", + assistant_id="lead_agent", + title="Daily summary", + prompt="Summarize thread", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=None, + ) + request = SimpleNamespace() + user = SimpleNamespace(id="user-1") + config = _Config() + thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True)) + + old_repo = scheduled_tasks.get_scheduled_task_repo + old_thread_store = scheduled_tasks.get_thread_store + old_config = scheduled_tasks.get_config + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_thread_store = lambda _request: thread_store + scheduled_tasks.get_config = lambda: config + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + + result = await scheduled_tasks.update_scheduled_task.__wrapped__( + task_id=task["id"], + request=request, + body=scheduled_tasks.ScheduledTaskUpdateRequest(title="Updated title"), + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_thread_store = old_thread_store + scheduled_tasks.get_config = old_config + scheduled_tasks.get_optional_user_from_request = old_user + + assert result["title"] == "Updated title" + + +@pytest.mark.asyncio +async def test_delete_scheduled_task_deletes_repo_row(): + repo = _Repo() + task = await repo.create( + task_id="task-1", + user_id="user-1", + thread_id="thread-1", + context_mode="reuse_thread", + assistant_id="lead_agent", + title="Daily summary", + prompt="Summarize thread", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=None, + ) + request = SimpleNamespace() + user = SimpleNamespace(id="user-1") + + old_repo = scheduled_tasks.get_scheduled_task_repo + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + + result = await scheduled_tasks.delete_scheduled_task.__wrapped__( + task_id=task["id"], + request=request, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_optional_user_from_request = old_user + + assert result == {"id": "task-1", "deleted": True} + assert repo.items == {} + + +@pytest.mark.asyncio +async def test_pause_and_resume_scheduled_task_update_status(): + repo = _Repo() + task = await repo.create( + task_id="task-1", + user_id="user-1", + thread_id="thread-1", + context_mode="reuse_thread", + assistant_id="lead_agent", + title="Daily summary", + prompt="Summarize thread", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=None, + ) + request = SimpleNamespace() + user = SimpleNamespace(id="user-1") + + old_repo = scheduled_tasks.get_scheduled_task_repo + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + + paused = await scheduled_tasks.pause_scheduled_task.__wrapped__( + task_id=task["id"], + request=request, + ) + paused_status = paused["status"] + resumed = await scheduled_tasks.resume_scheduled_task.__wrapped__( + task_id=task["id"], + request=request, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_optional_user_from_request = old_user + + assert paused_status == "paused" + assert resumed["status"] == "enabled" + + +@pytest.mark.asyncio +async def test_pause_rejects_running_task(): + repo = _Repo() + task = await repo.create( + task_id="task-1", + user_id="user-1", + thread_id="thread-1", + context_mode="reuse_thread", + assistant_id="lead_agent", + title="Daily summary", + prompt="Summarize thread", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=None, + ) + task["status"] = "running" + request = SimpleNamespace() + user = SimpleNamespace(id="user-1") + + old_repo = scheduled_tasks.get_scheduled_task_repo + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + + with pytest.raises(Exception) as exc_info: + await scheduled_tasks.pause_scheduled_task.__wrapped__( + task_id=task["id"], + request=request, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_optional_user_from_request = old_user + + assert "currently running" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_update_rejects_running_task(): + repo = _Repo() + task = await repo.create( + task_id="task-1", + user_id="user-1", + thread_id="thread-1", + context_mode="reuse_thread", + assistant_id="lead_agent", + title="Daily summary", + prompt="Summarize thread", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=None, + ) + task["status"] = "running" + request = SimpleNamespace() + user = SimpleNamespace(id="user-1") + config = _Config() + thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True)) + + old_repo = scheduled_tasks.get_scheduled_task_repo + old_thread_store = scheduled_tasks.get_thread_store + old_config = scheduled_tasks.get_config + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_thread_store = lambda _request: thread_store + scheduled_tasks.get_config = lambda: config + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + + with pytest.raises(Exception) as exc_info: + await scheduled_tasks.update_scheduled_task.__wrapped__( + task_id=task["id"], + request=request, + body=scheduled_tasks.ScheduledTaskUpdateRequest(title="Updated title"), + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_thread_store = old_thread_store + scheduled_tasks.get_config = old_config + scheduled_tasks.get_optional_user_from_request = old_user + + assert "currently running" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_list_thread_scheduled_tasks_filters_by_thread_id(): + repo = _Repo() + await repo.create( + task_id="task-1", + user_id="user-1", + thread_id="thread-1", + context_mode="reuse_thread", + assistant_id="lead_agent", + title="Thread one task", + prompt="Prompt", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=None, + ) + await repo.create( + task_id="task-2", + user_id="user-1", + thread_id="thread-2", + context_mode="reuse_thread", + assistant_id="lead_agent", + title="Thread two task", + prompt="Prompt", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=None, + ) + + request = SimpleNamespace() + user = SimpleNamespace(id="user-1") + + old_repo = scheduled_tasks.get_scheduled_task_repo + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + + result = await scheduled_tasks.list_thread_scheduled_tasks.__wrapped__( + thread_id="thread-1", + request=request, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_optional_user_from_request = old_user + + assert [task["id"] for task in result] == ["task-1"] + + +@pytest.mark.asyncio +async def test_list_scheduled_task_runs_returns_persisted_rows_without_side_effects(): + repo = _Repo() + task = await repo.create( + task_id="task-1", + user_id="user-1", + thread_id="thread-1", + context_mode="reuse_thread", + assistant_id="lead_agent", + title="Task", + prompt="Prompt", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=None, + ) + run_repo = SimpleNamespace( + list_by_task=AsyncMock( + return_value=[ + { + "id": "task-run-1", + "task_id": "task-1", + "thread_id": "thread-1", + "run_id": "run-1", + "status": "running", + "error": None, + } + ] + ), + ) + request = SimpleNamespace() + user = SimpleNamespace(id="user-1") + + old_task_repo = scheduled_tasks.get_scheduled_task_repo + old_run_repo = scheduled_tasks.get_scheduled_task_run_repo + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_scheduled_task_run_repo = lambda _request: run_repo + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + + result = await scheduled_tasks.list_scheduled_task_runs.__wrapped__( + task_id=task["id"], + request=request, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_task_repo + scheduled_tasks.get_scheduled_task_run_repo = old_run_repo + scheduled_tasks.get_optional_user_from_request = old_user + + assert result[0]["status"] == "running" + + +@pytest.mark.asyncio +async def test_create_once_task_enforces_minimum_delay(): + repo = _Repo() + request = SimpleNamespace() + body = scheduled_tasks.ScheduledTaskCreateRequest( + thread_id="thread-1", + title="Soon task", + prompt="Run soon", + schedule_type="once", + schedule_spec={"run_at": (datetime.now(UTC) + timedelta(seconds=30)).isoformat()}, + timezone="UTC", + ) + user = SimpleNamespace(id="user-1") + thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True)) + config = _Config(min_once_delay_seconds=60) + + old_repo = scheduled_tasks.get_scheduled_task_repo + old_thread_store = scheduled_tasks.get_thread_store + old_config = scheduled_tasks.get_config + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_thread_store = lambda _request: thread_store + scheduled_tasks.get_config = lambda: config + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + + with pytest.raises(Exception) as exc_info: + await scheduled_tasks.create_scheduled_task.__wrapped__( + request=request, + body=body, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_thread_store = old_thread_store + scheduled_tasks.get_config = old_config + scheduled_tasks.get_optional_user_from_request = old_user + + assert "once schedule must be at least" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_update_terminal_once_task_with_future_run_at_rearms_it(): + """PATCHing a fresh future run_at onto a completed/failed/cancelled once + task must reset status to enabled — claim_due_tasks only admits enabled + rows, so keeping the terminal status returns a next_run_at that never fires.""" + repo = _Repo() + task = await repo.create( + task_id="task-terminal", + user_id="user-1", + thread_id=None, + context_mode="fresh_thread_per_run", + assistant_id="lead_agent", + title="Once done", + prompt="p", + schedule_type="once", + schedule_spec={"run_at": "2026-07-01T00:00:00+00:00"}, + timezone="UTC", + next_run_at=None, + ) + task["status"] = "completed" + future_run_at = (datetime.now(UTC) + timedelta(hours=1)).isoformat() + request = SimpleNamespace() + user = SimpleNamespace(id="user-1") + + old_repo = scheduled_tasks.get_scheduled_task_repo + old_config = scheduled_tasks.get_config + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_config = lambda: _Config() + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + + result = await scheduled_tasks.update_scheduled_task.__wrapped__( + task_id=task["id"], + request=request, + body=scheduled_tasks.ScheduledTaskUpdateRequest(schedule_spec={"run_at": future_run_at}), + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_config = old_config + scheduled_tasks.get_optional_user_from_request = old_user + + assert result["status"] == "enabled" + assert result["next_run_at"] is not None diff --git a/backend/tests/test_scheduled_task_schedules.py b/backend/tests/test_scheduled_task_schedules.py new file mode 100644 index 000000000..fcaac8676 --- /dev/null +++ b/backend/tests/test_scheduled_task_schedules.py @@ -0,0 +1,49 @@ +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) diff --git a/backend/tests/test_scheduled_task_service.py b/backend/tests/test_scheduled_task_service.py new file mode 100644 index 000000000..e837f3bb6 --- /dev/null +++ b/backend/tests/test_scheduled_task_service.py @@ -0,0 +1,539 @@ +from datetime import UTC, datetime, timedelta + +import pytest + +from app.scheduler.service import ScheduledTaskService +from deerflow.runtime import ConflictError, RunStatus +from deerflow.runtime.runs.manager import RunRecord +from deerflow.runtime.runs.schemas import DisconnectMode + + +class DummyTaskRepo: + def __init__(self, rows): + self.rows = rows + self.claimed = False + self.updated = None + self.cancelled_stuck_once = None + + async def cancel_stuck_once_tasks(self, *, error): + self.cancelled_stuck_once = error + return 0 + + 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) + + async def get(self, task_id: str, *, user_id: str): + row = next((item for item in self.rows if item["id"] == task_id and item["user_id"] == user_id), None) + return dict(row) if row is not None else None + + async def update(self, task_id: str, *, user_id: str, updates): + row = next((item for item in self.rows if item["id"] == task_id and item["user_id"] == user_id), None) + if row is None: + return None + row.update(updates) + return dict(row) + + +class DummyRunRepo: + def __init__(self, *, active=False, active_count=0): + self.created = None + self.updated = [] + self.active = active + self.active_count = active_count + self.stale_marked = None + + async def count_active_runs(self): + return self.active_count + + 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.append((run_record_id, kwargs)) + + async def has_active_runs(self, task_id): + return self.active + + async def mark_stale_active_runs(self, *, error): + self.stale_marked = error + return 0 + + +@pytest.mark.asyncio +async def test_service_claims_and_dispatches_due_task(): + async def fake_launch(**kwargs): + assert kwargs["owner_user_id"] == "user-1" + assert kwargs["metadata"]["scheduled_task_id"] == "task-1" + assert kwargs["metadata"]["scheduled_trigger"] == "scheduled" + return {"run_id": "run-1", "thread_id": kwargs["thread_id"]} + + task_repo = DummyTaskRepo( + [ + { + "id": "task-1", + "user_id": "user-1", + "thread_id": "thread-1", + "context_mode": "reuse_thread", + "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[0][1]["status"] == "running" + assert run_repo.updated[0][1]["protect_terminal"] is True + # `once` terminal status is owned by handle_run_completion, not the launch. + assert task_repo.updated[1]["status"] == "running" + + +@pytest.mark.asyncio +async def test_manual_trigger_keeps_paused_cron_task_paused(): + async def fake_launch(**kwargs): + return {"run_id": "run-2", "thread_id": kwargs["thread_id"]} + + task_repo = DummyTaskRepo( + [ + { + "id": "task-2", + "user_id": "user-1", + "thread_id": "thread-1", + "context_mode": "reuse_thread", + "assistant_id": "lead_agent", + "prompt": "Summarize thread", + "schedule_type": "cron", + "schedule_spec": {"cron": "0 9 * * *"}, + "timezone": "UTC", + "status": "paused", + } + ] + ) + 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.dispatch_task( + task_repo.rows[0], + now=datetime.now(UTC), + trigger="manual", + ) + + assert task_repo.updated[1]["status"] == "paused" + + +@pytest.mark.asyncio +async def test_fresh_thread_per_run_creates_new_execution_thread(): + async def fake_launch(**kwargs): + assert kwargs["thread_id"] != "thread-template" + return {"run_id": "run-3", "thread_id": kwargs["thread_id"]} + + task_repo = DummyTaskRepo( + [ + { + "id": "task-3", + "user_id": "user-1", + "thread_id": "thread-template", + "context_mode": "fresh_thread_per_run", + "assistant_id": "lead_agent", + "prompt": "Summarize thread", + "schedule_type": "cron", + "schedule_spec": {"cron": "0 9 * * *"}, + "timezone": "UTC", + "status": "enabled", + } + ] + ) + 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.dispatch_task( + task_repo.rows[0], + now=datetime.now(UTC), + trigger="scheduled", + ) + + assert run_repo.created["thread_id"] != "thread-template" + assert task_repo.updated[1]["last_thread_id"] == run_repo.created["thread_id"] + + +@pytest.mark.asyncio +async def test_scheduled_overlap_conflict_is_recorded_as_skip(): + async def fake_launch(**_kwargs): + raise ConflictError("Thread thread-1 already has an active run") + + task_repo = DummyTaskRepo( + [ + { + "id": "task-4", + "user_id": "user-1", + "thread_id": "thread-1", + "context_mode": "reuse_thread", + "assistant_id": "lead_agent", + "prompt": "Summarize thread", + "schedule_type": "cron", + "schedule_spec": {"cron": "0 9 * * *"}, + "timezone": "UTC", + "status": "running", + "overlap_policy": "skip", + "last_run_id": "run-old", + "last_thread_id": "thread-1", + "last_run_at": "2026-07-01T00:00:00+00:00", + } + ] + ) + 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, + ) + + result = await service.dispatch_task( + task_repo.rows[0], + now=datetime.now(UTC), + trigger="scheduled", + ) + + assert result["outcome"] == "skipped" + assert run_repo.updated[-1][1]["status"] == "skipped" + assert task_repo.updated[1]["status"] == "enabled" + + +@pytest.mark.asyncio +async def test_manual_overlap_conflict_returns_conflict(): + async def fake_launch(**_kwargs): + raise ConflictError("Thread thread-1 already has an active run") + + task_repo = DummyTaskRepo( + [ + { + "id": "task-5", + "user_id": "user-1", + "thread_id": "thread-1", + "context_mode": "reuse_thread", + "assistant_id": "lead_agent", + "prompt": "Summarize thread", + "schedule_type": "cron", + "schedule_spec": {"cron": "0 9 * * *"}, + "timezone": "UTC", + "status": "enabled", + "overlap_policy": "skip", + } + ] + ) + 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, + ) + + result = await service.dispatch_task( + task_repo.rows[0], + now=datetime.now(UTC), + trigger="manual", + ) + + assert result["outcome"] == "conflict" + assert run_repo.updated[-1][1]["status"] == "failed" + + +@pytest.mark.asyncio +async def test_handle_run_completion_persists_success(): + task_repo = DummyTaskRepo( + [ + { + "id": "task-6", + "user_id": "user-1", + "thread_id": None, + "context_mode": "fresh_thread_per_run", + "assistant_id": "lead_agent", + "prompt": "Summarize thread", + "schedule_type": "cron", + "schedule_spec": {"cron": "0 9 * * *"}, + "timezone": "UTC", + "status": "enabled", + } + ] + ) + run_repo = DummyRunRepo() + service = ScheduledTaskService( + task_repo=task_repo, + task_run_repo=run_repo, + launch_run=lambda **_kwargs: None, + poll_interval_seconds=5, + lease_seconds=120, + max_concurrent_runs=3, + ) + + record = RunRecord( + run_id="run-6", + thread_id="thread-6", + assistant_id="lead_agent", + status=RunStatus.success, + on_disconnect=DisconnectMode.continue_, + metadata={ + "scheduled_task_id": "task-6", + "scheduled_task_run_id": "task-run-6", + }, + user_id="user-1", + ) + + await service.handle_run_completion(record) + + assert run_repo.updated[-1][0] == "task-run-6" + assert run_repo.updated[-1][1]["status"] == "success" + assert task_repo.rows[0]["last_error"] is None + + +def _make_service(task_repo, run_repo): + return ScheduledTaskService( + task_repo=task_repo, + task_run_repo=run_repo, + launch_run=lambda **_kwargs: None, + poll_interval_seconds=5, + lease_seconds=120, + max_concurrent_runs=3, + ) + + +def _once_task_row(task_id="task-once", status="running"): + return { + "id": task_id, + "user_id": "user-1", + "thread_id": None, + "context_mode": "fresh_thread_per_run", + "assistant_id": "lead_agent", + "prompt": "Summarize thread", + "schedule_type": "once", + "schedule_spec": {"run_at": "2026-07-02T01:00:00+00:00"}, + "timezone": "UTC", + "status": status, + } + + +def _completion_record(status, *, task_id="task-once", error=None): + return RunRecord( + run_id="run-x", + thread_id="thread-x", + assistant_id="lead_agent", + status=status, + on_disconnect=DisconnectMode.continue_, + metadata={ + "scheduled_task_id": task_id, + "scheduled_task_run_id": "task-run-x", + }, + user_id="user-1", + error=error, + ) + + +@pytest.mark.asyncio +async def test_once_task_completes_only_via_completion_hook(): + task_repo = DummyTaskRepo([_once_task_row()]) + run_repo = DummyRunRepo() + service = _make_service(task_repo, run_repo) + + await service.handle_run_completion(_completion_record(RunStatus.success)) + + assert run_repo.updated[-1][1]["status"] == "success" + assert task_repo.rows[0]["status"] == "completed" + + +@pytest.mark.asyncio +async def test_once_task_failed_run_marks_task_failed(): + task_repo = DummyTaskRepo([_once_task_row()]) + run_repo = DummyRunRepo() + service = _make_service(task_repo, run_repo) + + await service.handle_run_completion(_completion_record(RunStatus.error, error="boom")) + + assert run_repo.updated[-1][1]["status"] == "failed" + assert run_repo.updated[-1][1]["error"] == "boom" + assert task_repo.rows[0]["status"] == "failed" + assert task_repo.rows[0]["last_error"] == "boom" + + +@pytest.mark.asyncio +async def test_interrupted_run_is_distinct_and_cancels_once_task(): + task_repo = DummyTaskRepo([_once_task_row()]) + run_repo = DummyRunRepo() + service = _make_service(task_repo, run_repo) + + await service.handle_run_completion(_completion_record(RunStatus.interrupted)) + + run_update = run_repo.updated[-1][1] + assert run_update["status"] == "interrupted" + assert run_update["error"] == "run was interrupted before completion" + assert task_repo.rows[0]["status"] == "cancelled" + + +@pytest.mark.asyncio +async def test_interrupted_cron_run_keeps_task_enabled(): + row = _once_task_row(task_id="task-cron") + row.update({"schedule_type": "cron", "schedule_spec": {"cron": "0 9 * * *"}, "status": "enabled"}) + task_repo = DummyTaskRepo([row]) + run_repo = DummyRunRepo() + service = _make_service(task_repo, run_repo) + + await service.handle_run_completion(_completion_record(RunStatus.interrupted, task_id="task-cron")) + + assert run_repo.updated[-1][1]["status"] == "interrupted" + assert task_repo.rows[0]["status"] == "enabled" + + +@pytest.mark.asyncio +async def test_skip_policy_applies_to_fresh_thread_runs(): + launched = [] + + async def fake_launch(**kwargs): + launched.append(kwargs) + return {"run_id": "run-9", "thread_id": kwargs["thread_id"]} + + row = _once_task_row(task_id="task-9") + row.update({"schedule_type": "cron", "schedule_spec": {"cron": "* * * * *"}, "status": "running", "overlap_policy": "skip"}) + task_repo = DummyTaskRepo([row]) + run_repo = DummyRunRepo(active=True) + 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, + ) + + result = await service.dispatch_task(row, now=datetime.now(UTC), trigger="scheduled") + + assert result["outcome"] == "skipped" + assert launched == [] + assert run_repo.created["status"] == "queued" + assert run_repo.updated[-1][1]["status"] == "skipped" + assert task_repo.updated[1]["status"] == "enabled" + assert task_repo.updated[1]["increment_run_count"] is False + + +@pytest.mark.asyncio +async def test_startup_sweep_reconciles_stale_runs_and_stuck_once_tasks(): + task_repo = DummyTaskRepo([]) + run_repo = DummyRunRepo() + service = _make_service(task_repo, run_repo) + + await service.start() + await service.stop() + + assert run_repo.stale_marked is not None + assert task_repo.cancelled_stuck_once == run_repo.stale_marked + + +@pytest.mark.asyncio +async def test_manual_trigger_with_active_run_returns_conflict_without_launching(): + launched = [] + + async def fake_launch(**kwargs): + launched.append(kwargs) + return {"run_id": "run-x", "thread_id": kwargs["thread_id"]} + + row = _once_task_row(task_id="task-manual-busy") + row.update({"schedule_type": "cron", "schedule_spec": {"cron": "* * * * *"}, "status": "enabled", "overlap_policy": "skip"}) + task_repo = DummyTaskRepo([row]) + run_repo = DummyRunRepo(active=True) + 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, + ) + + result = await service.dispatch_task(row, now=datetime.now(UTC), trigger="manual") + + assert result["outcome"] == "conflict" + assert launched == [] + # Nothing was scheduled to happen, so no run-history row is recorded. + assert run_repo.created is None + assert result["task_run_id"] is None + + +@pytest.mark.asyncio +async def test_run_once_claims_only_into_remaining_global_budget(): + claim_limits = [] + + class BudgetTaskRepo(DummyTaskRepo): + async def claim_due_tasks(self, **kwargs): + claim_limits.append(kwargs["limit"]) + return [] + + task_repo = BudgetTaskRepo([]) + run_repo = DummyRunRepo(active_count=2) + service = _make_service(task_repo, run_repo) + + await service.run_once(now=datetime.now(UTC)) + assert claim_limits == [1] + + run_repo.active_count = 3 + await service.run_once(now=datetime.now(UTC)) + # Budget exhausted: no claim at all this cycle. + assert claim_limits == [1] + + +@pytest.mark.asyncio +async def test_launch_bookkeeping_passes_protect_terminal(): + async def fake_launch(**kwargs): + return {"run_id": "run-pt", "thread_id": kwargs["thread_id"]} + + task_repo = DummyTaskRepo([_once_task_row(task_id="task-pt", status="enabled")]) + 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.dispatch_task(task_repo.rows[0], now=datetime.now(UTC), trigger="scheduled") + + assert task_repo.updated[1]["protect_terminal"] is True diff --git a/backend/uv.lock b/backend/uv.lock index b45c7fc38..0d9df677d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -851,6 +851,7 @@ dependencies = [ { name = "agent-sandbox" }, { name = "aiosqlite" }, { name = "alembic" }, + { name = "croniter" }, { name = "cryptography" }, { name = "ddgs" }, { name = "dotenv" }, @@ -910,6 +911,7 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.19" }, { name = "alembic", specifier = ">=1.13" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29" }, + { name = "croniter", specifier = ">=6.0.0" }, { name = "cryptography", specifier = ">=48.0.1" }, { name = "ddgs", specifier = ">=9.10.0" }, { name = "dotenv", specifier = ">=0.9.9" }, diff --git a/config.example.yaml b/config.example.yaml index da1add862..79ea3dafb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -15,7 +15,7 @@ # ============================================================================ # Bump this number when the config schema changes. # Run `make config-upgrade` to merge new fields into your local config.yaml. -config_version: 17 +config_version: 18 # ============================================================================ # Logging @@ -1404,6 +1404,30 @@ run_events: max_trace_content: 10240 track_token_usage: true +# ============================================================================ +# Scheduled Tasks Configuration +# ============================================================================ +# Background scheduler for one-time and recurring (cron) agent runs. +# All fields are restart-required (captured at Gateway lifespan startup). +# +# Multi-worker note: the scheduler runs once per uvicorn worker. SQLite silently +# ignores row-level locks, so multiple workers can double-fire the same task. +# For multi-worker deployments (GATEWAY_WORKERS > 1), use the Postgres database +# backend, where FOR UPDATE SKIP LOCKED serializes claims correctly. +# +# scheduler: +# enabled: false # Master switch for the background poller +# poll_interval_seconds: 5 # How often to scan for due tasks +# lease_seconds: 120 # Claim lease; a crashed process's task becomes reclaimable after this +# max_concurrent_runs: 3 # Global cap on active scheduled runs; each poll claims only into the remaining budget +# min_once_delay_seconds: 60 # Minimum future offset for one-time tasks at creation time +scheduler: + enabled: false + poll_interval_seconds: 5 + lease_seconds: 120 + max_concurrent_runs: 3 + min_once_delay_seconds: 60 + # ============================================================================ # Stream Bridge Configuration # ============================================================================ diff --git a/docs/superpowers/plans/2026-07-01-scheduled-tasks-mvp.md b/docs/superpowers/plans/2026-07-01-scheduled-tasks-mvp.md new file mode 100644 index 000000000..4e5ead8fb --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-scheduled-tasks-mvp.md @@ -0,0 +1,2136 @@ +# 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 `once` and `cron`; it must not add `interval`. +- 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: + - `SchedulerConfig` with fields: + - `enabled: bool` + - `poll_interval_seconds: int` + - `lease_seconds: int` + - `max_concurrent_runs: int` + - `min_once_delay_seconds: int` + - `ScheduledTaskRow` + - `ScheduledTaskRunRow` + +- [ ] **Step 1: Write the failing tests** + +Create `backend/tests/test_scheduled_task_models.py`: + +```python +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: + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_models.py -v +``` + +Expected: +- fail because `AppConfig` has no `scheduler` +- fail because `ScheduledTaskRow` / `ScheduledTaskRunRow` do not exist + +- [ ] **Step 3: Implement minimal config and model skeleton** + +Create `backend/packages/harness/deerflow/config/scheduler_config.py`: + +```python +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: + +```python +from deerflow.config.scheduler_config import SchedulerConfig +``` + +Add field inside `AppConfig`: + +```python + scheduler: SchedulerConfig = Field( + default_factory=SchedulerConfig, + description="Scheduled task runtime configuration", + ) +``` + +Create `backend/packages/harness/deerflow/persistence/scheduled_tasks/model.py`: + +```python +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`: + +```python +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: + +```python +from .model import ScheduledTaskRow + +__all__ = ["ScheduledTaskRow"] +``` + +```python +from .model import ScheduledTaskRunRow + +__all__ = ["ScheduledTaskRunRow"] +``` + +Update `backend/packages/harness/deerflow/persistence/models/__init__.py`: + +```python +from deerflow.persistence.scheduled_task_runs.model import ScheduledTaskRunRow +from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow +``` + +Append to `__all__`: + +```python + "ScheduledTaskRow", + "ScheduledTaskRunRow", +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_models.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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: + - `ScheduledTaskRepository` + - `ScheduledTaskRunRepository` + - task CRUD API and owner-scoped listing + +- [ ] **Step 1: Write the failing tests** + +Create `backend/tests/test_scheduled_task_repository.py`: + +```python +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: + +```bash +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`: + +```python +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`: + +```python +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: + +```python +from .model import ScheduledTaskRow +from .sql import ScheduledTaskRepository + +__all__ = ["ScheduledTaskRow", "ScheduledTaskRepository"] +``` + +```python +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: + +```python +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: + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_repository.py tests/test_scheduled_task_models.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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) -> str` + - `normalize_cron_expression(expr: str) -> str` + - `next_run_at(schedule_type: str, schedule_spec: dict[str, object], timezone_name: str, *, now: datetime) -> datetime | None` + - `validate_once_time(...)` + +- [ ] **Step 1: Write the failing tests** + +Create `backend/tests/test_scheduled_task_schedules.py`: + +```python +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: + +```bash +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`: + +```python +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`: + +```python +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`: + +```python +from datetime import UTC, datetime + + +def utc_now() -> datetime: + return datetime.now(UTC) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_schedules.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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`: + +```python +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: + +```bash +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: + +```python +from datetime import UTC, datetime, timedelta + +from sqlalchemy import and_, or_ +``` + +Add methods: + +```python + 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`: + +```python + 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: + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_claims.py tests/test_scheduled_task_repository.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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(...)` + - `ScheduledTaskService` + - `get_scheduled_task_service` + +- [ ] **Step 1: Write the failing tests** + +Create `backend/tests/test_scheduled_task_service.py`: + +```python +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: + +```bash +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`: + +```python +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`: + +```python +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`: + +```python +from .service import ScheduledTaskService + +__all__ = ["ScheduledTaskService"] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_service.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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: + +```python +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: + +```bash +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: + +```python +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`: + +```python +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: + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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_repo` + - `get_scheduled_task_run_repo` + - `get_scheduled_task_service` + +- [ ] **Step 1: Extend the failing tests** + +Append to `backend/tests/test_scheduled_task_router.py`: + +```python +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: + +```bash +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`: + +```python +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: + +```python +@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: + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_router.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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_repo` + - `scheduled_task_run_repo` + - `scheduled_task_service` + +- [ ] **Step 1: Write the failing tests** + +Create `backend/tests/test_scheduled_task_lifecycle.py`: + +```python +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: + +```bash +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: + +```python + 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: + +```python + 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: + +```python + 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: + +```bash +cd backend && PYTHONPATH=. uv run pytest tests/test_scheduled_task_lifecycle.py tests/test_scheduled_task_router.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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: + - `ScheduledTask` + - `ScheduledTaskRun` + - `useScheduledTasks` + - `/workspace/scheduled-tasks` + +- [ ] **Step 1: Write the failing frontend tests** + +Create `frontend/tests/e2e/scheduled-tasks.spec.ts`: + +```typescript +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`: + +```typescript +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: + +```bash +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`: + +```typescript +export type ScheduledTask = { + id: string; + thread_id: string; + title: string; + prompt: string; + schedule_type: "once" | "cron"; + schedule_spec: Record; + 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`: + +```typescript +import { fetch } from "@/core/api/fetcher"; +import { getBackendBaseURL } from "@/core/config"; + +import type { ScheduledTask, ScheduledTaskRun } from "./types"; + +export async function fetchScheduledTasks(): Promise { + const response = await fetch(`${getBackendBaseURL()}/api/scheduled-tasks`); + return response.json(); +} + +export async function fetchThreadScheduledTasks( + threadId: string, +): Promise { + const response = await fetch( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/scheduled-tasks`, + ); + return response.json(); +} + +export async function fetchScheduledTaskRuns( + taskId: string, +): Promise { + const response = await fetch( + `${getBackendBaseURL()}/api/scheduled-tasks/${encodeURIComponent(taskId)}/runs`, + ); + return response.json(); +} +``` + +Create `frontend/src/core/scheduled-tasks/hooks.ts`: + +```typescript +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`: + +```typescript +"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 ( + + + +
+

{t.sidebar.scheduledTasks}

+
+ {(data ?? []).map((task) => ( +
{task.title}
+ ))} +
+
+
+
+ ); +} +``` + +Update sidebar types and translations with `scheduledTasks`. + +Update `frontend/src/components/workspace/workspace-nav-chat-list.tsx` to add a link: + +```typescript +import { CalendarClock, BotIcon, MessagesSquare } from "lucide-react"; +``` + +Add menu item: + +```typescript + + + + + {t.sidebar.scheduledTasks} + + + +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +cd frontend && pnpm test +cd frontend && pnpm test:e2e scheduled-tasks.spec.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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=` + +- [ ] **Step 1: Extend the failing E2E test** + +Append to `frontend/tests/e2e/scheduled-tasks.spec.ts`: + +```typescript +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: + +```bash +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`: + +```typescript +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 ( + + ); +} +``` + +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: + +```bash +cd frontend && pnpm test:e2e scheduled-tasks.spec.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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: + +```bash +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: + +```bash +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** + +```bash +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: + +```bash +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: + +```bash +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: + +```bash +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** + +```bash +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: + +```bash +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 diff --git a/docs/superpowers/specs/2026-07-01-scheduled-tasks-mvp-design.md b/docs/superpowers/specs/2026-07-01-scheduled-tasks-mvp-design.md new file mode 100644 index 000000000..b9381063a --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-scheduled-tasks-mvp-design.md @@ -0,0 +1,596 @@ +# DeerFlow Scheduled Tasks MVP Design + +**Date**: 2026-07-01 +**Status**: Approved for implementation +**Scope**: First-class scheduled-task management for DeerFlow web workspace + +--- + +## Problem Statement + +DeerFlow main does not ship a real scheduled-task product surface today. The repository already has internal timers, worker pools, and run persistence, but users cannot create, inspect, pause, resume, trigger, or delete durable background tasks from the product. + +This creates three concrete problems: + +1. Users cannot automate recurring DeerFlow work such as daily summaries, periodic follow-ups, or recurring repo triage from the normal workspace. +2. Existing cron-related PRs prove demand, but they either cut scope too broadly or start from the wrong interaction surface, which makes them hard to merge and harder to operate safely. +3. Without a management surface, any future chat-created schedule would be operationally unsafe because users would have no first-class place to inspect or stop unattended jobs. + +The first implementation must solve the operational and product-control problem before natural-language schedule creation. + +## Solution Summary + +Build a scheduled-task MVP with these hard boundaries: + +1. **Durable backend resource**: add a `scheduled_task` resource with DB-backed persistence and DB-backed task-run history. +2. **Shared execution path**: scheduled executions must launch through the existing DeerFlow run lifecycle, not a parallel agent path. +3. **Workspace management page**: add a first-class page at `/workspace/scheduled-tasks` for list/detail/create/edit/pause/resume/trigger/delete. +4. **Execution context mode is explicit**: every task chooses whether runs reuse an existing thread or create a fresh thread per occurrence. +5. **Minimal schedule surface**: MVP supports `once` and `cron`, but not `interval`. +6. **Opt-in runtime gate**: background scheduling remains disabled by default and requires explicit config enablement. + +## Explicit Non-Goals + +The MVP intentionally does **not** include: + +1. Conversation-created schedules or a `schedule_task` tool. +2. Text-only notification jobs. +3. Channel, IM, or GitHub dispatch targets. +4. Goal-backed scheduled work. +5. Retry/dead-letter orchestration. +6. Distributed leader election beyond a single enabled scheduler instance with DB lease claims. +7. Intervals shorter than 60 seconds for user-created tasks. + +These exclusions are not optional polish cuts. They are what keeps the first PR reviewable. + +## Chosen Architecture + +### Why this shape + +This MVP combines the right parts of prior DeerFlow cron attempts without inheriting their problems: + +1. Keep the **execution discipline** from the narrower backend MVPs: scheduler decides *when* to run, existing run services decide *how* to run. +2. Keep the **durable task identity + task history** shape from broader implementations. +3. Put **management UI before chat-created scheduling**, because users need a reliable control plane before background automation can be created from conversation. + +### Resource Model + +The MVP introduces two durable entities: + +1. `scheduled_tasks` +2. `scheduled_task_runs` + +`scheduled_tasks` is the durable trigger definition. `scheduled_task_runs` is the execution ledger per occurrence. + +This keeps schedule identity separate from DeerFlow `runs`, which already model one concrete execution attempt. + +## User Stories + +1. As a DeerFlow user, I want to create a one-time task that can run in a fresh thread, so periodic automation does not silently accumulate old context. +2. As a DeerFlow user, I want to create a recurring cron task that can either reuse a thread or create a fresh thread per run, so I can choose between continuity and isolation explicitly. +3. As a DeerFlow user, I want to see next run time, last run result, and last error at a glance, so I know whether automation is healthy. +4. As a DeerFlow user, I want to pause and resume a task, so I can stop automation without deleting configuration. +5. As a DeerFlow user, I want to trigger a task manually, so I can test or re-run it on demand. +6. As a DeerFlow user, I want to inspect task run history, so I can audit what happened. +7. As a DeerFlow user, I want tasks to be owner-scoped, so no other user can list or mutate my automations. +8. As a maintainer, I want scheduler execution to reuse existing run-launch code, so scheduled runs do not become a second runtime stack. + +## MVP Product Shape + +### Supported Task Kinds + +Only one execution kind is supported in MVP: + +- `task_type = "agent"` +- `dispatch_type = "thread"` + +That means every scheduled task is defined as: + +- context mode +- optional target thread id +- title +- prompt override +- schedule definition +- runtime policy + +When it fires, DeerFlow launches a normal run, but the execution thread is selected by `context_mode`. + +These are fixed MVP semantics, not user-editable API fields and not persisted schema columns. The first PR must behave as if every task implicitly carries those values, without prematurely generalizing the contract. + +### Supported Schedule Kinds + +The user-facing MVP supports: + +1. `once` +2. `cron` + +The MVP does **not** support `interval` because: + +1. it adds another schedule parser path, +2. it enlarges frontend validation, +3. it increases edge-case surface around cadence drift and minimum interval enforcement, +4. it is not required to prove the scheduler architecture. + +If later added, `interval` can be layered on the same resource model. + +### Execution Context Rule + +The MVP supports two execution-context modes: + +1. `fresh_thread_per_run` — default. Each scheduled occurrence creates a fresh DeerFlow thread. +2. `reuse_thread` — optional. Each scheduled occurrence reuses an existing thread. + +This is deliberate: + +1. recurring digests, summaries, and automation jobs should not silently accumulate context forever; +2. follow-up and reminder use cases still need an explicit reuse mode; +3. the scheduler definition stays separate from the execution thread used by each occurrence. + +## Backend Design + +### Persistence Layout + +Add harness-owned persistence packages: + +- `backend/packages/harness/deerflow/persistence/scheduled_tasks/` +- `backend/packages/harness/deerflow/persistence/scheduled_task_runs/` + +Add ORM registration in: + +- `backend/packages/harness/deerflow/persistence/models/__init__.py` + +Add Alembic migration under: + +- `backend/packages/harness/deerflow/persistence/migrations/versions/` + +### `scheduled_tasks` schema + +Fields: + +- `id`: string primary key +- `user_id`: owner user id, indexed +- `thread_id`: nullable target thread id, indexed +- `context_mode`: `fresh_thread_per_run | reuse_thread` +- `assistant_id`: nullable assistant id snapshot +- `title`: user-visible task title +- `prompt`: explicit prompt to send when the task runs +- `schedule_type`: `once | cron` +- `schedule_spec`: JSON payload +- `timezone`: IANA timezone +- `status`: `enabled | paused | running | completed | failed | cancelled` +- `overlap_policy`: fixed to `skip` in MVP, still persisted explicitly +- `misfire_policy`: fixed to `run_once` in MVP, still persisted explicitly +- `next_run_at`: UTC timestamp, indexed +- `last_run_at`: nullable UTC timestamp +- `last_run_id`: nullable DeerFlow run id +- `last_thread_id`: nullable DeerFlow thread id from the latest execution +- `last_error`: nullable text +- `lease_owner`: nullable string +- `lease_expires_at`: nullable UTC timestamp +- `run_count`: integer +- `max_runs`: nullable integer +- `created_at` +- `updated_at` + +Not included in MVP schema: + +- `dispatch_type` +- `dispatch_target` +- `task_type` +- `sandbox_profile` +- `trust_policy` +- `credential_scope` + +Reason: those are real future needs, but introducing dormant columns now weakens the first implementation and invites half-implemented policy behavior. The first PR should store only what it truly enforces. + +### `scheduled_task_runs` schema + +Fields: + +- `id`: string primary key +- `task_id`: foreign-key-like indexed link to `scheduled_tasks.id` +- `thread_id`: indexed for efficient thread-level lookup +- `run_id`: nullable DeerFlow run id +- `scheduled_for`: UTC timestamp +- `trigger`: `scheduled | manual` +- `status`: `queued | running | success | failed | skipped` +- `error`: nullable text +- `started_at`: nullable UTC timestamp +- `finished_at`: nullable UTC timestamp +- `created_at` + +This run ledger is distinct from DeerFlow `runs` because: + +1. a scheduled occurrence may fail before a DeerFlow run is created, +2. an overlap skip still deserves audit visibility, +3. manual and scheduled triggers need explicit occurrence records. + +### Repository APIs + +Create two repositories: + +1. `ScheduledTaskRepository` +2. `ScheduledTaskRunRepository` + +Required repository behavior: + +- create/get/list/update/delete tasks +- owner-scoped search +- claim due tasks atomically +- update lease / clear lease +- record status transitions +- insert run history rows +- list task run history + +Atomic due-claim API must operate in one DB transaction: + +- find due enabled tasks +- skip tasks with live unexpired lease +- set `lease_owner`, `lease_expires_at`, and temporary `status="running"` +- return claimed rows + +The scheduler service must not implement claim logic in Python-only in-memory filters. + +## Scheduler Runtime Design + +### Location + +Runtime service lives under: + +- `backend/app/scheduler/` + +Reason: it needs app-layer dependencies and shared run-launch services. Harness persistence remains app-agnostic. + +### Lifecycle + +The scheduler starts during FastAPI lifespan only when config enables it. + +Suggested config section: + +```yaml +scheduler: + enabled: false + poll_interval_seconds: 5 + lease_seconds: 120 + max_concurrent_runs: 3 + min_interval_seconds: 60 +``` + +There is no separate `leader` toggle in MVP. The DB lease is the operational guard. If deployments later require multi-instance topology, a leader dimension can be added in hardening. + +### Execution flow + +For each poll cycle: + +1. fetch up to `max_concurrent_runs` due tasks via repository claim, +2. for each claimed task: + - create `scheduled_task_runs` row with `status=queued`, + - compute and persist the next schedule before or immediately after launch, + - dispatch a normal DeerFlow run through shared run-launch helper, + - persist `last_run_id`, `last_run_at`, `run_count`, task-run status, and error fields, + - release lease. + +### Shared run-launch helper + +MVP must extract or reuse a non-router helper based on existing logic in: + +- [backend/app/gateway/services.py](/Users/nowcoder/Desktop/auto-code-work/deer-flow/.worktrees/scheduled-tasks-mvp/backend/app/gateway/services.py) +- [backend/app/gateway/routers/thread_runs.py](/Users/nowcoder/Desktop/auto-code-work/deer-flow/.worktrees/scheduled-tasks-mvp/backend/app/gateway/routers/thread_runs.py) + +Required property: + +- manual API trigger and background scheduler trigger both call the same launch helper. + +The helper takes: + +- target thread id +- target assistant id +- prompt content +- authenticated owner context +- origin metadata indicating `scheduled_task_id` and `scheduled_trigger` + +### Overlap semantics + +MVP uses one fixed overlap rule: + +- if the target thread already has a pending/running run, record the occurrence as `skipped`, update `next_run_at`, and do not launch another run. + +This is intentionally narrower than exposing multiple overlap policies in the first PR. + +### Misfire semantics + +MVP uses one fixed misfire rule: + +- `run_once` + +If the scheduler was down and multiple occurrences were missed, only the latest eligible missed occurrence runs when the scheduler comes back. + +Reason: + +1. avoids backlog explosion, +2. avoids unreviewed catch-up storms, +3. keeps first implementation deterministic. + +### One-time task completion + +For `once` tasks: + +- successful dispatch marks task `completed` +- dispatch failure marks task `failed` +- task remains visible and queryable from UI/history after completion or failure + +MVP uses soft retention, not destructive deletion. + +### Cron semantics + +Cron rules: + +1. accept exactly 5 fields +2. reject 6-field cron with seconds +3. store explicit IANA timezone +4. compute `next_run_at` in UTC +5. normalize weekday semantics consistently and test them explicitly + +The implementation must not silently depend on an ambiguous day-of-week interpretation. + +## API Design + +Add REST routes under `/api/scheduled-tasks`. + +### Routes + +- `GET /api/scheduled-tasks` +- `POST /api/scheduled-tasks` +- `GET /api/scheduled-tasks/{task_id}` +- `PATCH /api/scheduled-tasks/{task_id}` +- `POST /api/scheduled-tasks/{task_id}/pause` +- `POST /api/scheduled-tasks/{task_id}/resume` +- `POST /api/scheduled-tasks/{task_id}/trigger` +- `DELETE /api/scheduled-tasks/{task_id}` +- `GET /api/scheduled-tasks/{task_id}/runs` +- `GET /api/threads/{thread_id}/scheduled-tasks` + +There is intentionally **no** dispatch-target discovery endpoint in MVP because the only target is an owned thread. + +### Request validation + +Create: + +- title required +- prompt required +- thread id required and must be owner-accessible +- `schedule_type` required +- `once` requires run timestamp +- `cron` requires valid 5-field cron +- timezone required + +Update: + +- allow title/prompt/schedule/timezone changes +- disallow owner/thread reassignment across users +- disallow mutation while task is in temporary `running` state if that would invalidate schedule semantics + +### Authorization + +Owner checks are mandatory for: + +- list +- detail +- run history +- patch +- pause +- resume +- trigger +- delete +- thread-scoped list + +This should reuse existing auth patterns from thread/runs routers rather than inventing a new access scheme. + +## Frontend Design + +### Navigation + +Add new workspace nav item: + +- `/workspace/scheduled-tasks` + +This belongs beside existing high-level workspace surfaces in `WorkspaceNavChatList`, not hidden under settings. + +### Main page + +Add page: + +- `frontend/src/app/workspace/scheduled-tasks/page.tsx` + +The page includes: + +1. list table/cards +2. filter bar +3. create-task button +4. detail drawer or side panel + +### List columns + +- title +- thread title +- schedule summary +- status +- next run +- last run +- last result +- actions + +### Filters + +MVP filters: + +- status +- schedule type +- thread + +No owner filter is needed in MVP because tasks are already owner-scoped. + +### Create/edit form + +Fields: + +- title +- thread selector +- prompt textarea +- schedule type: `once | cron` +- once datetime picker +- cron input +- timezone selector + +Validation: + +- prompt non-empty +- title non-empty +- once datetime must be in the future +- cron must be valid before submit + +### Detail view + +Displays: + +- full prompt +- thread link +- raw schedule config +- last error +- run history list +- actions: pause/resume/trigger/delete/edit + +### Thread-level entry point + +Thread chat pages gain a visible entry point to view schedules for the current thread. + +MVP behavior: + +- small button/link in thread page header opens filtered scheduled-task page for current thread + +It does not need a full embedded task manager in-thread. Reusing the main page keeps the first PR smaller. + +## State and Data Fetching + +Frontend adds a small `scheduled-tasks` API layer under: + +- `frontend/src/core/scheduled-tasks/` + +Recommended pieces: + +- typed request/response models +- list/detail/run-history fetchers +- mutations for create/update/pause/resume/trigger/delete +- React Query hooks + +This should follow the same shape the repo already uses for threads and feedback, not ad-hoc local fetch calls sprinkled through components. + +## Error Handling + +### Backend + +Explicit failures that must surface cleanly: + +1. missing or deleted thread +2. unauthorized owner access +3. invalid cron +4. invalid timezone +5. task already paused/resumed +6. trigger rejected due to active in-flight thread run +7. scheduler launch failure before run creation + +Failure must never cause infinite immediate retry loops. + +### Frontend + +Users should see: + +1. inline form validation errors +2. mutation toasts for pause/resume/trigger/delete +3. visible failed state in task row +4. visible `last_error` in details + +The UI must not show a healthy-looking task row when the last scheduler attempt failed. + +## Testing Strategy + +### Backend unit tests + +1. valid and invalid cron expressions +2. valid and invalid timezone handling +3. weekday normalization semantics +4. next-run computation across timezone boundaries and DST-sensitive cases +5. one-time schedule status transitions +6. due-task claim logic and lease expiry +7. overlap skip behavior +8. misfire `run_once` behavior + +### Backend integration tests + +1. CRUD API with owner isolation +2. thread-scoped task list route +3. pause/resume/trigger/delete flows +4. manual trigger creates a normal DeerFlow run through shared launch helper +5. scheduler loop claims each due task once +6. dispatch failure writes task and task-run errors correctly +7. deleted thread does not hot-loop retries + +### Frontend unit tests + +1. scheduled-task nav item renders and routes +2. list renders status/next run/last result +3. create dialog validates form state +4. action buttons settle correctly after API response +5. detail drawer renders history and last error + +### Frontend E2E + +Playwright with mocked APIs: + +1. list page loads +2. create task from UI +3. pause/resume/trigger/delete flows +4. thread header link navigates to filtered scheduled-task view + +### Real-path validation + +Required before claiming feature complete: + +1. start backend and frontend +2. create a one-time task due soon from the real UI +3. observe row move through live status updates +4. confirm linked DeerFlow run exists +5. confirm completed/failure state is visible in the management page + +## Documentation Updates Required + +If code lands, update: + +1. `README.md` with feature overview and enablement note +2. `AGENTS.md` and `backend/AGENTS.md` with scheduler/runtime ownership and commands if architecture changes +3. config docs for new `scheduler` section + +## Code Review Checklist + +1. Scheduled runs reuse the existing run lifecycle. +2. Harness persistence does not import `app.*`. +3. Due-task claim logic is atomic. +4. No hot loop after dispatch failure. +5. Day-of-week semantics are explicit and tested. +6. Owner checks cover list/detail/history/mutate/trigger/delete. +7. UI shows failing state honestly. +8. Background scheduler remains opt-in. +9. Thread-level entry point does not introduce duplicate management UI logic. +10. Chat-created scheduling remains absent from MVP. + +## Implementation Order + +1. Backend persistence and repository layer +2. Schedule parser / next-run computation +3. Shared run-launch helper +4. Scheduler service and API +5. Frontend API layer and page +6. Thread header entry point +7. E2E and real-path validation + +This order is mandatory because the frontend cannot be implemented against an unstable backend contract. diff --git a/docs/superpowers/specs/2026-07-02-scheduled-tasks-i18n-recipes-design.md b/docs/superpowers/specs/2026-07-02-scheduled-tasks-i18n-recipes-design.md new file mode 100644 index 000000000..95fc9570c --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-scheduled-tasks-i18n-recipes-design.md @@ -0,0 +1,78 @@ +# Scheduled-tasks page: full i18n + recipe templates — design + +Follow-up to the preset-driven schedule form (commit `1ca27a73`). Scope kept +front-end only and backend-contract-free so it stays reviewable inside PR #3898. + +## Background + +The preset form i18n'd the schedule section but left the rest of the +scheduled-tasks page in hard-coded English (filters, detail pane, actions, edit +form, run list). A `zh-CN` user saw a half-English page right next to the +i18n'd schedule input. Separately, the page still required users to hand-write +prompts for the headline use cases (GitHub Trending, news digest, issue triage, +weekly report). + +## Goals + +1. Every visible string on `/workspace/scheduled-tasks` goes through + `t.scheduledTasks.*` (en + zh). +2. One-click "quick create" via four built-in recipe templates that pre-fill + title + prompt + schedule. +3. No backend change. No new dependency. + +## Design + +### Full i18n + +Added to the `scheduledTasks` i18n section (types + en-US + zh-CN): + +- `create`, `context`, `filters`, `detail`, `actions`, `edit` — UI labels. +- `status`, `runTrigger`, `runStatus` — enum maps so list/detail/run rows render + localized values instead of raw `enabled` / `manual` / `success`. +- `recipes` — recipe labels (below). + +The page maps raw enum values through small lookup helpers +(`statusLabel`, `scheduleTypeLabel`, `contextModeLabel`, `runTriggerLabel`, +`runStatusLabel`) that fall back to the raw value if unknown. E2E selectors +switched from exact English strings to case-insensitive regex so they survive +the i18n capitalization change (`cron · enabled` → `Cron · Enabled`). + +### Recipe templates + +New `frontend/src/core/scheduled-tasks/recipes.ts` exports `RECIPES: Recipe[]` +with `{ id, icon, titleKey, prompt, schedule }`. Four starters: + +| id | icon | schedule | prompt gist | +|---|---|---|---| +| `trending` | 🔥 | daily 09:00 | web_search GitHub Trending, summarize top 10 | +| `news` | 📰 | daily 09:00 | web_search top tech news, 5-item digest | +| `issues` | 🏷️ | daily 09:00 | triage open issues in `{{repo}}` (user fills placeholder) | +| `weekly` | 📅 | weekly Mon 09:00 | weekly report | + +`schedule.timezone` is intentionally `""` so the `ScheduledTaskScheduleInput` +falls back to the browser timezone when applied. Recipe titles/descriptions live +in i18n (`recipes.{id}.{title,desc}`); the file only stores the prompt + schedule. + +### Wiring into the create form + +- A `createNonce` counter state keys the create-form `ScheduledTaskScheduleInput` + (`key={createNonce}`). Bumping the nonce forces the component to remount so the + recipe's schedule is re-initialized into its `useState`, not just emitted. +- `applyRecipe(recipe)` sets title + prompt + createSchedule + contextMode + + bumps the nonce. +- A chip row (`data-testid="schedule-recipes"`) renders above the form fields. + +## Verification + +- `pnpm check` 0 errors; `pnpm test` all green (403 unit tests). +- Real browser (chrome-devtools, zh-CN): 4 recipe chips render, clicking + 「每周周报」pre-fills title + prompt + preset (weekly) + preview + `每周 周一 09:00 (Asia/Shanghai)`. +- Playwright `scheduled-tasks` suite re-verified with regex selectors. + +## Non-goals (next session) + +- Backend dispatch targets / `task_type` (RFC phase 2) — kept out because it + changes schema/API/executor and would muddy this PR's review. +- Backend-stored / user-authored recipes — front-end built-ins are enough for + the first cut (YAGNI). diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 2ac92822a..76f816ee8 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ use: { baseURL: "http://localhost:3000", + locale: "en-US", trace: "on-first-retry", }, @@ -22,7 +23,7 @@ export default defineConfig({ ], webServer: { - command: "pnpm build && pnpm start", + command: "./node_modules/.bin/next build && ./node_modules/.bin/next start", url: "http://localhost:3000", reuseExistingServer: !process.env.CI, timeout: 120_000, diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 6b93e268b..368ffcbf8 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -19,6 +19,7 @@ import { MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; +import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link"; import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; @@ -194,6 +195,7 @@ export default function ChatPage() {
+ {!isNewThread && } (null); + const [contextMode, setContextMode] = useState< + "fresh_thread_per_run" | "reuse_thread" + >(threadId ? "reuse_thread" : "fresh_thread_per_run"); + const [targetThreadId, setTargetThreadId] = useState(threadId ?? ""); + const [title, setTitle] = useState(""); + const [prompt, setPrompt] = useState(""); + const [createSchedule, setCreateSchedule] = useState({ + schedule_type: "cron", + schedule_spec: { cron: "0 9 * * *" }, + timezone: "", + }); + const [statusFilter, setStatusFilter] = useState< + "all" | "enabled" | "paused" | "running" | "completed" | "failed" + >("all"); + const [typeFilter, setTypeFilter] = useState<"all" | "once" | "cron">("all"); + const [formError, setFormError] = useState(null); + const [editing, setEditing] = useState(false); + const [editTitle, setEditTitle] = useState(""); + const [editPrompt, setEditPrompt] = useState(""); + const [editSchedule, setEditSchedule] = useState({ + schedule_type: "cron", + schedule_spec: { cron: "0 9 * * *" }, + timezone: "UTC", + }); + const [createNonce, setCreateNonce] = useState(0); + const filteredData = (data ?? []).filter((task) => { + const statusPass = statusFilter === "all" || task.status === statusFilter; + const typePass = typeFilter === "all" || task.schedule_type === typeFilter; + return statusPass && typePass; + }); + const selectedTask = + filteredData.find((task) => task.id === selectedTaskId) ?? filteredData[0]; + const taskRunsQuery = useScheduledTaskRuns(selectedTask?.id); + const createTask = useCreateScheduledTask(); + const updateTask = useUpdateScheduledTask(selectedTask?.id ?? ""); + const pauseTask = usePauseScheduledTask(); + const resumeTask = useResumeScheduledTask(); + const triggerTask = useTriggerScheduledTask(); + const deleteTask = useDeleteScheduledTask(); + + const scheduleTypeLabel = (v: string) => + v === "cron" + ? st.scheduleType.cron + : v === "once" + ? st.scheduleType.once + : v; + const statusLabel = (v: string) => + (st.status as Record)[v] ?? v; + const contextModeLabel = (v: string) => + v === "fresh_thread_per_run" + ? st.context.fresh + : v === "reuse_thread" + ? st.context.reuse + : v; + const runTriggerLabel = (v: string) => + (st.runTrigger as Record)[v] ?? v; + const runStatusLabel = (v: string) => + (st.runStatus as Record)[v] ?? v; + const taskSummary = (task: ScheduledTask) => + `${scheduleTypeLabel(task.schedule_type)} · ${statusLabel(task.status)}`; + const runSummary = (run: ScheduledTaskRun) => + `${runTriggerLabel(run.trigger)} · ${runStatusLabel(run.status)}`; + const applyRecipe = (recipe: Recipe) => { + const labels = st.recipes[recipe.titleKey]; + setTitle(labels.title); + setPrompt(recipe.prompt); + setCreateSchedule(recipe.schedule); + setContextMode("fresh_thread_per_run"); + setCreateNonce((n) => n + 1); + }; + + useEffect(() => { + document.title = `${t.sidebar.scheduledTasks} - ${t.pages.appName}`; + }, [t.pages.appName, t.sidebar.scheduledTasks]); + + useEffect(() => { + if (!selectedTaskId) { + return; + } + const stillVisible = filteredData.some( + (task) => task.id === selectedTaskId, + ); + if (!stillVisible) { + setSelectedTaskId(filteredData[0]?.id ?? null); + setEditing(false); + } + }, [filteredData, selectedTaskId]); + + useEffect(() => { + if (!selectedTask) { + setEditing(false); + return; + } + setEditTitle(selectedTask.title); + setEditPrompt(selectedTask.prompt); + const spec = selectedTask.schedule_spec as { + cron?: string; + run_at?: string; + }; + setEditSchedule({ + schedule_type: selectedTask.schedule_type, + schedule_spec: { + cron: typeof spec.cron === "string" ? spec.cron : undefined, + run_at: typeof spec.run_at === "string" ? spec.run_at : undefined, + }, + timezone: selectedTask.timezone || "UTC", + }); + // Depend on id only so a background refetch (same task, new object reference) + // does not wipe edits in progress. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedTask?.id]); + + return ( + + + +
+

{t.sidebar.scheduledTasks}

+
+
{st.create.title}
+
+ + {st.recipes.label}: + + {RECIPES.map((recipe) => ( + + ))} +
+
+ + +
+ {contextMode === "reuse_thread" && ( + setTargetThreadId(event.target.value)} + placeholder={st.context.threadIdPlaceholder} + /> + )} + setTitle(event.target.value)} + placeholder={st.create.taskTitle} + /> +