feat(R07): task repository, schedule calculator, Qt clock adapter, task/AI-planner services
Team Hoa, EPIC R07 (Scheduling & Workflow Runtime) - Team Hoa scope only (R07-T01 -> T05; R07-T06 Co4EWorkflowService is Team Nam's). - R07-T01: infrastructure/persistence/json/task_repository_impl.py wraps core/tasks.py's CRUD; core/tasks.py::save_task now writes through atomic_write.write_json (same durability fix as R06-T02, save_task was still doing a plain write_text). - R07-T02: domain/tasks/schedule_calculator.py::ScheduleCalculator - the cron/interval/daily/weekly/monthly due-time math extracted from core/tasks.py, pure Python with is_holiday/make_cron injected so domain/ never imports core (ADR-001 I2). core/tasks.py keeps its old function names as thin wrappers so every existing caller is unchanged. This was previously untested; now has its own unit suite. - R07-T03: infrastructure/qt/qt_scheduler_clock.py::QtSchedulerClock wraps the QTimer TaskScheduler used to own directly, injected via a new `clock=` constructor param (defaults to a real one). Originally planned at platform/qt/... ; moved after confirming that name shadows the stdlib platform module (used by core/windows_sandbox_vm.py, core/appcontainer_sandbox.py) whenever the repo root is on sys.path. tests/fakes/fake_clock.py lets scheduler dispatch be tested tick-by-tick with no Qt event loop. - R07-T04: application/scheduling/task_application_service.py centralizes run_now/duplicate/pause/delete/bulk_delete and the Kanban drag-drop business rules (move_to_status), currently only reachable by driving the real ui/schedule_task_tab.py widget. - R07-T05: application/scheduling/ai_task_planner_service.py wraps core/ai_task_planner.py::plan_tasks and core/task_import.py::import_tasks as a seam, plus the attachment-stamping step that used to only exist inside the AI-create dialog's worker closure. pytest: 328 pass (same 4 pre-existing failures as the R05/R06 baseline, unrelated to this work - see docs/refactor/BaoCao_TeamHoa_R05_R06.md). scripts/check_imports.py: PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+20
-10
@@ -17,7 +17,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QObject, QTimer, Signal
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from .tasks import (
|
||||
advance_after_run, chain_action, dependencies_met, due_tasks, format_run_at,
|
||||
@@ -39,22 +39,32 @@ class TaskScheduler(QObject):
|
||||
# which fires before the worker thread has even begun).
|
||||
history_ready = Signal(str) # task_id
|
||||
|
||||
def __init__(self, ctx, tasks_dir: Optional[Path] = None, parent=None):
|
||||
def __init__(self, ctx, tasks_dir: Optional[Path] = None, parent=None, clock=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self.tasks_dir = tasks_dir # None → default TASKS_DIR
|
||||
self._workers: Dict[str, AgentWorker] = {} # task_id → running worker
|
||||
self._retries: Dict[str, int] = {}
|
||||
self._session_ids: Dict[str, str] = {} # task_id → its run's History session id
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(TICK_MS)
|
||||
self._timer.timeout.connect(self.tick)
|
||||
# R07-T03: the QTimer this class used to own directly is now behind a
|
||||
# small clock interface (start/stop/pump) — see
|
||||
# platform/qt/qt_scheduler_clock.py::QtSchedulerClock. Defaulting to a
|
||||
# real one here keeps every existing production call site (which
|
||||
# never passes `clock=`) unchanged; tests inject
|
||||
# tests/fakes/fake_clock.py::FakeClock to control ticks by hand with
|
||||
# no Qt event loop running. Imported lazily so importing core.tasks/
|
||||
# core.task_scheduler for the Qt-free logic doesn't require the Qt
|
||||
# adapter module to even exist in a headless test context.
|
||||
if clock is None:
|
||||
from ..infrastructure.qt.qt_scheduler_clock import QtSchedulerClock
|
||||
clock = QtSchedulerClock(self)
|
||||
self._clock = clock
|
||||
|
||||
# ---- lifecycle ----------------------------------------------------
|
||||
def start(self) -> None:
|
||||
self._recover_orphans()
|
||||
self.tick() # catch up overdue tasks right at app start
|
||||
self._timer.start()
|
||||
self._clock.start(TICK_MS, self.tick)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Request every running worker to stop, then WAIT (bounded) for them
|
||||
@@ -67,15 +77,15 @@ class TaskScheduler(QObject):
|
||||
``_on_done`` (the only place that writes the run into the task's
|
||||
history) never runs. The task's real output can already be sitting on
|
||||
disk while its history stays stuck on "running" forever. Pumping
|
||||
``processEvents()`` here lets that queued signal actually get
|
||||
delivered before the app finishes quitting.
|
||||
the clock here lets that queued signal actually get delivered before
|
||||
the app finishes quitting.
|
||||
"""
|
||||
self._timer.stop()
|
||||
self._clock.stop()
|
||||
deadline = time.monotonic() + STOP_WAIT_SECS
|
||||
while self._workers and time.monotonic() < deadline:
|
||||
for w in list(self._workers.values()):
|
||||
w.request_stop()
|
||||
QCoreApplication.processEvents()
|
||||
self._clock.pump()
|
||||
for w in list(self._workers.values()):
|
||||
w.wait(50)
|
||||
# Anything still alive past the deadline is abandoned here;
|
||||
|
||||
+29
-71
@@ -151,7 +151,14 @@ def save_task(task: Dict[str, Any], directory: Path = None) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
|
||||
path = task_path(task["task_id"], directory)
|
||||
path.write_text(json.dumps(task, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
# R07-T01: atomic write — same class of bug already fixed in
|
||||
# core/projects.py and core/history.py at R06-T02 (plain write_text has a
|
||||
# gap between truncate and write; a crash there leaves a half-written
|
||||
# tasks/<id>.json that load_task() then silently treats as "missing",
|
||||
# dropping the task). Lazy import to match the existing call sites and
|
||||
# avoid a core -> infrastructure import at module load time.
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
write_json(path, task)
|
||||
return path
|
||||
|
||||
|
||||
@@ -287,36 +294,32 @@ def chain_error(tasks: List[Dict[str, Any]], task_id: str,
|
||||
|
||||
|
||||
# ---- schedule math --------------------------------------------------------
|
||||
def _is_excluded_day(dt: datetime, sched: Dict[str, Any]) -> bool:
|
||||
"""True when ``dt`` falls on a day this schedule must skip: a weekend
|
||||
(working_days_only) or a public holiday of the configured country."""
|
||||
if sched.get("working_days_only") and dt.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
return True
|
||||
if sched.get("skip_holidays"):
|
||||
# R07-T02: the actual date/cron math now lives in
|
||||
# domain/tasks/schedule_calculator.py::ScheduleCalculator (pure Python, unit
|
||||
# tested on its own — see tests/unit/test_schedule_calculator.py). Everything
|
||||
# below is a thin wrapper kept for backward compatibility: task_scheduler.py,
|
||||
# task_executors.py and ui/task_editor_dialog.py all still import these
|
||||
# module-level names from core.tasks, and core/holiday_calendar.py::is_holiday
|
||||
# / core/cron.py::Cron are only wired in HERE (lazily, matching the previous
|
||||
# lazy-import style) — domain/ is not allowed to import core/ (ADR-001 I2).
|
||||
_calculator: Optional[Any] = None
|
||||
|
||||
|
||||
def _get_calculator():
|
||||
global _calculator
|
||||
if _calculator is None:
|
||||
from .cron import Cron
|
||||
from .holiday_calendar import is_holiday
|
||||
from ..domain.tasks.schedule_calculator import ScheduleCalculator
|
||||
|
||||
if is_holiday(dt.date(), sched.get("holiday_country", "")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _add_month(dt: datetime) -> datetime:
|
||||
import calendar
|
||||
|
||||
year = dt.year + (1 if dt.month == 12 else 0)
|
||||
month = 1 if dt.month == 12 else dt.month + 1
|
||||
day = min(dt.day, calendar.monthrange(year, month)[1])
|
||||
return dt.replace(year=year, month=month, day=day)
|
||||
_calculator = ScheduleCalculator(is_holiday=is_holiday, make_cron=Cron)
|
||||
return _calculator
|
||||
|
||||
|
||||
def shift_off_excluded_days(dt: datetime, sched: Dict[str, Any]) -> datetime:
|
||||
"""Push ``dt`` forward one day at a time until it lands on an allowed day
|
||||
(same time of day) — used for one-time schedules set on a weekend/holiday."""
|
||||
guard = 0
|
||||
while _is_excluded_day(dt, sched) and guard < 400:
|
||||
dt += timedelta(days=1)
|
||||
guard += 1
|
||||
return dt
|
||||
return _get_calculator().shift_off_excluded_days(dt, sched)
|
||||
|
||||
|
||||
def compute_next_run(task: Dict[str, Any], after: datetime) -> Optional[datetime]:
|
||||
@@ -324,57 +327,12 @@ def compute_next_run(task: Dict[str, Any], after: datetime) -> Optional[datetime
|
||||
(daily / weekly / monthly / cron), or None for one-shot schedules.
|
||||
Occurrences on excluded days (weekends with working_days_only, public
|
||||
holidays with skip_holidays+holiday_country) are skipped forward."""
|
||||
sched = task.get("schedule", {})
|
||||
repeat = sched.get("repeat_type", "none")
|
||||
|
||||
if repeat == "cron":
|
||||
from .cron import Cron, CronError
|
||||
|
||||
try:
|
||||
cron = Cron(sched.get("cron_expression") or "")
|
||||
except CronError:
|
||||
return None
|
||||
nxt = cron.next_after(after)
|
||||
guard = 0
|
||||
while nxt is not None and _is_excluded_day(nxt, sched) and guard < 400:
|
||||
nxt = cron.next_after(nxt)
|
||||
guard += 1
|
||||
return nxt
|
||||
|
||||
base = parse_run_at(sched.get("run_at"))
|
||||
if base is None:
|
||||
return None
|
||||
if repeat == "daily":
|
||||
advance = lambda d: d + timedelta(days=1) # noqa: E731
|
||||
elif repeat == "weekly":
|
||||
advance = lambda d: d + timedelta(weeks=1) # noqa: E731
|
||||
elif repeat == "monthly":
|
||||
advance = _add_month
|
||||
else:
|
||||
return None
|
||||
nxt = base
|
||||
while nxt <= after:
|
||||
nxt = advance(nxt)
|
||||
guard = 0
|
||||
while _is_excluded_day(nxt, sched) and guard < 400:
|
||||
nxt = advance(nxt)
|
||||
guard += 1
|
||||
return nxt
|
||||
return _get_calculator().compute_next_run(task, after)
|
||||
|
||||
|
||||
def due_tasks(tasks: List[Dict[str, Any]], now: datetime) -> List[Dict[str, Any]]:
|
||||
"""Tasks that should start now: Scheduled + schedule enabled + run_at due."""
|
||||
due = []
|
||||
for t in tasks:
|
||||
if t.get("status") != "scheduled":
|
||||
continue
|
||||
sched = t.get("schedule", {})
|
||||
if not sched.get("enabled"):
|
||||
continue
|
||||
run_at = parse_run_at(sched.get("run_at"))
|
||||
if run_at is not None and run_at <= now:
|
||||
due.append(t)
|
||||
return due
|
||||
return _get_calculator().due_tasks(tasks, now)
|
||||
|
||||
|
||||
# ---- post-run bookkeeping (pure; scheduler applies + saves) ---------------
|
||||
|
||||
Reference in New Issue
Block a user