Files
nmfs-agents/tests/test_executor.py
qiuruiandClaude Sonnet 4.6 900e388f08 feat: Executor 集成 EventBus + OpsHub 事件消费星形枢纽
- executor.py:__init__ 增加 event_bus 可选参数;_run_task 在 mark_done/mark_failed 后发布对应事件
- scheduler.py:_run_pending_queue 启动时尝试连接 Redis,可用时注入 EventBus,不可用时降级静默
- ops.py:新增 OpsHub 类,消费 ops-hub consumer group 事件,写入 DailyReport 并对 alert/version_pass 入队广播任务
- tests/test_executor.py:新增 test_executor_publishes_event_on_done,使用 fakeredis 验证事件发布
- tests/test_ops_hub.py:新增两个测试,覆盖事件→日报写入和 alert→任务分发链路

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 23:32:53 +08:00

216 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
import pytest
from pathlib import Path
from unittest.mock import MagicMock, patch
from nmfs_agents.core.executor import Executor
from nmfs_agents.core.queue import TaskQueue, Task
from nmfs_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
from nmfs_agents.tools.project_memory import ProjectMemory
def _cfg(tmp_path: Path) -> AgentsConfig:
return AgentsConfig(
projects={"yolo": ProjectConfig(path=tmp_path / "yolo", mode="report")},
scheduler=SchedulerConfig(max_concurrent=2),
claude=ClaudeConfig(api_key="fake"),
feishu=FeishuConfig(),
devices={},
)
@pytest.mark.asyncio
async def test_run_next_marks_task_done(tmp_path):
cfg = _cfg(tmp_path)
q = TaskQueue(db_path=tmp_path / "tasks.db")
tid = q.enqueue(Task(project="yolo", type="fix_bug", title="测试",
priority=1, mode="report", agent_role="developer"))
mem = ProjectMemory(db_path=tmp_path / "mem.db")
executor = Executor(cfg, queue=q, memory=mem)
with patch("nmfs_agents.core.executor.DeveloperAgent") as MockAgent:
mock_agent = MockAgent.return_value
mock_agent.run.return_value = MagicMock(status="done", summary="测试完成")
await executor.run_next()
# 任务应标记为 done
with q._conn() as c:
row = c.execute("SELECT status FROM tasks WHERE id=?", (tid,)).fetchone()
assert row["status"] == "done"
def test_executor_writes_history_after_task(tmp_path):
"""Executor must write task result to ProjectMemory history after completion."""
import asyncio
from unittest.mock import patch
from nmfs_agents.core.executor import Executor
from nmfs_agents.core.queue import TaskQueue, Task
from nmfs_agents.tools.project_memory import ProjectMemory
from nmfs_agents.agents.developer import AgentResult
mem = ProjectMemory(db_path=tmp_path / "mem.db")
queue = TaskQueue(db_path=tmp_path / "q.db")
queue.enqueue(Task(project="yolo", type="fix_bug", title="修复零检出",
context="", priority=2, mode="report", agent_role="developer"))
cfg = _cfg(tmp_path)
executor = Executor(cfg, queue=queue, memory=mem)
mock_result = AgentResult(status="done", summary="已修复")
with patch("nmfs_agents.agents.developer.DeveloperAgent.run",
return_value=mock_result):
asyncio.run(executor.run_next())
history = mem.get_recent_history("yolo")
assert len(history) == 1
assert history[0]["status"] == "done"
assert history[0]["task_title"] == "修复零检出"
@pytest.mark.asyncio
async def test_confirm_mode_marks_done_not_waiting(tmp_path):
"""confirm 模式任务完成后直接 mark_done(hook 级审批已在执行中发生,无需 task-level 等待)"""
cfg = _cfg(tmp_path)
q = TaskQueue(db_path=tmp_path / "tasks.db")
tid = q.enqueue(Task(project="yolo", type="fix_bug", title="修复",
priority=1, mode="confirm", agent_role="developer"))
mem = ProjectMemory(db_path=tmp_path / "mem.db")
executor = Executor(cfg, queue=q, memory=mem)
mock_result = MagicMock(status="done", summary="修复完成")
with patch("nmfs_agents.core.executor._make_agent") as mock_make:
mock_make.return_value.run.return_value = mock_result
await executor.run_next()
with q._conn() as c:
row = c.execute("SELECT status FROM tasks WHERE id=?", (tid,)).fetchone()
assert row["status"] == "done" # 不是 waiting_confirm
def test_notify_wx_called_on_task_run(tmp_path):
"""任务执行时调用 _notify_wxtask-start + task-end"""
import asyncio
from unittest.mock import patch, MagicMock
from nmfs_agents.core.executor import Executor
from nmfs_agents.core.queue import TaskQueue, Task
from nmfs_agents.agents.developer import AgentResult
from nmfs_agents.tools.project_memory import ProjectMemory
cfg = _cfg(tmp_path)
cfg.claude_wx_url = "http://localhost:9999"
queue = TaskQueue(db_path=tmp_path / "q.db")
queue.enqueue(Task(project="yolo", type="fix_bug", title="测试通知",
context="", priority=1, mode="auto", agent_role="developer"))
mem = ProjectMemory(db_path=tmp_path / "mem.db")
executor = Executor(cfg, queue=queue, memory=mem)
notified_paths = []
def fake_notify(path, data):
notified_paths.append(path)
executor._notify_wx = fake_notify
mock_result = AgentResult(status="done", summary="完成")
with patch("nmfs_agents.agents.developer.DeveloperAgent.run", return_value=mock_result):
asyncio.run(executor.run_next())
assert "/agent/task-start" in notified_paths
assert "/agent/task-end" in notified_paths
def test_notify_wx_skipped_when_no_url(tmp_path):
"""claude_wx_url 未配置时,_notify_wx 不发请求"""
from nmfs_agents.core.executor import Executor
from nmfs_agents.config import AgentsConfig, ProjectConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
cfg = AgentsConfig(
projects={},
scheduler=SchedulerConfig(),
claude=ClaudeConfig(api_key="fake"),
feishu=FeishuConfig(),
devices={},
claude_wx_url="", # 未配置
)
executor = Executor(cfg)
# 不应抛出异常,也不发请求
executor._notify_wx("/agent/task-start", {"task_id": 1})
@pytest.fixture
def mock_config(tmp_path):
from nmfs_agents.config import AgentsConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
from unittest.mock import MagicMock
cfg = MagicMock(spec=AgentsConfig)
cfg.projects = {}
cfg.claude = ClaudeConfig(model="claude-sonnet-4-6", api_key=None)
cfg.scheduler = SchedulerConfig()
cfg.feishu = FeishuConfig()
cfg.claude_wx_url = ""
return cfg
@pytest.mark.parametrize("role,expected_class", [
("os-engineer", "OsEngineerAgent"),
("algo-researcher", "AlgoResearcherAgent"),
("base-validator", "BaseValidatorAgent"),
("base-architect", "BaseArchitectAgent"),
("system-tester", "SystemTesterAgent"),
("vision-analyst", "VisionAnalystAgent"),
("market-pm", "MarketPmAgent"),
("media-producer", "MediaProducerAgent"),
])
def test_executor_routes_new_roles(role, expected_class, mock_config, tmp_path):
from nmfs_agents.core.executor import _make_agent
from nmfs_agents.core.queue import Task
from nmfs_agents.tools.project_memory import ProjectMemory
task = Task(project="test", type="test", title="t",
context="", priority=3, mode="auto", agent_role=role)
mem = ProjectMemory(db_path=tmp_path / "m.db")
agent = _make_agent(mock_config, task, mem)
assert type(agent).__name__ == expected_class, \
f"role={role} 路由到 {type(agent).__name__},期望 {expected_class}"
def test_executor_publishes_event_on_done(tmp_path):
import asyncio
import fakeredis
from unittest.mock import MagicMock, AsyncMock
from nmfs_agents.tools.event_bus import EventBus
from nmfs_agents.core.queue import Task, TaskQueue
from nmfs_agents.core.executor import Executor
from nmfs_agents.agents.developer import AgentResult
from nmfs_agents.config import AgentsConfig, SchedulerConfig, ClaudeConfig, FeishuConfig
import nmfs_agents.core.executor as _executor_mod
cfg = AgentsConfig(
projects={},
scheduler=SchedulerConfig(max_concurrent=2),
claude=ClaudeConfig(api_key="x"),
feishu=FeishuConfig(),
devices={},
)
r = fakeredis.FakeRedis(decode_responses=True)
bus = EventBus(redis_client=r)
q = TaskQueue(db_path=tmp_path / "t.db")
task = Task(project="antishake", type="code_improve", title="EIS 迭代",
priority=3, mode="auto", agent_role="algo-antishake")
q.enqueue(task)
executor = Executor(cfg, queue=q, event_bus=bus)
mock_agent = MagicMock()
mock_agent.run = MagicMock(return_value=AgentResult(status="done", summary="EIS 防抖达标"))
orig = _executor_mod._make_agent
_executor_mod._make_agent = lambda *a, **kw: mock_agent
try:
asyncio.run(executor.run_all_pending())
finally:
_executor_mod._make_agent = orig
events = bus.read_new("ops-hub", count=10, block_ms=100)
assert len(events) == 1
assert events[0].event_type == "task_done"
assert events[0].project == "antishake"