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;
|
||||
|
||||
Reference in New Issue
Block a user