Files
Agentswarm/scripts/test-workflow-e2e.py
Songhaoz666andClaude Opus 4.8 15fe5d379b 契约冻结 v1:Manager/客户端 Swarm Run 查询契约(Refs #2 #14 #15)
回应 HM 驾驶舱(heicode-mananger #28/#45/#46)经 agent_swarm#14(runtime-contract)
+ #15(event-schema)提出的消费需求。HM 只读查询已落地(HM PR #53),唯一前置是本
仓契约冻结。本 PR 把回调契约冻结为 v1 并落代码 + 测试。

代码(orchestrator/):
- swarm_runtime.emit_event:回调 envelope 新增 **per-swarm 严格递增 `sequence`**
  (INCR 计数键 swarm_event_seq:{swarm_id},从 1、无空洞,供客户端 events?after= 去重/续传)。
- 新增冻结的客户端 13 类事件(FROZEN_CLIENT_EVENT_TYPES)中此前缺的 6 类,均**附加**发出
  (不动既有 deployment.status_changed,HM 仍用其更新 AgentDeployment.Status):
  · swarm.completed/failed(refresh_swarm_run_status 终态)、swarm.stopped(stop_run);
  · approval.approved/rejected(record_approval_decision 决定落地);
  · handoff.created(child 任务建立时)。
- artifact.created envelope 补扁平字段:created_at(默认 occurred_at)、task_id(回填)、
  size_bytes 透传(未知则省略,不伪造,规则 #9)。
- redis_client 新增原子 incr(真实 + 两处 fake stub)。

文档(docs/integration/,FROZEN v1):
- event-schema.md:envelope sequence、artifact 扁平字段、事件注册表标注 ⭐13 类 + 新增 6 类、
  对齐状态更新(title/threshold_pct/sequence/artifact 已在 emit 统一处理)。
- runtime-contract.md:冻结 stop 端点 + ID 映射;§4.1 新增**状态机映射表**——运行时不臆造
  preparing/degraded/verifying(规则 #9),由 HM/客户端按表映射真实状态
  (blocked→degraded、评审期→verifying 等);终态另发 swarm.* 事件。

测试:
- 新增 scripts/test-contract-freeze.py(hermetic):sequence 单调/每-swarm/无空洞、13 类
  round-trip、artifact 形状(含未知 size 不伪造)、approval.*/swarm.stopped 真实发出、
  明文凭据脱敏而 azkv secret_ref 透传。接入 CI + CLAUDE.md 提交前清单。
- test-workflow-e2e.py:全流程 e2e 额外断言 swarm.completed + sequence 无空洞。
- 两处 FakeRedis stub 补 incr。

影响范围:仅 agent_swarm(orchestrator + docs/integration + 测试 + CI + CLAUDE.md)。
- Manager:回调**新增** sequence 字段与 6 类事件——向后兼容(旧消费方忽略新字段/新类型即可);
  HM 注册表需登记新 6 类方能对外暴露(agent_swarm#15.2,已在 doc 列为剩余项)。
- 计费/审计:不涉及(查询面不计费由 HM 保证;本仓未改计费/审计字段)。
- 密钥:envelope 不含明文凭据;secret_ref 仍为 azkv 引用,HM 对客户端再脱敏。

Refs #2
Refs #14
Refs #15

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

153 lines
6.3 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)
ev_types = [e.get("event_type") for e in events]
seqs = [e.get("sequence") for e in events]
# ---- 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")))
# ---- frozen Manager/client contract (agent_swarm#14/#15) ----
check("contract: terminal swarm.completed event emitted", "swarm.completed" in ev_types)
check("contract: every event carries a per-swarm sequence",
bool(seqs) and all(isinstance(s, int) for s in seqs))
check("contract: sequence is strictly increasing and gap-free",
seqs == list(range(1, len(seqs) + 1)))
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)