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:
2026-08-27 17:27:53 +09:00
co-authored by Claude Sonnet 5
parent 8ab29800db
commit 69ab8e125b
20 changed files with 1347 additions and 95 deletions
+20 -10
View File
@@ -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
View File
@@ -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) ---------------