Files
Agentswarm/orchestrator/guard.py
T
Songhaoz666andClaude Opus 4.8 f281370209 去中心化蜂群重构:从 Master 中心化切换为播种+自组织(唯一行为)
把本仓从「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>
2026-06-10 16:11:47 +08:00

144 lines
6.2 KiB
Python

"""Swarm health guard — detect when the swarm CANNOT function, and say why.
The decentralized swarm has no central controller to fall back on, so the one piece of
non-happy-path handling it keeps is a GUARD: a pure diagnostic that inspects a run snapshot and
reports concrete blockers when the swarm cannot make progress. It does NOT steer the run (it is
advisory) — the orchestrator stores the report and surfaces it so an operator/Manager can see
*why* a run is stuck instead of watching it hang.
Blockers detected (each with a human-readable detail; never a fabricated cause — rule #9):
- NO_AGENTS_CONNECTED — pending work but no connected agent at all.
- NO_CAPABLE_AGENT — a ready task whose required capabilities no connected agent covers.
- DEPENDENCY_DEADLOCK — a pending task can never run: a dependency FAILED, is missing, or
forms a cycle.
- BUDGET_EXHAUSTED — the run's budget is spent while work remains.
- SEED_UNDECOMPOSED — only the seed exists and it is terminal, yet no subtasks were
proposed (the swarm produced nothing to do).
Pure: no Redis/WebSocket/model. The orchestrator assembles the snapshot and calls `diagnose`.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Sequence, Set
class Blocker(str, Enum):
NO_AGENTS_CONNECTED = "no_agents_connected"
NO_CAPABLE_AGENT = "no_capable_agent"
DEPENDENCY_DEADLOCK = "dependency_deadlock"
BUDGET_EXHAUSTED = "budget_exhausted"
SEED_UNDECOMPOSED = "seed_undecomposed"
_ACTIVE = {"pending", "assigned", "in_progress", "blocked"}
_PENDING = {"pending"}
_FAILED = {"failed"}
_OK = {"completed"}
@dataclass
class HealthReport:
healthy: bool
blockers: List[Dict[str, Any]] = field(default_factory=list)
summary: str = ""
def to_dict(self) -> Dict[str, Any]:
return {"healthy": self.healthy, "blockers": list(self.blockers), "summary": self.summary}
def _status(task: Any) -> str:
s = task.get("status") if isinstance(task, dict) else getattr(task, "status", None)
return str(getattr(s, "value", s) or "").lower()
def _field(task: Any, name: str, default):
return task.get(name, default) if isinstance(task, dict) else getattr(task, name, default)
def diagnose(run_state: Dict[str, Any]) -> HealthReport:
"""Inspect a run snapshot and report why the swarm cannot make progress (pure).
run_state:
tasks: list of {task_id, status, required_capabilities, depends_on, source}
connected_agent_caps: list of capability lists, one per connected agent
budget_state: optional {exhausted: bool, ...} (from convergence.derive_budget_state)
"""
tasks = list(run_state.get("tasks") or [])
agent_caps: List[Set[str]] = [set(c or []) for c in (run_state.get("connected_agent_caps") or [])]
budget_state = run_state.get("budget_state") or {}
by_id = {_field(t, "task_id", ""): t for t in tasks}
active = [t for t in tasks if _status(t) in _ACTIVE]
blockers: List[Dict[str, Any]] = []
# If nothing is active, the run is either done or empty — not "stuck". No blockers.
if not active:
# SEED_UNDECOMPOSED: the only task is a terminal seed and nothing else was produced.
if len(tasks) == 1 and _field(tasks[0], "source", "") == "seed" and _status(tasks[0]) in _OK:
# A completed seed with no proposed subtasks means the swarm did no real work.
blockers.append({
"reason": Blocker.SEED_UNDECOMPOSED.value,
"detail": "seed completed but no subtasks were proposed — the swarm produced no work",
})
return _finalize(blockers)
# 1. No agents at all.
if not agent_caps:
blockers.append({
"reason": Blocker.NO_AGENTS_CONNECTED.value,
"detail": f"{len(active)} task(s) need work but no agent is connected",
})
# 2. Per ready pending task: is any connected agent capable? + dependency deadlock.
for task in tasks:
if _status(task) not in _PENDING:
continue
required = set(_field(task, "required_capabilities", []) or [])
tid = _field(task, "task_id", "")
# dependency deadlock: a dep that failed / is missing / cycles → never satisfiable.
for dep_id in _field(task, "depends_on", []) or []:
dep = by_id.get(dep_id)
if dep is None:
blockers.append({"reason": Blocker.DEPENDENCY_DEADLOCK.value,
"detail": f"task {tid} depends on missing task {dep_id}"})
elif _status(dep) in _FAILED:
blockers.append({"reason": Blocker.DEPENDENCY_DEADLOCK.value,
"detail": f"task {tid} depends on FAILED task {dep_id} (can never complete)"})
elif dep_id == tid:
blockers.append({"reason": Blocker.DEPENDENCY_DEADLOCK.value,
"detail": f"task {tid} depends on itself (cycle)"})
# capability coverage (only meaningful when agents exist).
if agent_caps and required and not any(required <= caps for caps in agent_caps):
blockers.append({
"reason": Blocker.NO_CAPABLE_AGENT.value,
"detail": f"task {tid} requires {sorted(required)}; no connected agent covers it",
})
# 3. Budget exhausted while work remains.
if budget_state.get("exhausted"):
blockers.append({
"reason": Blocker.BUDGET_EXHAUSTED.value,
"detail": "run budget exhausted while tasks are still active",
})
return _finalize(blockers)
def _finalize(blockers: List[Dict[str, Any]]) -> HealthReport:
# De-dup identical blockers, keep order.
seen, deduped = set(), []
for b in blockers:
key = (b["reason"], b["detail"])
if key not in seen:
seen.add(key)
deduped.append(b)
if not deduped:
return HealthReport(healthy=True, summary="swarm healthy: progress is possible")
reasons = ", ".join(sorted({b["reason"] for b in deduped}))
return HealthReport(healthy=False, blockers=deduped,
summary=f"swarm blocked: {reasons}")