mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-18 18:46:17 +00:00
A review pass over domain/schedule found three genuinely untested branches, all of which now have cases: - ensure_launchable with a naive `now`. next_after already had this covered; the delay floor did not, so a caller handing over a naive clock reading could have had it shifted by the local offset unnoticed. - update_task changing the prompt. Only the title path was exercised. - _save finding the row gone. get_task saw it and save no longer does, which is a concurrent delete; the caller must get the same not-found it would have got a moment earlier rather than a None leaking out. That takes service.py and every model module to 100%. The one remaining uncovered line is croniter's naive-return guard, carried over verbatim from schedules.py and unreachable with an aware input -- it now says so instead of looking like an untested branch. Also fills in the documentation the migration skipped: TaskStatus, ContextMode and RunStatus arrived from the original draft without docstrings while their newer siblings had them, and ScheduleService plus four of its use cases were undocumented. Each now records the reasoning a reader would otherwise have to reconstruct -- why RUNNING is not "the agent is executing", why SKIPPED never passes through QUEUED, why INTERRUPTED is not FAILED. CRON_FIELD_COUNT stops being exported: it has no consumer outside the module that defines it.
148 lines
6.5 KiB
Python
148 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
from croniter import croniter
|
|
|
|
from deerflow.domain.schedule.model.enums import ScheduleType
|
|
from deerflow.domain.schedule.model.errors import InvalidScheduleError
|
|
|
|
CRON_FIELD_COUNT = 5
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SchedulePolicy:
|
|
"""Operator-tunable thresholds the domain needs but must not read itself.
|
|
|
|
Built by the composition root from the scheduler configuration and passed
|
|
in. Deliberately not held by any aggregate: a task whose meaning changes
|
|
with deployment config is not a domain object.
|
|
|
|
The defaults are the permissive ones on purpose -- "nobody configured a
|
|
policy" must not invent a business constraint. Real values only ever
|
|
arrive from the outer ring.
|
|
"""
|
|
|
|
min_once_delay_seconds: int = 0
|
|
"""How far ahead a one-shot schedule must be at submission time. Read by
|
|
`ScheduleSpec.ensure_launchable`; a cron schedule is never subject to it."""
|
|
|
|
max_concurrent_runs: int = 1
|
|
"""Ceiling on active scheduled executions across ALL tasks. Long runs
|
|
accumulate across polls, so each poll may only claim into what is left."""
|
|
|
|
lease_seconds: int = 60
|
|
"""How long a claim on a task stays valid. Bounds how quickly a task
|
|
orphaned between claim and dispatch becomes reachable again."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScheduleSpec:
|
|
"""Parsed, validated view of (schedule_type, schedule_spec, timezone).
|
|
|
|
The stored JSON spec is mapped in and out by the adapter layer, never here:
|
|
a `Mapping[str, Any]` in a domain signature would mean the domain is
|
|
handling a persistence/transport format. The two halves of that parsing
|
|
split cleanly — structural checks (is the key present? is it a str?) belong
|
|
to the boundary, value rules (5-field cron, resolvable timezone, run_at
|
|
present) belong to __post_init__ below. Storage keeps the same raw JSON, so
|
|
this needs no migration.
|
|
|
|
Normalization happens in __post_init__ rather than in the factories below,
|
|
so direct construction cannot bypass it: a frozen dataclass is still
|
|
constructible field-by-field, and "valid on construction" has to hold for
|
|
that path too.
|
|
"""
|
|
|
|
schedule_type: ScheduleType
|
|
timezone: str
|
|
cron: str | None = None
|
|
run_at: datetime | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
# The timezone is checked first because normalizing a naive run_at
|
|
# below needs it to already be known-good.
|
|
try:
|
|
zone = ZoneInfo(self.timezone)
|
|
except ZoneInfoNotFoundError as exc:
|
|
raise InvalidScheduleError(f"Unknown timezone: {self.timezone}") from exc
|
|
|
|
if self.schedule_type is ScheduleType.CRON:
|
|
if not self.cron:
|
|
raise InvalidScheduleError("cron schedule requires schedule_spec.cron")
|
|
fields = [part for part in self.cron.split() if part]
|
|
if len(fields) != CRON_FIELD_COUNT:
|
|
raise InvalidScheduleError(f"Cron expression must contain exactly {CRON_FIELD_COUNT} fields")
|
|
object.__setattr__(self, "cron", " ".join(fields))
|
|
|
|
if self.schedule_type is ScheduleType.ONCE:
|
|
if self.run_at is None:
|
|
raise InvalidScheduleError("once schedule requires run_at")
|
|
if self.run_at.tzinfo is None:
|
|
# A naive run_at means wall-clock time in this schedule's own
|
|
# timezone (schedules.py:40-43). Localizing here rather than at
|
|
# every read site keeps the rest of this class tz-aware only.
|
|
object.__setattr__(self, "run_at", self.run_at.replace(tzinfo=zone))
|
|
|
|
@classmethod
|
|
def cron_schedule(cls, expr: str, timezone: str) -> ScheduleSpec:
|
|
"""Readability sugar — all validation lives in __post_init__."""
|
|
return cls(ScheduleType.CRON, timezone, cron=expr)
|
|
|
|
@classmethod
|
|
def once_at(cls, run_at: datetime, timezone: str) -> ScheduleSpec:
|
|
"""Readability sugar — all validation lives in __post_init__."""
|
|
return cls(ScheduleType.ONCE, timezone, run_at=run_at)
|
|
|
|
def next_after(self, now: datetime) -> datetime | None:
|
|
"""Next fire time in UTC, or None when there is no future occurrence.
|
|
|
|
The dispatch-path calculation (was `next_run_at` in schedules.py:24-55).
|
|
It applies no submission-time policy — see ensure_launchable for that,
|
|
and do not swap the two: re-arming a cron task through the stricter one
|
|
would reject it right after a perfectly normal launch.
|
|
|
|
ONCE returns run_at while it is still ahead of `now`, else None (the
|
|
single occurrence is in the past). CRON is evaluated in this schedule's
|
|
timezone and returned as UTC. A naive `now` is read as UTC
|
|
(schedules.py:32-33).
|
|
"""
|
|
if now.tzinfo is None:
|
|
now = now.replace(tzinfo=UTC)
|
|
|
|
if self.schedule_type is ScheduleType.ONCE:
|
|
return self.run_at if self.run_at > now else None
|
|
|
|
zone = ZoneInfo(self.timezone)
|
|
next_local = croniter(self.cron, now.astimezone(zone)).get_next(datetime)
|
|
if next_local.tzinfo is None:
|
|
# Unreachable with an aware input on today's croniter, and kept
|
|
# verbatim from schedules.py:51-52 rather than dropped: it costs
|
|
# one branch and guards a library detail we do not control.
|
|
next_local = next_local.replace(tzinfo=zone)
|
|
return next_local.astimezone(UTC)
|
|
|
|
def ensure_launchable(self, now: datetime, policy: SchedulePolicy) -> datetime | None:
|
|
"""Next fire time, with the constraints that only apply at submission.
|
|
|
|
Used by create/update; the dispatch path must use next_after instead.
|
|
|
|
Raises:
|
|
InvalidScheduleError: a ONCE schedule with no future occurrence, or
|
|
one closer than policy.min_once_delay_seconds. CRON is never
|
|
subject to the delay floor (router:105, router:196).
|
|
"""
|
|
if now.tzinfo is None:
|
|
now = now.replace(tzinfo=UTC)
|
|
|
|
next_at = self.next_after(now)
|
|
if self.schedule_type is not ScheduleType.ONCE:
|
|
return next_at
|
|
if next_at is None:
|
|
raise InvalidScheduleError("once schedule must be in the future")
|
|
if (next_at - now).total_seconds() < policy.min_once_delay_seconds:
|
|
raise InvalidScheduleError(f"once schedule must be at least {policy.min_once_delay_seconds} seconds in the future")
|
|
return next_at
|