回应 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>
234 lines
9.1 KiB
Python
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")
|