auto-sync: 2026-06-05 11:03:30

This commit is contained in:
cfdaily
2026-06-05 11:03:30 +08:00
parent e9c9aaddfe
commit 6a649aba07
30 changed files with 602 additions and 1276 deletions
+51
View File
@@ -0,0 +1,51 @@
import uuid
import pytest
from pathlib import Path
from fastapi.testclient import TestClient
def pytest_configure(config):
markers = {
"unit": "单元测试:纯逻辑,mock 外部依赖",
"integration": "集成测试:API 端点 + 真实/临时 DB",
"e2e": "端到端测试:真实 daemon + Agent(手动触发)",
"slow": "慢测试(>5s",
"broadcast": "广播认领相关",
"mail": "邮件系统相关",
"state_machine": "状态机转换",
"classify": "Classify Outcome 相关",
"review": "审查/Rebuttal 相关",
}
for name, desc in markers.items():
config.addinivalue_line("markers", f"{name}: {desc}")
@pytest.fixture
def isolated_data_root(tmp_path):
"""隔离的 data_root,测试结束自动清理"""
data_root = tmp_path / "test_data"
data_root.mkdir()
return data_root
@pytest.fixture
def isolated_registry(isolated_data_root):
"""隔离的 registry.db"""
from src.blackboard.registry import ProjectRegistry
registry = ProjectRegistry(isolated_data_root)
return registry
@pytest.fixture
def client_with_isolation(isolated_data_root):
"""带数据隔离的 TestClient"""
import src.utils as utils
original = utils.get_data_root
utils.get_data_root = lambda: isolated_data_root
from src.main import app
client = TestClient(app)
yield client
utils.get_data_root = original
View File
@@ -1,3 +1,7 @@
import pytest
pytestmark = pytest.mark.e2e
"""v2.7 端到端测试 — 全链路真实环境
覆盖项目管理 Task CRUD SubTask Stage进度 状态聚合 依赖链 超时 Mail 真实Agent调度
@@ -1,3 +1,7 @@
import pytest
pytestmark = pytest.mark.e2e
"""v3.1 端到端测试 — 新增场景覆盖
覆盖 v3.1 新增功能
@@ -1,3 +1,7 @@
import pytest
pytestmark = pytest.mark.e2e
"""#01 四相循环 单元测试
不依赖 daemon / Agent纯逻辑验证覆盖
View File
@@ -12,6 +12,8 @@ from src.blackboard.models import Task
from src.blackboard.registry import ProjectRegistry
from src.main import app
pytestmark = pytest.mark.integration
@pytest.fixture
def project_env(tmp_path):
@@ -0,0 +1,128 @@
"""F9 Agent 调度器集成测试 — rollback/on_complete DB 交互"""
import asyncio
import json
import pytest
from pathlib import Path
from src.blackboard.models import Task
from src.blackboard.operations import Blackboard
from src.daemon.dispatcher import Dispatcher
pytestmark = pytest.mark.integration
# ---------------------------------------------------------------------------
# 司马懿评审补充:_rollback_current_agent + on_complete 统一(v2.8 #07.2
# ---------------------------------------------------------------------------
class TestRollbackAndOnComplete:
"""司马懿评审遗漏 #3 + #4: crash 后 current_agent 回退 + on_complete 统一路径"""
def test_rollback_current_agent_on_crash(self, tmp_path):
"""executor crash → _rollback_current_agent 回退 current_agent → assignee"""
db_path = tmp_path / "blackboard.db"
bb = Blackboard(db_path)
bb.create_task(Task(
id="t1", title="T", status="working",
assigned_by="daemon", assignee="zhangfei-dev",
))
conn = bb._conn()
try:
conn.execute("UPDATE tasks SET current_agent=? WHERE id=?", ("zhangfei-dev", "t1"))
conn.commit()
finally:
conn.close()
dispatcher = Dispatcher(registered_agents=["zhangfei-dev"])
dispatcher._rollback_current_agent(db_path, "t1", "zhangfei-dev")
import sqlite3
conn2 = sqlite3.connect(str(db_path))
conn2.row_factory = sqlite3.Row
row = conn2.execute("SELECT current_agent, assignee FROM tasks WHERE id=?", ("t1",)).fetchone()
conn2.close()
assert row["current_agent"] == row["assignee"] == "zhangfei-dev"
def test_rollback_different_agent(self, tmp_path):
"""current_agent ≠ agent_id → 不回退(安全检查)"""
db_path = tmp_path / "blackboard.db"
bb = Blackboard(db_path)
bb.create_task(Task(
id="t1", title="T", status="working",
assigned_by="daemon", assignee="zhangfei-dev",
))
conn = bb._conn()
try:
conn.execute("UPDATE tasks SET current_agent=? WHERE id=?", ("simayi-challenger", "t1"))
conn.commit()
finally:
conn.close()
dispatcher = Dispatcher(registered_agents=["zhangfei-dev"])
dispatcher._rollback_current_agent(db_path, "t1", "wrong-agent")
import sqlite3
conn2 = sqlite3.connect(str(db_path))
conn2.row_factory = sqlite3.Row
row = conn2.execute("SELECT current_agent FROM tasks WHERE id=?", ("t1",)).fetchone()
conn2.close()
assert row["current_agent"] == "simayi-challenger"
def test_on_complete_crash_rollback_executor(self, tmp_path):
"""executor crash → rollback current_agent + _task_auto_complete(标 review"""
db_path = tmp_path / "blackboard.db"
bb = Blackboard(db_path)
bb.create_task(Task(
id="t1", title="T", status="working",
assigned_by="daemon", assignee="zhangfei-dev",
))
conn = bb._conn()
try:
conn.execute("UPDATE tasks SET current_agent=? WHERE id=?", ("zhangfei-dev", "t1"))
conn.commit()
finally:
conn.close()
dispatcher = Dispatcher(registered_agents=["zhangfei-dev"])
dispatcher._rollback_current_agent(db_path, "t1", "zhangfei-dev")
import sqlite3
conn2 = sqlite3.connect(str(db_path))
conn2.row_factory = sqlite3.Row
row = conn2.execute("SELECT current_agent FROM tasks WHERE id=?", ("t1",)).fetchone()
conn2.close()
assert row["current_agent"] == "zhangfei-dev"
def test_on_complete_crash_rollback_review(self, tmp_path):
"""review crash → rollback current_agent + 保持 review 状态"""
db_path = tmp_path / "blackboard.db"
bb = Blackboard(db_path)
bb.create_task(Task(
id="t1", title="T", status="review",
assigned_by="daemon", assignee="zhangfei-dev",
))
conn = bb._conn()
try:
conn.execute("UPDATE tasks SET current_agent=? WHERE id=?", ("simayi-challenger", "t1"))
conn.commit()
finally:
conn.close()
dispatcher = Dispatcher(registered_agents=["simayi-challenger"])
dispatcher._rollback_current_agent(db_path, "t1", "simayi-challenger")
import sqlite3
conn2 = sqlite3.connect(str(db_path))
conn2.row_factory = sqlite3.Row
row = conn2.execute("SELECT current_agent, status FROM tasks WHERE id=?", ("t1",)).fetchone()
conn2.close()
assert row["current_agent"] == "zhangfei-dev"
assert row["status"] == "review"
@@ -5,6 +5,8 @@ from fastapi.testclient import TestClient
from src.main import app, config, load_config
pytestmark = pytest.mark.integration
@pytest.fixture
def client():
@@ -1,11 +1,9 @@
"""F12 Review Pipeline + F13 Guardrail + F14 Rebuttal 单元测试
"""F12 Review Pipeline + F13 Guardrail 集成测试
test-plan-v2.6.md §F12-F14
test-plan-v2.6.md §F12-F13
- F12 T1: 验证流水线四步P0
- F12 T2: 评分计算P0
- F13 T1: Guardrail 门控P0
- F14 T1: 反驳权流程P0
- F14 T2: 最大轮次限制P0
"""
import json
@@ -16,12 +14,13 @@ from unittest.mock import MagicMock
from src.blackboard.models import Task
from src.blackboard.operations import Blackboard
from src.daemon.review import (
RebuttalManager,
ReviewPipeline,
ReviewResult,
ReviewVerdict,
)
pytestmark = pytest.mark.integration
@pytest.fixture
def db_path(tmp_path):
@@ -177,63 +176,3 @@ class TestGuardrail:
outputs = [{"content": "valid output here", "type": "text"}]
result = p.run_review(task, outputs=outputs)
assert result["gate"] == "optional"
# ---------------------------------------------------------------------------
# F14 T1: 反驳权流程
# ---------------------------------------------------------------------------
class TestRebuttal:
def test_submit_rebuttal_accepted(self, bb):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
bb.create_task(task)
rm = RebuttalManager(bb=bb)
result = rm.submit_rebuttal("t1", "agent-1", "I disagree with the review")
assert result["status"] == "accepted"
assert result["round"] == 1
assert result["escalation_target"] == "simayi-challenger"
def test_second_round_escalates_to_pangtong(self, bb):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
bb.create_task(task)
rm = RebuttalManager(bb=bb)
rm.submit_rebuttal("t1", "agent-1", "Round 1")
result = rm.submit_rebuttal("t1", "agent-1", "Round 2")
assert result["status"] == "accepted"
assert result["round"] == 2
assert result["escalation_target"] == "pangtong-fujunshi"
# ---------------------------------------------------------------------------
# F14 T2: 最大轮次限制
# ---------------------------------------------------------------------------
class TestRebuttalLimits:
def test_max_rounds_rejected(self, bb):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
bb.create_task(task)
rm = RebuttalManager(bb=bb)
rm.submit_rebuttal("t1", "a", "R1")
rm.submit_rebuttal("t1", "a", "R2")
result = rm.submit_rebuttal("t1", "a", "R3")
assert result["status"] == "rejected"
assert "Max" in result["reason"]
def test_rebuttal_without_bb(self):
rm = RebuttalManager(bb=None)
result = rm.submit_rebuttal("t1", "a", "reason")
assert result["status"] == "accepted"
assert result["round"] == 1
def test_rebuttal_observation_recorded(self, bb):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
bb.create_task(task)
rm = RebuttalManager(bb=bb)
rm.submit_rebuttal("t1", "agent-1", "test reason", evidence="file.txt")
obs = bb.get_observations(task_id="t1")
rebuttals = [o for o in obs if "Rebuttal round" in (o.body or "")]
assert len(rebuttals) == 1
@@ -0,0 +1,73 @@
"""F9 Agent Spawner 集成测试 — 超时/失败/spawn 真实流程"""
import asyncio
import pytest
from pathlib import Path
from src.blackboard.operations import Blackboard
from src.daemon.spawner import AgentSpawner
pytestmark = pytest.mark.integration
@pytest.fixture
def db_path(tmp_path):
return tmp_path / "blackboard.db"
@pytest.fixture
def bb(db_path):
return Blackboard(db_path)
@pytest.fixture
def real_spawner(db_path):
return AgentSpawner(db_path=db_path, dry_run=False, agent_timeout=2.0)
# ---------------------------------------------------------------------------
# T2: 超时处理
# ---------------------------------------------------------------------------
class TestTimeout:
def test_timeout_kills_process(self, tmp_path):
"""超时后 kill 进程"""
db_path = tmp_path / "blackboard.db"
Blackboard(db_path) # init
spawner = AgentSpawner(db_path=db_path, dry_run=False, agent_timeout=0.5)
# Spawn a long-running process (sleep 10)
session_id = asyncio.run(
spawner.spawn_full_agent(
"test-agent",
"sleep 10",
task_id=None,
)
)
# Wait for timeout
asyncio.run(asyncio.sleep(1.0))
session = spawner.get_session(session_id)
if session:
assert session["status"] in ("timed_out", "running", "completed")
# ---------------------------------------------------------------------------
# T3: spawn 失败
# ---------------------------------------------------------------------------
class TestSpawnFailure:
def test_nonexistent_command(self, real_spawner, db_path, bb):
"""命令不存在 → spawn_failed"""
bb.create_task(
__import__("src.blackboard.models", fromlist=["Task"]).Task(
id="t1", title="T", status="pending", assigned_by="d"
)
)
try:
asyncio.run(
real_spawner.spawn_full_agent("test", "msg", task_id="t1")
)
except Exception:
pass # Expected - command may fail
@@ -12,6 +12,8 @@ import json
import pytest
from unittest.mock import AsyncMock, MagicMock
pytestmark = pytest.mark.integration
from src.daemon.sse import (
Hook,
HookManager,
@@ -24,6 +24,8 @@ from src.blackboard.registry import ProjectRegistry
from src.blackboard.queries import Queries
from src.daemon.ticker import Ticker
pytestmark = pytest.mark.integration
# ---------------------------------------------------------------------------
# Fixtures
@@ -446,20 +448,15 @@ class TestCheckTimeoutsUnified:
return registry, db_path, bb
def test_crash_limit_working(self, timeout_project):
"""E12.1: executor crash 3 次/30min → _check_timeouts 标 failed
"""E12.1: executor crash 3 次/30min → _check_timeouts 标 failed"""
#07.2 将 crash_limit 从 _dispatch_reviews 移到 _check_timeouts
覆盖 working review 状态
"""
registry, db_path, bb = timeout_project
# 创建 working 任务
bb.create_task(Task(
id="t-crash", title="Crash Task", status="working",
assigned_by="daemon", current_agent="agent-a",
))
# 模拟 3 次 crash 的 task_attempts
from datetime import datetime, timedelta
conn = bb._conn()
try:
@@ -476,20 +473,13 @@ class TestCheckTimeoutsUnified:
conn.close()
ticker = Ticker(registry, tick_interval=30)
# 如果有 dispatcher + _check_crash_limit,它会在 _check_timeouts 中触发
# 测试基本结构:_check_timeouts 应该能处理 working 状态任务
result = ticker._check_timeouts(db_path)
# 即使没有 dispatcher_check_crash_limit 需要),超时检查本身不应崩溃
assert isinstance(result, list)
def test_crash_limit_review(self, timeout_project):
"""E12.2: reviewer crash 3 次/30min → _check_timeouts 标 failed
#07.2 统一后,review 状态的 crash_limit 也走 _check_timeouts。
"""
"""E12.2: reviewer crash 3 次/30min → _check_timeouts 标 failed"""
registry, db_path, bb = timeout_project
# 创建 review 状态任务
bb.create_task(Task(
id="t-review-crash", title="Review Crash Task", status="review",
assigned_by="daemon", current_agent="simayi-challenger",
@@ -498,32 +488,24 @@ class TestCheckTimeoutsUnified:
ticker = Ticker(registry, tick_interval=30)
result = ticker._check_timeouts(db_path)
assert isinstance(result, list)
# _check_timeouts 不应崩溃,review 状态在统一逻辑中被正确处理
def test_updated_at_fallback(self, timeout_project):
"""E12.3: mail auto-working 无 started_at/claimed_at → updated_at fallback
#07.3 ACT-1: _check_timeouts 使用 updated_at 作为最后 fallback
确保 PM2 重启后 mail 孤儿任务也能被回收
"""
"""E12.3: mail auto-working 无 started_at/claimed_at → updated_at fallback"""
registry, db_path, bb = timeout_project
from datetime import datetime, timedelta
# 创建 working 任务,只有 updated_at(模拟 mail auto-working
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",
))
# 手动设置 updated_at(模拟 PM2 重启前的时间戳)
conn = bb._conn()
try:
conn.execute(
"UPDATE tasks SET updated_at = ? WHERE id = ?",
(old_time, "t-mail-orphan"),
)
# 确保 started_at 和 claimed_at 为 NULL
conn.execute(
"UPDATE tasks SET started_at = NULL, claimed_at = NULL WHERE id = ?",
("t-mail-orphan",),
@@ -534,25 +516,18 @@ class TestCheckTimeoutsUnified:
ticker = Ticker(registry, tick_interval=30, default_task_timeout_minutes=30)
reclaimed = ticker._check_timeouts(db_path)
# updated_at fallback 应让这个任务被回收
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
#07.2: process_dead 对 review 状态的处理——保持 review,
_dispatch_reviews 下个 tick 自然 dispatch
"""
"""E12.4: review agent 进程死 → 保持 review 状态(不推 pending"""
registry, db_path, bb = timeout_project
# 创建 review 状态任务
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:
@@ -567,6 +542,4 @@ class TestCheckTimeoutsUnified:
ticker = Ticker(registry, tick_interval=30, default_task_timeout_minutes=30)
reclaimed = ticker._check_timeouts(db_path)
# 没有 process_dead 的模拟(无 counter/spawner),纯超时路径
# review 任务时间戳较新 → 不应被超时回收
assert "t-review-dead" not in reclaimed
@@ -6,6 +6,8 @@ from pathlib import Path
import pytest
pytestmark = pytest.mark.integration
from src.blackboard.db import init_db, get_connection
from src.blackboard.models import Task
from src.blackboard.operations import Blackboard
-212
View File
@@ -1,212 +0,0 @@
"""F11 Bootstrap 拼装单元测试
按 test-plan-v2.6.md §F11
- T1: 各 role 拼装(P0
- T2: token 估算(P0
- T3: 缺失组件降级(P1
- T4: 模板变量替换(P1
"""
import pytest
from pathlib import Path
from src.blackboard.models import Task
from src.daemon.bootstrap import BootstrapBuilder, estimate_tokens
@pytest.fixture
def builder():
return BootstrapBuilder(max_tokens=4096)
@pytest.fixture
def builder_with_templates(tmp_path):
template_dir = tmp_path / "templates"
template_dir.mkdir()
(template_dir / "executor.md").write_text("# Executor Role\nYou execute tasks.")
(template_dir / "reviewer.md").write_text("# Reviewer Role\nYou review code.")
(template_dir / "planner.md").write_text("# Planner Role\nYou plan tasks.")
return BootstrapBuilder(template_dir=template_dir, max_tokens=4096)
# ---------------------------------------------------------------------------
# T1: 各 role 拼装
# ---------------------------------------------------------------------------
class TestRoleBootstrap:
def test_executor_bootstrap(self, builder):
b = builder.build(
role="executor",
task_context={"task_id": "t1", "title": "Write tests"},
)
assert "Write tests" in b
assert "t1" in b
def test_reviewer_bootstrap(self, builder):
b = builder.build(
role="reviewer",
task_context={"task_id": "t2", "title": "Review PR"},
)
assert "Review PR" in b
def test_planner_bootstrap(self, builder):
b = builder.build(
role="planner",
task_context={"task_id": "t3", "title": "Plan sprint"},
)
assert "Plan sprint" in b
def test_executor_with_guardrail(self, builder):
b = builder.build(
role="executor",
guardrail_rules="## Guardrail\nNo dangerous ops",
)
assert "Guardrail" in b
def test_reviewer_no_guardrail(self, builder):
b = builder.build(
role="reviewer",
guardrail_rules="## Guardrail\nNo dangerous ops",
)
assert "Guardrail" not in b # reviewer 不注入 guardrail
def test_executor_with_review_protocol(self, builder):
b = builder.build(
role="executor",
review_protocols="## Review Protocol\nCheck tests",
)
assert "Review Protocol" in b
def test_reviewer_with_review_protocol(self, builder):
b = builder.build(
role="reviewer",
review_protocols="## Review Protocol\nCheck quality",
)
assert "Review Protocol" in b
def test_with_template(self, builder_with_templates):
b = builder_with_templates.build(role="executor")
assert "Executor Role" in b
assert "You execute tasks" in b
# ---------------------------------------------------------------------------
# T2: token 估算
# ---------------------------------------------------------------------------
class TestTokenEstimation:
def test_estimate_tokens_basic(self):
assert estimate_tokens("hello") > 0
def test_estimate_tokens_long_text(self):
text = "a" * 1000
tokens = estimate_tokens(text)
assert 200 < tokens < 400 # ~333
def test_bootstrap_under_limit(self, builder):
b = builder.build(
role="executor",
task_context={"title": "Short task"},
)
assert estimate_tokens(b) <= 4096
def test_bootstrap_over_limit_truncates(self):
builder = BootstrapBuilder(max_tokens=10)
b = builder.build(
role="executor",
task_context={"title": "A" * 10000},
)
assert estimate_tokens(b) <= 20 # 接近限制
assert "truncated" in b
# ---------------------------------------------------------------------------
# T3: 缺失组件降级
# ---------------------------------------------------------------------------
class TestGracefulDegradation:
def test_no_task_context(self, builder):
b = builder.build(role="executor")
assert b # 不为空
def test_no_project_context(self, builder):
b = builder.build(
role="executor",
task_context={"title": "Task"},
)
assert "Task" in b
def test_no_template(self, builder):
b = builder.build(role="executor")
assert b # 没有 template 也不崩溃
def test_empty_experiences(self, builder):
b = builder.build(role="executor", experiences=[])
assert b
def test_template_dir_not_exists(self, tmp_path):
builder = BootstrapBuilder(template_dir=tmp_path / "nonexistent")
b = builder.build(role="executor")
assert b # 不崩溃
# ---------------------------------------------------------------------------
# T4: 便捷方法 + 项目上下文
# ---------------------------------------------------------------------------
class TestBuildForTask:
def test_build_for_task_object(self, builder):
task = Task(
id="t1", title="Build Feature", status="pending",
assigned_by="daemon", task_type="coding",
description="Implement X with tests",
must_haves="Unit tests, Documentation",
risk_level="high",
)
b = builder.build_for_task(task, role="executor")
assert "Build Feature" in b
assert "Implement X" in b
assert "high" in b
def test_build_for_task_with_project(self, builder):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
b = builder.build_for_task(
task, role="executor",
project_config={"name": "My Project", "agents": ["a1", "a2"]},
)
assert "My Project" in b
assert "a1" in b
def test_with_experiences(self, builder):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
experiences = [
{"category": "pitfall", "summary": "Always test edge cases"},
{"category": "best_practice", "summary": "Use type hints"},
]
b = builder.build_for_task(task, role="executor", experiences=experiences)
assert "pitfall" in b
assert "Always test edge cases" in b
def test_with_skills(self, builder):
skills = [
{"name": "code-review", "description": "Review code quality"},
]
b = builder.build(
role="executor",
skill_descriptions=skills,
)
assert "code-review" in b
assert "Review code quality" in b
def test_with_depends_on_outputs(self, builder):
b = builder.build(
role="executor",
task_context={
"title": "T",
"depends_on_outputs": [
{"task_id": "t0", "summary": "Data downloaded"},
],
},
)
assert "前序产出" in b
assert "Data downloaded" in b
-629
View File
@@ -1,629 +0,0 @@
"""#01 四相循环 E2E 集成测试
需要 daemon 运行 + RUN_INTEGRATION=1。覆盖:
E1 comment + @mention 端到端(真实 Agent spawn
E2 一轮结束 → 庞统 review spawn(真实庞统 spawn
E3 多轮迭代(庞统真实 spawn + 真实创建 sub task
E4 round 上限强制停止
E5 mention 重试(可靠制造 Agent busy
E6 failed sub 触发 reviewBUG-2 验证)
B1-B6 边界测试
全部真实环境执行,不 mock Agent 行为。
"""
import json
import os
import sqlite3
import sys
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List
import pytest
import requests as http_requests
# ── 路径设置 ──
DEPLOY_DIR = Path.home() / ".sanguo_projects" / "sanguo_moziplus_v2"
sys.path.insert(0, str(DEPLOY_DIR))
from src.utils import get_data_root
# ── 常量 ──
API_BASE = "http://localhost:8083"
POLL_INTERVAL = 5
MAX_WAIT_DISPATCH = 120
MAX_WAIT_PANGTONG = 900
E2E_PREFIX = "e2e-01-"
DATA_ROOT = get_data_root()
# ── 工具函数 ──
def _check_environment():
try:
resp = http_requests.get(f"{API_BASE}/api/daemon/status", timeout=5)
data = resp.json()
if data.get("status") != "running" or not data.get("ticker_running"):
pytest.skip(f"Daemon not ready: {data}")
except Exception as e:
pytest.skip(f"Production API not available: {e}")
def _cleanup_project(pid: str):
try:
http_requests.post(f"{API_BASE}/api/projects/{pid}/archive", timeout=5)
except Exception:
pass
def _create_project(plist: list, prefix: str = "E01",
agents: list = None) -> str:
pid = f"{E2E_PREFIX}{uuid.uuid4().hex[:6]}"
config = {"agents": agents or ["zhangfei-dev", "simayi-challenger", "zhaoyun-data"]}
resp = http_requests.post(f"{API_BASE}/api/projects", json={
"id": pid, "name": f"{prefix}-{pid}", "config": config,
}, timeout=10)
assert resp.status_code == 200, f"Create project failed: {resp.text}"
plist.append(pid)
return pid
def _create_task(pid: str, **kwargs) -> str:
tid = kwargs.pop("id", None) or f"e2e-task-{uuid.uuid4().hex[:8]}"
body = {"id": tid, "status": "pending", "priority": 5, **kwargs}
resp = http_requests.post(
f"{API_BASE}/api/projects/{pid}/tasks", json=body, timeout=10)
assert resp.status_code == 200, f"Create task failed: {resp.text}"
return tid
def _get_task(pid: str, tid: str) -> Dict:
resp = http_requests.get(
f"{API_BASE}/api/projects/{pid}/tasks/{tid}?expand=all", timeout=10)
assert resp.status_code == 200
return resp.json()
def _list_tasks(pid: str, **params) -> List[Dict]:
resp = http_requests.get(
f"{API_BASE}/api/projects/{pid}/tasks", params=params, timeout=10)
assert resp.status_code == 200
data = resp.json()
# API 返回 {"tasks": [...]} 或直接 [...]
if isinstance(data, dict) and "tasks" in data:
return data["tasks"]
return data
def _update_status(pid: str, tid: str, status: str,
agent: str = "test", detail: str = "") -> Dict:
body = {"status": status, "agent": agent}
if detail:
body["detail"] = detail
resp = http_requests.post(
f"{API_BASE}/api/projects/{pid}/tasks/{tid}/status",
json=body, timeout=10)
return resp.json()
def _add_comment(pid: str, tid: str, author: str, body: str,
mentions: list = None) -> int:
resp = http_requests.post(
f"{API_BASE}/api/projects/{pid}/tasks/{tid}/comments",
json={"author": author, "body": body, "comment_type": "general",
"mentions": mentions or []},
timeout=10)
assert resp.status_code == 200
data = resp.json()
return data.get("comment_id") or data.get("id")
def _get_db_path(pid: str) -> Path:
return DATA_ROOT / pid / "blackboard.db"
def _query_mentions(db_path: Path, status: str = None) -> list:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
try:
if status:
rows = conn.execute(
"SELECT * FROM mention_queue WHERE status=?", (status,)
).fetchall()
else:
rows = conn.execute("SELECT * FROM mention_queue").fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def _set_round_count(db_path: Path, tid: str, count: int):
conn = sqlite3.connect(str(db_path))
try:
conn.execute("UPDATE tasks SET round_count=? WHERE id=?", (count, tid))
conn.commit()
finally:
conn.close()
def _get_round_count(db_path: Path, tid: str) -> int:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
try:
row = conn.execute(
"SELECT round_count FROM tasks WHERE id=?", (tid,)).fetchone()
return row["round_count"] if row else 0
finally:
conn.close()
def _count_subtasks(db_path: Path, parent_tid: str) -> int:
conn = sqlite3.connect(str(db_path))
try:
row = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE parent_task=?", (parent_tid,)
).fetchone()
return row[0]
finally:
conn.close()
def _wait_round(db_path: Path, tid: str, min_count: int,
timeout: int = MAX_WAIT_DISPATCH) -> int:
deadline = time.time() + timeout
while time.time() < deadline:
time.sleep(POLL_INTERVAL)
rc = _get_round_count(db_path, tid)
if rc >= min_count:
return rc
rc = _get_round_count(db_path, tid)
pytest.fail(f"round_count < {min_count} after {timeout}s (now={rc})")
def _wait_subtasks(db_path: Path, parent_tid: str, min_count: int,
timeout: int = MAX_WAIT_PANGTONG) -> int:
deadline = time.time() + timeout
while time.time() < deadline:
time.sleep(POLL_INTERVAL)
cnt = _count_subtasks(db_path, parent_tid)
if cnt >= min_count:
return cnt
cnt = _count_subtasks(db_path, parent_tid)
pytest.fail(f"subtasks < {min_count} after {timeout}s (now={cnt})")
def _push_done(pid: str, tid: str, agent: str = "test"):
for s in ("claimed", "working", "review", "done"):
_update_status(pid, tid, s, agent=agent)
def _push_failed(pid: str, tid: str, agent: str = "test"):
_update_status(pid, tid, "claimed", agent=agent)
_update_status(pid, tid, "working", agent=agent)
_update_status(pid, tid, "failed", agent=agent, detail="E2E forced failure")
# ===================================================================
# E1: comment + @mention 端到端
# ===================================================================
@pytest.mark.integration
@pytest.mark.skipif(not os.environ.get("RUN_INTEGRATION"),
reason="RUN_INTEGRATION=1 required")
class TestE01MentionE2E:
@pytest.fixture(autouse=True)
def setup(self):
_check_environment()
self._p = []
yield
for p in self._p:
_cleanup_project(p)
def test_mention_spawn_e2e(self):
pid = _create_project(self._p, "E01")
tid = _create_task(pid, title="E2E mention", description="mention test",
assignee="simayi-challenger")
_add_comment(pid, tid, "simayi-challenger",
"@zhaoyun-data 请查看", mentions=["zhaoyun-data"])
print(f"\n🚀 E1: comment written")
db = _get_db_path(pid)
deadline = time.time() + MAX_WAIT_DISPATCH
while time.time() < deadline:
time.sleep(POLL_INTERVAL)
ms = _query_mentions(db)
if ms and ms[0]["status"] in ("notified", "failed"):
break
else:
pytest.fail(f"mention not processed after {MAX_WAIT_DISPATCH}s")
m = _query_mentions(db)[0]
assert m["mentioned_agent"] == "zhaoyun-data"
assert m["status"] == "notified", f"spawn failed: {m['status']}"
print(f" ✅ mention e2e OK (spawned)")
# ===================================================================
# E2: 一轮结束 → 庞统 review spawn
# ===================================================================
@pytest.mark.integration
@pytest.mark.skipif(not os.environ.get("RUN_INTEGRATION"),
reason="RUN_INTEGRATION=1 required")
class TestE02RoundComplete:
@pytest.fixture(autouse=True)
def setup(self):
_check_environment()
self._p = []
yield
for p in self._p:
_cleanup_project(p)
def test_round_complete_triggers_review(self):
pid = _create_project(self._p, "E02",
agents=["pangtong-fujunshi", "zhangfei-dev", "simayi-challenger"])
parent = _create_task(pid, id=f"parent-{uuid.uuid4().hex[:6]}",
title="E2E Parent", description="round test")
s1 = _create_task(pid, title="Sub1", description="s1",
assignee="zhangfei-dev", parent_task=parent)
s2 = _create_task(pid, title="Sub2", description="s2",
assignee="simayi-challenger", parent_task=parent)
_push_done(pid, s1, "zhangfei-dev")
_push_done(pid, s2, "simayi-challenger")
print(f"\n🚀 E2: subs done, waiting for review")
db = _get_db_path(pid)
rc = _wait_round(db, parent, 1)
print(f" round_count={rc}")
print(f" ✅ E2 round complete OK")
# ===================================================================
# E3: 多轮迭代(庞统真实 spawn + 真实创建 sub task
# ===================================================================
@pytest.mark.integration
@pytest.mark.slow
@pytest.mark.skipif(not os.environ.get("RUN_INTEGRATION"),
reason="RUN_INTEGRATION=1 required")
class TestE03MultiRound:
"""Round 1 → 庞统真实 review → 创建新 sub → Round 2
庞统完整链路:spawn → 读黑板 → 创建 sub task。
耗时 10-20 分钟。
"""
@pytest.fixture(autouse=True)
def setup(self):
_check_environment()
self._p = []
yield
for p in self._p:
_cleanup_project(p)
def test_multi_round_full_chain(self):
pid = _create_project(self._p, "E03",
agents=["pangtong-fujunshi", "zhangfei-dev", "simayi-challenger"])
parent = _create_task(
pid, id=f"parent-{uuid.uuid4().hex[:6]}",
title="E2E Multi-Round: Hello World",
description="创建 hello.py 输出 Hello World。第一轮创建,第二轮验证。",
task_type="coding",
must_haves=json.dumps({"capability": "python"}))
s1 = _create_task(pid, title="Round1: 创建 hello.py",
description="创建 hello.py: print('Hello World')",
assignee="zhangfei-dev", parent_task=parent)
_push_done(pid, s1, "zhangfei-dev")
print(f"\n🚀 E3 R1: sub done, waiting pangtong review")
db = _get_db_path(pid)
rc1 = _wait_round(db, parent, 1, timeout=MAX_WAIT_PANGTONG)
print(f" R1: round_count={rc1}, waiting for new sub tasks...")
# 等庞统创建新 sub task
cnt = _wait_subtasks(db, parent, 2, timeout=MAX_WAIT_PANGTONG)
print(f" pangtong created new subs (total={cnt})")
# 推新 sub 到 done
tasks = _list_tasks(pid, parent_task=parent)
new = [t for t in tasks if t["id"] != s1
and t["status"] not in ("done", "failed")]
assert len(new) >= 1, f"no new subs: {[t['id']+'='+t['status'] for t in tasks]}"
for t in new:
_push_done(pid, t["id"], "zhangfei-dev")
print(f" pushed '{t['title']}' → done")
rc2 = _wait_round(db, parent, 2, timeout=MAX_WAIT_PANGTONG)
print(f" R2: round_count={rc2}")
print(f" ✅ E3 multi-round OK (pangtong real spawn + new sub)")
# ===================================================================
# E4: round 上限强制停止
# ===================================================================
@pytest.mark.integration
@pytest.mark.skipif(not os.environ.get("RUN_INTEGRATION"),
reason="RUN_INTEGRATION=1 required")
class TestE04RoundLimit:
@pytest.fixture(autouse=True)
def setup(self):
_check_environment()
self._p = []
yield
for p in self._p:
_cleanup_project(p)
def test_round_limit(self):
pid = _create_project(self._p, "E04",
agents=["pangtong-fujunshi", "zhangfei-dev"])
parent = _create_task(pid, id=f"parent-{uuid.uuid4().hex[:6]}",
title="E2E Limit", description="limit test")
s1 = _create_task(pid, title="Sub", description="limit sub",
assignee="zhangfei-dev", parent_task=parent)
_push_done(pid, s1, "zhangfei-dev")
db = _get_db_path(pid)
_set_round_count(db, parent, 5)
print(f"\n🚀 E4: round_count=5, waiting 2 ticks")
time.sleep(60)
rc = _get_round_count(db, parent)
assert rc == 5, f"round_count changed: {rc}"
print(f" ✅ E4 round limit OK (rc=5 unchanged)")
# ===================================================================
# E5: mention 重试(可靠 Agent busy
# ===================================================================
@pytest.mark.integration
@pytest.mark.skipif(not os.environ.get("RUN_INTEGRATION"),
reason="RUN_INTEGRATION=1 required")
class TestE05MentionRetry:
"""可靠制造 busy:先让 zhaoyun-data 有个 working task,再 @ 它。
1. 创建 blocker task → ticker dispatch → zhaoyun-data working
2. 创建 mention task → @zhaoyun-data → busy → retry
3. 推 blocker done → zhaoyun-data 空闲 → mention spawn 成功
"""
@pytest.fixture(autouse=True)
def setup(self):
_check_environment()
self._p = []
yield
for p in self._p:
_cleanup_project(p)
def test_mention_retry_on_busy(self):
pid = _create_project(self._p, "E05",
agents=["zhaoyun-data", "simayi-challenger"])
# 1. blocker task
blocker = _create_task(pid, title="E2E Blocker",
description="占用 zhaoyun-data",
assignee="zhaoyun-data")
print(f"\n🚀 E5: blocker {blocker}, waiting for zhaoyun-data spawn")
deadline = time.time() + MAX_WAIT_DISPATCH
busy = False
while time.time() < deadline:
time.sleep(POLL_INTERVAL)
t = _get_task(pid, blocker)
if t["status"] in ("claimed", "working"):
busy = True
print(f" zhaoyun-data busy: status={t['status']}")
break
if not busy:
_update_status(pid, blocker, "claimed", agent="zhaoyun-data")
_update_status(pid, blocker, "working", agent="zhaoyun-data")
print(f" forced blocker → working")
# 2. @zhaoyun-data on another task
mtid = _create_task(pid, title="E2E Mention Task",
description="mention retry test",
assignee="simayi-challenger")
_add_comment(pid, mtid, "simayi-challenger",
"@zhaoyun-data 请查看", mentions=["zhaoyun-data"])
print(f" mention created while zhaoyun-data busy")
db = _get_db_path(pid)
time.sleep(35) # 1 tick
ms = _query_mentions(db)
assert len(ms) >= 1, "no mention written"
print(f" mention: status={ms[0]['status']} retry={ms[0]['retry_count']}")
# 3. release zhaoyun-data
_update_status(pid, blocker, "review", agent="zhaoyun-data")
_update_status(pid, blocker, "done", agent="zhaoyun-data")
print(f" blocker → done, zhaoyun-data free")
# 4. wait for mention resolution
deadline2 = time.time() + MAX_WAIT_DISPATCH
while time.time() < deadline2:
time.sleep(POLL_INTERVAL)
ms2 = _query_mentions(db)
if ms2 and ms2[0]["status"] in ("notified", "failed"):
break
mf = _query_mentions(db)[0]
print(f" final: status={mf['status']} retry={mf['retry_count']}")
assert mf["status"] in ("notified", "failed"), f"unresolved: {mf}"
if mf["retry_count"] > 0:
print(f" ✅ E5 retry verified (retry_count={mf['retry_count']})")
else:
print(f" ✅ E5 resolved directly (timing OK)")
print(f" ✅ E5 mention retry OK")
# ===================================================================
# E6: failed sub 触发 review
# ===================================================================
@pytest.mark.integration
@pytest.mark.skipif(not os.environ.get("RUN_INTEGRATION"),
reason="RUN_INTEGRATION=1 required")
class TestE06FailedSubReview:
@pytest.fixture(autouse=True)
def setup(self):
_check_environment()
self._p = []
yield
for p in self._p:
_cleanup_project(p)
def test_failed_sub_review(self):
pid = _create_project(self._p, "E06",
agents=["pangtong-fujunshi", "zhangfei-dev", "simayi-challenger"])
parent = _create_task(pid, id=f"parent-{uuid.uuid4().hex[:6]}",
title="E2E Failed Sub", description="BUG-2 test")
s1 = _create_task(pid, title="Done", description="s1",
assignee="zhangfei-dev", parent_task=parent)
s2 = _create_task(pid, title="Failed", description="s2",
assignee="simayi-challenger", parent_task=parent)
_push_done(pid, s1, "zhangfei-dev")
_push_failed(pid, s2, "simayi-challenger")
print(f"\n🚀 E6: done+failed, waiting for review")
db = _get_db_path(pid)
rc = _wait_round(db, parent, 1)
print(f" round_count={rc}")
print(f" ✅ E6 failed-sub review OK (BUG-2 verified)")
# ===================================================================
# B1-B6: 边界测试
# ===================================================================
@pytest.mark.integration
@pytest.mark.skipif(not os.environ.get("RUN_INTEGRATION"),
reason="RUN_INTEGRATION=1 required")
class TestBoundary:
@pytest.fixture(autouse=True)
def setup(self):
_check_environment()
self._p = []
yield
for p in self._p:
_cleanup_project(p)
def test_B1_empty_mentions(self):
"""空 mentions 列表 → 不写入 mention_queue"""
pid = _create_project(self._p, "B1")
tid = _create_task(pid, title="B1")
_add_comment(pid, tid, "test", "no mentions", mentions=[])
db = _get_db_path(pid)
assert len(_query_mentions(db)) == 0
print(f" ✅ B1: empty mentions → no queue entry")
def test_B2_nonexistent_agent(self):
"""不存在的 agent_id → mention 写入成功,spawn 失败后 retry 递增"""
pid = _create_project(self._p, "B2", agents=["simayi-challenger"])
tid = _create_task(pid, title="B2")
_add_comment(pid, tid, "test", "@ghost-agent 你在哪",
mentions=["ghost-agent"])
db = _get_db_path(pid)
# 等写入
time.sleep(2)
ms = _query_mentions(db)
assert len(ms) >= 1
assert ms[0]["mentioned_agent"] == "ghost-agent"
assert ms[0]["status"] == "pending"
# 等 ticker 尝试 spawn → 失败 → retry_count 递增
deadline = time.time() + MAX_WAIT_DISPATCH
retried = False
while time.time() < deadline:
time.sleep(POLL_INTERVAL)
ms2 = _query_mentions(db)
if ms2 and ms2[0]["retry_count"] > 0:
retried = True
print(f" ghost-agent retry_count={ms2[0]['retry_count']}")
break
if ms2 and ms2[0]["status"] == "failed":
retried = True
print(f" ghost-agent failed (retries exhausted)")
break
assert retried, "ghost-agent mention never retried"
print(f" ✅ B2: nonexistent agent → retry OK")
def test_B3_duplicate_mentions(self):
"""同一 comment @ 同一 agent 多次 → 去重只写 1 条"""
pid = _create_project(self._p, "B3")
tid = _create_task(pid, title="B3")
cid = _add_comment(pid, tid, "test", "@agent-a @agent-a",
mentions=["agent-a", "agent-a"])
db = _get_db_path(pid)
time.sleep(2)
ms = _query_mentions(db)
# mentions 列表有两个 agent-a,但 record_mentions 会去重
agent_a_count = sum(1 for m in ms if m["mentioned_agent"] == "agent-a")
assert agent_a_count <= 2 # 允许 1 或 2(取决于去重在哪一层)
print(f" ✅ B3: duplicate mention count={agent_a_count}")
def test_B4_parent_no_subs(self):
"""parent 无 sub task → _check_round_complete 不触发"""
pid = _create_project(self._p, "B4",
agents=["pangtong-fujunshi"])
parent = _create_task(pid, id=f"parent-{uuid.uuid4().hex[:6]}",
title="B4 Parent", description="no subs")
db = _get_db_path(pid)
# 等 2 tick
time.sleep(60)
rc = _get_round_count(db, parent)
assert rc == 0, f"round_count should stay 0, got {rc}"
print(f" ✅ B4: parent with no subs → no review")
def test_B5_parent_done_no_subs(self):
"""parent done 且无 sub → 不触发"""
pid = _create_project(self._p, "B5",
agents=["pangtong-fujunshi"])
parent = _create_task(pid, id=f"parent-{uuid.uuid4().hex[:6]}",
title="B5 Parent", description="done no subs")
_update_status(pid, parent, "claimed", agent="test")
_update_status(pid, parent, "working", agent="test")
_update_status(pid, parent, "review", agent="test")
_update_status(pid, parent, "done", agent="test")
db = _get_db_path(pid)
time.sleep(60)
rc = _get_round_count(db, parent)
assert rc == 0
print(f" ✅ B5: parent done no subs → no review")
def test_B6_comment_deleted_mention_handling(self):
"""comment 被删除后 mention 仍存在于 queue"""
pid = _create_project(self._p, "B6")
tid = _create_task(pid, title="B6")
_add_comment(pid, tid, "test", "@simayi-challenger check this",
mentions=["simayi-challenger"])
db = _get_db_path(pid)
time.sleep(2)
ms = _query_mentions(db)
assert len(ms) >= 1
# 删除 comment(通过 DB 直接操作模拟)
conn = sqlite3.connect(str(db))
try:
conn.execute("DELETE FROM comments WHERE task_id=?", (tid,))
conn.commit()
finally:
conn.close()
# mention 仍在 queue(没有 FK cascade 或 JOIN 过滤时)
ms2 = _query_mentions(db)
# 如果 get_pending_mentions JOIN comments,可能过滤掉
# 这里验证 mention 行本身仍存在
assert len(ms2) >= 1, "mention row should persist even after comment deleted"
print(f" ✅ B6: mention persists after comment deleted")
View File
@@ -24,6 +24,8 @@ from src.blackboard.models import (
from src.blackboard.operations import Blackboard
from src.blackboard.queries import Queries
pytestmark = pytest.mark.unit
@pytest.fixture
def tmp_db(tmp_path):
+191
View File
@@ -0,0 +1,191 @@
"""#11 Bootstrap 四段式拼装单元测试(v2.1)
覆盖:
- T1: build(task, role) 4 段结构
- T2: token 估算 + 预算告警
- T3: 缺失组件降级
- T4: build_for_task 便捷方法
- T5: _read_skill fallback
- T6: ROLE_SKILL_MAP 覆盖
"""
import pytest
pytestmark = pytest.mark.unit
from unittest.mock import patch
from pathlib import Path
from src.daemon.bootstrap import BootstrapBuilder, estimate_tokens
@pytest.fixture
def builder():
return BootstrapBuilder(max_tokens=4096)
# ---------------------------------------------------------------------------
# T1: build(task, role) 4 段结构
# ---------------------------------------------------------------------------
class TestFourSectionBuild:
def test_basic_executor_build(self, builder):
b = builder.build(
task={"task_id": "t1", "title": "Write tests", "description": "Write unit tests",
"must_haves": "100% coverage", "status": "claimed"},
role="executor",
)
# 段 1: 任务上下文
assert "Write tests" in b
assert "t1" in b
assert "100% coverage" in b
# 段 4: 硬约束
assert "review" in b
assert "handoff" in b
def test_basic_reviewer_build(self, builder):
b = builder.build(
task={"task_id": "t2", "title": "Review PR"},
role="reviewer",
)
assert "Review PR" in b
# 段 4: reviewer 硬约束
assert "pass/fail" in b or "pass" in b
def test_planner_build(self, builder):
b = builder.build(
task={"task_id": "t3", "title": "Plan sprint"},
role="planner",
)
assert "Plan sprint" in b
def test_depends_on_outputs_injected(self, builder):
b = builder.build(
task={
"title": "T",
"depends_on_outputs": [
{"task_id": "t0", "summary": "Data downloaded"},
],
},
role="executor",
)
assert "前序产出" in b
assert "Data downloaded" in b
def test_no_depends_on_omitted(self, builder):
b = builder.build(
task={"title": "T"},
role="executor",
)
assert "前序产出" not in b
# ---------------------------------------------------------------------------
# T2: token 估算 + 预算告警
# ---------------------------------------------------------------------------
class TestTokenEstimation:
def test_estimate_tokens_basic(self):
assert estimate_tokens("hello") > 0
def test_estimate_tokens_long_text(self):
text = "a" * 1000
tokens = estimate_tokens(text)
assert 200 < tokens < 400
# ---------------------------------------------------------------------------
# T3: 缺失组件降级
# ---------------------------------------------------------------------------
class TestGracefulDegradation:
def test_empty_task(self, builder):
b = builder.build(task={}, role="executor")
assert b # 不为空
assert "硬约束" in b
def test_partial_task(self, builder):
b = builder.build(task={"title": "Only title"}, role="executor")
assert "Only title" in b
# ---------------------------------------------------------------------------
# T4: build_for_task 便捷方法
# ---------------------------------------------------------------------------
class TestBuildForTask:
def test_build_for_task_object(self, builder):
"""用 mock task 对象测试 build_for_task"""
class MockTask:
id = "t1"
title = "Build Feature"
description = "Implement X with tests"
must_haves = "Unit tests, Documentation"
status = "claimed"
task = MockTask()
b = builder.build_for_task(task, role="executor")
assert "Build Feature" in b
assert "Implement X" in b
def test_build_for_task_ignores_kwargs(self, builder):
"""build_for_task 忽略旧参数"""
class MockTask:
id = "t1"
title = "T"
description = ""
must_haves = ""
status = ""
task = MockTask()
# 旧参数 project_config/experiences 不应报错
b = builder.build_for_task(
task, role="executor",
project_config={"name": "Old"},
experiences=[{"x": 1}],
)
assert "T" in b
# 不应出现旧参数内容
assert "Old" not in b
# ---------------------------------------------------------------------------
# T5: _read_skill fallback
# ---------------------------------------------------------------------------
class TestReadSkillFallback:
def test_missing_skill_file_returns_empty(self, builder):
"""Skill 文件不存在时返回空字符串,不抛异常"""
result = builder._read_skill("nonexistent-skill-xyz")
assert result == ""
def test_existing_skill_file_read(self, builder):
"""能读取实际存在的 Skill 文件"""
# blackboard-executor 应该存在(P1 已创建)
result = builder._read_skill("blackboard-executor")
assert "执行" in result or "executor" in result.lower()
# ---------------------------------------------------------------------------
# T6: ROLE_SKILL_MAP 覆盖
# ---------------------------------------------------------------------------
class TestRoleSkillMap:
def test_all_roles_mapped(self):
assert set(BootstrapBuilder.ROLE_SKILL_MAP.keys()) == {
"executor", "reviewer", "reviewer-simayi",
"reviewer-pangtong", "planner", "claim",
}
def test_unknown_role_warns(self, builder):
"""未映射的 role 输出 warning"""
import logging
with patch("src.daemon.bootstrap.logger") as mock_logger:
builder.build(task={"title": "T"}, role="unknown_role")
mock_logger.warning.assert_called_with(
"No skill mapping for role: %s", "unknown_role"
)
def test_discussion_role_no_warning(self, builder, caplog):
"""discussion 角色不应触发 warning"""
import logging
with caplog.at_level(logging.WARNING, logger="moziplus-v2.bootstrap"):
builder.build(task={"title": "T"}, role="discussion")
assert "No skill mapping" not in caplog.text
@@ -6,6 +6,8 @@ from pathlib import Path
import pytest
pytestmark = pytest.mark.unit
from src.cli.blackboard import run_blackboard_cli, run_admin_cli
from src.blackboard.operations import Blackboard
from src.blackboard.models import Task
@@ -10,6 +10,8 @@
import asyncio
import pytest
pytestmark = pytest.mark.unit
from src.daemon.counter import ActiveAgentCounter
@@ -1,14 +1,4 @@
"""F9 Agent 调度器单元测试
test-plan-v2.6.md §F9
- T1: 三级决策树P0
- T2: 调度不阻塞P0
- T3: 队列满拒绝P0
- T4: 任务优先级排序P1
v2.8 新增#07 AgentBusyError 分类):
- E14.3: Dispatcher 错误区分1 个测试
"""
"""F9 Agent 调度器单元测试 — 三级决策树 + 批量决策"""
import asyncio
import json
@@ -21,6 +11,8 @@ from src.blackboard.models import Task
from src.daemon.dispatcher import Dispatcher, DispatchLevel
from src.daemon.spawner import AgentBusyError
pytestmark = pytest.mark.unit
# ---------------------------------------------------------------------------
# Fixtures
@@ -184,7 +176,7 @@ class TestDispatch:
class TestConcurrencyControl:
def test_counter_busy_skips(self, dispatcher, task_pending):
"""Agent 忙 → skipv2.8 #07: AgentBusyError 从 spawn_full_agent 抛出)"""
"""Agent 忙 → skip"""
mock_spawner = MagicMock()
mock_spawner.spawn_full_agent = AsyncMock(
side_effect=AgentBusyError("zhangfei-dev", reason="counter_blocked")
@@ -196,7 +188,7 @@ class TestConcurrencyControl:
assert "busy" in result["reason"].lower()
def test_counter_releases_on_error(self, dispatcher, task_pending):
"""spawn 失败后释放 counterv2.8 #07: counter.release 由 spawn_full_agent 内部保证)"""
"""spawn 失败后释放 counter"""
mock_spawner = MagicMock()
mock_spawner.spawn_full_agent = AsyncMock(side_effect=RuntimeError("fail"))
dispatcher.spawner = mock_spawner
@@ -277,159 +269,4 @@ class TestDispatcherErrorClassification:
dispatcher.spawner = mock_spawner
result = asyncio.run(dispatcher.dispatch(task_pending))
# counter_blocked 通常在 can_acquire 阶段被拦,结果为 skipped
# 如果穿透到 spawn_full_agent,则为 error
assert result["status"] in ("skipped", "error")
# ---------------------------------------------------------------------------
# 司马懿评审补充:_rollback_current_agent + on_complete 统一(v2.8 #07.2
# ---------------------------------------------------------------------------
class TestRollbackAndOnComplete:
"""司马懿评审遗漏 #3 + #4: crash 后 current_agent 回退 + on_complete 统一路径"""
def test_rollback_current_agent_on_crash(self, tmp_path):
"""executor crash → _rollback_current_agent 回退 current_agent → assignee
#07.2 核心改动:executor crash 后也回退 current_agent
避免 _dispatch_reviews exclude_current 卡死
"""
from src.blackboard.operations import Blackboard
from src.blackboard.models import Task
db_path = tmp_path / "blackboard.db"
bb = Blackboard(db_path)
bb.create_task(Task(
id="t1", title="T", status="working",
assigned_by="daemon", assignee="zhangfei-dev",
))
# 通过状态变更设置 current_agentcreate_task 不持久化 current_agent
conn = bb._conn()
try:
conn.execute("UPDATE tasks SET current_agent=? WHERE id=?", ("zhangfei-dev", "t1"))
conn.commit()
finally:
conn.close()
dispatcher = Dispatcher(registered_agents=["zhangfei-dev"])
dispatcher._rollback_current_agent(db_path, "t1", "zhangfei-dev")
import sqlite3
conn2 = sqlite3.connect(str(db_path))
conn2.row_factory = sqlite3.Row
row = conn2.execute("SELECT current_agent, assignee FROM tasks WHERE id=?", ("t1",)).fetchone()
conn2.close()
assert row["current_agent"] == row["assignee"] == "zhangfei-dev"
def test_rollback_different_agent(self, tmp_path):
"""current_agent ≠ agent_id → 不回退(安全检查)
_rollback_current_agent WHERE 条件包含 current_agent=?
如果 agent_id 不匹配 current_agent 则不执行更新
"""
from src.blackboard.operations import Blackboard
from src.blackboard.models import Task
db_path = tmp_path / "blackboard.db"
bb = Blackboard(db_path)
bb.create_task(Task(
id="t1", title="T", status="working",
assigned_by="daemon", assignee="zhangfei-dev",
))
# 设置 current_agent 为 simayi-challenger
conn = bb._conn()
try:
conn.execute("UPDATE tasks SET current_agent=? WHERE id=?", ("simayi-challenger", "t1"))
conn.commit()
finally:
conn.close()
dispatcher = Dispatcher(registered_agents=["zhangfei-dev"])
# 用错误的 agent_id 回退
dispatcher._rollback_current_agent(db_path, "t1", "wrong-agent")
import sqlite3
conn2 = sqlite3.connect(str(db_path))
conn2.row_factory = sqlite3.Row
row = conn2.execute("SELECT current_agent FROM tasks WHERE id=?", ("t1",)).fetchone()
conn2.close()
# current_agent 不变
assert row["current_agent"] == "simayi-challenger"
def test_on_complete_crash_rollback_executor(self, tmp_path):
"""executor crash → rollback current_agent + _task_auto_complete(标 review
#07.2: crash 回退在 if _is_review 之前执行。
"""
from src.blackboard.operations import Blackboard
from src.blackboard.models import Task
db_path = tmp_path / "blackboard.db"
bb = Blackboard(db_path)
bb.create_task(Task(
id="t1", title="T", status="working",
assigned_by="daemon", assignee="zhangfei-dev",
))
# 设置 current_agent
conn = bb._conn()
try:
conn.execute("UPDATE tasks SET current_agent=? WHERE id=?", ("zhangfei-dev", "t1"))
conn.commit()
finally:
conn.close()
dispatcher = Dispatcher(registered_agents=["zhangfei-dev"])
# executor crash: rollback current_agent
dispatcher._rollback_current_agent(db_path, "t1", "zhangfei-dev")
import sqlite3
conn2 = sqlite3.connect(str(db_path))
conn2.row_factory = sqlite3.Row
row = conn2.execute("SELECT current_agent FROM tasks WHERE id=?", ("t1",)).fetchone()
conn2.close()
assert row["current_agent"] == "zhangfei-dev" # rollback 到 assignee
def test_on_complete_crash_rollback_review(self, tmp_path):
"""review crash → rollback current_agent + 保持 review 状态
#07.2: crash 回退在 if _is_review 之前执行。
review crash current_agent 回退但任务保持 review 状态
"""
from src.blackboard.operations import Blackboard
from src.blackboard.models import Task
db_path = tmp_path / "blackboard.db"
bb = Blackboard(db_path)
bb.create_task(Task(
id="t1", title="T", status="review",
assigned_by="daemon", assignee="zhangfei-dev",
))
# 设置 current_agent 为 reviewer
conn = bb._conn()
try:
conn.execute("UPDATE tasks SET current_agent=? WHERE id=?", ("simayi-challenger", "t1"))
conn.commit()
finally:
conn.close()
dispatcher = Dispatcher(registered_agents=["simayi-challenger"])
dispatcher._rollback_current_agent(db_path, "t1", "simayi-challenger")
import sqlite3
conn2 = sqlite3.connect(str(db_path))
conn2.row_factory = sqlite3.Row
row = conn2.execute("SELECT current_agent, status FROM tasks WHERE id=?", ("t1",)).fetchone()
conn2.close()
# current_agent 回退到 assignee
assert row["current_agent"] == "zhangfei-dev"
# review 状态不变
assert row["status"] == "review"
@@ -9,6 +9,8 @@
import json
import pytest
pytestmark = pytest.mark.unit
from pathlib import Path
from src.daemon.experience import (
@@ -10,6 +10,8 @@
import asyncio
import json
import pytest
pytestmark = pytest.mark.unit
from pathlib import Path
from src.blackboard.operations import Blackboard
@@ -12,6 +12,8 @@ import asyncio
import json
import threading
import pytest
pytestmark = pytest.mark.unit
from pathlib import Path
from src.daemon.inbox import InboxWatcher
@@ -7,6 +7,8 @@ from pathlib import Path
import pytest
pytestmark = pytest.mark.unit
from src.blackboard.registry import ProjectRegistry
from src.blackboard.operations import Blackboard
from src.blackboard.models import Task
+87
View File
@@ -0,0 +1,87 @@
"""F14 Rebuttal 单元测试
按 test-plan-v2.6.md §F14
- F14 T1: 反驳权流程(P0
- F14 T2: 最大轮次限制(P0
"""
import json
import pytest
from pathlib import Path
from unittest.mock import MagicMock
from src.blackboard.models import Task
from src.blackboard.operations import Blackboard
from src.daemon.review import RebuttalManager
pytestmark = pytest.mark.unit
@pytest.fixture
def db_path(tmp_path):
return tmp_path / "blackboard.db"
@pytest.fixture
def bb(db_path):
return Blackboard(db_path)
# ---------------------------------------------------------------------------
# F14 T1: 反驳权流程
# ---------------------------------------------------------------------------
class TestRebuttal:
def test_submit_rebuttal_accepted(self, bb):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
bb.create_task(task)
rm = RebuttalManager(bb=bb)
result = rm.submit_rebuttal("t1", "agent-1", "I disagree with the review")
assert result["status"] == "accepted"
assert result["round"] == 1
assert result["escalation_target"] == "simayi-challenger"
def test_second_round_escalates_to_pangtong(self, bb):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
bb.create_task(task)
rm = RebuttalManager(bb=bb)
rm.submit_rebuttal("t1", "agent-1", "Round 1")
result = rm.submit_rebuttal("t1", "agent-1", "Round 2")
assert result["status"] == "accepted"
assert result["round"] == 2
assert result["escalation_target"] == "pangtong-fujunshi"
# ---------------------------------------------------------------------------
# F14 T2: 最大轮次限制
# ---------------------------------------------------------------------------
class TestRebuttalLimits:
def test_max_rounds_rejected(self, bb):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
bb.create_task(task)
rm = RebuttalManager(bb=bb)
rm.submit_rebuttal("t1", "a", "R1")
rm.submit_rebuttal("t1", "a", "R2")
result = rm.submit_rebuttal("t1", "a", "R3")
assert result["status"] == "rejected"
assert "Max" in result["reason"]
def test_rebuttal_without_bb(self):
rm = RebuttalManager(bb=None)
result = rm.submit_rebuttal("t1", "a", "reason")
assert result["status"] == "accepted"
assert result["round"] == 1
def test_rebuttal_observation_recorded(self, bb):
task = Task(id="t1", title="T", status="pending", assigned_by="d")
bb.create_task(task)
rm = RebuttalManager(bb=bb)
rm.submit_rebuttal("t1", "agent-1", "test reason", evidence="file.txt")
obs = bb.get_observations(task_id="t1")
rebuttals = [o for o in obs if "Rebuttal round" in (o.body or "")]
assert len(rebuttals) == 1
@@ -1,6 +1,8 @@
"""Router 单元测试 — 三种路由模式 + 校验 + fallback"""
import pytest
pytestmark = pytest.mark.unit
from unittest.mock import MagicMock, patch
from src.daemon.router import (
@@ -9,6 +9,8 @@
import json
import pytest
pytestmark = pytest.mark.unit
from pathlib import Path
from src.daemon.skill_system import (
@@ -1,16 +1,4 @@
"""F9 Agent Spawner 单元测试
test-plan-v2.6.md §F9 Spawner
- T1: spawn 成功P0
- T2: 超时处理P0
- T3: spawn 失败P0
- T4: session 清理P1
v2.8 新增#07 Acquire-First + Compact Hanging + AgentBusyError):
- E11: Spawner Acquire-First Phase 0-46 个测试
- E13: Compact Hanging 不标 failed3 个测试
- E14: AgentBusyError 分类3 个测试
"""
"""F9 Agent Spawner 单元测试 — classify/session 管理"""
import asyncio
import pytest
@@ -19,6 +7,8 @@ from pathlib import Path
from src.blackboard.operations import Blackboard
from src.daemon.spawner import AgentSpawner, AgentBusyError
pytestmark = pytest.mark.unit
@pytest.fixture
def db_path(tmp_path):
@@ -35,13 +25,8 @@ def spawner(db_path):
return AgentSpawner(db_path=db_path, dry_run=True)
@pytest.fixture
def real_spawner(db_path):
return AgentSpawner(db_path=db_path, dry_run=False, agent_timeout=2.0)
# ---------------------------------------------------------------------------
# T1: spawn 成功
# T1: spawn 成功dry_run
# ---------------------------------------------------------------------------
class TestSpawnSuccess:
@@ -78,57 +63,6 @@ class TestSpawnSuccess:
assert len(set(ids)) == 3 # 每个 session_id 唯一
# ---------------------------------------------------------------------------
# T2: 超时处理
# ---------------------------------------------------------------------------
class TestTimeout:
def test_timeout_kills_process(self, tmp_path):
"""超时后 kill 进程"""
db_path = tmp_path / "blackboard.db"
Blackboard(db_path) # init
spawner = AgentSpawner(db_path=db_path, dry_run=False, agent_timeout=0.5)
# Spawn a long-running process (sleep 10)
session_id = asyncio.run(
spawner.spawn_full_agent(
"test-agent",
"sleep 10", # will be passed as --message, actual agent may ignore
task_id=None, # no task to avoid DB writes for non-existent task
)
)
# Wait for timeout
asyncio.run(asyncio.sleep(1.0))
session = spawner.get_session(session_id)
if session:
# Process should have been killed
assert session["status"] in ("timed_out", "running", "completed")
# ---------------------------------------------------------------------------
# T3: spawn 失败
# ---------------------------------------------------------------------------
class TestSpawnFailure:
def test_nonexistent_command(self, real_spawner, db_path, bb):
"""命令不存在 → spawn_failed"""
bb.create_task(
__import__("src.blackboard.models", fromlist=["Task"]).Task(
id="t1", title="T", status="pending", assigned_by="d"
)
)
# Spawner will try to run "openclaw" which may not exist in test env
# This test is about error handling, not the actual command
try:
asyncio.run(
real_spawner.spawn_full_agent("test", "msg", task_id="t1")
)
except Exception:
pass # Expected - command may fail
# ---------------------------------------------------------------------------
# T4: session 清理
# ---------------------------------------------------------------------------
@@ -164,44 +98,29 @@ class TestAcquireFirst:
"""E11: #07.1 Acquire-First 重构后的 Phase 0-4 测试"""
def test_phase0_revive_before_acquire(self, spawner):
"""E11.1 Phase 0: timeout/failed 状态 → revive → acquire 成功
Phase 0 counter acquire 之前执行修复 timeout/failed session
这里用 dry_run spawner 测试 _revive_session 的调用路径
"""
# 在 dry_run 模式下,Phase 0 检查 session state
# 如果 session 不存在(正常情况),status=None → 不触发 revive
# 测试正常路径:不超时/失败 → 直接 acquire + spawn
"""E11.1 Phase 0: timeout/failed 状态 → revive → acquire 成功"""
session_id = asyncio.run(
spawner.spawn_full_agent(
"test-agent", "do something",
task_id="t1", use_main_session=True,
)
)
assert session_id # dry_run 返回 "main" 或 session_id
assert session_id
def test_phase0_stuck_detection(self, spawner):
"""E11.2 Phase 0: status=running + lock PID 死 → revive
session state 显示 running lock PID 已死Phase 0 应自动 revive
"""
# 模拟:通过 mock _check_session_state 返回假死状态
"""E11.2 Phase 0: status=running + lock PID 死 → revive"""
original_check = spawner._check_session_state
call_count = [0]
def mock_check(agent_id):
call_count[0] += 1
if call_count[0] == 1:
# Phase 0: 假死状态
return {"status": "running", "lock_pid_alive": False}
# Phase 2: revive 后正常
return {"status": "idle", "lock_pid_alive": False}
spawner._check_session_state = mock_check
revive_called = [False]
original_revive = spawner._revive_session
def mock_revive(agent_id):
revive_called[0] = True
return True
@@ -217,22 +136,16 @@ class TestAcquireFirst:
assert revive_called[0], "Phase 0 should have called _revive_session for stuck session"
def test_phase1_counter_acquire_exclusive(self, spawner):
"""E11.3 Phase 1: counter acquire 互斥
同一 agent 并发 spawn 第二个 AgentBusyError(reason counter)
使用 max_concurrent_sessions=1 确保同 agent 第二次 acquire 失败
"""
"""E11.3 Phase 1: counter acquire 互斥"""
from src.daemon.counter import ActiveAgentCounter
counter = ActiveAgentCounter(max_global=5, max_concurrent_sessions=1)
spawner.counter = counter
# 第一次 acquire 成功
session_id = asyncio.run(
spawner.spawn_full_agent("test-agent", "task", task_id="t1")
)
assert session_id
# 第二次 acquire → counter blocked(同 agent 已有活跃 session
with pytest.raises(AgentBusyError) as exc_info:
asyncio.run(
spawner.spawn_full_agent("test-agent", "task2", task_id="t2")
@@ -240,15 +153,11 @@ class TestAcquireFirst:
assert "counter" in exc_info.value.reason or "blocked" in exc_info.value.reason
def test_phase2_session_check_under_lock(self, spawner):
"""E11.4 Phase 2: session check 在锁保护下执行
counter acquire session locked release counter AgentBusyError
"""
"""E11.4 Phase 2: session check 在锁保护下执行"""
from src.daemon.counter import ActiveAgentCounter
counter = ActiveAgentCounter(max_global=5, max_per_agent=1)
spawner.counter = counter
# 模拟 session locked
spawner._check_session_state = lambda agent_id: {
"status": "idle",
"lock_pid_alive": True,
@@ -263,14 +172,10 @@ class TestAcquireFirst:
)
)
assert "session_locked" in exc_info.value.reason
# counter 应被 release(不会泄漏)
assert counter.global_active == 0
def test_phase2_multiple_blockers(self, spawner):
"""E11.5 Phase 2: 多 blocker 并列收集
session locked + compact 同时存在 detail.blockers 包含两者
"""
"""E11.5 Phase 2: 多 blocker 并列收集"""
spawner._check_session_state = lambda agent_id: {
"status": "idle",
"lock_pid_alive": True,
@@ -292,10 +197,7 @@ class TestAcquireFirst:
assert "session_compacting" in blocker_reasons
def test_phase3_on_checks_passed_exception_rollback(self, spawner):
"""E11.6 Phase 3: on_checks_passed 抛异常 → counter 自动 release
回调异常不应导致 counter 泄漏
"""
"""E11.6 Phase 3: on_checks_passed 抛异常 → counter 自动 release"""
from src.daemon.counter import ActiveAgentCounter
counter = ActiveAgentCounter(max_global=5, max_per_agent=1)
spawner.counter = counter
@@ -311,7 +213,6 @@ class TestAcquireFirst:
on_checks_passed=bad_callback,
)
)
# counter 应被 release
assert counter.global_active == 0
@@ -323,10 +224,7 @@ class TestCompactHanging:
"""E13: compact_hanging 后不标 failed,只 release counter → 任务保持 working"""
def test_compact_hanging_releases_counter(self):
"""E13.1: compact 超限 → compact_hanging → release counter → 任务保持 working
compact_hanging outcome counter 应被 release任务不应被标 failed
"""
"""E13.1: compact 超限 → compact_hanging → release counter → 任务保持 working"""
from src.daemon.counter import ActiveAgentCounter
from src.blackboard.models import Task as TaskModel
counter = ActiveAgentCounter(max_global=5, max_per_agent=1)
@@ -340,7 +238,6 @@ class TestCompactHanging:
spawner = AgentSpawner(db_path=db_path, dry_run=True)
spawner.counter = counter
# 模拟 compact_hanging outcome 的 on_complete
outcomes = []
async def mock_on_complete(aid, outcome):
outcomes.append((aid, outcome))
@@ -350,7 +247,6 @@ class TestCompactHanging:
on_complete=mock_on_complete,
))
# 手动模拟 counter releasecompact_hanging 路径)
counter.release("test-agent", sid)
assert counter.global_active == 0
finally:
@@ -358,15 +254,11 @@ class TestCompactHanging:
db_path.unlink()
def test_retry_agent_busy_releases_counter(self, spawner):
"""E13.3: _do_retry 遇 AgentBusyError → release counter → 任务保持 working
retry session busy 时应 release counter不持有
"""
"""E13.3: _do_retry 遇 AgentBusyError → release counter → 任务保持 working"""
from src.daemon.counter import ActiveAgentCounter
counter = ActiveAgentCounter(max_global=5, max_per_agent=1)
spawner.counter = counter
# 模拟 retry 场景:spawn 遇 AgentBusyError
spawner._check_session_state = lambda agent_id: {
"status": "running",
"lock_pid_alive": True,
@@ -380,7 +272,6 @@ class TestCompactHanging:
task_id="t1", use_main_session=True,
)
)
# counter 不应泄漏
assert counter.global_active == 0
@@ -397,7 +288,6 @@ class TestAgentBusyErrorClassification:
counter = ActiveAgentCounter(max_global=1, max_per_agent=1)
spawner.counter = counter
# Fill counter
asyncio.run(counter.acquire("test-agent"))
with pytest.raises(AgentBusyError) as exc_info:
@@ -411,19 +301,14 @@ class TestAgentBusyErrorClassification:
"""E14.2: session locked/running/compacting → 具体 reason + detail.blockers"""
test_cases = [
{
# session locked: lock PID alive + not expired
"state": {"status": "idle", "lock_pid_alive": True, "lock_expired": False},
"expected": "session_locked",
},
{
# session running: status=running + lock alive
# 注意:running + lock alive 同时触发 session_locked 和 session_running
# primary_reason 取第一个 blockersession_locked
"state": {"status": "running", "lock_pid_alive": True, "lock_expired": False},
"expected": "session_locked", # session_locked 排在 session_running 之前
"expected": "session_locked",
},
{
# session compacting: recent compact
"state": {"status": "idle", "lock_pid_alive": False, "recent_compact": True},
"expected": "session_compacting",
},
@@ -448,11 +333,7 @@ class TestAgentBusyErrorClassification:
f"Case {i}: expected detail to be set"
def test_session_running_in_blockers(self, spawner):
"""session_running 出现在 blockers 列表中(session_locked 优先)
status=running + lock alive session_locked session_running 同时存在
session_locked 排第一primary_reason blockers 列表包含 session_running
"""
"""session_running 出现在 blockers 列表中(session_locked 优先)"""
spawner._check_session_state = lambda aid: {
"status": "running", "lock_pid_alive": True, "lock_expired": False,
}
@@ -464,9 +345,7 @@ class TestAgentBusyErrorClassification:
task_id="t1", use_main_session=True,
)
)
# primary_reason 是 session_locked
assert "session_locked" in exc_info.value.reason
# blockers 列表包含 session_running
blockers = exc_info.value.detail.get("blockers", [])
blocker_reasons = [b[0] for b in blockers]
assert "session_running" in blocker_reasons
@@ -494,22 +373,15 @@ class TestPhase25AndStuck:
"""司马懿评审遗漏 #1 + session_stuck 遗漏补充"""
def test_phase25_stuck_fallback(self, spawner):
"""Phase 0 不触发(status 非 running),Phase 2 检测到假死 → revive → 成功 spawn
Phase 2.5 #07 v1.1 加的兜底:Phase 0 时 session 正常(idle),
Phase 2 检查时变为 running + lock PID Phase 2.5 revive 重检 idle spawn 成功
"""
"""Phase 2.5: Phase 2 检测到假死 → revive → 成功 spawn"""
call_count = [0]
def mock_check(agent_id):
call_count[0] += 1
if call_count[0] <= 1:
# Phase 0: 正常 idle,不触发 revive
return {"status": "idle", "lock_pid_alive": False}
if call_count[0] == 2:
# Phase 2: 假死(Phase 0 和 Phase 2 之间进程变 stuck
return {"status": "running", "lock_pid_alive": False}
# Phase 2.5 重检:revive 后恢复 idle
return {"status": "idle", "lock_pid_alive": False}
spawner._check_session_state = mock_check
@@ -521,7 +393,6 @@ class TestPhase25AndStuck:
spawner._revive_session = mock_revive
# Phase 2.5 应触发 revive → 重检 → idle → 正常 spawn
session_id = asyncio.run(
spawner.spawn_full_agent(
"test-agent", "task",
@@ -529,31 +400,11 @@ class TestPhase25AndStuck:
)
)
assert revive_called[0], "Phase 2.5 should have revived stuck session"
assert session_id # spawn 成功
assert session_id
def test_session_stuck_after_failed_revive(self, spawner):
"""Phase 2.5 revive 失败 → session_stuck
假死 revive status 仍为 running AgentBusyError(session_stuck)
"""
call_count = [0]
def mock_check(agent_id):
call_count[0] += 1
if call_count[0] <= 1:
return {"status": "idle", "lock_pid_alive": False}
# Phase 2: 假死
return {"status": "running", "lock_pid_alive": False}
spawner._check_session_state = mock_check
# revive 后 session 仍 stuck
def mock_revive(agent_id):
return True # revive 成功但 mock_check 不变,下次仍返回 running
# 让 revive 后 check 返回 stuck
"""Phase 2.5 revive 失败 → session_stuck"""
revive_and_check_count = [0]
original_check = mock_check
def mock_check_v2(agent_id):
revive_and_check_count[0] += 1
@@ -561,11 +412,10 @@ class TestPhase25AndStuck:
return {"status": "idle", "lock_pid_alive": False}
if revive_and_check_count[0] == 2:
return {"status": "running", "lock_pid_alive": False}
# revive 后重检:仍 stuck
return {"status": "running", "lock_pid_alive": False}
spawner._check_session_state = mock_check_v2
spawner._revive_session = mock_revive
spawner._revive_session = lambda agent_id: True
with pytest.raises(AgentBusyError) as exc_info:
asyncio.run(