Files
Agentswarm/scripts/test-convergence.py
T
Songhaoz666andClaude Opus 4.8 f2662e4268 复审整改(PR #26):删死开关 helper、文档对齐"无条件"、swarm.health 改内部、#6 降为 Refs
回应 Fasthei 终审三点:

1) [P1 文档/代码冲突 + 死代码] 删除从不被调用的 *_enabled() helper(autonomous_tasks.proposals_enabled
   / task_competition.task_competition_enabled / convergence.convergence_report_enabled)及其
   import os;模块 docstring 与四份协议文档(autonomous-task-generation / task-competition-protocol
   / review-loop-protocol / convergence-protocol)从"默认关/未接入/待 PR/cutover 转无条件"全部改为
   "无条件接入(无开关)",删除引用死 helper 的过时集成代码样例;同步删除三个模块单测里的
   "flag default OFF" 断言。

2) [P1 验收] #6 "Closes" 降为 "Refs":#6 DoD 需 ARB 决策记录链接,当前只有 owner 指示断言、无链接。
   product-positioning.md 改为如实记录决策来源(owner 指示 + 本 PR + 文档)并把"补 ARB 记录链接(或
   owner 明确接受断言)"列为关闭 #6 的前置;纠正其"flag 门控、默认行为不变"的过时表述(重构已无条件)。

3) [P2 契约卫生] assess_swarm_health 不再 emit_event("swarm.health")(避免向订阅全部的 Manager 回调
   投递未注册事件);改为存 run.metadata["health"] + 内部 health_log。test-swarm-guard 相应断言
   "无 swarm.health 外发 + 内部 health_log 已记"。

本地受影响 11 套全绿。影响范围:agent_swarm(orchestrator 模块/文档/测试);不改 Manager↔Swarm 契约。

Refs #6
Refs #7
Refs #8
Refs #11
Refs #12
Refs #18

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:48:27 +08:00

234 lines
9.1 KiB
Python

"""Hermetic unit tests for the swarm convergence protocol (Issue #12).
Module-level only: no WebSocket, no Redis, no model, no FastAPI. Exercises the
pure functions in orchestrator/convergence.py directly.
Run from agent_swarm_v6 (Windows, repo .venv):
..\\.venv\\Scripts\\python.exe scripts\\test-convergence.py
If the .venv is missing, it only needs the stdlib + the repo on sys.path:
py -m venv ..\\.venv
..\\.venv\\Scripts\\python.exe -m pip install -r orchestrator\\requirements.txt
..\\.venv\\Scripts\\python.exe scripts\\test-convergence.py
(No third-party import is actually required by this test — convergence.py uses
only the stdlib — but the command above matches the repo convention.)
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from orchestrator.convergence import ( # noqa: E402
ConflictType,
ConvergenceStatus,
TerminationReason,
detect_conflicts,
evaluate_convergence,
event_conflict_detected,
event_conflict_resolved,
event_consensus_updated,
event_convergence_failed,
event_convergence_reached,
event_convergence_started,
resolve_conflicts,
)
failures = []
def check(name, cond):
print(("PASS" if cond else "FAIL"), "-", name)
if not cond:
failures.append(name)
# --- scenario: two conflicting artifacts; one conflict resolvable ---------
# task-a and task-b both wrote different content to src/app.py (artifact
# mismatch). The master critic REJECTED the work and flagged task-b for redo,
# which makes the review-disagreement conflict actionable (resolvable), while
# the artifact mismatch stays unresolved (review did not accept a winner).
run_state = {
"tasks": [
{
"task_id": "task-a",
"status": "completed",
"depends_on": [],
"result": {
"files_modified": ["src/app.py"],
"file_hashes": {"src/app.py": "hashAAA"},
"tests_passed": True,
},
},
{
"task_id": "task-b",
"status": "completed",
"depends_on": [],
"result": {
"files_modified": ["src/app.py"],
"file_hashes": {"src/app.py": "hashBBB"},
"tests_passed": True,
},
},
],
"review_verdict": {
"accepted": False,
"retry_tasks": ["task-b"],
"summary": "Two versions of src/app.py disagree; redo task-b",
},
"budget": {"max_cost_usd": 10.0},
"usage": {"total_cost_usd": 1.0},
}
conflicts = detect_conflicts(run_state["tasks"], run_state["review_verdict"])
types = {c.type for c in conflicts}
check("artifact mismatch detected", ConflictType.ARTIFACT_MISMATCH in types)
check("review disagreement detected", ConflictType.REVIEW_DISAGREEMENT in types)
resolved, unresolved = resolve_conflicts(conflicts, run_state["review_verdict"])
check("at least one conflict resolved (review disagreement closes)", len(resolved) >= 1)
check("review disagreement is among the resolved",
any(c.type == ConflictType.REVIEW_DISAGREEMENT and c.resolved for c in resolved))
check("artifact mismatch stays unresolved (no accepted winner)",
any(c.type == ConflictType.ARTIFACT_MISMATCH and not c.resolved for c in unresolved))
check("every resolved conflict carries a resolution note",
all(c.resolution for c in resolved))
report = evaluate_convergence(run_state)
check("conflicting run is not silently CONVERGED (blocked by unresolved artifact)",
report.status == ConvergenceStatus.BLOCKED)
check("blocked run carries a concrete termination_reason",
report.termination_reason == TerminationReason.RISK_BLOCKED)
check("consensus < 100 when work is in conflict", report.consensus_score < 100.0)
check("report.to_dict() round-trips conflicts",
len(report.to_dict()["conflicts"]) == len(conflicts))
# --- assert: every TERMINAL state carries a termination_reason ------------
# Build one run-state per intended terminal outcome and assert the reason.
def completed_task(tid, **result):
return {"task_id": tid, "status": "completed", "depends_on": [], "result": result}
terminal_cases = {
# clean completion -> tasks_completed fallback
"tasks_completed": {
"tasks": [completed_task("t1", tests_passed=True)],
"expect_status": ConvergenceStatus.CONVERGED,
"expect_reason": TerminationReason.TASKS_COMPLETED,
},
# quality gate reached
"quality_reached": {
"tasks": [completed_task("t1", tests_passed=True)],
"quality": {"test_pass_rate": 1.0, "graded": True},
"expect_status": ConvergenceStatus.CONVERGED,
"expect_reason": TerminationReason.QUALITY_REACHED,
},
# budget exhausted
"budget_exhausted": {
"tasks": [completed_task("t1", tests_passed=True)],
"budget": {"max_cost_usd": 5.0},
"usage": {"total_cost_usd": 5.0},
"expect_status": ConvergenceStatus.CONVERGED,
"expect_reason": TerminationReason.BUDGET_EXHAUSTED,
},
# max rounds reached
"max_rounds_reached": {
"tasks": [completed_task("t1", tests_passed=True)],
"review_cycles": 2,
"max_review_cycles": 2,
"expect_status": ConvergenceStatus.CONVERGED,
"expect_reason": TerminationReason.MAX_ROUNDS_REACHED,
},
# risk blocked (explicit blocking input risk)
"risk_blocked": {
"tasks": [completed_task("t1", tests_passed=True)],
"risks": [{"id": "r1", "description": "secret leak suspected", "blocking": True}],
"expect_status": ConvergenceStatus.BLOCKED,
"expect_reason": TerminationReason.RISK_BLOCKED,
},
# a failed task -> FAILED, fallback reason when nothing else explains it
"failed_fallback": {
"tasks": [
completed_task("t1", tests_passed=True),
{"task_id": "t2", "status": "failed", "depends_on": [], "result": {}},
],
"expect_status": ConvergenceStatus.FAILED,
"expect_reason": TerminationReason.TASKS_COMPLETED,
},
}
for label, case in terminal_cases.items():
rep = evaluate_convergence(case)
check(f"[{label}] terminal status == {case['expect_status'].value}",
rep.status == case["expect_status"])
check(f"[{label}] is_terminal()", rep.is_terminal())
check(f"[{label}] carries a non-None termination_reason",
rep.termination_reason is not None)
check(f"[{label}] termination_reason == {case['expect_reason'].value}",
rep.termination_reason == case["expect_reason"])
# Invariant: EVERY terminal report has a reason; non-terminal has none.
for label, case in terminal_cases.items():
rep = evaluate_convergence(case)
if rep.is_terminal():
check(f"[{label}] invariant: terminal -> reason set",
rep.termination_reason is not None)
# A still-running run must NOT carry a termination_reason.
running = evaluate_convergence({"tasks": [{"task_id": "x", "status": "in_progress", "depends_on": []}]})
check("running run is RUNNING", running.status == ConvergenceStatus.RUNNING)
check("running run has no termination_reason", running.termination_reason is None)
check("running run is not terminal", not running.is_terminal())
# --- dependency inconsistency detector ------------------------------------
dep_state = {
"tasks": [
{"task_id": "parent", "status": "completed", "depends_on": ["child"], "result": {}},
{"task_id": "child", "status": "failed", "depends_on": [], "result": {}},
],
}
dep_conflicts = detect_conflicts(dep_state["tasks"])
check("dependency inconsistency detected",
any(c.type == ConflictType.DEPENDENCY_INCONSISTENCY for c in dep_conflicts))
# --- test failure detector ------------------------------------------------
tf_conflicts = detect_conflicts([completed_task("t1", tests_passed=False)])
check("test failure detected",
any(c.type == ConflictType.TEST_FAILURE for c in tf_conflicts))
check("no test signal is not a failure",
not any(c.type == ConflictType.TEST_FAILURE
for c in detect_conflicts([completed_task("t1")])))
# --- event builders -------------------------------------------------------
et, _ = event_convergence_started(run_state)
check("convergence.started event type", et == "convergence.started")
et, _ = event_conflict_detected(conflicts[0])
check("conflict.detected event type", et == "conflict.detected")
et, _ = event_conflict_resolved(resolved[0])
check("conflict.resolved event type", et == "conflict.resolved")
et, payload = event_consensus_updated(report)
check("consensus.updated event type + score", et == "consensus.updated" and "consensus_score" in payload)
et, payload = event_convergence_failed(report)
check("convergence.failed carries termination_reason",
et == "convergence.failed" and payload["termination_reason"] == "risk_blocked")
clean = evaluate_convergence(terminal_cases["tasks_completed"])
et, payload = event_convergence_reached(clean)
check("convergence.reached carries termination_reason",
et == "convergence.reached" and payload["termination_reason"] == "tasks_completed")
print()
if failures:
print(f"{len(failures)} convergence check(s) FAILED: {failures}")
sys.exit(1)
print("all convergence protocol checks passed")