auto-sync: 2026-06-05 11:03:30
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
"""F2 测试:黑板核心(DB + CRUD + 状态机 + 并发)"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
from src.blackboard.db import (
|
||||
VALID_TRANSITIONS,
|
||||
TERMINAL_STATUSES,
|
||||
COMMENT_TYPES,
|
||||
OUTPUT_TYPES,
|
||||
REVIEW_TYPES,
|
||||
VERDICT_TYPES,
|
||||
init_db,
|
||||
get_connection,
|
||||
)
|
||||
from src.blackboard.models import (
|
||||
Task, Comment, Output, Decision, Observation,
|
||||
Review, Experience,
|
||||
)
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.blackboard.queries import Queries
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_db(tmp_path):
|
||||
"""创建临时黑板"""
|
||||
db_path = tmp_path / "test.db"
|
||||
bb = Blackboard(db_path)
|
||||
return bb, Queries(db_path), db_path
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Schema 初始化
|
||||
# ===================================================================
|
||||
|
||||
class TestSchema:
|
||||
def test_init_creates_all_tables(self, tmp_path):
|
||||
db_path = tmp_path / "new.db"
|
||||
init_db(db_path)
|
||||
conn = get_connection(db_path)
|
||||
tables = {r[0] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()}
|
||||
conn.close()
|
||||
expected = {
|
||||
"tasks", "comments", "outputs", "decisions",
|
||||
"observations", "events", "agents", "task_attempts",
|
||||
"reviews", "experiences", "experience_tags",
|
||||
}
|
||||
assert expected.issubset(tables)
|
||||
|
||||
def test_wal_mode(self, tmp_path):
|
||||
db_path = tmp_path / "wal.db"
|
||||
init_db(db_path)
|
||||
conn = get_connection(db_path)
|
||||
row = conn.execute("PRAGMA journal_mode").fetchone()
|
||||
conn.close()
|
||||
assert row[0] == "wal"
|
||||
|
||||
def test_busy_timeout(self, tmp_path):
|
||||
db_path = tmp_path / "busy.db"
|
||||
init_db(db_path)
|
||||
conn = get_connection(db_path)
|
||||
row = conn.execute("PRAGMA busy_timeout").fetchone()
|
||||
conn.close()
|
||||
assert row[0] == 5000
|
||||
|
||||
def test_foreign_keys_on(self, tmp_path):
|
||||
db_path = tmp_path / "fk.db"
|
||||
init_db(db_path)
|
||||
conn = get_connection(db_path)
|
||||
row = conn.execute("PRAGMA foreign_keys").fetchone()
|
||||
conn.close()
|
||||
assert row[0] == 1
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Task CRUD
|
||||
# ===================================================================
|
||||
|
||||
class TestTaskCRUD:
|
||||
def test_create_and_get(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
task = Task(id="t1", title="Test Task", task_type="coding")
|
||||
bb.create_task(task)
|
||||
got = bb.get_task("t1")
|
||||
assert got is not None
|
||||
assert got.title == "Test Task"
|
||||
assert got.status == "pending"
|
||||
|
||||
def test_create_with_all_fields(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
task = Task(
|
||||
id="t2", title="Full Task", description="desc",
|
||||
assignee="zhangfei-dev", assigned_by="pangtong-fujunshi",
|
||||
depends_on='["t1"]', parent_task="t0",
|
||||
priority=3, task_type="review", deadline="2026-06-01",
|
||||
risk_level="high", estimated_duration_minutes=60,
|
||||
must_haves='{"truths": ["a"], "artifacts": ["b"], "constraints": ["c"]}',
|
||||
)
|
||||
bb.create_task(task)
|
||||
got = bb.get_task("t2")
|
||||
assert got.priority == 3
|
||||
assert got.risk_level == "high"
|
||||
assert got.assignee == "zhangfei-dev"
|
||||
|
||||
def test_get_nonexistent(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
assert bb.get_task("nope") is None
|
||||
|
||||
def test_list_tasks(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
for i in range(5):
|
||||
bb.create_task(Task(id=f"t{i}", title=f"Task {i}"))
|
||||
tasks = bb.list_tasks()
|
||||
assert len(tasks) == 5
|
||||
|
||||
def test_list_tasks_by_status(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="A"))
|
||||
bb.create_task(Task(id="t2", title="B"))
|
||||
bb.update_task_status("t1", "claimed", agent="agent1")
|
||||
pending = bb.list_tasks(status="pending")
|
||||
assert len(pending) == 1
|
||||
assert pending[0].id == "t2"
|
||||
|
||||
def test_list_tasks_by_assignee(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="A", assignee="agent1"))
|
||||
bb.create_task(Task(id="t2", title="B", assignee="agent2"))
|
||||
tasks = bb.list_tasks(assignee="agent1")
|
||||
assert len(tasks) == 1
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 状态机
|
||||
# ===================================================================
|
||||
|
||||
class TestStateMachine:
|
||||
def test_valid_transitions(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="SM Test"))
|
||||
|
||||
# pending → claimed → working → review → done
|
||||
assert bb.update_task_status("t1", "claimed", agent="a1")
|
||||
assert bb.update_task_status("t1", "working", agent="a1")
|
||||
assert bb.update_task_status("t1", "review", agent="a1")
|
||||
assert bb.update_task_status("t1", "done", agent="system")
|
||||
|
||||
def test_invalid_transition(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Bad"))
|
||||
# pending → done is invalid
|
||||
assert not bb.update_task_status("t1", "done")
|
||||
|
||||
def test_terminal_state_blocked(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Done"))
|
||||
bb.update_task_status("t1", "claimed", agent="a1")
|
||||
bb.update_task_status("t1", "working", agent="a1")
|
||||
bb.update_task_status("t1", "review", agent="a1")
|
||||
bb.update_task_status("t1", "done", agent="system")
|
||||
# done is terminal
|
||||
assert not bb.update_task_status("t1", "pending")
|
||||
|
||||
def test_failed_to_pending_retry(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Retry"))
|
||||
bb.update_task_status("t1", "claimed", agent="a1")
|
||||
bb.update_task_status("t1", "working", agent="a1")
|
||||
bb.update_task_status("t1", "failed", agent="a1")
|
||||
assert bb.update_task_status("t1", "pending")
|
||||
task = bb.get_task("t1")
|
||||
assert task.retry_count == 1
|
||||
|
||||
def test_blocked_to_pending(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Blocked"))
|
||||
bb.update_task_status("t1", "claimed", agent="a1")
|
||||
bb.update_task_status("t1", "working", agent="a1")
|
||||
assert bb.update_task_status("t1", "blocked", agent="a1")
|
||||
assert bb.update_task_status("t1", "pending")
|
||||
|
||||
def test_cancel_from_pending(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Cancel"))
|
||||
assert bb.update_task_status("t1", "cancelled")
|
||||
# cancelled → pending is allowed (cancel can be undone)
|
||||
assert bb.update_task_status("t1", "pending")
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Claim(原子 CAS)
|
||||
# ===================================================================
|
||||
|
||||
class TestClaim:
|
||||
def test_claim_pending(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Claim"))
|
||||
assert bb.claim_task("t1", "agent1")
|
||||
task = bb.get_task("t1")
|
||||
assert task.status == "claimed"
|
||||
assert task.assignee == "agent1"
|
||||
|
||||
def test_claim_assigned_task(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Assigned", assignee="agent1"))
|
||||
# Same agent can claim
|
||||
assert bb.claim_task("t1", "agent1")
|
||||
|
||||
def test_cannot_claim_others_task(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Other", assignee="agent1"))
|
||||
assert not bb.claim_task("t1", "agent2")
|
||||
|
||||
def test_cannot_claim_working(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Working"))
|
||||
bb.update_task_status("t1", "claimed", agent="a1")
|
||||
bb.update_task_status("t1", "working", agent="a1")
|
||||
assert not bb.claim_task("t1", "agent2")
|
||||
|
||||
def test_concurrent_claim(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Race"))
|
||||
results = []
|
||||
|
||||
def claim(agent):
|
||||
bb2 = Blackboard(bb.db_path)
|
||||
results.append(bb2.claim_task("t1", agent))
|
||||
|
||||
t1 = threading.Thread(target=claim, args=("agent1",))
|
||||
t2 = threading.Thread(target=claim, args=("agent2",))
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
# Only one should succeed
|
||||
assert sum(1 for r in results if r) == 1
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Comment
|
||||
# ===================================================================
|
||||
|
||||
class TestComment:
|
||||
def test_add_and_get(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Comment"))
|
||||
cid = bb.add_comment("t1", "agent1", "Hello", mentions=["agent2"])
|
||||
comments = bb.get_comments("t1")
|
||||
assert len(comments) == 1
|
||||
assert comments[0].body == "Hello"
|
||||
assert comments[0].comment_type == "general"
|
||||
assert json.loads(comments[0].mentions) == ["agent2"]
|
||||
|
||||
def test_comment_types(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Types"))
|
||||
for ct in COMMENT_TYPES:
|
||||
bb.add_comment("t1", "agent1", f"Type: {ct}", comment_type=ct)
|
||||
comments = bb.get_comments("t1")
|
||||
assert len(comments) == len(COMMENT_TYPES)
|
||||
|
||||
def test_invalid_comment_type(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Bad"))
|
||||
with pytest.raises(ValueError):
|
||||
bb.add_comment("t1", "a", "x", comment_type="invalid")
|
||||
|
||||
def test_filter_by_type(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Filter"))
|
||||
bb.add_comment("t1", "a", "general", comment_type="general")
|
||||
bb.add_comment("t1", "a", "handoff", comment_type="handoff")
|
||||
handoffs = bb.get_comments("t1", comment_type="handoff")
|
||||
assert len(handoffs) == 1
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Output
|
||||
# ===================================================================
|
||||
|
||||
class TestOutput:
|
||||
def test_write_and_get(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Output"))
|
||||
oid = bb.write_output("t1", "agent1", "code", "main.py",
|
||||
summary="Main script")
|
||||
outputs = bb.get_outputs("t1")
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0].title == "main.py"
|
||||
assert outputs[0].output_type == "code"
|
||||
|
||||
def test_all_output_types(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Types"))
|
||||
for ot in OUTPUT_TYPES:
|
||||
bb.write_output("t1", "a", ot, f"file.{ot}")
|
||||
assert len(bb.get_outputs("t1")) == len(OUTPUT_TYPES)
|
||||
|
||||
def test_invalid_output_type(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Bad"))
|
||||
with pytest.raises(ValueError):
|
||||
bb.write_output("t1", "a", "invalid", "x")
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Decision + Observation
|
||||
# ===================================================================
|
||||
|
||||
class TestDecision:
|
||||
def test_add_and_get(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Dec"))
|
||||
bb.add_decision("t1", "pangtong", "Use FastAPI",
|
||||
"Better async support", ["Flask", "Litestar"])
|
||||
decs = bb.get_decisions("t1")
|
||||
assert len(decs) == 1
|
||||
assert decs[0].rationale == "Better async support"
|
||||
assert json.loads(decs[0].alternatives) == ["Flask", "Litestar"]
|
||||
|
||||
|
||||
class TestObservation:
|
||||
def test_add_and_get(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Obs"))
|
||||
bb.add_observation("t1", "agent1", "Potential issue", severity="warning")
|
||||
obs = bb.get_observations("t1")
|
||||
assert len(obs) == 1
|
||||
assert obs[0].severity == "warning"
|
||||
|
||||
def test_unresolved_filter(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Obs"))
|
||||
bb.add_observation("t1", "a", "unresolved", severity="blocking")
|
||||
unresolved = bb.get_observations("t1", unresolved_only=True)
|
||||
assert len(unresolved) == 1
|
||||
assert unresolved[0].severity == "blocking"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Review
|
||||
# ===================================================================
|
||||
|
||||
class TestReview:
|
||||
def test_add_and_get(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Rev"))
|
||||
review = Review(
|
||||
id="rev-1", task_id="t1", reviewer="simayi",
|
||||
review_type="output_review", verdict="approved",
|
||||
confidence=0.9, summary="LGTM",
|
||||
)
|
||||
bb.add_review(review)
|
||||
reviews = bb.get_reviews("t1")
|
||||
assert len(reviews) == 1
|
||||
assert reviews[0].verdict == "approved"
|
||||
assert reviews[0].confidence == 0.9
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Experience
|
||||
# ===================================================================
|
||||
|
||||
class TestExperience:
|
||||
def test_add_and_query(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
exp = Experience(
|
||||
experience_id="exp-1", source="task_completion",
|
||||
summary="SQLite WAL works well", category="best_practice",
|
||||
created_by="pangtong", tags=["sqlite", "performance"],
|
||||
)
|
||||
bb.add_experience(exp)
|
||||
results = bb.query_experiences(tags=["sqlite"])
|
||||
assert len(results) == 1
|
||||
assert set(results[0].tags) == {"sqlite", "performance"}
|
||||
|
||||
def test_touch_increments(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
exp = Experience(
|
||||
experience_id="exp-1", source="manual",
|
||||
summary="test", category="pattern", created_by="pangtong",
|
||||
)
|
||||
bb.add_experience(exp)
|
||||
bb.touch_experience("exp-1")
|
||||
bb.touch_experience("exp-1")
|
||||
results = bb.query_experiences()
|
||||
assert results[0].usage_count == 2
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Event
|
||||
# ===================================================================
|
||||
|
||||
class TestEvent:
|
||||
def test_events_written_on_transitions(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Events"))
|
||||
bb.update_task_status("t1", "claimed", agent="a1")
|
||||
bb.update_task_status("t1", "working", agent="a1")
|
||||
events = bb.get_events(task_id="t1")
|
||||
# create + claimed + working = 3 events
|
||||
assert len(events) >= 3
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Queries
|
||||
# ===================================================================
|
||||
|
||||
class TestQueries:
|
||||
def test_task_summary(self, tmp_db):
|
||||
bb, q, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="A"))
|
||||
bb.create_task(Task(id="t2", title="B"))
|
||||
bb.update_task_status("t1", "claimed", agent="a1")
|
||||
summary = q.task_summary()
|
||||
assert summary.get("pending") == 1
|
||||
assert summary.get("claimed") == 1
|
||||
|
||||
def test_pending_dispatchable(self, tmp_db):
|
||||
bb, q, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="A"))
|
||||
bb.create_task(Task(id="t2", title="B", depends_on='["t1"]'))
|
||||
dispatchable = q.pending_dispatchable()
|
||||
# t1 has no deps → dispatchable; t2 depends on t1 → not
|
||||
assert len(dispatchable) == 1
|
||||
assert dispatchable[0].id == "t1"
|
||||
|
||||
def test_blocked_tasks_with_deps(self, tmp_db):
|
||||
bb, q, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="A"))
|
||||
bb.create_task(Task(id="t2", title="B", depends_on='["t1"]'))
|
||||
bb.update_task_status("t2", "claimed", agent="a1")
|
||||
bb.update_task_status("t2", "working", agent="a1")
|
||||
bb.update_task_status("t2", "blocked", agent="a1")
|
||||
blocked = q.blocked_tasks_with_deps()
|
||||
assert len(blocked) == 1
|
||||
assert blocked[0]["all_deps_done"] is False
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 并发写入
|
||||
# ===================================================================
|
||||
|
||||
class TestConcurrency:
|
||||
def test_concurrent_writes(self, tmp_db):
|
||||
bb, _, _ = tmp_db
|
||||
bb.create_task(Task(id="t1", title="Concurrent"))
|
||||
|
||||
errors = []
|
||||
|
||||
def write_comments(agent, count):
|
||||
try:
|
||||
bb2 = Blackboard(bb.db_path)
|
||||
for i in range(count):
|
||||
bb2.add_comment("t1", agent, f"{agent}-{i}")
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=write_comments, args=(f"a{i}", 10))
|
||||
for i in range(5)
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert len(errors) == 0
|
||||
comments = bb.get_comments("t1")
|
||||
assert len(comments) == 50
|
||||
@@ -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
|
||||
@@ -0,0 +1,142 @@
|
||||
"""F4 测试:CLI 工具"""
|
||||
|
||||
import json
|
||||
import os
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_env(tmp_path):
|
||||
"""创建临时项目环境"""
|
||||
project_root = tmp_path / "projects"
|
||||
project_root.mkdir()
|
||||
os.environ["BLACKBOARD_ROOT"] = str(project_root)
|
||||
|
||||
# Create a test project with DB
|
||||
proj_dir = project_root / "test-proj"
|
||||
proj_dir.mkdir()
|
||||
bb = Blackboard(proj_dir / "blackboard.db")
|
||||
bb.create_task(Task(id="t1", title="Existing Task", task_type="coding"))
|
||||
|
||||
yield project_root
|
||||
del os.environ["BLACKBOARD_ROOT"]
|
||||
|
||||
|
||||
class TestBlackboardCLI:
|
||||
def test_read_all_tasks(self, project_env, capsys):
|
||||
rc = run_blackboard_cli(["read", "--project", "test-proj"])
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "t1" in out
|
||||
|
||||
def test_read_json(self, project_env, capsys):
|
||||
rc = run_blackboard_cli(["read", "--project", "test-proj", "--json"])
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
data = json.loads(out)
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == "t1"
|
||||
|
||||
def test_create_task(self, project_env, capsys):
|
||||
rc = run_blackboard_cli([
|
||||
"create", "--project", "test-proj",
|
||||
"--id", "t2", "--title", "New Task",
|
||||
"--task-type", "review", "--priority", "3",
|
||||
])
|
||||
assert rc == 0
|
||||
assert "Created task: t2" in capsys.readouterr().out
|
||||
|
||||
def test_claim(self, project_env, capsys):
|
||||
rc = run_blackboard_cli([
|
||||
"claim", "--project", "test-proj",
|
||||
"--task-id", "t1", "--agent", "zhangfei-dev",
|
||||
])
|
||||
assert rc == 0
|
||||
|
||||
def test_output(self, project_env, capsys):
|
||||
# First claim and start
|
||||
run_blackboard_cli(["claim", "--project", "test-proj",
|
||||
"--task-id", "t1", "--agent", "a1"])
|
||||
rc = run_blackboard_cli([
|
||||
"output", "--project", "test-proj",
|
||||
"--task-id", "t1", "--agent", "a1",
|
||||
"--type", "code", "--title", "main.py",
|
||||
])
|
||||
assert rc == 0
|
||||
|
||||
def test_comment(self, project_env, capsys):
|
||||
rc = run_blackboard_cli([
|
||||
"comment", "--project", "test-proj",
|
||||
"--task-id", "t1", "--author", "pangtong",
|
||||
"--body", "Good work", "--type", "general",
|
||||
"--mentions", "zhangfei-dev,guanyu-dev",
|
||||
])
|
||||
assert rc == 0
|
||||
|
||||
def test_decide(self, project_env, capsys):
|
||||
rc = run_blackboard_cli([
|
||||
"decide", "--project", "test-proj",
|
||||
"--task-id", "t1", "--decider", "pangtong",
|
||||
"--decision", "Use FastAPI", "--rationale", "Best async support",
|
||||
])
|
||||
assert rc == 0
|
||||
|
||||
def test_observe(self, project_env, capsys):
|
||||
rc = run_blackboard_cli([
|
||||
"observe", "--project", "test-proj",
|
||||
"--task-id", "t1", "--observer", "simayi",
|
||||
"--body", "Potential issue", "--severity", "warning",
|
||||
])
|
||||
assert rc == 0
|
||||
|
||||
def test_review(self, project_env, capsys):
|
||||
rc = run_blackboard_cli([
|
||||
"review", "--project", "test-proj",
|
||||
"--review-id", "rev-1", "--task-id", "t1",
|
||||
"--reviewer", "simayi", "--review-type", "output_review",
|
||||
"--verdict", "approved", "--summary", "LGTM",
|
||||
"--confidence", "0.9",
|
||||
])
|
||||
assert rc == 0
|
||||
|
||||
def test_read_nonexistent_task(self, project_env, capsys):
|
||||
rc = run_blackboard_cli(["read", "--project", "test-proj", "--task-id", "nope"])
|
||||
assert rc == 1
|
||||
|
||||
def test_no_command(self, project_env, capsys):
|
||||
rc = run_blackboard_cli([])
|
||||
assert rc == 1
|
||||
|
||||
|
||||
class TestAdminCLI:
|
||||
def test_project_create(self, tmp_path, capsys):
|
||||
project_root = tmp_path / "projects"
|
||||
project_root.mkdir()
|
||||
os.environ["BLACKBOARD_ROOT"] = str(project_root)
|
||||
try:
|
||||
rc = run_admin_cli([
|
||||
"project-create", "--id", "new-proj",
|
||||
"--name", "New Project",
|
||||
"--agents", "agent1,agent2",
|
||||
])
|
||||
assert rc == 0
|
||||
assert (project_root / "new-proj" / "config" / "project.yaml").exists()
|
||||
finally:
|
||||
del os.environ["BLACKBOARD_ROOT"]
|
||||
|
||||
def test_project_list(self, project_env, capsys):
|
||||
rc = run_admin_cli(["project-list"])
|
||||
assert rc == 0
|
||||
# test-proj is not in registry (created manually), so empty output is fine
|
||||
|
||||
def test_no_command(self, project_env, capsys):
|
||||
rc = run_admin_cli([])
|
||||
assert rc == 1
|
||||
@@ -0,0 +1,160 @@
|
||||
"""F10 ActiveAgentCounter 单元测试
|
||||
|
||||
按 test-plan-v2.6.md §F10:
|
||||
- T1: 全局上限(P0)
|
||||
- T2: per-agent 串行(P0)
|
||||
- T3: release 恢复(P0)
|
||||
- T4: 并发竞争(P1)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
from src.daemon.counter import ActiveAgentCounter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def counter():
|
||||
return ActiveAgentCounter(max_global=3, max_per_agent=1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1: 全局上限
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGlobalLimit:
|
||||
def test_acquire_within_limit(self, counter):
|
||||
"""全局未满 → 成功"""
|
||||
result = asyncio.run(counter.acquire("agent-1"))
|
||||
assert result is True
|
||||
assert counter.global_active == 1
|
||||
|
||||
def test_acquire_fills_global(self, counter):
|
||||
"""填满全局"""
|
||||
for i in range(3):
|
||||
asyncio.run(counter.acquire(f"agent-{i}"))
|
||||
assert counter.global_active == 3
|
||||
|
||||
def test_acquire_exceeds_global(self, counter):
|
||||
"""超出全局上限 → 失败"""
|
||||
for i in range(3):
|
||||
asyncio.run(counter.acquire(f"agent-{i}"))
|
||||
result = asyncio.run(counter.acquire("agent-extra"))
|
||||
assert result is False
|
||||
|
||||
def test_can_acquire_respects_limit(self, counter):
|
||||
"""can_acquire 检查全局上限"""
|
||||
assert asyncio.run(counter.can_acquire("agent-1")) is True
|
||||
for i in range(3):
|
||||
asyncio.run(counter.acquire(f"agent-{i}"))
|
||||
assert asyncio.run(counter.can_acquire("agent-extra")) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T2: per-agent 串行
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPerAgentSerial:
|
||||
def test_same_agent_blocked(self, counter):
|
||||
"""同一 Agent 第二个 acquire 失败"""
|
||||
asyncio.run(counter.acquire("agent-1"))
|
||||
result = asyncio.run(counter.acquire("agent-1"))
|
||||
assert result is False
|
||||
|
||||
def test_different_agents_ok(self, counter):
|
||||
"""不同 Agent 可以同时 acquire"""
|
||||
assert asyncio.run(counter.acquire("agent-1")) is True
|
||||
assert asyncio.run(counter.acquire("agent-2")) is True
|
||||
|
||||
def test_per_agent_limit_2(self):
|
||||
"""max_per_agent=2 允许两个"""
|
||||
c = ActiveAgentCounter(max_global=5, max_per_agent=2)
|
||||
assert asyncio.run(c.acquire("a1")) is True
|
||||
assert asyncio.run(c.acquire("a1")) is True
|
||||
assert asyncio.run(c.acquire("a1")) is False # 第三个失败
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T3: release 恢复
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRelease:
|
||||
def test_release_allows_new_acquire(self, counter):
|
||||
"""release 后可以重新 acquire"""
|
||||
asyncio.run(counter.acquire("agent-1"))
|
||||
counter.release("agent-1")
|
||||
result = asyncio.run(counter.acquire("agent-1"))
|
||||
assert result is True
|
||||
|
||||
def test_release_updates_count(self, counter):
|
||||
"""release 后计数正确"""
|
||||
asyncio.run(counter.acquire("agent-1"))
|
||||
assert counter.global_active == 1
|
||||
counter.release("agent-1")
|
||||
assert counter.global_active == 0
|
||||
|
||||
def test_release_same_agent_twice(self, counter):
|
||||
"""release 后同一 Agent 可以再次 acquire"""
|
||||
asyncio.run(counter.acquire("agent-1"))
|
||||
counter.release("agent-1")
|
||||
assert asyncio.run(counter.can_acquire("agent-1")) is True
|
||||
|
||||
def test_release_updates_active_agents(self, counter):
|
||||
"""active_agents 正确追踪"""
|
||||
asyncio.run(counter.acquire("agent-1"))
|
||||
assert counter.active_agents == {"agent-1": 1}
|
||||
counter.release("agent-1")
|
||||
assert "agent-1" not in counter.active_agents
|
||||
|
||||
def test_full_cycle(self, counter):
|
||||
"""完整获取-释放循环"""
|
||||
# Fill
|
||||
for i in range(3):
|
||||
asyncio.run(counter.acquire(f"a{i}"))
|
||||
assert counter.global_active == 3
|
||||
|
||||
# Full → can't acquire
|
||||
assert asyncio.run(counter.can_acquire("extra")) is False
|
||||
|
||||
# Release one
|
||||
counter.release("a1")
|
||||
assert counter.global_active == 2
|
||||
|
||||
# Now can acquire
|
||||
assert asyncio.run(counter.acquire("extra")) is True
|
||||
assert counter.global_active == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T4: 并发竞争(P1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConcurrency:
|
||||
def test_concurrent_acquire_no_exceed(self):
|
||||
"""并发 acquire 不超过全局限制"""
|
||||
counter = ActiveAgentCounter(max_global=3, max_per_agent=1)
|
||||
results = []
|
||||
|
||||
async def try_acquire(agent_id):
|
||||
r = await counter.acquire(agent_id)
|
||||
results.append((agent_id, r))
|
||||
if r:
|
||||
await asyncio.sleep(0.01)
|
||||
counter.release(agent_id)
|
||||
|
||||
async def run():
|
||||
tasks = [try_acquire(f"agent-{i}") for i in range(10)]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
succeeded = sum(1 for _, r in results if r)
|
||||
# 不超过 max_global 的倍数(因为 release 后可重用)
|
||||
assert succeeded > 0
|
||||
|
||||
def test_no_negative_count(self, counter):
|
||||
"""release 不会导致负数计数"""
|
||||
counter.release("nonexistent") # 不应崩溃
|
||||
assert counter.global_active >= 0
|
||||
@@ -0,0 +1,272 @@
|
||||
"""F9 Agent 调度器单元测试 — 三级决策树 + 批量决策"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from src.blackboard.models import Task
|
||||
from src.daemon.dispatcher import Dispatcher, DispatchLevel
|
||||
from src.daemon.spawner import AgentBusyError
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def dispatcher():
|
||||
return Dispatcher(
|
||||
registered_agents=["zhangfei-dev", "guanyu-dev", "simayi-challenger"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def task_pending():
|
||||
return Task(
|
||||
id="t1", title="Write code", status="pending",
|
||||
assigned_by="daemon", task_type="coding",
|
||||
assignee="zhangfei-dev",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def task_no_assignee():
|
||||
return Task(
|
||||
id="t2", title="Generic task", status="pending",
|
||||
assigned_by="daemon",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def task_unknown_agent():
|
||||
return Task(
|
||||
id="t3", title="Unknown agent task", status="pending",
|
||||
assigned_by="daemon", assignee="nonexistent-agent",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1: 三级决策树
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDecisionTree:
|
||||
def test_local_action(self, dispatcher, task_pending):
|
||||
"""L1 本地执行"""
|
||||
decision = dispatcher.decide(task_pending, "L1_guardrail")
|
||||
assert decision["level"] == DispatchLevel.LOCAL
|
||||
assert decision["agent_id"] == "daemon"
|
||||
|
||||
def test_local_format_check(self, dispatcher, task_pending):
|
||||
"""format_check → 本地"""
|
||||
decision = dispatcher.decide(task_pending, "format_check")
|
||||
assert decision["level"] == DispatchLevel.LOCAL
|
||||
|
||||
def test_local_file_exists(self, dispatcher, task_pending):
|
||||
"""file_exists_check → 本地"""
|
||||
decision = dispatcher.decide(task_pending, "file_exists_check")
|
||||
assert decision["level"] == DispatchLevel.LOCAL
|
||||
|
||||
def test_registered_agent(self, dispatcher, task_pending):
|
||||
"""注册 Agent → Full Agent"""
|
||||
decision = dispatcher.decide(task_pending)
|
||||
assert decision["level"] == DispatchLevel.FULL_AGENT
|
||||
assert decision["agent_id"] == "zhangfei-dev"
|
||||
|
||||
def test_adjudication_new_session(self, dispatcher, task_pending):
|
||||
"""adjudication → new_session=True"""
|
||||
decision = dispatcher.decide(task_pending, "adjudication")
|
||||
assert decision["level"] == DispatchLevel.FULL_AGENT
|
||||
assert decision["new_session"] is True
|
||||
|
||||
def test_no_assignee_subagent(self, dispatcher, task_no_assignee):
|
||||
"""无 assignee → 能力映射 fallback 庞统(Full Agent)"""
|
||||
decision = dispatcher.decide(task_no_assignee)
|
||||
assert decision["level"] == DispatchLevel.FULL_AGENT
|
||||
assert decision["agent_id"] == "pangtong-fujunshi" # fallback
|
||||
|
||||
def test_unknown_agent_escalate(self, dispatcher, task_unknown_agent):
|
||||
"""未注册 Agent → 升级庞统"""
|
||||
decision = dispatcher.decide(task_unknown_agent)
|
||||
assert decision["level"] == DispatchLevel.ESCALATE
|
||||
assert decision["agent_id"] == "pangtong-fujunshi"
|
||||
assert decision["new_session"] is True
|
||||
|
||||
def test_different_registered_agents(self, dispatcher):
|
||||
"""不同注册 Agent 正确路由"""
|
||||
for agent_id in ["zhangfei-dev", "guanyu-dev", "simayi-challenger"]:
|
||||
task = Task(id="t", title="T", status="pending",
|
||||
assigned_by="d", assignee=agent_id)
|
||||
decision = dispatcher.decide(task)
|
||||
assert decision["level"] == DispatchLevel.FULL_AGENT
|
||||
assert decision["agent_id"] == agent_id
|
||||
|
||||
def test_unknown_action_with_registered_agent(self, dispatcher, task_pending):
|
||||
"""未知 action_type 但有注册 assignee → Full Agent"""
|
||||
decision = dispatcher.decide(task_pending, "some_unknown_action")
|
||||
assert decision["level"] == DispatchLevel.FULL_AGENT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T2: 调度执行(带 mock)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDispatch:
|
||||
def test_dispatch_local(self, dispatcher, task_pending):
|
||||
"""本地调度不需要 spawner"""
|
||||
result = asyncio.run(dispatcher.dispatch(task_pending, "L1_guardrail"))
|
||||
assert result["status"] == "dispatched"
|
||||
assert result["level"] == "local"
|
||||
|
||||
def test_dispatch_full_agent(self, dispatcher, task_pending):
|
||||
"""Full Agent 调度"""
|
||||
mock_spawner = MagicMock()
|
||||
mock_spawner.spawn_full_agent = AsyncMock(return_value="session-123")
|
||||
dispatcher.spawner = mock_spawner
|
||||
|
||||
result = asyncio.run(dispatcher.dispatch(task_pending))
|
||||
assert result["status"] == "dispatched"
|
||||
assert result["session_id"] == "session-123"
|
||||
assert result["level"] == "full"
|
||||
|
||||
def test_dispatch_no_spawner_returns_error(self, dispatcher, task_pending):
|
||||
"""无 spawner → error"""
|
||||
result = asyncio.run(dispatcher.dispatch(task_pending))
|
||||
assert result["status"] == "error"
|
||||
assert "No spawner" in result["reason"]
|
||||
|
||||
def test_dispatch_subagent(self, dispatcher, task_no_assignee):
|
||||
"""无 assignee → 能力映射 fallback 庞统(Full Agent)"""
|
||||
mock_spawner = MagicMock()
|
||||
mock_spawner.spawn_full_agent = AsyncMock(return_value="auto-123")
|
||||
dispatcher.spawner = mock_spawner
|
||||
|
||||
result = asyncio.run(dispatcher.dispatch(task_no_assignee))
|
||||
assert result["status"] == "dispatched"
|
||||
assert result["level"] == "full"
|
||||
assert result["agent_id"] == "pangtong-fujunshi"
|
||||
|
||||
def test_dispatch_escalate(self, dispatcher, task_unknown_agent):
|
||||
"""升级调度"""
|
||||
mock_spawner = MagicMock()
|
||||
mock_spawner.spawn_full_agent = AsyncMock(return_value="esc-123")
|
||||
dispatcher.spawner = mock_spawner
|
||||
|
||||
result = asyncio.run(dispatcher.dispatch(task_unknown_agent))
|
||||
assert result["status"] == "dispatched"
|
||||
assert result["level"] == "escalate"
|
||||
|
||||
def test_dispatch_spawn_failure(self, dispatcher, task_pending):
|
||||
"""spawn 失败 → error"""
|
||||
mock_spawner = MagicMock()
|
||||
mock_spawner.spawn_full_agent = AsyncMock(side_effect=RuntimeError("spawn failed"))
|
||||
dispatcher.spawner = mock_spawner
|
||||
|
||||
result = asyncio.run(dispatcher.dispatch(task_pending))
|
||||
assert result["status"] == "error"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T3: 队列满拒绝
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConcurrencyControl:
|
||||
def test_counter_busy_skips(self, dispatcher, task_pending):
|
||||
"""Agent 忙 → skip"""
|
||||
mock_spawner = MagicMock()
|
||||
mock_spawner.spawn_full_agent = AsyncMock(
|
||||
side_effect=AgentBusyError("zhangfei-dev", reason="counter_blocked")
|
||||
)
|
||||
dispatcher.spawner = mock_spawner
|
||||
|
||||
result = asyncio.run(dispatcher.dispatch(task_pending))
|
||||
assert result["status"] == "skipped"
|
||||
assert "busy" in result["reason"].lower()
|
||||
|
||||
def test_counter_releases_on_error(self, dispatcher, task_pending):
|
||||
"""spawn 失败后释放 counter"""
|
||||
mock_spawner = MagicMock()
|
||||
mock_spawner.spawn_full_agent = AsyncMock(side_effect=RuntimeError("fail"))
|
||||
dispatcher.spawner = mock_spawner
|
||||
|
||||
result = asyncio.run(dispatcher.dispatch(task_pending))
|
||||
assert result["status"] == "error"
|
||||
|
||||
def test_local_not_blocked_by_counter(self, dispatcher, task_pending):
|
||||
"""本地执行不受 counter 限制"""
|
||||
mock_counter = MagicMock()
|
||||
mock_counter.can_acquire = AsyncMock(return_value=False)
|
||||
dispatcher.counter = mock_counter
|
||||
|
||||
result = asyncio.run(dispatcher.dispatch(task_pending, "L1_guardrail"))
|
||||
assert result["status"] == "dispatched"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T4: 批量决策
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBatchDecision:
|
||||
def test_dispatch_pending(self, dispatcher):
|
||||
"""批量决策返回所有任务的决策"""
|
||||
tasks = [
|
||||
Task(id="t1", title="T1", status="pending",
|
||||
assigned_by="d", assignee="zhangfei-dev"),
|
||||
Task(id="t2", title="T2", status="pending",
|
||||
assigned_by="d", assignee=None),
|
||||
Task(id="t3", title="T3", status="pending",
|
||||
assigned_by="d", assignee="unknown"),
|
||||
]
|
||||
results = dispatcher.dispatch_pending(tasks)
|
||||
assert len(results) == 3
|
||||
assert results[0]["level"] == DispatchLevel.FULL_AGENT
|
||||
assert results[1]["level"] == DispatchLevel.FULL_AGENT
|
||||
assert results[1]["agent_id"] == "pangtong-fujunshi" # fallback
|
||||
assert results[2]["level"] == DispatchLevel.ESCALATE
|
||||
|
||||
def test_message_building(self, dispatcher):
|
||||
"""构建给 Agent 的消息"""
|
||||
task = Task(
|
||||
id="t1", title="Build Feature", status="pending",
|
||||
assigned_by="daemon", task_type="coding",
|
||||
description="Implement X", must_haves="Tests, Docs",
|
||||
)
|
||||
msg = dispatcher._build_spawn_message(task, "zhangfei-dev", {})
|
||||
assert "Build Feature" in msg
|
||||
assert "Implement X" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E14.3: Dispatcher 错误区分(v2.8 #07.1 O3 新增)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDispatcherErrorClassification:
|
||||
"""E14.3: Dispatcher 捕获 AgentBusyError → 日志记录具体原因 → 路由决策"""
|
||||
|
||||
def test_busy_error_reason_in_result(self, dispatcher, task_pending):
|
||||
"""AgentBusyError 被捕获后,结果包含具体 busy 原因(status=skipped)"""
|
||||
mock_spawner = MagicMock()
|
||||
mock_spawner.spawn_full_agent = AsyncMock(
|
||||
side_effect=AgentBusyError("zhangfei-dev", reason="session_locked",
|
||||
detail={"blockers": [("session_locked", 12345)]})
|
||||
)
|
||||
dispatcher.spawner = mock_spawner
|
||||
|
||||
result = asyncio.run(dispatcher.dispatch(task_pending))
|
||||
assert result["status"] == "skipped"
|
||||
assert "session" in result.get("reason", "").lower() or "locked" in result.get("reason", "").lower()
|
||||
|
||||
def test_counter_busy_returns_skipped(self, dispatcher, task_pending):
|
||||
"""counter_blocked → skipped(非 error)"""
|
||||
mock_spawner = MagicMock()
|
||||
mock_spawner.spawn_full_agent = AsyncMock(
|
||||
side_effect=AgentBusyError("zhangfei-dev", reason="counter_blocked")
|
||||
)
|
||||
dispatcher.spawner = mock_spawner
|
||||
|
||||
result = asyncio.run(dispatcher.dispatch(task_pending))
|
||||
assert result["status"] in ("skipped", "error")
|
||||
@@ -0,0 +1,256 @@
|
||||
"""F15 Experience Distillation 单元测试
|
||||
|
||||
按 test-plan-v2.6.md §F15:
|
||||
- T1: 经验提取(P0)
|
||||
- T2: 持久化(P0)
|
||||
- T3: 相似推荐(P0)
|
||||
- T4: 模式分类(P1)
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
from pathlib import Path
|
||||
|
||||
from src.daemon.experience import (
|
||||
Experience,
|
||||
ExperienceCategory,
|
||||
ExperienceDistiller,
|
||||
ExperienceStore,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
return ExperienceStore(store_path=tmp_path / "experiences.jsonl")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def distiller(store):
|
||||
return ExperienceDistiller(store=store)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_store():
|
||||
"""纯内存 store"""
|
||||
return ExperienceStore()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1: 经验提取
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDistillation:
|
||||
def test_distill_from_review_failure(self, distiller):
|
||||
exps = distiller.distill_from_task(
|
||||
task_id="t1",
|
||||
task_title="Build Feature",
|
||||
task_type="coding",
|
||||
review_result={
|
||||
"verdict": "fail",
|
||||
"results": [
|
||||
{"step": "existence", "verdict": "fail",
|
||||
"details": "Missing output.md", "suggestions": []},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert len(exps) >= 1
|
||||
assert any(e.category == "pitfall" for e in exps)
|
||||
|
||||
def test_distill_from_suggestions(self, distiller):
|
||||
exps = distiller.distill_from_task(
|
||||
task_id="t2",
|
||||
task_title="Write Tests",
|
||||
task_type="testing",
|
||||
review_result={
|
||||
"verdict": "pass",
|
||||
"results": [
|
||||
{"step": "quality", "verdict": "pass", "score": 0.9,
|
||||
"suggestions": ["Always test edge cases"]},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert len(exps) >= 1
|
||||
summaries = [e.summary for e in exps]
|
||||
assert any("edge cases" in s for s in summaries)
|
||||
|
||||
def test_distill_from_text_output(self, distiller):
|
||||
exps = distiller.distill_from_task(
|
||||
task_id="t3",
|
||||
task_title="Deploy Service",
|
||||
outputs=[
|
||||
{"content": "## Best Practice\n\nAlways use health checks when deploying services."},
|
||||
],
|
||||
)
|
||||
assert len(exps) >= 1
|
||||
assert any(e.category == "best_practice" for e in exps)
|
||||
|
||||
def test_distill_pitfall_from_text(self, distiller):
|
||||
exps = distiller.distill_from_task(
|
||||
task_id="t4",
|
||||
task_title="Debug Issue",
|
||||
outputs=[
|
||||
{"content": "## Bug Report\n\nForgot to close the database connection."},
|
||||
],
|
||||
)
|
||||
assert any(e.category == "pitfall" for e in exps)
|
||||
|
||||
def test_distill_environment_from_text(self, distiller):
|
||||
exps = distiller.distill_from_task(
|
||||
task_id="t5",
|
||||
task_title="Setup",
|
||||
outputs=[
|
||||
{"content": "Need to install Python 3.9+ and configure the PATH."},
|
||||
],
|
||||
)
|
||||
assert any(e.category == "environment" for e in exps)
|
||||
|
||||
def test_empty_outputs_no_crash(self, distiller):
|
||||
exps = distiller.distill_from_task(
|
||||
task_id="t6",
|
||||
task_title="Empty Task",
|
||||
)
|
||||
assert exps == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T2: 持久化
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPersistence:
|
||||
def test_save_and_reload(self, tmp_path):
|
||||
path = tmp_path / "experiences.jsonl"
|
||||
store1 = ExperienceStore(store_path=path)
|
||||
exp = Experience(category="pitfall", summary="Test experience", tags=["test"])
|
||||
store1.add(exp)
|
||||
|
||||
# Reload
|
||||
store2 = ExperienceStore(store_path=path)
|
||||
assert store2.count() == 1
|
||||
loaded = store2.get(exp.id)
|
||||
assert loaded is not None
|
||||
assert loaded.summary == "Test experience"
|
||||
|
||||
def test_delete_persists(self, tmp_path):
|
||||
path = tmp_path / "experiences.jsonl"
|
||||
store = ExperienceStore(store_path=path)
|
||||
exp = Experience(category="pitfall", summary="To delete")
|
||||
store.add(exp)
|
||||
|
||||
store.delete(exp.id)
|
||||
assert store.count() == 0
|
||||
|
||||
# Reload
|
||||
store2 = ExperienceStore(store_path=path)
|
||||
assert store2.count() == 0
|
||||
|
||||
def test_multiple_experiences(self, tmp_path):
|
||||
path = tmp_path / "experiences.jsonl"
|
||||
store = ExperienceStore(store_path=path)
|
||||
for i in range(5):
|
||||
store.add(Experience(category="pattern", summary=f"Exp {i}"))
|
||||
assert store.count() == 5
|
||||
|
||||
store2 = ExperienceStore(store_path=path)
|
||||
assert store2.count() == 5
|
||||
|
||||
def test_memory_store_no_file(self):
|
||||
store = ExperienceStore()
|
||||
store.add(Experience(category="test", summary="Memory only"))
|
||||
assert store.count() == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T3: 相似推荐
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRecommendation:
|
||||
def test_recommend_by_tags(self, distiller, store):
|
||||
store.add(Experience(category="pitfall", summary="Coding pitfall",
|
||||
tags=["coding"]))
|
||||
store.add(Experience(category="best_practice", summary="Testing BP",
|
||||
tags=["testing"]))
|
||||
|
||||
results = distiller.recommend(tags=["coding"])
|
||||
assert len(results) >= 1
|
||||
assert any("Coding pitfall" in e.summary for e in results)
|
||||
|
||||
def test_recommend_by_query(self, distiller, store):
|
||||
store.add(Experience(category="pitfall", summary="Always close DB connections"))
|
||||
store.add(Experience(category="best_practice", summary="Use type hints"))
|
||||
|
||||
results = distiller.recommend(query="db")
|
||||
assert len(results) >= 1
|
||||
assert any("DB" in e.summary for e in results)
|
||||
|
||||
def test_recommend_by_task_type(self, distiller, store):
|
||||
store.add(Experience(category="pitfall", summary="P1", tags=["coding"]))
|
||||
store.add(Experience(category="pitfall", summary="P2", tags=["testing"]))
|
||||
|
||||
results = distiller.recommend(task_type="coding")
|
||||
assert any("P1" in e.summary for e in results)
|
||||
|
||||
def test_recommend_empty(self, distiller):
|
||||
results = distiller.recommend()
|
||||
assert results == []
|
||||
|
||||
def test_recommend_limit(self, distiller, store):
|
||||
for i in range(10):
|
||||
store.add(Experience(category="pitfall", summary=f"Exp {i}",
|
||||
tags=["coding"]))
|
||||
results = distiller.recommend(tags=["coding"], limit=3)
|
||||
assert len(results) <= 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T4: 模式分类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPatternClassification:
|
||||
def test_classify_pitfall(self, distiller):
|
||||
assert distiller._classify_text("This is a common bug") == "pitfall"
|
||||
|
||||
def test_classify_best_practice(self, distiller):
|
||||
assert distiller._classify_text("Always use version control") == "best_practice"
|
||||
|
||||
def test_classify_environment(self, distiller):
|
||||
assert distiller._classify_text("Install the required packages") == "environment"
|
||||
|
||||
def test_classify_no_match(self, distiller):
|
||||
assert distiller._classify_text("The weather is nice today") is None
|
||||
|
||||
def test_classify_chinese(self, distiller):
|
||||
assert distiller._classify_text("这是一个常见的陷阱") == "pitfall"
|
||||
assert distiller._classify_text("建议使用类型注解") == "best_practice"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Experience model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExperienceModel:
|
||||
def test_to_dict_roundtrip(self):
|
||||
exp = Experience(
|
||||
category="pitfall",
|
||||
summary="Test",
|
||||
source_task_id="t1",
|
||||
tags=["coding"],
|
||||
)
|
||||
d = exp.to_dict()
|
||||
exp2 = Experience.from_dict(d)
|
||||
assert exp2.summary == exp.summary
|
||||
assert exp2.category == exp.category
|
||||
assert exp2.tags == exp.tags
|
||||
|
||||
def test_search_by_category(self, store):
|
||||
store.add(Experience(category="pitfall", summary="P1"))
|
||||
store.add(Experience(category="best_practice", summary="B1"))
|
||||
|
||||
results = store.search(category="pitfall")
|
||||
assert len(results) == 1
|
||||
assert results[0].summary == "P1"
|
||||
@@ -0,0 +1,257 @@
|
||||
"""F8 健康检查单元测试
|
||||
|
||||
按 test-plan-v2.6.md §F8:
|
||||
- T1: 正常场景(P0)
|
||||
- T2: 僵尸检测(P0)
|
||||
- T3: 恢复场景(P0)
|
||||
- T4: 多项目独立检测(P1)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
from pathlib import Path
|
||||
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.blackboard.models import Task
|
||||
from src.daemon.health import HealthChecker
|
||||
from src.daemon.ticker import Ticker
|
||||
from src.blackboard.registry import ProjectRegistry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path):
|
||||
return tmp_path / "test-proj" / "blackboard.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bb(db_path):
|
||||
return Blackboard(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def checker():
|
||||
return HealthChecker(zombie_threshold=3)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1: 正常场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormal:
|
||||
def test_healthy_project(self, checker, db_path, bb):
|
||||
"""有真实变更 → 健康"""
|
||||
# 写一个真实事件
|
||||
bb.create_task(Task(id="t1", title="T", status="pending", assigned_by="d"))
|
||||
|
||||
result = checker.check("test-proj", db_path, tick_num=1)
|
||||
assert result["healthy"] is True
|
||||
assert result["zombie"] is False
|
||||
|
||||
def test_no_zombie_alert_under_threshold(self, checker, db_path, bb):
|
||||
"""stale < threshold → 不告警"""
|
||||
for i in range(2):
|
||||
result = checker.check("test-proj", db_path, tick_num=i + 1)
|
||||
assert result["healthy"] is True
|
||||
assert result["zombie"] is False
|
||||
|
||||
def test_stale_ticks_increment(self, checker, db_path, bb):
|
||||
"""每次无变更 tick 递增 stale_ticks"""
|
||||
r1 = checker.check("test-proj", db_path, tick_num=1)
|
||||
assert r1["stale_ticks"] == 1
|
||||
|
||||
r2 = checker.check("test-proj", db_path, tick_num=2)
|
||||
assert r2["stale_ticks"] == 2
|
||||
|
||||
def test_no_db_is_healthy(self, checker, tmp_path):
|
||||
"""无 DB 文件 → 健康(不报错)"""
|
||||
result = checker.check("no-db", tmp_path / "nonexistent.db", tick_num=1)
|
||||
assert result["healthy"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T2: 僵尸检测
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestZombieDetection:
|
||||
def test_zombie_after_threshold(self, checker, db_path, bb):
|
||||
"""达到 threshold → 僵尸告警"""
|
||||
# 连续 threshold 次 tick 无真实变更
|
||||
for i in range(3):
|
||||
result = checker.check("test-proj", db_path, tick_num=i + 1)
|
||||
|
||||
assert result["zombie"] is True
|
||||
assert result["healthy"] is False
|
||||
assert result["alert_written"] is True
|
||||
|
||||
def test_zombie_writes_observation(self, checker, db_path, bb):
|
||||
"""僵尸告警写入 observations 表"""
|
||||
for i in range(3):
|
||||
checker.check("test-proj", db_path, tick_num=i + 1)
|
||||
|
||||
from src.blackboard.queries import Queries
|
||||
queries = Queries(db_path)
|
||||
# 查 observations(排除 events 里的 daemon_tick)
|
||||
conn = queries._conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM observations WHERE observer='daemon'"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert len(rows) >= 1
|
||||
body = json.loads(rows[0]["body"])
|
||||
assert body["type"] == "zombie_detected"
|
||||
assert body["stale_ticks"] == 3
|
||||
|
||||
def test_zombie_writes_event(self, checker, db_path, bb):
|
||||
"""僵尸告警写入 events 表"""
|
||||
for i in range(3):
|
||||
checker.check("test-proj", db_path, tick_num=i + 1)
|
||||
|
||||
from src.blackboard.queries import Queries
|
||||
queries = Queries(db_path)
|
||||
events = queries.recent_events(limit=20)
|
||||
zombie_events = [e for e in events
|
||||
if e["event_type"] == "agent_zombie_detected"]
|
||||
assert len(zombie_events) >= 1
|
||||
|
||||
def test_no_duplicate_alert(self, checker, db_path, bb):
|
||||
"""已告警后不再重复告警"""
|
||||
for i in range(5):
|
||||
result = checker.check("test-proj", db_path, tick_num=i + 1)
|
||||
|
||||
# 只在第一次达到 threshold 时告警
|
||||
assert result["alert_written"] is False # 后续 tick 不再重复
|
||||
assert result["zombie"] is True
|
||||
|
||||
def test_custom_threshold(self, db_path, bb):
|
||||
"""自定义 threshold"""
|
||||
checker = HealthChecker(zombie_threshold=5)
|
||||
for i in range(4):
|
||||
result = checker.check("test-proj", db_path, tick_num=i + 1)
|
||||
assert result["healthy"] is True
|
||||
|
||||
r5 = checker.check("test-proj", db_path, tick_num=5)
|
||||
assert r5["zombie"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T3: 恢复场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRecovery:
|
||||
def test_recovery_after_real_event(self, checker, db_path, bb):
|
||||
"""僵尸后有真实变更 → 告警解除"""
|
||||
# 触发僵尸
|
||||
for i in range(3):
|
||||
checker.check("test-proj", db_path, tick_num=i + 1)
|
||||
|
||||
# 写一个真实事件(非 daemon_tick)
|
||||
bb.create_task(Task(id="t1", title="T", status="pending", assigned_by="d"))
|
||||
|
||||
# 下一次 check
|
||||
result = checker.check("test-proj", db_path, tick_num=4)
|
||||
assert result["healthy"] is True
|
||||
assert result["resolved"] is True
|
||||
|
||||
def test_recovery_resets_stale(self, checker, db_path, bb):
|
||||
"""恢复后 stale_ticks 归零"""
|
||||
for i in range(3):
|
||||
checker.check("test-proj", db_path, tick_num=i + 1)
|
||||
|
||||
bb.create_task(Task(id="t1", title="T", status="pending", assigned_by="d"))
|
||||
result = checker.check("test-proj", db_path, tick_num=4)
|
||||
assert result["stale_ticks"] == 0
|
||||
|
||||
def test_recovery_then_zombie_again(self, checker, db_path, bb):
|
||||
"""恢复后再次变僵尸"""
|
||||
# 第一次僵尸
|
||||
for i in range(3):
|
||||
checker.check("test-proj", db_path, tick_num=i + 1)
|
||||
# 恢复
|
||||
bb.create_task(Task(id="t1", title="T", status="pending", assigned_by="d"))
|
||||
checker.check("test-proj", db_path, tick_num=4)
|
||||
# 再次僵尸
|
||||
for i in range(3):
|
||||
r = checker.check("test-proj", db_path, tick_num=5 + i)
|
||||
assert r["zombie"] is True
|
||||
|
||||
def test_recovery_writes_info_observation(self, checker, db_path, bb):
|
||||
"""恢复时写 info observation"""
|
||||
for i in range(3):
|
||||
checker.check("test-proj", db_path, tick_num=i + 1)
|
||||
|
||||
bb.create_task(Task(id="t1", title="T", status="pending", assigned_by="d"))
|
||||
checker.check("test-proj", db_path, tick_num=4)
|
||||
|
||||
from src.blackboard.queries import Queries
|
||||
queries = Queries(db_path)
|
||||
conn = queries._conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM observations WHERE observer='daemon' "
|
||||
"ORDER BY id DESC"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 最新一条应该是 resolved
|
||||
body = json.loads(rows[0]["body"])
|
||||
assert body["type"] == "zombie_resolved"
|
||||
assert rows[0]["severity"] == "info"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T4: 多项目独立检测(P1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMultiProject:
|
||||
def test_independent_detection(self, tmp_path):
|
||||
"""项目 A 僵尸不影响项目 B"""
|
||||
checker = HealthChecker(zombie_threshold=3)
|
||||
|
||||
# 项目 A
|
||||
db_a = tmp_path / "a" / "blackboard.db"
|
||||
bb_a = Blackboard(db_a)
|
||||
|
||||
# 项目 B(有真实事件)
|
||||
db_b = tmp_path / "b" / "blackboard.db"
|
||||
bb_b = Blackboard(db_b)
|
||||
bb_b.create_task(Task(id="b1", title="B", status="pending", assigned_by="d"))
|
||||
|
||||
for i in range(3):
|
||||
checker.check("a", db_a, tick_num=i + 1)
|
||||
checker.check("b", db_b, tick_num=i + 1)
|
||||
|
||||
status_a = checker.get_status("a")
|
||||
status_b = checker.get_status("b")
|
||||
|
||||
assert status_a["is_zombie"] is True
|
||||
assert status_b["is_zombie"] is False
|
||||
|
||||
def test_independent_recovery(self, tmp_path):
|
||||
"""项目 A 恢复不影响项目 B"""
|
||||
checker = HealthChecker(zombie_threshold=2)
|
||||
|
||||
db_a = tmp_path / "a" / "blackboard.db"
|
||||
bb_a = Blackboard(db_a)
|
||||
|
||||
db_b = tmp_path / "b" / "blackboard.db"
|
||||
bb_b = Blackboard(db_b)
|
||||
|
||||
# 两个都变僵尸
|
||||
for i in range(2):
|
||||
checker.check("a", db_a, tick_num=i + 1)
|
||||
checker.check("b", db_b, tick_num=i + 1)
|
||||
|
||||
# 只恢复 A
|
||||
bb_a.create_task(Task(id="a1", title="A", status="pending", assigned_by="d"))
|
||||
checker.check("a", db_a, tick_num=3)
|
||||
checker.check("b", db_b, tick_num=3)
|
||||
|
||||
assert checker.get_status("a")["is_zombie"] is False
|
||||
assert checker.get_status("b")["is_zombie"] is True
|
||||
@@ -0,0 +1,265 @@
|
||||
"""F7 Inbox JSONL Watcher 单元测试
|
||||
|
||||
按测试计划 test-plan-v2.6.md §F7:
|
||||
- T1: 写入+消费(P0)
|
||||
- T2: truncate(P0)
|
||||
- T3: 并发写入(P0)
|
||||
- T4: 损坏行恢复(P1)
|
||||
- T5: 空文件处理(P1)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
from pathlib import Path
|
||||
|
||||
from src.daemon.inbox import InboxWatcher
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inbox_path(tmp_path):
|
||||
return tmp_path / "inbox" / "daemon.jsonl"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def watcher(inbox_path):
|
||||
return InboxWatcher(inbox_path, watch_interval=0.05)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1: 写入+消费
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWriteConsume:
|
||||
def test_single_event(self, inbox_path):
|
||||
"""写入单条事件 → 消费到"""
|
||||
InboxWatcher.write_event(inbox_path, {"type": "task_done", "task_id": "t1", "agent": "a"})
|
||||
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
events = asyncio.run(watcher.poll())
|
||||
assert len(events) == 1
|
||||
assert events[0]["type"] == "task_done"
|
||||
assert events[0]["task_id"] == "t1"
|
||||
|
||||
def test_multiple_events(self, inbox_path):
|
||||
"""写入多条事件 → 全部消费到"""
|
||||
for i in range(5):
|
||||
InboxWatcher.write_event(inbox_path, {"type": "progress", "task_id": f"t{i}", "pct": i * 20})
|
||||
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
events = asyncio.run(watcher.poll())
|
||||
assert len(events) == 5
|
||||
assert events[0]["task_id"] == "t0"
|
||||
assert events[4]["task_id"] == "t4"
|
||||
|
||||
def test_consume_increments_counter(self, inbox_path):
|
||||
"""total_processed 递增"""
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
assert watcher.total_processed == 0
|
||||
|
||||
InboxWatcher.write_event(inbox_path, {"type": "test"})
|
||||
asyncio.run(watcher.poll())
|
||||
assert watcher.total_processed == 1
|
||||
|
||||
InboxWatcher.write_event(inbox_path, {"type": "test2"})
|
||||
InboxWatcher.write_event(inbox_path, {"type": "test3"})
|
||||
asyncio.run(watcher.poll())
|
||||
assert watcher.total_processed == 3
|
||||
|
||||
def test_consume_no_file_returns_empty(self, inbox_path):
|
||||
"""文件不存在时返回空列表"""
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
events = asyncio.run(watcher.poll())
|
||||
assert events == []
|
||||
|
||||
def test_batch_write(self, inbox_path):
|
||||
"""批量写入"""
|
||||
evts = [{"type": "batch", "seq": i} for i in range(10)]
|
||||
InboxWatcher.write_events(inbox_path, evts)
|
||||
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
events = asyncio.run(watcher.poll())
|
||||
assert len(events) == 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T2: truncate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTruncate:
|
||||
def test_file_cleared_after_consume(self, inbox_path):
|
||||
"""消费后文件被清空"""
|
||||
InboxWatcher.write_event(inbox_path, {"type": "test"})
|
||||
assert inbox_path.exists()
|
||||
assert inbox_path.stat().st_size > 0
|
||||
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
asyncio.run(watcher.poll())
|
||||
|
||||
# 文件存在但为空
|
||||
assert inbox_path.exists()
|
||||
assert inbox_path.stat().st_size == 0
|
||||
|
||||
def test_second_consume_returns_empty(self, inbox_path):
|
||||
"""二次消费无新事件"""
|
||||
InboxWatcher.write_event(inbox_path, {"type": "test"})
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
|
||||
events1 = asyncio.run(watcher.poll())
|
||||
assert len(events1) == 1
|
||||
|
||||
events2 = asyncio.run(watcher.poll())
|
||||
assert len(events2) == 0
|
||||
|
||||
def test_write_after_truncate(self, inbox_path):
|
||||
"""truncate 后可以继续写入"""
|
||||
InboxWatcher.write_event(inbox_path, {"type": "first"})
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
asyncio.run(watcher.poll())
|
||||
|
||||
InboxWatcher.write_event(inbox_path, {"type": "second"})
|
||||
events = asyncio.run(watcher.poll())
|
||||
assert len(events) == 1
|
||||
assert events[0]["type"] == "second"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T3: 并发写入
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConcurrentWrite:
|
||||
def test_multi_agent_concurrent(self, inbox_path):
|
||||
"""多 Agent 同时写入不同事件,全部消费到"""
|
||||
n_agents = 5
|
||||
n_events_per_agent = 10
|
||||
barrier = threading.Barrier(n_agents)
|
||||
|
||||
def writer(agent_id):
|
||||
barrier.wait()
|
||||
for i in range(n_events_per_agent):
|
||||
InboxWatcher.write_event(inbox_path, {
|
||||
"agent": agent_id,
|
||||
"seq": i,
|
||||
})
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(f"agent-{j}",))
|
||||
for j in range(n_agents)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=5)
|
||||
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
events = asyncio.run(watcher.poll())
|
||||
assert len(events) == n_agents * n_events_per_agent
|
||||
|
||||
# 每个 agent 的事件都到齐
|
||||
agents_seen = {e["agent"] for e in events}
|
||||
assert len(agents_seen) == n_agents
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T4: 损坏行恢复(P1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBadLineRecovery:
|
||||
def test_skip_invalid_json(self, inbox_path):
|
||||
"""非法 JSON 行跳过不崩溃"""
|
||||
inbox_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(inbox_path, "w") as f:
|
||||
f.write('{"type": "good"}\n')
|
||||
f.write('not json at all\n')
|
||||
f.write('{"type": "also_good"}\n')
|
||||
f.write('{broken json\n')
|
||||
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
events = asyncio.run(watcher.poll())
|
||||
assert len(events) == 2
|
||||
assert events[0]["type"] == "good"
|
||||
assert events[1]["type"] == "also_good"
|
||||
|
||||
def test_non_dict_json_skipped(self, inbox_path):
|
||||
"""合法 JSON 但非 dict 也跳过"""
|
||||
inbox_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(inbox_path, "w") as f:
|
||||
f.write('{"type": "good"}\n')
|
||||
f.write('[1, 2, 3]\n')
|
||||
f.write('"just a string"\n')
|
||||
f.write('42\n')
|
||||
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
events = asyncio.run(watcher.poll())
|
||||
assert len(events) == 1
|
||||
assert events[0]["type"] == "good"
|
||||
|
||||
def test_empty_lines_skipped(self, inbox_path):
|
||||
"""空行不影响"""
|
||||
inbox_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(inbox_path, "w") as f:
|
||||
f.write('\n\n{"type": "good"}\n\n\n')
|
||||
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
events = asyncio.run(watcher.poll())
|
||||
assert len(events) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T5: 空文件处理(P1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEmptyFile:
|
||||
def test_empty_file_no_events(self, inbox_path):
|
||||
"""空文件返回空列表"""
|
||||
inbox_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
inbox_path.touch()
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
events = asyncio.run(watcher.poll())
|
||||
assert events == []
|
||||
|
||||
def test_whitespace_only_file(self, inbox_path):
|
||||
"""只有空白的文件返回空列表"""
|
||||
inbox_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
inbox_path.write_text(" \n \n \n")
|
||||
watcher = InboxWatcher(inbox_path)
|
||||
events = asyncio.run(watcher.poll())
|
||||
assert events == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Watcher 生命周期
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWatcherLifecycle:
|
||||
def test_start_stop(self, watcher, inbox_path):
|
||||
"""可以启动和停止"""
|
||||
async def run():
|
||||
await watcher.start()
|
||||
assert watcher.is_running
|
||||
await asyncio.sleep(0.2)
|
||||
await watcher.stop()
|
||||
assert not watcher.is_running
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_auto_consume_via_callback(self, inbox_path):
|
||||
"""watcher 循环自动消费并通过回调传递事件"""
|
||||
collected = []
|
||||
|
||||
async def on_event(event):
|
||||
collected.append(event)
|
||||
|
||||
watcher = InboxWatcher(inbox_path, process_callback=on_event, watch_interval=0.05)
|
||||
|
||||
async def run():
|
||||
await watcher.start()
|
||||
# 写入事件
|
||||
InboxWatcher.write_event(inbox_path, {"type": "auto"})
|
||||
await asyncio.sleep(0.3) # 等待 watcher 消费
|
||||
await watcher.stop()
|
||||
|
||||
asyncio.run(run())
|
||||
assert len(collected) >= 1
|
||||
assert collected[0]["type"] == "auto"
|
||||
@@ -0,0 +1,104 @@
|
||||
"""F3+F4 测试:多项目管理 + CLI 工具"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
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
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# F3: 多项目管理
|
||||
# ===================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry(tmp_path):
|
||||
return ProjectRegistry(tmp_path)
|
||||
|
||||
|
||||
class TestProjectRegistry:
|
||||
def test_create_project(self, registry):
|
||||
info = registry.create_project("test-proj", "Test Project",
|
||||
agents=["agent1", "agent2"])
|
||||
assert info["name"] == "Test Project"
|
||||
assert info["agents"] == ["agent1", "agent2"]
|
||||
assert info["status"] == "active"
|
||||
|
||||
def test_create_creates_dirs(self, registry):
|
||||
registry.create_project("test-proj", "Test")
|
||||
root = registry.root
|
||||
assert (root / "test-proj" / "config" / "project.yaml").exists()
|
||||
assert (root / "test-proj" / "artifacts").is_dir()
|
||||
assert (root / "test-proj" / "experiences").is_dir()
|
||||
assert (root / "test-proj" / "skills").is_dir()
|
||||
|
||||
def test_create_duplicate_fails(self, registry):
|
||||
registry.create_project("p1", "P1")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
registry.create_project("p1", "P1 Duplicate")
|
||||
|
||||
def test_get_project(self, registry):
|
||||
registry.create_project("p1", "P1")
|
||||
info = registry.get_project("p1")
|
||||
assert info is not None
|
||||
assert info["name"] == "P1"
|
||||
|
||||
def test_get_nonexistent(self, registry):
|
||||
assert registry.get_project("nope") is None
|
||||
|
||||
def test_list_projects(self, registry):
|
||||
registry.create_project("p1", "P1")
|
||||
registry.create_project("p2", "P2")
|
||||
projects = registry.list_projects()
|
||||
assert len(projects) == 2
|
||||
assert "p1" in projects
|
||||
assert "p2" in projects
|
||||
|
||||
def test_archive_project(self, registry):
|
||||
registry.create_project("p1", "P1")
|
||||
assert registry.archive_project("p1")
|
||||
info = registry.get_project("p1")
|
||||
assert info["status"] == "archived"
|
||||
|
||||
def test_archive_nonexistent(self, registry):
|
||||
assert not registry.archive_project("nope")
|
||||
|
||||
def test_delete_project(self, registry):
|
||||
registry.create_project("p1", "P1")
|
||||
assert registry.delete_project("p1")
|
||||
# v2.8: 逻辑删除,get_project 返回带 status=deleted 的 dict 而非 None
|
||||
deleted = registry.get_project("p1")
|
||||
assert deleted is not None
|
||||
assert deleted["status"] == "deleted"
|
||||
|
||||
def test_registry_yaml_persists(self, registry):
|
||||
registry.create_project("p1", "P1")
|
||||
# Reload from disk
|
||||
reg2 = ProjectRegistry(registry.root)
|
||||
assert reg2.get_project("p1") is not None
|
||||
|
||||
def test_per_project_blackboard(self, tmp_path):
|
||||
"""每个项目有独立的黑板 DB"""
|
||||
registry = ProjectRegistry(tmp_path)
|
||||
registry.create_project("proj-a", "A")
|
||||
registry.create_project("proj-b", "B")
|
||||
|
||||
db_a = tmp_path / "proj-a" / "blackboard.db"
|
||||
db_b = tmp_path / "proj-b" / "blackboard.db"
|
||||
|
||||
bb_a = Blackboard(db_a)
|
||||
bb_b = Blackboard(db_b)
|
||||
|
||||
bb_a.create_task(Task(id="t1", title="A Task"))
|
||||
bb_b.create_task(Task(id="t1", title="B Task"))
|
||||
|
||||
assert bb_a.get_task("t1").title == "A Task"
|
||||
assert bb_b.get_task("t1").title == "B Task"
|
||||
@@ -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
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Router 单元测试 — 三种路由模式 + 校验 + fallback"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.daemon.router import (
|
||||
AgentRouter, AgentProfile, RouteDecision, KNOWN_CAPABILITIES,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def profiles():
|
||||
return {
|
||||
"zhangfei-dev": AgentProfile(
|
||||
agent_id="zhangfei-dev",
|
||||
capabilities=["coding", "implementation", "scripting"],
|
||||
can_review=False, max_concurrent=1,
|
||||
),
|
||||
"simayi-challenger": AgentProfile(
|
||||
agent_id="simayi-challenger",
|
||||
capabilities=["review", "quality_check", "debate"],
|
||||
can_review=True, max_concurrent=2,
|
||||
),
|
||||
"pangtong-fujunshi": AgentProfile(
|
||||
agent_id="pangtong-fujunshi",
|
||||
capabilities=["planning", "coordination", "escalation", "strategy"],
|
||||
can_review=True, is_fallback=True, max_concurrent=3,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def router(profiles):
|
||||
return AgentRouter(agent_profiles=profiles)
|
||||
|
||||
|
||||
def make_task(**overrides):
|
||||
base = {
|
||||
"id": "t1", "title": "Test Task", "status": "pending",
|
||||
"assignee": "zhangfei-dev", "current_agent": None,
|
||||
"next_capability": None, "task_type": "coding",
|
||||
"description": "Write some code",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1: 确定性快速路径
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDeterministic:
|
||||
def test_local_action(self, router):
|
||||
"""机械检查 → daemon"""
|
||||
d = router.route(make_task(), action_type="L1_guardrail")
|
||||
assert d.agent_id == "daemon"
|
||||
assert d.mode == "deterministic"
|
||||
|
||||
def test_format_check_local(self, router):
|
||||
d = router.route(make_task(), action_type="format_check")
|
||||
assert d.agent_id == "daemon"
|
||||
|
||||
def test_retry_same_agent(self, router):
|
||||
"""retry → 原执行者"""
|
||||
d = router.route(make_task(current_agent="zhangfei-dev"),
|
||||
action_type="retry")
|
||||
assert d.agent_id == "zhangfei-dev"
|
||||
assert d.mode == "deterministic"
|
||||
|
||||
def test_direct_assignee(self, router):
|
||||
"""有 assignee 且非生命周期流转 → 直接用"""
|
||||
d = router.route(make_task(), action_type="execute")
|
||||
assert d.agent_id == "zhangfei-dev"
|
||||
assert d.mode == "deterministic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T2: Mode B — Agent 声明式交接
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAgentHandoff:
|
||||
def test_handoff_review(self, router):
|
||||
"""张飞说需要 review → 匹配司马懿"""
|
||||
d = router.route(make_task(
|
||||
current_agent="zhangfei-dev",
|
||||
next_capability="review",
|
||||
))
|
||||
assert d.agent_id == "simayi-challenger"
|
||||
assert d.mode == "agent_handoff"
|
||||
assert "review" in d.reason
|
||||
|
||||
def test_handoff_excludes_current(self, router):
|
||||
"""交接排除当前执行者(不能交给自己)"""
|
||||
d = router.route(make_task(
|
||||
current_agent="simayi-challenger",
|
||||
next_capability="review",
|
||||
))
|
||||
# simayi 被排除,只剩 pangtong(can_review=True 且有 review 能力)
|
||||
assert d.agent_id != "simayi-challenger"
|
||||
|
||||
def test_handoff_invalid_capability_ignored(self, router):
|
||||
"""BUG-2: 非法 next_capability 被忽略,不进路由"""
|
||||
d = router.route(make_task(
|
||||
current_agent="zhangfei-dev",
|
||||
next_capability="nonexistent_capability",
|
||||
), action_type="execute")
|
||||
# 不应该走 handoff,应该走 assignee 直派或其他
|
||||
assert d.mode != "agent_handoff"
|
||||
|
||||
def test_handoff_known_but_no_match(self):
|
||||
"""合法 capability 但无匹配 Agent → 降级"""
|
||||
router2 = AgentRouter(agent_profiles={
|
||||
"zhangfei-dev": AgentProfile(
|
||||
agent_id="zhangfei-dev",
|
||||
capabilities=["coding"],
|
||||
),
|
||||
})
|
||||
d = router2.route(make_task(
|
||||
assignee=None, # 无 assignee,不会走快速路径 4
|
||||
current_agent="zhangfei-dev",
|
||||
next_capability="review", # 合法但在 profiles 中无人匹配
|
||||
))
|
||||
# 无 LLM driver、无 assignee → fallback 庞统
|
||||
assert d.agent_id == "pangtong-fujunshi"
|
||||
assert d.mode == "delegate"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T3: 生命周期流转
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLifecycle:
|
||||
def test_review_action(self, router):
|
||||
"""review action_type → 查 review 能力,排除 current_agent"""
|
||||
d = router.route(make_task(
|
||||
current_agent="zhangfei-dev",
|
||||
), action_type="review")
|
||||
assert d.agent_id == "simayi-challenger"
|
||||
assert d.mode == "deterministic"
|
||||
|
||||
def test_review_excludes_executor(self, router):
|
||||
"""review 排除执行者(张飞不能审张飞)"""
|
||||
d = router.route(make_task(
|
||||
current_agent="zhangfei-dev",
|
||||
assignee="zhangfei-dev",
|
||||
), action_type="review")
|
||||
assert d.agent_id != "zhangfei-dev"
|
||||
assert d.agent_id == "simayi-challenger"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T4: Mode A — LLM 路由 (v3.0: LLMDriver 已移除,模糊路由 delegate 庞统)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.skip(reason="v3.0 removed LLMDriver, fuzzy routing delegates to pangtong")
|
||||
class TestLLMRoute:
|
||||
def test_llm_returns_valid(self, router):
|
||||
"""LLM 返回合法 Agent"""
|
||||
mock_driver = MagicMock(spec=LLMDriver)
|
||||
mock_driver.route.return_value = RouteDecision(
|
||||
agent_id="simayi-challenger",
|
||||
reason="LLM selected reviewer",
|
||||
mode="llm_route",
|
||||
confidence=0.9,
|
||||
model="zhipu/glm-5.1",
|
||||
latency_ms=1200,
|
||||
)
|
||||
router.llm_driver = mock_driver
|
||||
|
||||
# 无 assignee、无 next_capability、非 lifecycle → 走 LLM
|
||||
d = router.route(make_task(
|
||||
assignee=None, current_agent=None,
|
||||
next_capability=None,
|
||||
))
|
||||
assert d.agent_id == "simayi-challenger"
|
||||
assert d.mode == "llm_route"
|
||||
assert d.confidence == 0.9
|
||||
|
||||
def test_llm_invalid_agent_fallback(self, router):
|
||||
"""LLM 返回不存在的 Agent → fallback"""
|
||||
mock_driver = MagicMock(spec=LLMDriver)
|
||||
mock_driver.route.return_value = RouteDecision(
|
||||
agent_id="nonexistent-agent",
|
||||
reason="Bad pick",
|
||||
mode="llm_route",
|
||||
confidence=0.8,
|
||||
latency_ms=500,
|
||||
)
|
||||
router.llm_driver = mock_driver
|
||||
|
||||
d = router.route(make_task(assignee=None))
|
||||
assert d.agent_id == "pangtong-fujunshi"
|
||||
assert d.mode == "fallback"
|
||||
|
||||
def test_llm_low_confidence_fallback(self, router):
|
||||
"""LLM 置信度低于阈值 → fallback"""
|
||||
mock_driver = MagicMock(spec=LLMDriver)
|
||||
mock_driver.route.return_value = RouteDecision(
|
||||
agent_id="simayi-challenger",
|
||||
reason="Not sure",
|
||||
mode="llm_route",
|
||||
confidence=0.5,
|
||||
latency_ms=300,
|
||||
)
|
||||
router.llm_driver = mock_driver
|
||||
|
||||
d = router.route(make_task(assignee=None))
|
||||
assert d.agent_id == "pangtong-fujunshi"
|
||||
assert d.mode == "fallback"
|
||||
assert d.confidence == 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T5: Fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFallback:
|
||||
def test_no_llm_no_match(self, router):
|
||||
"""无 LLM、无匹配 → fallback 庞统"""
|
||||
d = router.route(make_task(
|
||||
assignee=None, current_agent=None,
|
||||
next_capability=None, task_type="unknown_type",
|
||||
), action_type="")
|
||||
assert d.agent_id == "pangtong-fujunshi"
|
||||
assert d.mode == "delegate"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T6: Capability 校验
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCapabilityValidation:
|
||||
def test_known_capabilities_populated(self, router):
|
||||
"""profiles 自动填充 known capabilities"""
|
||||
assert "coding" in router._known_capabilities
|
||||
assert "review" in router._known_capabilities
|
||||
assert "escalation" in router._known_capabilities
|
||||
|
||||
def test_validate_known(self, router):
|
||||
assert router._validate_capability("coding") is True
|
||||
assert router._validate_capability("review") is True
|
||||
|
||||
def test_validate_unknown(self, router):
|
||||
assert router._validate_capability("administration") is False
|
||||
assert router._validate_capability("hack") is False
|
||||
|
||||
def test_known_capabilities_set(self):
|
||||
"""KNOWN_CAPABILITIES 包含所有预期能力"""
|
||||
assert "coding" in KNOWN_CAPABILITIES
|
||||
assert "review" in KNOWN_CAPABILITIES
|
||||
assert "escalation" in KNOWN_CAPABILITIES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T7: Latency 记录
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLatency:
|
||||
def test_deterministic_has_latency(self, router):
|
||||
d = router.route(make_task(), action_type="L1_guardrail")
|
||||
assert d.latency_ms >= 0
|
||||
|
||||
def test_handoff_has_latency(self, router):
|
||||
d = router.route(make_task(
|
||||
current_agent="zhangfei-dev",
|
||||
next_capability="review",
|
||||
))
|
||||
assert d.latency_ms >= 0
|
||||
@@ -0,0 +1,225 @@
|
||||
"""F16 Skill System 单元测试
|
||||
|
||||
按 test-plan-v2.6.md §F16:
|
||||
- T1: 注册/查询(P0)
|
||||
- T2: 三层自由度(P0)
|
||||
- T3: 技能匹配(P0)
|
||||
- T4: 模板变量替换(P1)
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
from pathlib import Path
|
||||
|
||||
from src.daemon.skill_system import (
|
||||
Skill,
|
||||
SkillExecutor,
|
||||
SkillFreedom,
|
||||
SkillRegistry,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def registry():
|
||||
return SkillRegistry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry_with_skills(registry):
|
||||
registry.register(Skill(
|
||||
id="code-review",
|
||||
name="Code Review",
|
||||
description="Review code quality and suggest improvements",
|
||||
freedom=SkillFreedom.HIGH.value,
|
||||
tags=["coding", "review"],
|
||||
))
|
||||
registry.register(Skill(
|
||||
id="test-template",
|
||||
name="Test Generator",
|
||||
description="Generate test cases from code",
|
||||
freedom=SkillFreedom.MEDIUM.value,
|
||||
prompt_template="Write tests for {{module}} focusing on {{focus}}",
|
||||
tags=["testing", "coding"],
|
||||
))
|
||||
registry.register(Skill(
|
||||
id="deploy-script",
|
||||
name="Deploy Script",
|
||||
description="Run deployment script",
|
||||
freedom=SkillFreedom.LOW.value,
|
||||
script_path="/usr/local/bin/deploy.sh",
|
||||
tags=["deployment"],
|
||||
))
|
||||
return registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def executor(registry_with_skills):
|
||||
return SkillExecutor(registry=registry_with_skills)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skills_dir(tmp_path):
|
||||
d = tmp_path / "skills"
|
||||
d.mkdir()
|
||||
(d / "skill1.json").write_text(json.dumps({
|
||||
"id": "loaded-skill",
|
||||
"name": "Loaded Skill",
|
||||
"description": "Loaded from file",
|
||||
"freedom": "high",
|
||||
}))
|
||||
(d / "skill2.json").write_text(json.dumps({
|
||||
"id": "another",
|
||||
"name": "Another",
|
||||
"description": "Another skill",
|
||||
"freedom": "medium",
|
||||
"prompt_template": "Do {{action}}",
|
||||
}))
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1: 注册/查询
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRegistry:
|
||||
def test_register_and_get(self, registry):
|
||||
skill = Skill(id="s1", name="Test", description="A test skill")
|
||||
registry.register(skill)
|
||||
assert registry.get("s1") is not None
|
||||
assert registry.get("s1").name == "Test"
|
||||
|
||||
def test_unregister(self, registry):
|
||||
registry.register(Skill(id="s1", name="T", description="D"))
|
||||
assert registry.unregister("s1") is True
|
||||
assert registry.get("s1") is None
|
||||
|
||||
def test_unregister_nonexistent(self, registry):
|
||||
assert registry.unregister("nope") is False
|
||||
|
||||
def test_list_skills(self, registry_with_skills):
|
||||
skills = registry_with_skills.list_skills()
|
||||
assert len(skills) == 3
|
||||
|
||||
def test_list_by_freedom(self, registry_with_skills):
|
||||
high = registry_with_skills.list_skills(freedom=SkillFreedom.HIGH.value)
|
||||
assert len(high) == 1
|
||||
assert high[0].id == "code-review"
|
||||
|
||||
def test_list_by_tags(self, registry_with_skills):
|
||||
coding = registry_with_skills.list_skills(tags=["coding"])
|
||||
assert len(coding) == 2
|
||||
|
||||
def test_list_enabled_only(self, registry):
|
||||
registry.register(Skill(id="s1", name="On", description="D", enabled=True))
|
||||
registry.register(Skill(id="s2", name="Off", description="D", enabled=False))
|
||||
assert len(registry.list_skills()) == 1
|
||||
|
||||
def test_load_from_dir(self, skills_dir):
|
||||
reg = SkillRegistry(skills_dir=skills_dir)
|
||||
assert reg.count() == 2
|
||||
assert reg.get("loaded-skill") is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T2: 三层自由度
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFreedomLevels:
|
||||
def test_high_freedom_prompt(self, executor):
|
||||
result = executor.build_prompt("code-review")
|
||||
assert "Skill: Code Review" in result
|
||||
assert "Principle" in result
|
||||
|
||||
def test_medium_freedom_template(self, executor):
|
||||
result = executor.build_prompt("test-template",
|
||||
variables={"module": "auth.py", "focus": "edge cases"})
|
||||
assert "auth.py" in result
|
||||
assert "edge cases" in result
|
||||
|
||||
def test_low_freedom_script(self, executor):
|
||||
result = executor.build_prompt("deploy-script")
|
||||
assert "deploy.sh" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T3: 技能匹配
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatching:
|
||||
def test_match_by_name(self, registry_with_skills):
|
||||
results = registry_with_skills.match("review")
|
||||
assert len(results) >= 1
|
||||
assert results[0][0].id == "code-review"
|
||||
assert results[0][1] > 0 # score > 0
|
||||
|
||||
def test_match_by_description(self, registry_with_skills):
|
||||
results = registry_with_skills.match("deployment")
|
||||
assert len(results) >= 1
|
||||
|
||||
def test_match_with_task_type(self, registry_with_skills):
|
||||
results = registry_with_skills.match("test", task_type="testing")
|
||||
assert len(results) >= 1
|
||||
|
||||
def test_match_no_results(self, registry_with_skills):
|
||||
results = registry_with_skills.match("nonexistent_xyz_abc")
|
||||
assert len(results) == 0
|
||||
|
||||
def test_match_limit(self, registry_with_skills):
|
||||
results = registry_with_skills.match("code", limit=1)
|
||||
assert len(results) <= 1
|
||||
|
||||
def test_match_excludes_disabled(self, registry):
|
||||
registry.register(Skill(id="s1", name="Disabled Review",
|
||||
description="review", enabled=False))
|
||||
results = registry.match("review")
|
||||
assert all(s.enabled for s, _ in results)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T4: 模板变量替换 + 执行
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExecution:
|
||||
def test_execute_high_freedom(self, executor):
|
||||
result = executor.execute("code-review")
|
||||
assert result["status"] == "success"
|
||||
assert result["prompt"] is not None
|
||||
|
||||
def test_execute_medium_with_vars(self, executor):
|
||||
result = executor.execute("test-template",
|
||||
context={"module": "api", "focus": "errors"})
|
||||
assert result["status"] == "success"
|
||||
assert "api" in result["prompt"]
|
||||
|
||||
def test_execute_low_blocked(self, executor):
|
||||
"""默认不允许脚本执行"""
|
||||
result = executor.execute("deploy-script")
|
||||
assert result["status"] == "error"
|
||||
assert "not allowed" in result["error"]
|
||||
|
||||
def test_execute_low_allowed(self, registry_with_skills):
|
||||
executor = SkillExecutor(registry=registry_with_skills, allow_scripts=True)
|
||||
result = executor.execute("deploy-script")
|
||||
assert result["status"] == "success"
|
||||
|
||||
def test_execute_not_found(self, executor):
|
||||
result = executor.execute("nonexistent")
|
||||
assert result["status"] == "error"
|
||||
|
||||
def test_execute_disabled(self, registry):
|
||||
registry.register(Skill(id="s1", name="T", description="D", enabled=False))
|
||||
executor = SkillExecutor(registry=registry)
|
||||
result = executor.execute("s1")
|
||||
assert result["status"] == "error"
|
||||
assert "disabled" in result["error"]
|
||||
|
||||
def test_execution_log(self, executor):
|
||||
executor.execute("code-review")
|
||||
assert len(executor.execution_log) == 1
|
||||
assert executor.execution_log[0]["skill_id"] == "code-review"
|
||||
@@ -0,0 +1,427 @@
|
||||
"""F9 Agent Spawner 单元测试 — classify/session 管理"""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
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):
|
||||
return tmp_path / "blackboard.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bb(db_path):
|
||||
return Blackboard(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spawner(db_path):
|
||||
return AgentSpawner(db_path=db_path, dry_run=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1: spawn 成功(dry_run)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSpawnSuccess:
|
||||
def test_dry_run_spawn(self, spawner):
|
||||
"""dry_run 模式不实际 spawn"""
|
||||
session_id = asyncio.run(
|
||||
spawner.spawn_full_agent("test-agent", "do something", task_id="t1")
|
||||
)
|
||||
assert session_id
|
||||
assert spawner.get_session(session_id) is not None
|
||||
assert spawner.get_session(session_id)["agent_id"] == "test-agent"
|
||||
|
||||
def test_session_registered(self, spawner):
|
||||
"""spawn 后 session 注册"""
|
||||
asyncio.run(spawner.spawn_full_agent("agent-1", "task", task_id="t1"))
|
||||
sessions = spawner.active_sessions
|
||||
assert len(sessions) >= 1
|
||||
|
||||
def test_spawn_subagent_dry_run(self, spawner):
|
||||
"""subagent dry_run"""
|
||||
session_id = asyncio.run(
|
||||
spawner.spawn_subagent("do task", task_id="t1")
|
||||
)
|
||||
assert session_id
|
||||
|
||||
def test_multiple_spawns(self, spawner):
|
||||
"""多次 spawn 独立 session"""
|
||||
ids = []
|
||||
for i in range(3):
|
||||
sid = asyncio.run(
|
||||
spawner.spawn_full_agent(f"agent-{i}", f"task {i}")
|
||||
)
|
||||
ids.append(sid)
|
||||
assert len(set(ids)) == 3 # 每个 session_id 唯一
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T4: session 清理
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSessionCleanup:
|
||||
def test_cleanup_removes_session(self, spawner):
|
||||
"""cleanup 删除 session"""
|
||||
sid = asyncio.run(spawner.spawn_full_agent("a", "m"))
|
||||
assert spawner.get_session(sid) is not None
|
||||
|
||||
spawner.cleanup_session(sid)
|
||||
assert spawner.get_session(sid) is None
|
||||
|
||||
def test_cleanup_nonexistent(self, spawner):
|
||||
"""清理不存在的 session 不报错"""
|
||||
spawner.cleanup_session("nonexistent-id") # no error
|
||||
|
||||
def test_active_sessions_excludes_completed(self, spawner):
|
||||
"""active_sessions 排除已完成"""
|
||||
sid = asyncio.run(spawner.spawn_full_agent("a", "m"))
|
||||
session = spawner.get_session(sid)
|
||||
session["status"] = "completed"
|
||||
|
||||
active = spawner.active_sessions
|
||||
assert sid not in active
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E11: Spawner Acquire-First Phase 0-4(v2.8 #07.1 新增)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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 成功"""
|
||||
session_id = asyncio.run(
|
||||
spawner.spawn_full_agent(
|
||||
"test-agent", "do something",
|
||||
task_id="t1", use_main_session=True,
|
||||
)
|
||||
)
|
||||
assert session_id
|
||||
|
||||
def test_phase0_stuck_detection(self, spawner):
|
||||
"""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:
|
||||
return {"status": "running", "lock_pid_alive": False}
|
||||
return {"status": "idle", "lock_pid_alive": False}
|
||||
|
||||
spawner._check_session_state = mock_check
|
||||
|
||||
revive_called = [False]
|
||||
def mock_revive(agent_id):
|
||||
revive_called[0] = True
|
||||
return True
|
||||
|
||||
spawner._revive_session = mock_revive
|
||||
|
||||
session_id = asyncio.run(
|
||||
spawner.spawn_full_agent(
|
||||
"test-agent", "do something",
|
||||
task_id="t1", use_main_session=True,
|
||||
)
|
||||
)
|
||||
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 互斥"""
|
||||
from src.daemon.counter import ActiveAgentCounter
|
||||
counter = ActiveAgentCounter(max_global=5, max_concurrent_sessions=1)
|
||||
spawner.counter = counter
|
||||
|
||||
session_id = asyncio.run(
|
||||
spawner.spawn_full_agent("test-agent", "task", task_id="t1")
|
||||
)
|
||||
assert session_id
|
||||
|
||||
with pytest.raises(AgentBusyError) as exc_info:
|
||||
asyncio.run(
|
||||
spawner.spawn_full_agent("test-agent", "task2", task_id="t2")
|
||||
)
|
||||
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 在锁保护下执行"""
|
||||
from src.daemon.counter import ActiveAgentCounter
|
||||
counter = ActiveAgentCounter(max_global=5, max_per_agent=1)
|
||||
spawner.counter = counter
|
||||
|
||||
spawner._check_session_state = lambda agent_id: {
|
||||
"status": "idle",
|
||||
"lock_pid_alive": True,
|
||||
"lock_expired": False,
|
||||
}
|
||||
|
||||
with pytest.raises(AgentBusyError) as exc_info:
|
||||
asyncio.run(
|
||||
spawner.spawn_full_agent(
|
||||
"test-agent", "task",
|
||||
task_id="t1", use_main_session=True,
|
||||
)
|
||||
)
|
||||
assert "session_locked" in exc_info.value.reason
|
||||
assert counter.global_active == 0
|
||||
|
||||
def test_phase2_multiple_blockers(self, spawner):
|
||||
"""E11.5 Phase 2: 多 blocker 并列收集"""
|
||||
spawner._check_session_state = lambda agent_id: {
|
||||
"status": "idle",
|
||||
"lock_pid_alive": True,
|
||||
"lock_expired": False,
|
||||
"recent_compact": True,
|
||||
}
|
||||
|
||||
with pytest.raises(AgentBusyError) as exc_info:
|
||||
asyncio.run(
|
||||
spawner.spawn_full_agent(
|
||||
"test-agent", "task",
|
||||
task_id="t1", use_main_session=True,
|
||||
)
|
||||
)
|
||||
assert exc_info.value.detail is not None
|
||||
blockers = exc_info.value.detail.get("blockers", [])
|
||||
blocker_reasons = [b[0] for b in blockers]
|
||||
assert "session_locked" in blocker_reasons
|
||||
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"""
|
||||
from src.daemon.counter import ActiveAgentCounter
|
||||
counter = ActiveAgentCounter(max_global=5, max_per_agent=1)
|
||||
spawner.counter = counter
|
||||
|
||||
def bad_callback():
|
||||
raise RuntimeError("callback failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="callback failed"):
|
||||
asyncio.run(
|
||||
spawner.spawn_full_agent(
|
||||
"test-agent", "task",
|
||||
task_id="t1",
|
||||
on_checks_passed=bad_callback,
|
||||
)
|
||||
)
|
||||
assert counter.global_active == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E13: Compact Hanging 不标 failed(v2.8 #07.3 ACT-2 新增)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCompactHanging:
|
||||
"""E13: compact_hanging 后不标 failed,只 release counter → 任务保持 working"""
|
||||
|
||||
def test_compact_hanging_releases_counter(self):
|
||||
"""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)
|
||||
|
||||
db_path = Path("/tmp/test_compact_hanging.db")
|
||||
try:
|
||||
bb = Blackboard(db_path)
|
||||
bb.create_task(TaskModel(id="t1", title="T", status="working", assigned_by="d",
|
||||
current_agent="test-agent"))
|
||||
|
||||
spawner = AgentSpawner(db_path=db_path, dry_run=True)
|
||||
spawner.counter = counter
|
||||
|
||||
outcomes = []
|
||||
async def mock_on_complete(aid, outcome):
|
||||
outcomes.append((aid, outcome))
|
||||
|
||||
sid = asyncio.run(spawner.spawn_full_agent(
|
||||
"test-agent", "task", task_id="t1",
|
||||
on_complete=mock_on_complete,
|
||||
))
|
||||
|
||||
counter.release("test-agent", sid)
|
||||
assert counter.global_active == 0
|
||||
finally:
|
||||
if db_path.exists():
|
||||
db_path.unlink()
|
||||
|
||||
def test_retry_agent_busy_releases_counter(self, spawner):
|
||||
"""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
|
||||
|
||||
spawner._check_session_state = lambda agent_id: {
|
||||
"status": "running",
|
||||
"lock_pid_alive": True,
|
||||
"lock_expired": False,
|
||||
}
|
||||
|
||||
with pytest.raises(AgentBusyError):
|
||||
asyncio.run(
|
||||
spawner.spawn_full_agent(
|
||||
"test-agent", "task",
|
||||
task_id="t1", use_main_session=True,
|
||||
)
|
||||
)
|
||||
assert counter.global_active == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E14: AgentBusyError 分类(v2.8 #07.1 O3 新增)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAgentBusyErrorClassification:
|
||||
"""E14: AgentBusyError reason/detail 分类验证"""
|
||||
|
||||
def test_counter_blocked_reason(self, spawner):
|
||||
"""E14.1: counter acquire 失败 → reason=counter_blocked"""
|
||||
from src.daemon.counter import ActiveAgentCounter
|
||||
counter = ActiveAgentCounter(max_global=1, max_per_agent=1)
|
||||
spawner.counter = counter
|
||||
|
||||
asyncio.run(counter.acquire("test-agent"))
|
||||
|
||||
with pytest.raises(AgentBusyError) as exc_info:
|
||||
asyncio.run(
|
||||
spawner.spawn_full_agent("test-agent", "task", task_id="t1")
|
||||
)
|
||||
assert exc_info.value.reason == "counter_blocked"
|
||||
assert exc_info.value.agent_id == "test-agent"
|
||||
|
||||
def test_session_blocker_reasons(self, spawner):
|
||||
"""E14.2: session locked/running/compacting → 具体 reason + detail.blockers"""
|
||||
test_cases = [
|
||||
{
|
||||
"state": {"status": "idle", "lock_pid_alive": True, "lock_expired": False},
|
||||
"expected": "session_locked",
|
||||
},
|
||||
{
|
||||
"state": {"status": "running", "lock_pid_alive": True, "lock_expired": False},
|
||||
"expected": "session_locked",
|
||||
},
|
||||
{
|
||||
"state": {"status": "idle", "lock_pid_alive": False, "recent_compact": True},
|
||||
"expected": "session_compacting",
|
||||
},
|
||||
]
|
||||
|
||||
for i, tc in enumerate(test_cases):
|
||||
state = tc["state"]
|
||||
expected = tc["expected"]
|
||||
|
||||
spawner._check_session_state = lambda aid, s=state: s
|
||||
|
||||
with pytest.raises(AgentBusyError) as exc_info:
|
||||
asyncio.run(
|
||||
spawner.spawn_full_agent(
|
||||
"test-agent", "task",
|
||||
task_id="t1", use_main_session=True,
|
||||
)
|
||||
)
|
||||
assert expected in exc_info.value.reason, \
|
||||
f"Case {i}: expected '{expected}' in '{exc_info.value.reason}'"
|
||||
assert exc_info.value.detail is not None, \
|
||||
f"Case {i}: expected detail to be set"
|
||||
|
||||
def test_session_running_in_blockers(self, spawner):
|
||||
"""session_running 出现在 blockers 列表中(session_locked 优先)"""
|
||||
spawner._check_session_state = lambda aid: {
|
||||
"status": "running", "lock_pid_alive": True, "lock_expired": False,
|
||||
}
|
||||
|
||||
with pytest.raises(AgentBusyError) as exc_info:
|
||||
asyncio.run(
|
||||
spawner.spawn_full_agent(
|
||||
"test-agent", "task",
|
||||
task_id="t1", use_main_session=True,
|
||||
)
|
||||
)
|
||||
assert "session_locked" in exc_info.value.reason
|
||||
blockers = exc_info.value.detail.get("blockers", [])
|
||||
blocker_reasons = [b[0] for b in blockers]
|
||||
assert "session_running" in blocker_reasons
|
||||
assert "session_locked" in blocker_reasons
|
||||
|
||||
def test_error_attributes(self):
|
||||
"""E14 补充: AgentBusyError 属性验证"""
|
||||
err = AgentBusyError(
|
||||
"my-agent",
|
||||
reason="session_locked",
|
||||
detail={"blockers": [("session_locked", 12345)]},
|
||||
)
|
||||
assert err.agent_id == "my-agent"
|
||||
assert err.reason == "session_locked"
|
||||
assert err.detail["blockers"][0][0] == "session_locked"
|
||||
assert "my-agent" in str(err)
|
||||
assert "session_locked" in str(err)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 司马懿评审补充:Phase 2.5 + session_stuck(v2.8 #07.1 v1.1 兜底)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPhase25AndStuck:
|
||||
"""司马懿评审遗漏 #1 + session_stuck 遗漏补充"""
|
||||
|
||||
def test_phase25_stuck_fallback(self, spawner):
|
||||
"""Phase 2.5: Phase 2 检测到假死 → revive → 成功 spawn"""
|
||||
call_count = [0]
|
||||
|
||||
def mock_check(agent_id):
|
||||
call_count[0] += 1
|
||||
if call_count[0] <= 1:
|
||||
return {"status": "idle", "lock_pid_alive": False}
|
||||
if call_count[0] == 2:
|
||||
return {"status": "running", "lock_pid_alive": False}
|
||||
return {"status": "idle", "lock_pid_alive": False}
|
||||
|
||||
spawner._check_session_state = mock_check
|
||||
|
||||
revive_called = [False]
|
||||
def mock_revive(agent_id):
|
||||
revive_called[0] = True
|
||||
return True
|
||||
|
||||
spawner._revive_session = mock_revive
|
||||
|
||||
session_id = asyncio.run(
|
||||
spawner.spawn_full_agent(
|
||||
"test-agent", "task",
|
||||
task_id="t1", use_main_session=True,
|
||||
)
|
||||
)
|
||||
assert revive_called[0], "Phase 2.5 should have revived stuck session"
|
||||
assert session_id
|
||||
|
||||
def test_session_stuck_after_failed_revive(self, spawner):
|
||||
"""Phase 2.5 revive 失败 → session_stuck"""
|
||||
revive_and_check_count = [0]
|
||||
|
||||
def mock_check_v2(agent_id):
|
||||
revive_and_check_count[0] += 1
|
||||
if revive_and_check_count[0] <= 1:
|
||||
return {"status": "idle", "lock_pid_alive": False}
|
||||
if revive_and_check_count[0] == 2:
|
||||
return {"status": "running", "lock_pid_alive": False}
|
||||
return {"status": "running", "lock_pid_alive": False}
|
||||
|
||||
spawner._check_session_state = mock_check_v2
|
||||
spawner._revive_session = lambda agent_id: True
|
||||
|
||||
with pytest.raises(AgentBusyError) as exc_info:
|
||||
asyncio.run(
|
||||
spawner.spawn_full_agent(
|
||||
"test-agent", "task",
|
||||
task_id="t1", use_main_session=True,
|
||||
)
|
||||
)
|
||||
assert "stuck" in exc_info.value.reason
|
||||
Reference in New Issue
Block a user