"""F6 Daemon Ticker 单元测试 按司马懿测试计划 test-plan-v2.6.md §F6: - T1: tick 循环正常运行(P0) - T2: scan_tasks 检测 pending(P0) - T3: 依赖推进(P0) - T4: events 写入(P0) - T5: 多项目轮询(P0) - T6: tick 异常不中断(P1) - T7: 手动 tick 端点(P1) v2.8 新增(#07.2 _check_timeouts 统一 + #07.3 ACT-1 updated_at fallback): - E12: _check_timeouts 统一超时(4 个测试) """ import asyncio import json import pytest from pathlib import Path from src.blackboard.operations import Blackboard from src.blackboard.models import Task from src.blackboard.registry import ProjectRegistry from src.blackboard.queries import Queries from src.daemon.ticker import Ticker pytestmark = pytest.mark.integration # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def data_root(tmp_path): return tmp_path / "projects" @pytest.fixture def registry(data_root): return ProjectRegistry(data_root) @pytest.fixture def project_with_tasks(registry, data_root): """创建一个项目并添加几个任务""" registry.create_project("test-proj", "Test Project", agents=["agent-a"]) db_path = data_root / "test-proj" / "blackboard.db" bb = Blackboard(db_path) # pending 任务 bb.create_task(Task( id="t1", title="Task 1", status="pending", assigned_by="daemon", task_type="coding", )) # blocked 任务(依赖 t1) bb.create_task(Task( id="t2", title="Task 2", status="blocked", assigned_by="daemon", task_type="coding", depends_on=json.dumps(["t1"]), )) return registry, db_path, bb # --------------------------------------------------------------------------- # T1: tick 循环正常运行 # --------------------------------------------------------------------------- class TestTickLoop: def test_ticker_runs(self, registry): """Ticker 可以启动和停止""" ticker = Ticker(registry, tick_interval=0.1, max_ticks=3) assert not ticker.is_running async def run(): await ticker.start() # 等待几个 tick await asyncio.sleep(0.5) await ticker.stop() asyncio.run(run()) assert ticker.tick_count >= 1 def test_max_ticks_respected(self, registry): """max_ticks 限制 tick 次数""" ticker = Ticker(registry, tick_interval=0.05, max_ticks=3) async def run(): await ticker.start() await asyncio.sleep(1.0) # max_ticks 达到后应自动停止 asyncio.run(run()) assert ticker.tick_count <= 4 # 允许少量误差 def test_tick_count_increments(self, registry): """tick_count 随 tick 递增""" ticker = Ticker(registry, tick_interval=0.05, max_ticks=2) async def run(): await ticker.start() await asyncio.sleep(0.3) asyncio.run(run()) assert ticker.tick_count >= 2 def test_ticker_no_projects(self, registry): """无项目时 tick 正常运行不报错""" ticker = Ticker(registry, tick_interval=0.05, max_ticks=1) async def run(): result = await ticker.tick() assert result["tick"] == 1 assert result["projects"] == {} asyncio.run(run()) # --------------------------------------------------------------------------- # T2: scan_tasks 检测 pending # --------------------------------------------------------------------------- class TestScanTasks: def test_scan_finds_pending(self, project_with_tasks): registry, db_path, bb = project_with_tasks ticker = Ticker(registry, tick_interval=30) async def run(): result = await ticker.tick() proj_result = result["projects"]["test-proj"] assert proj_result["status"] == "ok" assert proj_result["summary_before"]["pending"] == 1 assert proj_result["summary_before"]["blocked"] == 1 asyncio.run(run()) def test_scan_empty_project(self, registry, data_root): """有 DB 但无任务的项目 tick 返回 ok + 空状态""" registry.create_project("empty-proj", "Empty") # Init DB (empty) db_path = data_root / "empty-proj" / "blackboard.db" Blackboard(db_path) # creates tables ticker = Ticker(registry, tick_interval=30) async def run(): result = await ticker.tick() proj_result = result["projects"]["empty-proj"] assert proj_result["status"] == "ok" assert proj_result["summary_before"] == {} asyncio.run(run()) def test_scan_project_no_db(self, registry, data_root): """项目目录存在但无 DB 时返回 no_db""" registry.create_project("no-db-proj", "No DB") ticker = Ticker(registry, tick_interval=30) async def run(): result = await ticker.tick() proj_result = result["projects"]["no-db-proj"] assert proj_result["status"] == "no_db" asyncio.run(run()) # --------------------------------------------------------------------------- # T3: 依赖推进 # --------------------------------------------------------------------------- class TestDependencyAdvance: def test_blocked_advances_when_deps_done(self, project_with_tasks): """依赖完成后 blocked → pending""" registry, db_path, bb = project_with_tasks # 把 t1 标记为 done bb.update_task_status("t1", "claimed", agent="agent-a") bb.update_task_status("t1", "working", agent="agent-a") bb.update_task_status("t1", "review", agent="agent-a") bb.update_task_status("t1", "done", agent="agent-a") ticker = Ticker(registry, tick_interval=30) async def run(): result = await ticker.tick() proj_result = result["projects"]["test-proj"] assert "t2" in proj_result["advanced"] # 验证 t2 状态变为 pending from src.blackboard.queries import Queries queries = Queries(db_path) summary = queries.task_summary() assert summary.get("pending", 0) >= 1 # t2 现在 pending assert summary.get("blocked", 0) == 0 asyncio.run(run()) def test_blocked_stays_when_deps_not_done(self, project_with_tasks): """依赖未完成时 blocked 不推进""" registry, db_path, bb = project_with_tasks ticker = Ticker(registry, tick_interval=30) async def run(): result = await ticker.tick() proj_result = result["projects"]["test-proj"] assert len(proj_result["advanced"]) == 0 from src.blackboard.queries import Queries queries = Queries(db_path) summary = queries.task_summary() assert summary.get("blocked", 0) == 1 asyncio.run(run()) def test_chain_advance(self, registry, data_root): """链式依赖:t1 done → t2 unblock → t3 unblock""" registry.create_project("chain", "Chain") db_path = data_root / "chain" / "blackboard.db" bb = Blackboard(db_path) bb.create_task(Task(id="t1", title="T1", status="pending", assigned_by="d")) bb.create_task(Task( id="t2", title="T2", status="blocked", assigned_by="d", depends_on=json.dumps(["t1"]), )) bb.create_task(Task( id="t3", title="T3", status="blocked", assigned_by="d", depends_on=json.dumps(["t2"]), )) # t1 done for s in ("claimed", "working", "review", "done"): bb.update_task_status("t1", s, agent="a") ticker = Ticker(registry, tick_interval=30) async def run(): # First tick: t1 done → t2 unblock r1 = await ticker.tick() assert "t2" in r1["projects"]["chain"]["advanced"] assert "t3" not in r1["projects"]["chain"]["advanced"] # Second tick: t2 pending (just unblocked), but not done yet r2 = await ticker.tick() # t3 still blocked because t2 is pending not done assert "t3" not in r2["projects"]["chain"]["advanced"] asyncio.run(run()) def test_multi_dep_all_done(self, registry, data_root): """多依赖全部完成后才推进""" registry.create_project("multi", "Multi") db_path = data_root / "multi" / "blackboard.db" bb = Blackboard(db_path) bb.create_task(Task(id="t1", title="T1", status="pending", assigned_by="d")) bb.create_task(Task(id="t2", title="T2", status="pending", assigned_by="d")) bb.create_task(Task( id="t3", title="T3", status="blocked", assigned_by="d", depends_on=json.dumps(["t1", "t2"]), )) # Only t1 done for s in ("claimed", "working", "review", "done"): bb.update_task_status("t1", s, agent="a") ticker = Ticker(registry, tick_interval=30) async def run(): r1 = await ticker.tick() assert "t3" not in r1["projects"]["multi"]["advanced"] # Now t2 also done for s in ("claimed", "working", "review", "done"): bb.update_task_status("t2", s, agent="a") r2 = await ticker.tick() assert "t3" in r2["projects"]["multi"]["advanced"] asyncio.run(run()) # --------------------------------------------------------------------------- # T4: events 写入 # --------------------------------------------------------------------------- class TestEvents: def test_daemon_tick_event_written(self, project_with_tasks): registry, db_path, bb = project_with_tasks ticker = Ticker(registry, tick_interval=30) async def run(): await ticker.tick() from src.blackboard.queries import Queries queries = Queries(db_path) events = queries.recent_events(limit=5) tick_events = [e for e in events if e["event_type"] == "daemon_tick"] assert len(tick_events) >= 1 detail = json.loads(tick_events[0]["detail"]) assert detail["tick"] == 1 asyncio.run(run()) def test_advance_event_in_detail(self, project_with_tasks): registry, db_path, bb = project_with_tasks # Make t1 done so t2 can advance for s in ("claimed", "working", "review", "done"): bb.update_task_status("t1", s, agent="a") ticker = Ticker(registry, tick_interval=30) async def run(): await ticker.tick() from src.blackboard.queries import Queries queries = Queries(db_path) events = queries.recent_events(limit=10) tick_events = [e for e in events if e["event_type"] == "daemon_tick"] detail = json.loads(tick_events[0]["detail"]) assert detail["advanced_count"] == 1 asyncio.run(run()) # --------------------------------------------------------------------------- # T5: 多项目轮询 # --------------------------------------------------------------------------- class TestMultiProject: def test_ticks_all_active_projects(self, registry, data_root): """tick 遍历所有 active 项目""" for pid in ("proj-a", "proj-b", "proj-c"): registry.create_project(pid, f"Project {pid}") db_path = data_root / pid / "blackboard.db" bb = Blackboard(db_path) bb.create_task(Task( id=f"{pid}-t1", title=f"Task {pid}", status="pending", assigned_by="d", )) ticker = Ticker(registry, tick_interval=30) async def run(): result = await ticker.tick() assert len(result["projects"]) == 3 for pid in ("proj-a", "proj-b", "proj-c"): assert pid in result["projects"] assert result["projects"][pid]["status"] == "ok" asyncio.run(run()) def test_skips_archived_projects(self, registry, data_root): """归档项目不参与 tick""" registry.create_project("active", "Active") registry.create_project("archived", "Archived") registry.archive_project("archived") # Add DB to both for pid in ("active", "archived"): db_path = data_root / pid / "blackboard.db" Blackboard(db_path) ticker = Ticker(registry, tick_interval=30) async def run(): result = await ticker.tick() # archived 项目被 rename 到 _archived/,但 registry 仍记录它 # ticker 应跳过 status != "active" 的项目 for pid, pr in result["projects"].items(): assert pid != "archived" asyncio.run(run()) # --------------------------------------------------------------------------- # T6: tick 异常不中断(P1) # --------------------------------------------------------------------------- class TestTickResilience: def test_tick_continues_after_error(self, registry, data_root): """单次 tick 异常不影响后续 tick""" registry.create_project("good", "Good") db_path = data_root / "good" / "blackboard.db" Blackboard(db_path) ticker = Ticker(registry, tick_interval=0.05, max_ticks=3) tick_count = [0] original_tick_project = ticker._tick_project async def failing_tick(project_id, project_info): tick_count[0] += 1 if tick_count[0] == 1: raise RuntimeError("Simulated failure") return await original_tick_project(project_id, project_info) ticker._tick_project = failing_tick async def run(): await ticker.start() await asyncio.sleep(0.3) asyncio.run(run()) assert ticker.tick_count >= 2 # First tick had error but continued # --------------------------------------------------------------------------- # T7: 手动 tick 端点(P1) # --------------------------------------------------------------------------- class TestManualTick: def test_manual_tick(self, project_with_tasks): registry, db_path, bb = project_with_tasks ticker = Ticker(registry, tick_interval=30) async def run(): result = await ticker.manual_tick() assert result["manual"] is True assert result["tick"] == 1 assert "test-proj" in result["projects"] asyncio.run(run()) def test_manual_tick_increments_count(self, project_with_tasks): registry, db_path, bb = project_with_tasks ticker = Ticker(registry, tick_interval=30) async def run(): await ticker.manual_tick() await ticker.manual_tick() assert ticker.tick_count == 2 asyncio.run(run()) # --------------------------------------------------------------------------- # E12: _check_timeouts 统一超时(v2.8 #07.2/#07.3 新增) # --------------------------------------------------------------------------- class TestCheckTimeoutsUnified: """E12: #07.2 _check_timeouts 统一检查 + #07.3 ACT-1 updated_at fallback""" @pytest.fixture def timeout_project(self, tmp_path): """创建项目 + 添加可超时任务""" data_root = tmp_path / "projects" registry = ProjectRegistry(data_root) registry.create_project("timeout-proj", "Timeout Test", agents=["agent-a"]) db_path = data_root / "timeout-proj" / "blackboard.db" bb = Blackboard(db_path) return registry, db_path, bb def test_crash_limit_working(self, timeout_project): """E12.1: executor crash 3 次/30min → _check_timeouts 标 failed""" registry, db_path, bb = timeout_project bb.create_task(Task( id="t-crash", title="Crash Task", status="working", assigned_by="daemon", current_agent="agent-a", )) from datetime import datetime, timedelta conn = bb._conn() try: for i in range(3): attempt_time = datetime.utcnow() - timedelta(minutes=25 - i * 5) conn.execute( "INSERT INTO task_attempts (task_id, attempt_number, agent, outcome, started_at) " "VALUES (?, ?, ?, ?, ?)", ("t-crash", i + 1, "agent-a", "crashed", attempt_time.isoformat()), ) conn.commit() finally: conn.close() ticker = Ticker(registry, tick_interval=30) result = ticker._check_timeouts(db_path) assert isinstance(result, list) def test_crash_limit_review(self, timeout_project): """E12.2: reviewer crash 3 次/30min → _check_timeouts 标 failed""" registry, db_path, bb = timeout_project bb.create_task(Task( id="t-review-crash", title="Review Crash Task", status="review", assigned_by="daemon", current_agent="simayi-challenger", )) ticker = Ticker(registry, tick_interval=30) result = ticker._check_timeouts(db_path) assert isinstance(result, list) def test_updated_at_fallback(self, timeout_project): """E12.3: mail auto-working 无 started_at/claimed_at → updated_at fallback""" registry, db_path, bb = timeout_project from datetime import datetime, timedelta old_time = (datetime.utcnow() - timedelta(minutes=60)).isoformat() bb.create_task(Task( id="t-mail-orphan", title="Mail Orphan", status="working", assigned_by="daemon", current_agent="pangtong-fujunshi", )) conn = bb._conn() try: conn.execute( "UPDATE tasks SET updated_at = ? WHERE id = ?", (old_time, "t-mail-orphan"), ) conn.execute( "UPDATE tasks SET started_at = NULL, claimed_at = NULL WHERE id = ?", ("t-mail-orphan",), ) conn.commit() finally: conn.close() ticker = Ticker(registry, tick_interval=30, default_task_timeout_minutes=30) reclaimed = ticker._check_timeouts(db_path) assert "t-mail-orphan" in reclaimed, \ "Mail orphan with only updated_at should be reclaimed via fallback" def test_process_dead_keeps_review_status(self, timeout_project): """E12.4: review agent 进程死 → 保持 review 状态(不推 pending)""" registry, db_path, bb = timeout_project bb.create_task(Task( id="t-review-dead", title="Review Dead Process", status="review", assigned_by="daemon", current_agent="simayi-challenger", )) from datetime import datetime conn = bb._conn() try: conn.execute( "UPDATE tasks SET updated_at = ? WHERE id = ?", (datetime.utcnow().isoformat(), "t-review-dead"), ) conn.commit() finally: conn.close() ticker = Ticker(registry, tick_interval=30, default_task_timeout_minutes=30) reclaimed = ticker._check_timeouts(db_path) assert "t-review-dead" not in reclaimed