feat: 统一自动化任务调度配置并增强聊天流恢复

This commit is contained in:
qzl
2026-03-24 18:19:33 +08:00
parent 23359c2d01
commit 389f5248fc
30 changed files with 1144 additions and 888 deletions
@@ -7,12 +7,7 @@ from typing import Any
import yaml
from core.agentscope.tools.tool_config import AgentTool
from models.automation_jobs import ScheduleType
from schemas.automation import (
AutomationJobConfig,
MessageContextConfig,
)
from schemas.automation import AutomationJobConfig
_CONFIG_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
@@ -37,18 +32,4 @@ def load_static_automation_job_config(*, config_name: str) -> AutomationJobConfi
loaded: Any = yaml.safe_load(file) or {}
if not isinstance(loaded, dict):
raise ValueError(f"invalid automation config format: {path}")
config = AutomationJobConfig.model_validate(loaded)
if config_name == "memory_extraction":
if config.enabled_tools != [AgentTool.MEMORY_WRITE, AgentTool.MEMORY_FORGET]:
raise ValueError(
"memory_extraction enabled_tools must be [memory.write, memory.forget]"
)
if config.context != MessageContextConfig(window_count=2):
raise ValueError(
"memory_extraction context must be latest_chat/day with window_count=2"
)
if config.schedule is None:
raise ValueError("memory_extraction schedule must be configured")
if config.schedule.type != ScheduleType.DAILY:
raise ValueError("memory_extraction schedule type must be daily")
return config
return AutomationJobConfig.model_validate(loaded)
+37 -34
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime, time, timedelta
from typing import Protocol
from uuid import UUID, uuid4
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -13,7 +13,7 @@ from core.logging import get_logger
from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType
from models.memories import MemoryType
from models.profile import Profile
from schemas.automation import AutomationJobConfig
from schemas.automation import AutomationJobConfig, ScheduleConfig
from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent
from schemas.user.context import parse_profile_settings
from v1.auth.automation_static_config import load_static_automation_job_config
@@ -44,9 +44,7 @@ class RegistrationBootstrapRepository:
title: str,
config: AutomationJobConfig,
timezone_name: str,
run_at: datetime,
next_run_at: datetime,
schedule_type: ScheduleType,
) -> bool:
stmt = (
insert(AutomationJob)
@@ -56,8 +54,6 @@ class RegistrationBootstrapRepository:
bootstrap_key=bootstrap_key,
title=title,
config=config.model_dump(mode="json"),
schedule_type=schedule_type,
run_at=run_at,
next_run_at=next_run_at,
timezone=timezone_name,
status=AutomationJobStatus.ACTIVE,
@@ -103,9 +99,7 @@ class RegistrationBootstrapRepositoryLike(Protocol):
title: str,
config: AutomationJobConfig,
timezone_name: str,
run_at: datetime,
next_run_at: datetime,
schedule_type: ScheduleType,
) -> bool: ...
async def upsert_initial_memory(
@@ -123,35 +117,48 @@ class SessionLike(Protocol):
async def rollback(self) -> None: ...
def compute_next_local_time_utc(
def compute_first_run_at_utc(
*,
now_utc: datetime,
timezone_name: str,
local_hour: int,
local_minute: int,
schedule_type: ScheduleType,
) -> tuple[datetime, datetime]:
schedule: ScheduleConfig,
) -> datetime:
try:
timezone_obj = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
timezone_obj = ZoneInfo("UTC")
local_now = now_utc.astimezone(timezone_obj)
today_run_local = local_now.replace(
hour=local_hour,
minute=local_minute,
second=0,
microsecond=0,
run_clock = time(
hour=schedule.run_at.hour,
minute=schedule.run_at.minute,
tzinfo=timezone_obj,
)
run_local = (
today_run_local
if local_now <= today_run_local
else today_run_local + timedelta(days=1)
)
if schedule_type == ScheduleType.WEEKLY:
next_local = run_local + timedelta(weeks=1)
else:
next_local = run_local + timedelta(days=1)
return run_local.astimezone(UTC), next_local.astimezone(UTC)
if schedule.type == ScheduleType.DAILY:
candidate_local = datetime.combine(local_now.date(), run_clock)
if candidate_local <= local_now:
candidate_local = candidate_local + timedelta(days=1)
return candidate_local.astimezone(UTC)
weekdays = schedule.weekdays or []
if not weekdays:
raise ValueError("weekly schedule requires weekdays")
normalized_weekdays = sorted(set(weekdays))
for day_offset in range(0, 8):
candidate_day = local_now.date() + timedelta(days=day_offset)
if candidate_day.isoweekday() not in normalized_weekdays:
continue
candidate_local = datetime.combine(candidate_day, run_clock)
if candidate_local > local_now:
return candidate_local.astimezone(UTC)
fallback_day = local_now.date() + timedelta(days=7)
while fallback_day.isoweekday() not in normalized_weekdays:
fallback_day = fallback_day + timedelta(days=1)
fallback_local = datetime.combine(fallback_day, run_clock)
return fallback_local.astimezone(UTC)
class RegistrationAutomationBootstrapService:
@@ -203,12 +210,10 @@ class RegistrationAutomationBootstrapService:
raise ValueError(
f"bootstrap job {bootstrap_key} has no schedule configured"
)
run_at, next_run_at = compute_next_local_time_utc(
next_run_at = compute_first_run_at_utc(
now_utc=datetime.now(UTC),
timezone_name=timezone_name,
local_hour=schedule.run_at.hour,
local_minute=schedule.run_at.minute,
schedule_type=schedule.type,
schedule=schedule,
)
inserted = (
await self._repository.insert_bootstrap_automation_job_if_absent(
@@ -217,9 +222,7 @@ class RegistrationAutomationBootstrapService:
title=str(definition["title"]),
config=job_config,
timezone_name=timezone_name,
run_at=run_at,
next_run_at=next_run_at,
schedule_type=schedule.type,
)
)
inserted_any = inserted_any or inserted
+64 -73
View File
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from core.db.base_repository import BaseRepository
from models.agent_chat_session import AgentChatSession, SessionType
from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType
from schemas.automation import AutomationJobConfig, ScheduleConfig
if TYPE_CHECKING:
from v1.automation_jobs.schemas import (
@@ -107,26 +108,45 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
except ZoneInfoNotFoundError:
return ZoneInfo("UTC")
def _compute_initial_next_run_at(
def _compute_next_run_at(
self,
*,
run_at: time,
schedule: ScheduleConfig,
timezone_str: str,
now_utc: datetime,
schedule_type: ScheduleType,
) -> datetime:
tz = self._resolve_timezone(timezone_str)
local_now = now_utc.astimezone(tz)
run_at_local = datetime.combine(local_now.date(), run_at, tz)
if run_at_local.tzinfo is None:
run_at_local = run_at_local.replace(tzinfo=tz)
next_run_at = run_at_local
if next_run_at <= local_now:
if schedule_type == ScheduleType.DAILY:
next_run_at = next_run_at + timedelta(days=1)
else:
next_run_at = next_run_at + timedelta(weeks=1)
return next_run_at.astimezone(timezone.utc)
run_clock = time(
hour=schedule.run_at.hour,
minute=schedule.run_at.minute,
tzinfo=tz,
)
if schedule.type == ScheduleType.DAILY:
candidate_local = datetime.combine(local_now.date(), run_clock)
if candidate_local <= local_now:
candidate_local = candidate_local + timedelta(days=1)
return candidate_local.astimezone(timezone.utc)
weekdays = schedule.weekdays or []
if not weekdays:
raise ValueError("weekly schedule requires weekdays")
normalized_weekdays = sorted(set(weekdays))
for day_offset in range(0, 8):
candidate_day = local_now.date() + timedelta(days=day_offset)
if candidate_day.isoweekday() not in normalized_weekdays:
continue
candidate_local = datetime.combine(candidate_day, run_clock)
if candidate_local > local_now:
return candidate_local.astimezone(timezone.utc)
fallback_day = local_now.date() + timedelta(days=7)
while fallback_day.isoweekday() not in normalized_weekdays:
fallback_day = fallback_day + timedelta(days=1)
fallback_local = datetime.combine(fallback_day, run_clock)
return fallback_local.astimezone(timezone.utc)
async def create(
self,
@@ -134,16 +154,14 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
data: AutomationJobCreateRequest,
) -> AutomationJob:
now_utc = datetime.now(tz=timezone.utc)
timezone_obj = self._resolve_timezone(data.timezone)
local_now = now_utc.astimezone(timezone_obj)
date_ref = local_now.date()
local_dt = datetime.combine(date_ref, data.run_at, timezone_obj)
run_at_datetime = local_dt.astimezone(timezone.utc)
next_run_at = self._compute_initial_next_run_at(
run_at=data.run_at,
schedule = data.config.schedule
if schedule is None:
raise ValueError("config.schedule is required")
next_run_at = self._compute_next_run_at(
schedule=schedule,
timezone_str=data.timezone,
now_utc=now_utc,
schedule_type=data.schedule_type,
)
new_job = AutomationJob(
@@ -151,8 +169,6 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
created_by=owner_id,
bootstrap_key=None,
title=data.title,
schedule_type=data.schedule_type,
run_at=run_at_datetime,
timezone=data.timezone,
status=data.status,
config=data.config.model_dump(mode="json"),
@@ -168,69 +184,44 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
data: AutomationJobUpdateRequest,
) -> AutomationJob | None:
update_values: dict[str, object] = {}
existing_job: AutomationJob | None = None
existing_job = await self.get_by_id(job_id)
if existing_job is None:
return None
if data.title is not None:
update_values["title"] = data.title
if data.schedule_type is not None:
update_values["schedule_type"] = data.schedule_type
should_recompute_schedule = (
data.run_at is not None
or data.schedule_type is not None
or data.timezone is not None
)
if should_recompute_schedule:
now_utc = datetime.now(tz=timezone.utc)
if existing_job is None:
existing_job = await self.get_by_id(job_id)
if existing_job is None:
return None
effective_timezone = data.timezone or existing_job.timezone
effective_timezone_obj = self._resolve_timezone(effective_timezone)
effective_schedule_type = data.schedule_type or existing_job.schedule_type
if data.run_at is not None:
effective_run_at = data.run_at
else:
existing_timezone_obj = self._resolve_timezone(existing_job.timezone)
effective_run_at = (
existing_job.run_at.astimezone(existing_timezone_obj)
.time()
.replace(microsecond=0)
)
local_now = now_utc.astimezone(effective_timezone_obj)
local_dt = datetime.combine(
local_now.date(),
effective_run_at,
effective_timezone_obj,
)
update_values["run_at"] = local_dt.astimezone(timezone.utc)
update_values["next_run_at"] = self._compute_initial_next_run_at(
run_at=effective_run_at,
timezone_str=effective_timezone,
now_utc=now_utc,
schedule_type=effective_schedule_type,
)
if data.timezone is not None:
update_values["timezone"] = data.timezone
if data.status is not None:
update_values["status"] = data.status
merged_config_raw: dict[str, object] = dict(existing_job.config or {})
if data.config is not None:
if existing_job is None:
existing_job = await self.get_by_id(job_id)
if existing_job is None:
return None
merged_config = {
**existing_job.config,
merged_config_raw = {
**merged_config_raw,
**data.config.model_dump(mode="json", exclude_unset=True),
}
update_values["config"] = merged_config
normalized_config = AutomationJobConfig.model_validate(merged_config_raw)
update_values["config"] = normalized_config.model_dump(mode="json")
else:
normalized_config = AutomationJobConfig.model_validate(merged_config_raw)
schedule_changed = data.config is not None and (
"schedule" in data.config.model_dump(mode="json", exclude_unset=True)
)
if data.timezone is not None or schedule_changed:
if normalized_config.schedule is None:
raise ValueError("config.schedule is required")
effective_timezone = data.timezone or existing_job.timezone
update_values["next_run_at"] = self._compute_next_run_at(
schedule=normalized_config.schedule,
timezone_str=effective_timezone,
now_utc=datetime.now(tz=timezone.utc),
)
if not update_values:
return await self.get_by_id(job_id)
return existing_job
return await self.update_by_id(job_id, update_values)
+9 -11
View File
@@ -1,14 +1,14 @@
from __future__ import annotations
from datetime import datetime, time
from datetime import datetime
from typing import Self
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from models.automation_jobs import AutomationJob as OrmAutomationJob
from models.automation_jobs import AutomationJobStatus, ScheduleType
from models.automation_jobs import AutomationJobStatus
from schemas.automation import AutomationJobConfig
@@ -19,8 +19,6 @@ class AutomationJobResponse(BaseModel):
owner_id: UUID
bootstrap_key: str | None = None
title: str
schedule_type: ScheduleType
run_at: time
timezone: str
status: AutomationJobStatus
is_system: bool
@@ -37,8 +35,6 @@ class AutomationJobResponse(BaseModel):
owner_id=obj.owner_id,
bootstrap_key=obj.bootstrap_key,
title=obj.title,
schedule_type=obj.schedule_type,
run_at=obj.run_at.time(),
timezone=obj.timezone,
status=obj.status,
is_system=obj.bootstrap_key is not None,
@@ -54,12 +50,16 @@ class AutomationJobCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(..., min_length=1, max_length=255)
schedule_type: ScheduleType
run_at: time = Field(..., description="Local time in HH:MM:SS format")
timezone: str = Field(..., min_length=1, max_length=50)
status: AutomationJobStatus = Field(default=AutomationJobStatus.ACTIVE)
config: AutomationJobConfig
@model_validator(mode="after")
def validate_schedule_required(self) -> "AutomationJobCreateRequest":
if self.config.schedule is None:
raise ValueError("config.schedule is required")
return self
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
@@ -74,8 +74,6 @@ class AutomationJobUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str | None = Field(None, min_length=1, max_length=255)
schedule_type: ScheduleType | None = None
run_at: time | None = None
timezone: str | None = Field(None, min_length=1, max_length=50)
status: AutomationJobStatus | None = None
config: AutomationJobConfig | None = None
+50 -10
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import datetime, time, timedelta, timezone
from typing import TYPE_CHECKING, Protocol
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import HTTPException, status
from models.automation_jobs import ScheduleType
@@ -11,6 +12,7 @@ from schemas.automation import (
AutomationJob as AutomationJobSchema,
MessageContextConfig,
RuntimeConfig,
ScheduleConfig,
)
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
@@ -68,15 +70,53 @@ class DispatchFn(Protocol):
def _compute_next_run_at(
*,
current_next_run_at: datetime,
schedule: ScheduleConfig,
timezone_str: str,
now_utc: datetime,
schedule_type: ScheduleType,
) -> datetime:
delta = timedelta(days=1 if schedule_type == ScheduleType.DAILY else 7)
next_run_at = current_next_run_at
while next_run_at <= now_utc:
next_run_at = next_run_at + delta
return next_run_at
try:
tz = ZoneInfo(timezone_str)
except ZoneInfoNotFoundError:
tz = ZoneInfo("UTC")
local_now = now_utc.astimezone(tz)
run_clock = time(
hour=schedule.run_at.hour,
minute=schedule.run_at.minute,
tzinfo=tz,
)
if schedule.type == ScheduleType.DAILY:
candidate_local = datetime.combine(local_now.date(), run_clock)
if candidate_local <= local_now:
candidate_local = candidate_local + timedelta(days=1)
return candidate_local.astimezone(timezone.utc)
weekdays = schedule.weekdays or []
if not weekdays:
raise ValueError("weekly schedule requires weekdays")
normalized_weekdays = sorted(set(weekdays))
for day_offset in range(0, 8):
candidate_day = local_now.date() + timedelta(days=day_offset)
if candidate_day.isoweekday() not in normalized_weekdays:
continue
candidate_local = datetime.combine(candidate_day, run_clock)
if candidate_local > local_now:
return candidate_local.astimezone(timezone.utc)
fallback_day = local_now.date() + timedelta(days=7)
while fallback_day.isoweekday() not in normalized_weekdays:
fallback_day = fallback_day + timedelta(days=1)
fallback_local = datetime.combine(fallback_day, run_clock)
return fallback_local.astimezone(timezone.utc)
def _ensure_schedule(job: AutomationJobSchema) -> ScheduleConfig:
schedule = job.config.schedule
if schedule is None:
raise ValueError(f"job {job.id} config.schedule is missing")
return schedule
@dataclass(slots=True)
@@ -129,9 +169,9 @@ class AutomationJobsService:
await self._repository.update_job_schedule(
job_id=job.id,
next_run_at=_compute_next_run_at(
current_next_run_at=job.next_run_at,
schedule=_ensure_schedule(job),
timezone_str=job.timezone,
now_utc=now_utc,
schedule_type=job.schedule_type,
),
last_run_at=now_utc,
)