"""Daemon Ticker — 30s 状态扫描 + 依赖推进 + 事件驱动 Tick 循环是整个 Daemon 的核心驱动: 1. 遍历所有 active 项目 2. 每个 tick 扫描黑板状态 3. 推进依赖链(blocked → pending) 4. 写入 daemon_tick 事件 5. 调度 pending 任务(F9 实现) """ from __future__ import annotations import asyncio import json import logging from pathlib import Path from typing import Any, Callable, Coroutine, Dict, List, Optional from src.blackboard.operations import Blackboard from src.blackboard.db import get_connection from src.blackboard.models import Task from src.blackboard.queries import Queries from src.blackboard.registry import ProjectRegistry logger = logging.getLogger("moziplus-v2.ticker") class Ticker: """Daemon ticker 主循环""" def __init__( self, registry: ProjectRegistry, tick_interval: float = 30.0, max_ticks: Optional[int] = None, on_tick_complete: Optional[Callable[[], Coroutine[Any, Any, None]]] = None, ): """ Args: registry: 项目注册表 tick_interval: tick 间隔秒数 max_ticks: 测试用,限制最大 tick 次数 on_tick_complete: 每次 tick 完成后的回调(用于通知等) """ self.registry = registry self.tick_interval = tick_interval self.max_ticks = max_ticks self.on_tick_complete = on_tick_complete self._tick_count: int = 0 self._running: bool = False self._task: Optional[asyncio.Task] = None # 已初始化的 db_path 集合,避免每次 tick 重复 init_db self._initialized_dbs: set = set() # 每个项目上次 tick 的 event count(用于僵尸检测 F8) self._last_event_counts: Dict[str, int] = {} # ------------------------------------------------------------------ # 生命周期 # ------------------------------------------------------------------ async def start(self) -> None: """启动 tick 循环""" if self._running: return self._running = True self._task = asyncio.create_task(self._loop()) logger.info("Ticker started (interval=%.1fs)", self.tick_interval) async def stop(self) -> None: """停止 tick 循环""" self._running = False if self._task: self._task.cancel() try: await self._task except asyncio.CancelledError: pass logger.info("Ticker stopped (ticks=%d)", self._tick_count) @property def tick_count(self) -> int: return self._tick_count @property def is_running(self) -> bool: return self._running # ------------------------------------------------------------------ # 主循环 # ------------------------------------------------------------------ async def _loop(self) -> None: """Tick 主循环""" while self._running: try: await self.tick() except asyncio.CancelledError: raise except Exception: logger.exception("Tick %d error", self._tick_count + 1) if self.max_ticks and self._tick_count >= self.max_ticks: logger.info("Max ticks reached (%d), stopping", self.max_ticks) self._running = False break try: await asyncio.sleep(self.tick_interval) except asyncio.CancelledError: return # ------------------------------------------------------------------ # 单次 tick # ------------------------------------------------------------------ async def tick(self) -> Dict[str, Any]: """执行一次 tick,遍历所有 active 项目""" self._tick_count += 1 tick_num = self._tick_count results: Dict[str, Any] = { "tick": tick_num, "projects": {}, } projects = self.registry.list_projects() active_projects = { pid: info for pid, info in projects.items() if info.get("status") == "active" } for project_id, project_info in active_projects.items(): try: pr = await self._tick_project(project_id, project_info) results["projects"][project_id] = pr except Exception as e: logger.exception("Tick %d project %s error", tick_num, project_id) results["projects"][project_id] = {"error": str(e)} logger.debug("Tick %d complete: %d projects", tick_num, len(active_projects)) if self.on_tick_complete: try: await self.on_tick_complete() except Exception: logger.exception("on_tick_complete callback error") return results async def _tick_project(self, project_id: str, project_info: Dict[str, Any]) -> Dict[str, Any]: """单项目的 tick 处理""" project_dir = self.registry.root / project_id db_path = project_dir / "blackboard.db" if not db_path.exists(): return {"status": "no_db"} # 只在首次遇到该 db_path 时执行 init_db db_key = str(db_path) if db_key not in self._initialized_dbs: from src.blackboard.db import init_db init_db(db_path) self._initialized_dbs.add(db_key) result: Dict[str, Any] = { "status": "ok", "summary_before": {}, "summary_after": {}, "advanced": [], } # 1. 扫描当前状态 queries = Queries(db_path) result["summary_before"] = queries.task_summary() # 2. 依赖推进 advanced = self._advance_dependencies(db_path) result["advanced"] = advanced # 3. 写 daemon_tick 事件(直接用 get_connection,不再 Blackboard() → init_db) conn = get_connection(db_path) try: conn.execute("BEGIN IMMEDIATE") conn.execute( "INSERT INTO events (task_id, agent, event_type, detail) VALUES (?,?,?,?)", (None, "daemon", "daemon_tick", json.dumps({"tick": self._tick_count, "advanced_count": len(advanced)})), ) conn.commit() finally: conn.close() # 4. 扫描后状态 result["summary_after"] = queries.task_summary() return result # ------------------------------------------------------------------ # 依赖推进 # ------------------------------------------------------------------ def _advance_dependencies(self, db_path: Path) -> List[str]: """检查 blocked 任务,若所有依赖已完成则推进为 pending Returns: 被推进的 task_id 列表 """ queries = Queries(db_path) blocked = queries.blocked_tasks_with_deps() advanced: List[str] = [] if not blocked: return advanced conn = get_connection(db_path) try: for item in blocked: if item["all_deps_done"]: task_id = item["task_id"] ok = self._transition_status(conn, task_id, "pending", agent="daemon", detail={"reason": "all_dependencies_done"}) if ok: advanced.append(task_id) logger.info("Advanced %s: blocked → pending", task_id) finally: conn.close() return advanced def _transition_status(self, conn, task_id: str, new_status: str, agent: str = "daemon", detail: Optional[Dict] = None) -> bool: """轻量状态转换(不走 Blackboard 类,避免 init_db)""" from src.blackboard.db import VALID_TRANSITIONS, TERMINAL_STATUSES, EVENT_TYPES from datetime import datetime conn.execute("BEGIN IMMEDIATE") row = conn.execute("SELECT status FROM tasks WHERE id=?", (task_id,)).fetchone() if not row: return False old_status = row["status"] if old_status in TERMINAL_STATUSES or old_status == new_status: return old_status == new_status if new_status not in VALID_TRANSITIONS.get(old_status, set()): return False now = datetime.utcnow().isoformat() conn.execute( "UPDATE tasks SET status=?, updated_at=? WHERE id=?", (new_status, now, task_id), ) event_type = f"task_{new_status}" if event_type not in EVENT_TYPES: event_type = "daemon_tick" conn.execute( "INSERT INTO events (task_id, agent, event_type, detail) VALUES (?,?,?,?)", (task_id, agent, event_type, json.dumps({"from": old_status, "to": new_status, **(detail or {})})), ) conn.commit() return True # ------------------------------------------------------------------ # 手动 tick(API 端点触发) # ------------------------------------------------------------------ async def manual_tick(self) -> Dict[str, Any]: """手动触发一次 tick""" logger.info("Manual tick triggered") result = await self.tick() result["manual"] = True return result