把本仓从「Master 分解→派发→单评审」重构为去中心化自组织蜂群,并设为唯一行为
(本仓即 swarm 运行时,模式选择在仓外,无 ENABLE_swarm 开关)。依据:heicodeDocs 弱中心
定义 + 蜂群文章(stigmergy)+ OpenAI Swarm(handoff)。
流程(全部无条件生效):
- 播种 build_seed_task_specs(不主控分解)→ 自选 swarm_dispatch(能力+τ+负载+预算,唯一派发)
→ 自主分解 task_proposal(autonomous_tasks)→ 竞争/接管 task_bid/yield/takeover(task_competition)
→ 同伴交叉评审 ≥2 评审者(cross_review,取代单 critic)→ 收敛 ConvergenceReport+termination_reason
(convergence)→ 守卫 guard.diagnose(检测无法运作并列原因)。
删除:planner 主控分解、贪心/ACO/scored 派发与 scored_matchmake、单 critic 评审环、
全部 ENABLE_* swarm 构建开关。master_agent.synthesize 作为汇总工具保留。
测试:test-workflow-e2e 改写为真实 swarm 全流程;test-merge-smoke 改为 seeder/cross-review;
新增 test-swarm-{seed,dispatch,autonomous,competition,cross-review,convergence,guard} + 模块单测;
CI 同步。本地 18 套全绿。
影响范围:agent_swarm(orchestrator + 新模块 + 测试 + docs + CI)。不改 Manager↔Swarm 事件契约
(新事件均为运行时内部状态/遥测,不进 HM 注册表);不影响 Client/计费/密钥/审计/发布链路。
无新增长期开关。
诚实边界:不动 benchmark 验收(G_E 仍需真实 run,#13);convergence 暂为解释性(不强制覆盖
run.status);前端可见状态为跨端(#18);「去中心化是否更优」未证(需 Group C)。
#6 的 DoD(Path B 决策 + 定位文档)本 PR 已满足并实现 Path B → 关闭 #6。
#7/#8/#11/#12 的运行时机制已落地(事件 + e2e),但其 DoD 含前端可见状态(跨端 #18)与
多 Agent 回放,故以 Refs 链接、不自动关闭,留维护者在前端/验收就绪后关闭。
Closes #6
Refs #7
Refs #8
Refs #11
Refs #12
Refs #18
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
145 lines
5.7 KiB
Python
145 lines
5.7 KiB
Python
"""End-to-end workflow test: proves the program follows the expected workflow.
|
|
|
|
Boots the REAL orchestrator (uvicorn, in-process) on the in-memory store, connects a keyless
|
|
stub agent over WebSocket, submits one objective, and asserts the full loop:
|
|
|
|
decompose -> dispatch to experts -> execute -> master review (with one forced reopen)
|
|
-> iterate -> synthesize -> deliver
|
|
|
|
No OPENAI_API_KEY required: the planner is forced offline (static decomposition + heuristic
|
|
review + concatenated synthesis), and the stub agent returns canned results that make the
|
|
critic reject exactly once before accepting.
|
|
|
|
Run from the agent_swarm_v5 directory:
|
|
python scripts/test-workflow-e2e.py
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
# Configure the runtime for a deterministic, hermetic run BEFORE importing the app.
|
|
# No mode flags: this repo IS the swarm runtime — seed + self-organize is the only flow.
|
|
os.environ["REDIS_FAKE"] = "1"
|
|
os.environ["MAX_REVIEW_CYCLES"] = "2"
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
sys.path.insert(0, str(ROOT / "scripts"))
|
|
|
|
import httpx
|
|
import uvicorn
|
|
from orchestrator import main as orch
|
|
from stub_agent import StubAgent
|
|
|
|
# Force the planner/critic/synthesis offline so the workflow is deterministic regardless of any
|
|
# .env key (load_dotenv runs at import). Quiet the dummy-callback warnings.
|
|
orch.planner.client = None
|
|
logging.getLogger("orchestrator.swarm_runtime").setLevel(logging.ERROR)
|
|
|
|
PORT = 8123
|
|
BASE = f"http://127.0.0.1:{PORT}"
|
|
WS = f"ws://127.0.0.1:{PORT}"
|
|
|
|
failures = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(("PASS" if cond else "FAIL"), "-", name)
|
|
if not cond:
|
|
failures.append(name)
|
|
|
|
|
|
async def wait_health(client):
|
|
for _ in range(150):
|
|
try:
|
|
if (await client.get(f"{BASE}/health")).status_code == 200:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
await asyncio.sleep(0.1)
|
|
return False
|
|
|
|
|
|
async def main():
|
|
config = uvicorn.Config(orch.app, host="127.0.0.1", port=PORT, log_level="warning")
|
|
server = uvicorn.Server(config)
|
|
server.install_signal_handlers = lambda: None # required when not on the main thread
|
|
server_thread = threading.Thread(target=server.run, daemon=True)
|
|
server_thread.start()
|
|
|
|
agent = StubAgent(WS, "stub-1", [
|
|
"python", "code_generation", "testing", "pytest", "technical-writing", "general",
|
|
])
|
|
agent_task = None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
if not await wait_health(client):
|
|
check("orchestrator started", False)
|
|
return 1
|
|
|
|
agent_task = asyncio.create_task(agent.run())
|
|
await asyncio.sleep(0.5) # let the agent register
|
|
|
|
body = {
|
|
"mode": "swarm",
|
|
"requirement": {"objective": "Write a Python add(a,b) function with tests and docs"},
|
|
"callback": {"url": "http://127.0.0.1:9/cb", "subscribed_events": []},
|
|
"metadata": {"manager_deployment_id": "e2e-1"},
|
|
}
|
|
created = (await client.post(f"{BASE}/api/swarms", json=body)).json()
|
|
dep = created["data"]["deployment_id"]
|
|
|
|
# Poll until the run finishes (or times out).
|
|
status, wf = None, {}
|
|
for _ in range(200):
|
|
wf = (await client.get(f"{BASE}/api/swarms/{dep}/workflow")).json()["data"]
|
|
status = wf.get("status")
|
|
if status in ("completed", "failed"):
|
|
break
|
|
await asyncio.sleep(0.25)
|
|
|
|
tasks = (await client.get(f"{BASE}/api/swarms/{dep}/tasks")).json()["data"]["tasks"]
|
|
events = (await client.get(f"{BASE}/api/swarms/{dep}/logs")).json()["data"]["events"]
|
|
roles = sorted(t.get("agent_role") for t in tasks)
|
|
sources = [t.get("source") for t in tasks]
|
|
payloads = [(e.get("payload") or {}) for e in events]
|
|
termination_seen = any(p.get("termination_reason") for p in payloads)
|
|
|
|
# ---- swarm-flow assertions (seed → agent-decompose → self-select → converge) ----
|
|
check("seed: a single objective seed task was injected (no Master plan)",
|
|
sources.count("seed") == 1)
|
|
check("decompose: agents grew the graph bottom-up (>=2 agent-proposed subtasks)",
|
|
sources.count("agent_proposed") >= 2)
|
|
check("decompose: proposed roles include specialist roles",
|
|
{"implementation", "testing", "documentation"} & set(roles))
|
|
check("execute: every task (seed + proposed) completed",
|
|
bool(tasks) and all(t["status"] == "completed" for t in tasks))
|
|
check("converge: run reached completed", status == "completed")
|
|
check("converge: a termination_reason was emitted (convergence report)", termination_seen)
|
|
check("synthesize: a final unified summary is present", bool(wf.get("summary")))
|
|
finally:
|
|
agent.running = False
|
|
if agent_task:
|
|
agent_task.cancel()
|
|
server.should_exit = True
|
|
server_thread.join(timeout=5) # let uvicorn stop so no daemon thread lingers at exit
|
|
|
|
print()
|
|
if failures:
|
|
print(f"{len(failures)} workflow check(s) FAILED: {failures}")
|
|
return 1
|
|
print("swarm flow verified: seed -> agent self-select -> bottom-up decompose -> execute -> converge")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# os._exit after flushing avoids a Fatal Python error if the uvicorn daemon thread is still
|
|
# finalizing (writing to stderr) at interpreter shutdown — guarantees a deterministic exit code.
|
|
_code = asyncio.run(main())
|
|
sys.stdout.flush()
|
|
sys.stderr.flush()
|
|
os._exit(_code or 0)
|