把本仓从「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>
144 lines
6.2 KiB
Python
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}")
|