feat(scheduled-tasks): filter run history by occurrence status (#5384)

* feat(scheduled-tasks): filter run history by occurrence status

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>

* fix(scheduled-tasks): share occurrence status contract

---------

Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
tiammomo 2026-09-14 14:09:27 +08:00 committed by GitHub
parent 1b9667ea0e
commit 5d855e9b92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 232 additions and 16 deletions

View File

@ -1642,6 +1642,12 @@ Current MVP capabilities:
- Execute scheduled work through the normal DeerFlow run lifecycle
- Browse execution history in pages of 50; older pages pause automatic refresh, with an explicit return to the latest runs. Counts appear only after a successful read; loading and failed reads are not reported as zero runs.
**Filter execution history through the API**
To inspect failures without downloading every successful occurrence, authenticated clients with `threads:read` can request `GET /api/scheduled-tasks/{task_id}/runs?status=failed&limit=50&offset=0` for an owned task. The optional `status` accepts `queued`, `launching`, `running`, `success`, `failed`, `skipped`, or `interrupted`; these are occurrence statuses, so task statuses such as `completed` are invalid (422).
Filtering happens before pagination. `limit` (1200, default 50) and `offset` (nonnegative, default 0) apply to matching records, ordered by creation time then ID, both descending. Omitting `status` preserves the existing mixed-history array response; no matches return `[]`. The API does not change task execution, and the workspace history UI remains unfiltered.
Current MVP limits:
- No conversation-created `schedule_task` tool yet

View File

@ -840,6 +840,12 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务scheduled
- 定时任务通过正常的 DeerFlow run 生命周期执行
- 按每页 50 条浏览执行历史;历史页暂停自动刷新,可随时返回最新记录。 仅在读取成功后显示条数,加载中或失败不会误显示为零条。
**通过 API 筛选执行历史**
排查失败记录时,无需先下载所有成功记录。已认证且具有 `threads:read` 权限的客户端,可以针对自己的任务请求 `GET /api/scheduled-tasks/{task_id}/runs?status=failed&limit=50&offset=0`。可选的 `status` 支持 `queued``launching``running``success``failed``skipped``interrupted`;这些是执行记录的状态,`completed` 等任务状态会被拒绝422
筛选先于分页执行。`limit`1200默认 50`offset`(非负整数,默认 0作用于匹配记录按创建时间、ID 依次降序排列。不传 `status` 时保留原有的混合历史数组,无匹配项返回 `[]`。此 API 不改变任务执行行为workspace 历史界面仍展示未筛选的记录。
当前 MVP 限制:
- 暂时还没有可在对话中创建任务的 `schedule_task` 工具

View File

@ -15,7 +15,8 @@ The backend runs a LangGraph-based super agent with sandbox execution, persisten
- Gateway streams `write_file` and `str_replace` argument deltas in bounded batches for multi-mode `messages-tuple` consumers; single-mode message consumers retain the original per-chunk contract. Non-message frames flush pending batches, and `values` remains an optional complete-state snapshot rather than a prerequisite for batching.
- With `stream_subgraphs`, subgraph frames keep their namespace in the SSE event name (`values|<ns>`, LangGraph Platform style) instead of impersonating root frames — a delegated subagent inherits the parent checkpoint namespace, so publishing its `values` snapshot as bare `values` replaces the whole thread view in SDK clients (#4399). Root-only consumers (file-tool chunk batcher, subagent event persistence, LLM error-fallback detection) ignore namespaced frames. The web frontend does not request subgraph streaming; subtask progress rides root-namespace `task_*` custom events.
- Background subagent identity is deliberately split: the provider `tool_call_id` remains the correlation key for `ToolMessage`, `task_*` SSE events, persisted lifecycle events, frontend cards, and the public `ExtensionData.scope_id` contract (stored as `SubagentResult.external_task_id`), while `SubagentExecutor.execute_async()` generates a full server-side `execution_id` for `SubagentResult.task_id`, the process-wide registry, polling, cancellation, timeout handling, and cleanup. Provider IDs are not globally unique across parent runs, so they must never become registry ownership keys; scheduler closures retain their own `SubagentResult` rather than resolving ownership again through the mutable registry. Terminal subagent token usage travels in the current run's `ToolMessage.additional_kwargs` and is attributed from message state, never through a process-global provider-ID cache.
- 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. Scheduled launches pass `scheduler.recursion_limit` (default 1000, matching the web UI's `recursion_limit: 1000`, clamped by `max_recursion_limit`) via `launch_scheduled_thread_run`; the value is read from `get_app_config()` at dispatch, so a YAML edit applies to the next scheduled run without a Gateway restart.
- Scheduled tasks dispatch through the normal Gateway run path. `launch_scheduled_thread_run` reads `get_app_config()` at dispatch and passes `scheduler.recursion_limit` (default 1000, matching the web UI; clamped by `max_recursion_limit`), so YAML changes apply on the next run without restarting Gateway.
- Run-history `status` filters are occurrence states, not task states. `ScheduledTaskRunStatus` in `persistence/scheduled_tasks/model.py` is the shared API/repository vocabulary and must match the active and terminal occurrence-status sets. Keep owner lookup before reading history, and apply SQL task/status predicates before pagination; omitted status preserves the existing response.
- The background scheduler is single-instance by default. `scheduler.multi_instance=true` opts into lease-aware recovery across Gateway instances and requires shared Postgres, `run_ownership.heartbeat_enabled=true`, and `run_events.backend=db`; otherwise startup rejects the configuration. Live scheduled runs are preserved when a peer starts; expired launch claims return to the durable queue, expired run leases are atomically taken over, stale launch writes are fenced by lease ownership, and the Postgres advisory-locked budget makes `max_concurrent_runs` a shared global cap for `launching`/`running` rows.
- Long-running MCP work uses a separate durable task runtime (`McpTaskService` + `mcp_tasks`, lease-based recovery) rather than keeping remote task IDs or status polling inside the Agent loop; only submit remains Agent-visible, the database is the source of truth, and `ThreadState` receives only a bounded current-thread projection. Full contract (leases, cancellation fencing, delivery idempotency, management-tool exposure): [packages/harness/deerflow/mcp/AGENTS.md](packages/harness/deerflow/mcp/AGENTS.md).
- MCP task notification retries, dead-lettering, and the cancel endpoint's worker-stopped 503 are part of that same contract — see [packages/harness/deerflow/mcp/AGENTS.md](packages/harness/deerflow/mcp/AGENTS.md).

View File

@ -20,6 +20,7 @@ from app.gateway.deps import (
)
from deerflow.config.agents_config import AGENT_NAME_PATTERN, load_agent_config
from deerflow.persistence.scheduled_tasks import ActiveScheduledTaskMutationConflict
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRunStatus
from deerflow.scheduler.schedules import (
MAX_INTERVAL_SECONDS,
normalize_cron_expression,
@ -486,6 +487,7 @@ async def list_scheduled_task_runs(
request: Request,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
status: ScheduledTaskRunStatus | None = None,
):
task_repo = get_scheduled_task_repo(request)
run_repo = get_scheduled_task_run_repo(request)
@ -495,7 +497,7 @@ async def list_scheduled_task_runs(
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)
return await run_repo.list_by_task(task_id, limit=limit, offset=offset, status=status)
@router.get("/threads/{thread_id}/scheduled-tasks")

View File

@ -12,7 +12,12 @@ from deerflow.persistence.run import RunRepository
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.scheduled_task_runs.model import ScheduledTaskRunRow
from deerflow.persistence.scheduled_task_runs.projection import account_launch, can_project
from deerflow.persistence.scheduled_tasks.model import ACTIVE_RUN_STATUSES, TERMINAL_RUN_STATUSES, ScheduledTaskRow
from deerflow.persistence.scheduled_tasks.model import (
ACTIVE_RUN_STATUSES,
TERMINAL_RUN_STATUSES,
ScheduledTaskRow,
ScheduledTaskRunStatus,
)
from deerflow.scheduler.schedules import next_run_at as compute_next_run_at
from deerflow.utils.time import coerce_iso
@ -224,17 +229,18 @@ class ScheduledTaskRunRepository:
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 def list_by_task(
self,
task_id: str,
*,
limit: int = 50,
offset: int = 0,
status: ScheduledTaskRunStatus | None = None,
) -> list[dict[str, Any]]:
stmt = select(ScheduledTaskRunRow).where(ScheduledTaskRunRow.task_id == task_id)
if status is not None:
stmt = stmt.where(ScheduledTaskRunRow.status == status)
stmt = stmt.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()]

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import UTC, datetime
from enum import StrEnum
from sqlalchemy import JSON, BigInteger, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
@ -40,11 +41,36 @@ class ScheduledTaskRow(Base):
)
class ScheduledTaskRunStatus(StrEnum):
"""Canonical status vocabulary for scheduled-task occurrences."""
QUEUED = "queued"
LAUNCHING = "launching"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
INTERRUPTED = "interrupted"
# Status constants - shared between scheduled_tasks and scheduled_task_runs
# to avoid circular import and ensure consistency.
# Import these from deerflow.persistence.scheduled_tasks.model in both modules.
TERMINAL_RUN_STATUSES: frozenset[str] = frozenset({"success", "failed", "skipped", "interrupted"})
ACTIVE_RUN_STATUSES: frozenset[str] = frozenset({"queued", "launching", "running"})
TERMINAL_RUN_STATUSES: frozenset[str] = frozenset(
{
ScheduledTaskRunStatus.SUCCESS,
ScheduledTaskRunStatus.FAILED,
ScheduledTaskRunStatus.SKIPPED,
ScheduledTaskRunStatus.INTERRUPTED,
}
)
ACTIVE_RUN_STATUSES: frozenset[str] = frozenset(
{
ScheduledTaskRunStatus.QUEUED,
ScheduledTaskRunStatus.LAUNCHING,
ScheduledTaskRunStatus.RUNNING,
}
)
# Parent ``once`` task status projected from a terminal occurrence status.
# Shared by the completion path and both recovery paths so the mapping

View File

@ -0,0 +1,169 @@
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
import pytest_asyncio
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.authz import AuthContext
from app.gateway.routers import scheduled_tasks
from deerflow.persistence.scheduled_task_runs import ScheduledTaskRunRepository
from deerflow.persistence.scheduled_task_runs.model import ScheduledTaskRunRow
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
from deerflow.persistence.scheduled_tasks.model import (
ACTIVE_RUN_STATUSES,
TERMINAL_RUN_STATUSES,
ScheduledTaskRow,
ScheduledTaskRunStatus,
)
URL = "/api/scheduled-tasks/task-1/runs"
NOW = datetime(2026, 9, 12, tzinfo=UTC)
def occurrence(record_id, status, *, task_id="task-1", created_at=NOW):
return ScheduledTaskRunRow(id=record_id, task_id=task_id, thread_id="thread-1", scheduled_for=created_at, trigger="scheduled", status=status, created_at=created_at)
@pytest_asyncio.fixture
async def history(monkeypatch):
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
try:
async with engine.begin() as connection:
await connection.run_sync(ScheduledTaskRow.__table__.create)
await connection.run_sync(ScheduledTaskRunRow.__table__.create)
sf = async_sessionmaker(engine, expire_on_commit=False)
async with sf() as session:
for task_id, owner in [("task-1", "user-1"), ("task-other", "user-1"), ("task-foreign", "user-2"), ("task-empty", "user-1")]:
session.add(ScheduledTaskRow(id=task_id, user_id=owner, title="History example", prompt="Example", schedule_type="cron", schedule_spec={"cron": "0 9 * * *"}, timezone="UTC"))
session.add_all([occurrence(f"success-{i:02}", "success", created_at=NOW + timedelta(hours=1, seconds=i)) for i in range(60)])
session.add_all([occurrence(f"failed-{letter}", "failed") for letter in "abc"])
session.add_all(
[
occurrence("skipped-old", "skipped", created_at=NOW - timedelta(days=1)),
occurrence("interrupted-old", "interrupted", created_at=NOW - timedelta(days=2)),
occurrence("other-failure", "failed", task_id="task-other", created_at=NOW + timedelta(days=1)),
occurrence("foreign-failure", "failed", task_id="task-foreign", created_at=NOW + timedelta(days=1)),
]
)
await session.commit()
task_repo = ScheduledTaskRepository(sf)
run_repo = ScheduledTaskRunRepository(sf)
spy = AsyncMock(wraps=run_repo.list_by_task)
monkeypatch.setattr(run_repo, "list_by_task", spy)
monkeypatch.setattr(scheduled_tasks, "get_scheduled_task_repo", lambda request: task_repo)
monkeypatch.setattr(scheduled_tasks, "get_scheduled_task_run_repo", lambda request: run_repo)
async def user_from_request(request):
return request.state.auth.user
monkeypatch.setattr(scheduled_tasks, "get_optional_user_from_request", user_from_request)
app = FastAPI()
app.include_router(scheduled_tasks.router)
@app.middleware("http")
async def authenticate(request, call_next):
user = None if request.headers.get("x-test-auth") == "anonymous" else SimpleNamespace(id="user-1")
permissions = [] if request.headers.get("x-test-auth") == "denied" else ["threads:read"]
request.state.auth = AuthContext(user=user, permissions=permissions)
return await call_next(request)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
yield SimpleNamespace(client=client, sf=sf, repo=run_repo, spy=spy)
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_filter_finds_older_failures_and_paginates_matching_rows(history):
response = await history.client.get(URL, params={"status": "failed", "limit": 2})
assert response.status_code == 200
assert [row["id"] for row in response.json()] == ["failed-c", "failed-b"]
response = await history.client.get(URL, params={"status": "failed", "limit": 2, "offset": 2})
assert [row["id"] for row in response.json()] == ["failed-a"]
response = await history.client.get(URL, params={"status": "failed", "offset": 3})
assert response.json() == []
@pytest.mark.asyncio
async def test_repository_applies_status_before_limit_and_offset(history):
rows = await history.repo.list_by_task("task-1", status="failed", limit=1, offset=1)
assert [row["id"] for row in rows] == ["failed-b"]
@pytest.mark.asyncio
async def test_omitted_status_preserves_mixed_history_and_pagination(history):
response = await history.client.get(URL, params={"limit": 200})
assert response.status_code == 200
rows = response.json()
assert len(rows) == 65
assert [row["id"] for row in rows[:2]] == ["success-59", "success-58"]
assert {row["status"] for row in rows} == {"success", "failed", "skipped", "interrupted"}
assert all(row["task_id"] == "task-1" for row in rows)
response = await history.client.get(URL, params={"limit": 2, "offset": 60})
assert [row["id"] for row in response.json()] == ["failed-c", "failed-b"]
assert len((await history.client.get(URL)).json()) == 50
@pytest.mark.asyncio
@pytest.mark.parametrize("status", list(ScheduledTaskRunStatus))
async def test_each_occurrence_status_is_supported(history, status):
async with history.sf() as session:
session.add(occurrence("new-occurrence", status, created_at=NOW + timedelta(days=2)))
await session.commit()
response = await history.client.get(URL, params={"status": status, "limit": 200})
assert response.status_code == 200
rows = response.json()
assert rows[0]["id"] == "new-occurrence"
assert all(row["status"] == status and row["task_id"] == "task-1" for row in rows)
@pytest.mark.asyncio
async def test_occurrence_status_contract_is_shared_with_openapi(history):
statuses = {status.value for status in ScheduledTaskRunStatus}
assert statuses == ACTIVE_RUN_STATUSES | TERMINAL_RUN_STATUSES
response = await history.client.get("/openapi.json")
assert response.status_code == 200
assert set(response.json()["components"]["schemas"]["ScheduledTaskRunStatus"]["enum"]) == statuses
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["", "completed", "error", "FAILED", "failed,success"])
async def test_unknown_status_is_rejected_before_reading_history(history, status):
response = await history.client.get(URL, params={"status": status})
assert response.status_code == 422
history.spy.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("task_id", ["task-foreign", "missing-task"])
async def test_filter_cannot_read_another_owner_or_missing_task(history, task_id):
response = await history.client.get(f"/api/scheduled-tasks/{task_id}/runs", params={"status": "failed"})
assert response.status_code == 404
history.spy.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(("auth", "status"), [("anonymous", 401), ("denied", 403)])
async def test_filter_keeps_authentication_and_read_permission_checks(history, auth, status):
response = await history.client.get(URL, params={"status": "failed"}, headers={"x-test-auth": auth})
assert response.status_code == status
history.spy.assert_not_awaited()
@pytest.mark.asyncio
async def test_empty_or_unmatched_history_returns_empty_array(history):
assert (await history.client.get("/api/scheduled-tasks/task-empty/runs", params={"status": "failed"})).json() == []
assert (await history.client.get(URL, params={"status": "running"})).json() == []
@pytest.mark.asyncio
@pytest.mark.parametrize("params", [{"limit": 0}, {"limit": 201}, {"offset": -1}])
async def test_filter_preserves_pagination_bounds(history, params):
response = await history.client.get(URL, params={"status": "failed", **params})
assert response.status_code == 422
history.spy.assert_not_awaited()