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;