feat(swarm): 协作聚合收敛取代蜂后选优 + sandbox 用 pytest 验证
按 juejin 协作聚合模型重构收敛(取代 best-of-N 选优): - 删蜂后选优(queen.py/test-queen.py) - 新增聚合节点 result_aggregator.py:共享池收集→同文件 LLM/AST 整合→沙箱验证→单次落 main - 质量驱动闭环:不达标打回迭代(AGGREGATE_ACCEPTANCE_THRESHOLD + MAX_REVIEW_CYCLES) - agent 停 git 工作分支,产出走 task.result.files 共享池(AGENT_GIT_PUSH_ENABLED 默认 false) - sandbox_runner 改用 pytest(原生支持 pytest 风格 class),修 stdlib runner 收集失败 - 文档同步重写为协作聚合模型 本地验证:产物仓单分支 main + 三函数完整 + pytest 12/12 pass_rate=100 一次达标。 影响:Swarm 收敛/聚合层;Manager/客户端契约不变(artifact字段/sequence/状态机;契约测试全过)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
28a6c618ab
commit
1288fd19d7
+25
-2
@@ -403,6 +403,15 @@ class Agent:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to propose task: {e}")
|
logger.error(f"Failed to propose task: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _git_push_enabled() -> bool:
|
||||||
|
# New convergence architecture (feat/queen-convergence): worker agents no longer push a
|
||||||
|
# per-task git WORK branch into the delivery repo — intermediate artifacts ride in
|
||||||
|
# task.result["files"] and the orchestrator's aggregation node integrates them onto main.
|
||||||
|
# Default OFF. The legacy "commit + push agent/<id>/<task> branch" path runs ONLY when an
|
||||||
|
# operator explicitly opts in with AGENT_GIT_PUSH_ENABLED=true (e.g. for debugging).
|
||||||
|
return os.getenv("AGENT_GIT_PUSH_ENABLED", "false").lower() in {"1", "true", "yes"}
|
||||||
|
|
||||||
async def execute_assignment(self, assignment: TaskAssignment):
|
async def execute_assignment(self, assignment: TaskAssignment):
|
||||||
# Execute one accepted assignment, manage workspace/git flow, and publish lifecycle updates.
|
# Execute one accepted assignment, manage workspace/git flow, and publish lifecycle updates.
|
||||||
async with self.task_semaphore:
|
async with self.task_semaphore:
|
||||||
@@ -426,6 +435,7 @@ class Agent:
|
|||||||
|
|
||||||
git_enabled = False
|
git_enabled = False
|
||||||
task_git = None
|
task_git = None
|
||||||
|
git_push_enabled = self._git_push_enabled()
|
||||||
try:
|
try:
|
||||||
# Each task runs in its OWN git worktree cut from this agent's clone, so an agent can
|
# Each task runs in its OWN git worktree cut from this agent's clone, so an agent can
|
||||||
# run several tasks concurrently without sharing a checkout/index. The worktree holds
|
# run several tasks concurrently without sharing a checkout/index. The worktree holds
|
||||||
@@ -433,7 +443,13 @@ class Agent:
|
|||||||
# source there and the per-task GitOperations (task_git) commits/pushes that branch.
|
# source there and the per-task GitOperations (task_git) commits/pushes that branch.
|
||||||
# This fixes the prior empty per-task subdir → "Empty workspace: no files detected".
|
# This fixes the prior empty per-task subdir → "Empty workspace: no files detected".
|
||||||
# Falls back to the repo root (no isolated branch) only if worktree creation fails.
|
# Falls back to the repo root (no isolated branch) only if worktree creation fails.
|
||||||
if await self.workspace_git.is_git_workspace():
|
#
|
||||||
|
# Convergence default (AGENT_GIT_PUSH_ENABLED unset/false): we do NOT cut a per-task
|
||||||
|
# result branch at all — there is nothing to commit/push because artifacts are
|
||||||
|
# returned in task.result["files"]. The task simply executes against the shared repo
|
||||||
|
# root for read context. The isolated worktree branch is created only in the legacy
|
||||||
|
# opt-in push path below.
|
||||||
|
if git_push_enabled and await self.workspace_git.is_git_workspace():
|
||||||
async with self._git_admin_lock: # serialize shared-repo git plumbing only
|
async with self._git_admin_lock: # serialize shared-repo git plumbing only
|
||||||
result_branch = await self.workspace_git.add_task_worktree(str(task_workspace), task_id)
|
result_branch = await self.workspace_git.add_task_worktree(str(task_workspace), task_id)
|
||||||
if result_branch:
|
if result_branch:
|
||||||
@@ -468,7 +484,14 @@ class Agent:
|
|||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
self.last_summary = self._summarize_result(result) or self.last_summary
|
self.last_summary = self._summarize_result(result) or self.last_summary
|
||||||
if result.get("success") and not awaiting_handoff:
|
if result.get("success") and not awaiting_handoff:
|
||||||
if git_enabled and task_git:
|
if not git_push_enabled:
|
||||||
|
# Convergence default: artifacts are carried in result["files"]; the
|
||||||
|
# orchestrator aggregation node integrates them. No work branch is pushed.
|
||||||
|
result["git_skipped"] = (
|
||||||
|
"git push disabled (AGENT_GIT_PUSH_ENABLED=false); "
|
||||||
|
"artifacts returned in task.result.files"
|
||||||
|
)
|
||||||
|
elif git_enabled and task_git:
|
||||||
commit_sha = await task_git.commit_changes(
|
commit_sha = await task_git.commit_changes(
|
||||||
message=f"Task {task_id}: {description[:50]}"
|
message=f"Task {task_id}: {description[:50]}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ class TaskExecutor:
|
|||||||
"agent_id": self.agent_id,
|
"agent_id": self.agent_id,
|
||||||
"usage": self._usage_payload(time.time() - started_at),
|
"usage": self._usage_payload(time.time() - started_at),
|
||||||
}
|
}
|
||||||
|
# Hoist every subtask's generated files to a top-level `files` array so the produced
|
||||||
|
# artifacts travel in task.result and reach the orchestrator's aggregation node WITHOUT
|
||||||
|
# a git push of a work branch (the swarm's new convergence path; orchestrator
|
||||||
|
# quality.collect_generated_files consumes result['files']). Each entry keeps the frozen
|
||||||
|
# shape {path, content, action} with the COMPLETE file content; last writer wins per path
|
||||||
|
# so a later subtask that rewrites a file supersedes an earlier one. Deletes are carried
|
||||||
|
# through as {path, action:"delete"} (no content) for the aggregator to honor.
|
||||||
|
payload["files"] = self._aggregate_subtask_files(results)
|
||||||
if not success:
|
if not success:
|
||||||
# Surface the model's OWN failure explanation (what it saw / what was missing) as a
|
# Surface the model's OWN failure explanation (what it saw / what was missing) as a
|
||||||
# top-level `error`, so the orchestrator/Manager records WHY instead of a generic
|
# top-level `error`, so the orchestrator/Manager records WHY instead of a generic
|
||||||
@@ -175,6 +183,37 @@ class TaskExecutor:
|
|||||||
finally:
|
finally:
|
||||||
self.current_context = {}
|
self.current_context = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _aggregate_subtask_files(results: list[dict]) -> list[dict]:
|
||||||
|
"""Flatten the per-subtask file specs into one ordered, de-duplicated `files` list.
|
||||||
|
|
||||||
|
Each subtask result carries the model's `files` (the same {path, action, content} specs the
|
||||||
|
executor applied to the workspace). We re-emit them at the top level so the artifacts travel
|
||||||
|
in task.result instead of a pushed git branch. Last writer wins per path (a later subtask
|
||||||
|
rewriting/deleting a file supersedes an earlier write); ordering follows last occurrence.
|
||||||
|
Only writes with non-None content and deletes are kept; malformed entries are skipped.
|
||||||
|
"""
|
||||||
|
by_path: dict[str, dict] = {}
|
||||||
|
for r in results:
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
|
for f in (r.get("files") or []):
|
||||||
|
if not isinstance(f, dict):
|
||||||
|
continue
|
||||||
|
path = f.get("path")
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
action = f.get("action", "write")
|
||||||
|
if action == "delete":
|
||||||
|
by_path[path] = {"path": path, "action": "delete"}
|
||||||
|
elif action == "write":
|
||||||
|
content = f.get("content")
|
||||||
|
if content is None:
|
||||||
|
continue # an incomplete write (no content) is not a usable artifact
|
||||||
|
by_path[path] = {"path": path, "content": content, "action": "write"}
|
||||||
|
# unknown actions are ignored (the orchestrator only consumes write/delete)
|
||||||
|
return list(by_path.values())
|
||||||
|
|
||||||
async def _maybe_propose_subtasks(self, task_id: str, description: str, context: dict,
|
async def _maybe_propose_subtasks(self, task_id: str, description: str, context: dict,
|
||||||
proposal_callback: Optional[Callable]) -> int:
|
proposal_callback: Optional[Callable]) -> int:
|
||||||
"""agent_swarm#7: decompose a top-level seed and propose each subtask to the shared pool.
|
"""agent_swarm#7: decompose a top-level seed and propose each subtask to the shared pool.
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""北极星采集器 CLI — 从运行中的 orchestrator 采集一次 swarm run 的真实 metrics,持久化到本地。
|
||||||
|
|
||||||
|
设计:纯 stdlib + benchmark.metrics 公式,**不 import orchestrator 重依赖**,宿主直接跑、直接落桌面,
|
||||||
|
无需重构建镜像。数据源 = orchestrator HTTP API(tasks/events/summary)。
|
||||||
|
|
||||||
|
诚实(组织规则 #9):只采有真实数据源的 metrics;无数据的标 NaN + coverage False,绝不伪造 0/100。
|
||||||
|
持久化:① sqlite(~/Desktop/swarm-benchmark.db,可累积/可查询)② JSON(~/Desktop/)。
|
||||||
|
涌现增益 s_gain = Q_swarm - Q_base:给 --baseline 时用两个 run 的质量分(--q-swarm/--q-base 优先,
|
||||||
|
否则回退 completion 作代理并显式标注 proxy)。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python -m benchmark.collect_cli <deployment_id> \
|
||||||
|
[--baseline <deployment_id>] [--q-swarm 80] [--q-base 60] \
|
||||||
|
[--orchestrator http://localhost:8000] [--label humaneval-bon] [--db PATH]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from .metrics import (
|
||||||
|
completion_score, collaboration_score, robustness_score, communication_score,
|
||||||
|
cost_score, governance_score, emergence_gain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get(base: str, path: str) -> dict:
|
||||||
|
req = urllib.request.Request(base.rstrip("/") + path)
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
return json.loads(r.read().decode() or "{}")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_status(t: dict, name: str) -> bool:
|
||||||
|
return str(t.get("status", "")).lower() == name
|
||||||
|
|
||||||
|
|
||||||
|
def collect(base: str, dep_id: str) -> dict:
|
||||||
|
"""采集一次 run 的真实 metrics + coverage(诚实标注)。"""
|
||||||
|
tasks = (_get(base, f"/api/swarms/{dep_id}/tasks").get("data") or {}).get("tasks") or []
|
||||||
|
events = (_get(base, f"/api/swarms/{dep_id}/events").get("data") or {}).get("events") or []
|
||||||
|
summary = _get(base, f"/api/swarms/{dep_id}").get("data") or {}
|
||||||
|
et = Counter(e.get("event_type") for e in events)
|
||||||
|
cov: dict = {}
|
||||||
|
|
||||||
|
# completion(真实)
|
||||||
|
total = len(tasks)
|
||||||
|
done = sum(1 for t in tasks if _is_status(t, "completed"))
|
||||||
|
s_completion = completion_score(done, total) if total else math.nan
|
||||||
|
cov["s_completion"] = total > 0
|
||||||
|
|
||||||
|
# collaboration(真实):handoff 成功率 + 依赖解析率 + 负载均衡
|
||||||
|
req_h, comp_h = et.get("handoff.requested", 0), et.get("handoff.completed", 0)
|
||||||
|
handoff = (100.0 * comp_h / req_h) if req_h else 100.0
|
||||||
|
deps = [t for t in tasks if t.get("depends_on")]
|
||||||
|
done_ids = {t["task_id"] for t in tasks if _is_status(t, "completed")}
|
||||||
|
resolved = [t for t in deps if all(d in done_ids for d in t["depends_on"])]
|
||||||
|
dep_res = (100.0 * len(resolved) / len(deps)) if deps else 100.0
|
||||||
|
per_agent = Counter(t.get("assigned_agent_id") for t in tasks if t.get("assigned_agent_id"))
|
||||||
|
bal = (100.0 * min(per_agent.values()) / max(per_agent.values())) if per_agent else 100.0
|
||||||
|
s_collaboration = collaboration_score(handoff, dep_res, bal) if total else math.nan
|
||||||
|
cov["s_collaboration"] = total > 0
|
||||||
|
|
||||||
|
# robustness(真实):从失败中恢复
|
||||||
|
failures = [t for t in tasks if (t.get("retry_count") or 0) > 0 or _is_status(t, "failed")]
|
||||||
|
recovered = [t for t in failures if _is_status(t, "completed")]
|
||||||
|
s_robustness = robustness_score(len(recovered), len(failures))
|
||||||
|
cov["s_robustness"] = True
|
||||||
|
|
||||||
|
# communication(有 peer 消息才采)
|
||||||
|
msg_req = et.get("agent.message.request", 0) or et.get("message.request", 0)
|
||||||
|
msg_rep = et.get("agent.message.reply", 0) or et.get("message.reply", 0)
|
||||||
|
if msg_req:
|
||||||
|
s_communication = communication_score(min(msg_rep, msg_req), msg_req)
|
||||||
|
cov["s_communication"] = True
|
||||||
|
else:
|
||||||
|
s_communication = math.nan
|
||||||
|
cov["s_communication"] = False
|
||||||
|
|
||||||
|
# governance(有审批才采)
|
||||||
|
approvals = summary.get("approvals") or {}
|
||||||
|
appr_list = list(approvals.values()) if isinstance(approvals, dict) else (approvals or [])
|
||||||
|
if appr_list:
|
||||||
|
compliant = sum(1 for a in appr_list if a.get("decision") in ("approved", "rejected"))
|
||||||
|
s_governance = governance_score(compliant, len(appr_list))
|
||||||
|
cov["s_governance"] = True
|
||||||
|
else:
|
||||||
|
s_governance = math.nan
|
||||||
|
cov["s_governance"] = False
|
||||||
|
|
||||||
|
# cost(需 budget+usage,HTTP summary 一般不给 → 诚实 NaN)
|
||||||
|
s_cost = math.nan
|
||||||
|
cov["s_cost"] = False
|
||||||
|
|
||||||
|
quality = summary.get("quality") or {}
|
||||||
|
return {
|
||||||
|
"deployment_id": dep_id,
|
||||||
|
"swarm_id": summary.get("swarm_id"),
|
||||||
|
"status": summary.get("status") or summary.get("state"),
|
||||||
|
"n_tasks": total,
|
||||||
|
"n_completed": done,
|
||||||
|
"n_agents": len(per_agent),
|
||||||
|
"metrics": {
|
||||||
|
"s_completion": s_completion,
|
||||||
|
"s_collaboration": s_collaboration,
|
||||||
|
"s_robustness": s_robustness,
|
||||||
|
"s_communication": s_communication,
|
||||||
|
"s_governance": s_governance,
|
||||||
|
"s_cost": s_cost,
|
||||||
|
},
|
||||||
|
"coverage": cov,
|
||||||
|
"q_quality_run": quality.get("q_quality"),
|
||||||
|
"event_counts": dict(et),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_sqlite(db_path: str, row: dict):
|
||||||
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
con.execute("""CREATE TABLE IF NOT EXISTS runs(
|
||||||
|
ts INTEGER, label TEXT, deployment_id TEXT, swarm_id TEXT, status TEXT,
|
||||||
|
n_tasks INTEGER, n_completed INTEGER, n_agents INTEGER,
|
||||||
|
s_completion REAL, s_collaboration REAL, s_robustness REAL,
|
||||||
|
s_gain REAL, q_swarm REAL, q_base REAL,
|
||||||
|
coverage_json TEXT, raw_json TEXT)""")
|
||||||
|
m = row["metrics"]
|
||||||
|
con.execute("INSERT INTO runs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (
|
||||||
|
row["ts"], row.get("label", ""), row["deployment_id"], row.get("swarm_id"),
|
||||||
|
row.get("status"), row["n_tasks"], row["n_completed"], row["n_agents"],
|
||||||
|
m["s_completion"], m["s_collaboration"], m["s_robustness"],
|
||||||
|
row.get("s_gain", math.nan), row.get("q_swarm", math.nan), row.get("q_base", math.nan),
|
||||||
|
json.dumps(row["coverage"]), json.dumps(row, default=str),
|
||||||
|
))
|
||||||
|
con.commit(); con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="北极星采集器:采集 swarm run metrics 并持久化")
|
||||||
|
ap.add_argument("deployment_id")
|
||||||
|
ap.add_argument("--baseline", help="单 agent 基线 run 的 deployment_id(算涌现增益 s_gain)")
|
||||||
|
ap.add_argument("--q-swarm", type=float, help="蜂群质量分(0-100,如测试 pass rate);省略则用 completion 代理")
|
||||||
|
ap.add_argument("--q-base", type=float, help="基线质量分(0-100)")
|
||||||
|
ap.add_argument("--orchestrator", default=os.getenv("ORCH_URL", "http://localhost:8000"))
|
||||||
|
ap.add_argument("--label", default="")
|
||||||
|
ap.add_argument("--db", default=os.path.expanduser("~/Desktop/swarm-benchmark.db"))
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
ts = int(time.time())
|
||||||
|
row = collect(a.orchestrator, a.deployment_id)
|
||||||
|
row["ts"] = ts
|
||||||
|
row["label"] = a.label
|
||||||
|
|
||||||
|
# 涌现增益 s_gain = Q_swarm - Q_base
|
||||||
|
s_gain = math.nan; q_s = a.q_swarm; q_b = a.q_base; proxy = False
|
||||||
|
if a.baseline:
|
||||||
|
base = collect(a.orchestrator, a.baseline)
|
||||||
|
if q_s is None:
|
||||||
|
q_s = row["metrics"]["s_completion"]; proxy = True
|
||||||
|
if q_b is None:
|
||||||
|
q_b = base["metrics"]["s_completion"]; proxy = True
|
||||||
|
if not (math.isnan(q_s) or math.isnan(q_b)):
|
||||||
|
s_gain = emergence_gain(q_s, q_b)
|
||||||
|
row["baseline"] = base
|
||||||
|
row["s_gain"] = s_gain; row["q_swarm"] = q_s if q_s is not None else math.nan
|
||||||
|
row["q_base"] = q_b if q_b is not None else math.nan
|
||||||
|
row["s_gain_proxy"] = proxy
|
||||||
|
|
||||||
|
_persist_sqlite(a.db, row)
|
||||||
|
json_path = os.path.join(os.path.dirname(a.db),
|
||||||
|
f"swarm-benchmark-{a.label or a.deployment_id}-{ts}.json")
|
||||||
|
with open(json_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(row, f, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
def fmt(v):
|
||||||
|
return "NaN(未采)" if isinstance(v, float) and math.isnan(v) else f"{v:.1f}" if isinstance(v, float) else v
|
||||||
|
m = row["metrics"]
|
||||||
|
print(f"=== 采集 {a.deployment_id} (label={a.label}) ===")
|
||||||
|
print(f" 任务 {row['n_completed']}/{row['n_tasks']} 完成 | agent 数 {row['n_agents']} | 状态 {row['status']}")
|
||||||
|
print(f" s_completion = {fmt(m['s_completion'])} [cov={row['coverage']['s_completion']}]")
|
||||||
|
print(f" s_collaboration = {fmt(m['s_collaboration'])} [cov={row['coverage']['s_collaboration']}]")
|
||||||
|
print(f" s_robustness = {fmt(m['s_robustness'])} [cov={row['coverage']['s_robustness']}]")
|
||||||
|
print(f" s_communication = {fmt(m['s_communication'])} [cov={row['coverage']['s_communication']}]")
|
||||||
|
print(f" s_governance = {fmt(m['s_governance'])} [cov={row['coverage']['s_governance']}]")
|
||||||
|
print(f" s_cost = {fmt(m['s_cost'])} [cov={row['coverage']['s_cost']}]")
|
||||||
|
if a.baseline:
|
||||||
|
tag = "(completion 代理)" if proxy else "(质量分)"
|
||||||
|
print(f" 涌现增益 s_gain = {fmt(s_gain)} = Q_swarm({fmt(q_s)}) - Q_base({fmt(q_b)}) {tag}")
|
||||||
|
print(f"\n 持久化 → sqlite: {a.db}")
|
||||||
|
print(f" 持久化 → json: {json_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -125,22 +125,64 @@ consensus = 100 × (未被任何冲突牵连的已完成任务数 / 已完成任
|
|||||||
**诚实差距**:
|
**诚实差距**:
|
||||||
- **解释性,非权威**:报告**不覆盖** `run.status`(今天 next_status 与报告对 completed/failed 一致)。authoritative 模式(如 `BLOCKED`→置 run `blocked`)改变 Manager 面终态语义,须先过 `scripts/test-runtime-contract.py` + 契约评审——列为后续。
|
- **解释性,非权威**:报告**不覆盖** `run.status`(今天 next_status 与报告对 completed/failed 一致)。authoritative 模式(如 `BLOCKED`→置 run `blocked`)改变 Manager 面终态语义,须先过 `scripts/test-runtime-contract.py` + 契约评审——列为后续。
|
||||||
- **质量/预算/风险输入有条件**:`quality` 依赖 Group B fixture 评分(绑定 fixture 时);`budget`/`usage` 需 run 提供;`risks` 需上游注入。缺失时相应原因不触发,回退 `tasks_completed`(不伪造)。
|
- **质量/预算/风险输入有条件**:`quality` 依赖 Group B fixture 评分(绑定 fixture 时);`budget`/`usage` 需 run 提供;`risks` 需上游注入。缺失时相应原因不触发,回退 `tasks_completed`(不伪造)。
|
||||||
- **消解为保守首版**:不做自动 merge / 自动选胜,硬冲突留待重做或人工。
|
- **消解为保守首版**:`convergence.py` 的冲突消解层不做自动选胜,硬冲突留待重做或人工。注意这与 §7 的**协作聚合**是不同层次:聚合节点(§7.2)对**互补**子产物做 LLM 合并属正常收口,不是「冲突消解」;冲突消解针对的是同路径不一致/测试失败等异常。互补聚合不依赖「选胜」。
|
||||||
- **收敛事件不进 Manager 流**:六个 `convergence.*`/`conflict.*`/`consensus.*` 事件**构建器已实现但不经 `emit_event` 外发**(未在 Manager `agent_callback.go` 注册;与 `swarm.health` 同策略,避免向订阅全部的回调投递未登记事件)。登记后方可启用 Manager 侧发送。`termination_reason` 以**新增可选字段**附在 `timeline.updated`,对旧消费方向后兼容。
|
- **收敛事件不进 Manager 流**:六个 `convergence.*`/`conflict.*`/`consensus.*` 事件**构建器已实现但不经 `emit_event` 外发**(未在 Manager `agent_callback.go` 注册;与 `swarm.health` 同策略,避免向订阅全部的回调投递未登记事件)。登记后方可启用 Manager 侧发送。`termination_reason` 以**新增可选字段**附在 `timeline.updated`,对旧消费方向后兼容。
|
||||||
|
|
||||||
## 7. 蜂后收敛闭环(Queen,agent_swarm#8,`orchestrator/queen.py`)
|
## 7. 收敛 = 协作聚合(不是 best-of-N 选优)
|
||||||
|
|
||||||
fan-out 后的「收口」由**蜂后(Queen)**承担——一个**不执行、不分配任务**的终态仲裁层,把「任务完成即停」升级为「质量驱动收敛」。对齐公开 Swarm 范式的「感知→决策→交互→更新」迭代循环 + 终止「任务完成 ∨ 质量阈值 ∨ 预算/轮次」。
|
> **理念来源**:去中心化蜂群范式(掘金《蜂群智能多 Agent 框架》理念,舆情分析案例)——「**个体简单、群体智能**」。涌现来自**分工协作 + stigmergy 间接协调**,**不是**多个独立解相互竞争后选一个最优。本节据此把旧的「蜂后 best-of-N 选最优」收敛模型**重写为协作聚合模型**。
|
||||||
|
|
||||||
**职责(已实现)**:
|
### 7.0 两种收敛语义(先区分,再选默认)
|
||||||
- **best-of-N 选最优(M2 / SC-5·6·7)**:`queen.aggregate_run` 收集各 agent 候选产物 → `score_candidates`(共享 test 跑各 impl,复用 `sandbox.run_tests`)→ `select_best`(测试通过率最高,纯函数)。winner 标到 `deliverable.selected`,verdict 存 `run.metadata["queen"]`。这是单模型没有的涌现杠杆。
|
|
||||||
- **质量门打回(M3 / SC-9)**:`queen_quality_gate` 在 `run_cross_review` 之后、状态提交之前——最优分 < `QUEEN_ACCEPTANCE_THRESHOLD` 且 `review_cycles < MAX_REVIEW_CYCLES` → `reopen_task` 回灌迭代;达标/触顶 → 收敛。`should_bounce` 为纯函数。
|
|
||||||
- **失败隔离(M4 / SC-12)**:单 task 失败不拖垮整个 run;仅「全失败且无完成产物」才 failed,否则交蜂后/convergence 判定。
|
|
||||||
- **防跨 run 抢夺(P0 / #8)**:`extract_swarm_from_agent` / `_agent_belongs_to_run`——agent 只能竞争/认领自己 run 的 task;`swarm_dispatch` 过滤、`handle_task_bid/yield/takeover` 拒绝跨 run(`cross_run_denied`)。
|
|
||||||
|
|
||||||
**诚实差距**:
|
蜂群里「收口」有两种本质不同的语义,必须分开处理:
|
||||||
- **落 main 待端到端**:SC-7 的「git push 最优产物到产物仓 `main`」需 orchestrator 加 git CLI + 凭据持久化;当前先标记 winner(SWE-bench 语境产物是 patch,选最优即够)。
|
|
||||||
- **质量门默认禁用**:需 operator 设 `QUEEN_ACCEPTANCE_THRESHOLD` 才打回;评分依赖沙箱隔离(`HEICODE_SANDBOX_ISOLATED`),未确认隔离则不评分(unscored ≠ 0,不打回,规则 #9)。
|
| 语义 | 何时产生 | 收敛方式 | 是否本仓默认 |
|
||||||
- **convergence 全 authoritative(SC-8)**:质量驱动收敛已由 `queen_quality_gate` 实现;让 `ConvergenceReport.status` 完全覆盖 `run.status`(BLOCKED 等)动 Manager 终态语义,列为后续。
|
|---|---|---|---|
|
||||||
- **北极星(M5,后续)**:在 SWE-bench Pro 50 上对比单 Opus 4.8 的 resolved 率,客观验证涌现是否超越——前提是上述闭环 + 接入真实代码执行环境。
|
| **互补聚合**(complementary aggregation) | 一个种子任务经**自主分解(#7)**铺成多个**互补**子任务(各做一块,产出不重叠) | **聚合合并**:从共享池读所有子任务产出 → 整合成一份完整产物 | ✅ **主路径** |
|
||||||
- **测试**:`scripts/test-queen.py`(select_best + should_bounce)、`scripts/test-run-isolation.py`(防抢)。
|
| **多解选优**(best-of-N selection) | 同一个**原子任务**被多 Agent **竞争(#8)**各自给出**可互换**的完整解 | 选优:按质量排序取胜出解 | 仅竞争原子任务时的次要路径 |
|
||||||
|
|
||||||
|
> 文章主推**前者**:舆情案例里「情感分析 Agent」「趋势分析 Agent」产出**互补**,由「报告生成 Agent」**汇总所有分析结果**成一份报告——这是合并,不是选优。本仓 fan-out 的产物来自**自主分解的互补子任务**,因此默认走聚合合并。选优只在「同一原子任务有多个可互换完整解」时才适用,属次要路径。
|
||||||
|
|
||||||
|
### 7.1 共享结果池:中间产物不进交付仓
|
||||||
|
|
||||||
|
对齐文章 `SwarmEnvironment.results`(agent 把产出 `append` 进共享池):
|
||||||
|
|
||||||
|
- 每个子任务完成时,产物以 `task.result.files` 写入**共享结果池**(Redis run 状态里的任务结果,对应文章的 `environment.results`),**而不是**直接提交进交付(git)仓库。
|
||||||
|
- 共享池是聚合节点的**唯一输入源**:聚合前没有任何子任务分支落到产物仓。
|
||||||
|
- **产物仓永远只有 `main` 一份,无 per-agent 工作分支**——子任务产出停留在共享池(运行时状态)里,落仓是聚合之后的**单次**动作(§7.3)。这也消除了「artifact 碎在各结果分支、需要事后 merge」的旧问题。
|
||||||
|
|
||||||
|
### 7.2 聚合节点(ResultAggregator,扮演「报告生成 Agent」)
|
||||||
|
|
||||||
|
对齐文章的**专门聚合节点** + `ResultAggregator`(合并结果 / 按质量排序 / 格式转换)+ DAG `mode=wait_all`:
|
||||||
|
|
||||||
|
- **wait_all 栅栏**:聚合节点是 DAG 的汇聚点,**等所有上游互补子任务到达终态**后才触发——即文章舆情案例里报告节点 `mode=wait_all` 等情感/趋势分析全部完成。本仓以「该种子下所有互补子任务均完成」作为 wait_all 条件(无活跃 `pending|assigned|in_progress` 互补子任务)。
|
||||||
|
- **从共享池读取**:聚合节点从 `SwarmEnvironment.results`(共享结果池)取出该种子下所有子任务的 `task.result.files`,**不**重新执行子任务。
|
||||||
|
- **LLM 整合成完整产物**:把互补产出(各子任务的文件/片段)交 LLM **整合**为一份完整、自洽的产物(代码:合并到一致的文件树并消解接口/依赖;文档:汇总成一篇)。这是「报告生成 Agent」职责的实现——`master_agent.synthesize` 作为汇总工具在此复用(仅文字汇总能力;代码整合的边界见 §7.5)。
|
||||||
|
- **跑测试验证**:整合后的完整产物跑 held-out / 共享测试(沙箱见下方门控)做验收,而**不是**对每个候选解分别打分选优。
|
||||||
|
- **单次落 main**:验证通过后,聚合产物**一次性**提交到产物仓 `main`,并发**单一** `artifact.created`(见 §7.4 契约一致性)。
|
||||||
|
|
||||||
|
> `ResultAggregator` 三职责映射:**合并** = LLM 整合互补产出;**按质量排序** = 仅在 §7.0「多解选优」次要路径下对可互换解排序;**格式转换** = 把异构子产物归一为交付格式。主路径用「合并」,不用「排序选优」。
|
||||||
|
|
||||||
|
### 7.3 落 main 的单次提交
|
||||||
|
|
||||||
|
- 聚合 + 验证通过 → orchestrator 把整合后的完整产物**一次** push 到产物仓 `main`。
|
||||||
|
- 全程**无 per-agent 工作分支**、无多分支后置 merge:子任务产出活在共享池,分支层面只有 `main`。
|
||||||
|
- 失败隔离仍适用:个别互补子任务失败不必拖垮整个 run;聚合节点对**已到达共享池**的产出做整合,缺失部分按 `tasks_completed` / 冲突语义如实反映(不伪造,规则 #9)。
|
||||||
|
|
||||||
|
### 7.4 与冻结契约的一致性(FROZEN v1,不得违背)
|
||||||
|
|
||||||
|
聚合收敛流程**完全落在**现有冻结契约内,不新增/不改字段、类型、状态机:
|
||||||
|
|
||||||
|
- **单一 artifact**:聚合后**只**发一个 `artifact.created`(整合产物),扁平字段 `{uri, checksum, task_id, size_bytes?, created_at}` 不变。子任务中间产物**不**各发 `artifact.created`(它们在共享池里,不是交付物)。
|
||||||
|
- **sequence 递增**:聚合相关回调沿用 per-swarm 严格递增 `sequence`,无空洞。
|
||||||
|
- **状态机不变**:聚合是 run 收敛前的内部步骤,不引入新 run/task 状态;终态仍由 §3 终止函数判定(`completed`/`failed` 语义不变)。
|
||||||
|
- **13 类客户端事件冻结**:聚合不新增客户端可见事件类型;wait_all/合并属内部编排,对客户端仅体现为既有 `task.*` 与最终 `artifact.created` + `swarm.completed`。
|
||||||
|
|
||||||
|
### 7.5 诚实差距(规则 #9,不主张未实现的)
|
||||||
|
|
||||||
|
- **本节为目标模型**:上述聚合收敛是按文章理念重写的**协作聚合设计**;与之相对,旧的 **best-of-N 选优**(`queen.aggregate_run` → `score_candidates` → `select_best`,winner 标 `deliverable.selected`)**已废弃为主路径**,仅在 §7.0「多解选优」次要语义下保留(同一原子任务多个可互换解时排序取胜出)。文档以聚合为主路径,不再把选优当作默认收口。
|
||||||
|
- **代码整合非纯文字汇总**:`master_agent.synthesize` 当前仅文字汇总;把互补**代码**子产物整合成一致文件树(消解接口/import/依赖冲突)是更强能力,列为后续实现,不冒充已完成。
|
||||||
|
- **落 main 待端到端**:聚合产物 push 到 `main` 需 orchestrator 的 git CLI + 凭据持久化打通,端到端待验证。
|
||||||
|
- **测试沙箱门控**:聚合后跑验证测试依赖沙箱隔离双门控(`ENABLE_QUALITY_EVAL` + `HEICODE_SANDBOX_ISOLATED`);未确认隔离则不执行测试、不伪造分数(unscored ≠ 0/100,规则 #9)。
|
||||||
|
- **convergence 全 authoritative(后续)**:让 `ConvergenceReport.status` 完全覆盖 `run.status` 动 Manager 终态语义,仍列为后续(同 §6)。
|
||||||
|
- **防跨 run 抢夺(P0 / #8,保留)**:`extract_swarm_from_agent` / `_agent_belongs_to_run`——agent 只能竞争/认领自己 run 的 task;`swarm_dispatch` 过滤、`handle_task_bid/yield/takeover` 拒绝跨 run(`cross_run_denied`)。此为竞争路径的隔离保证,与聚合主路径并存。
|
||||||
|
|||||||
@@ -57,7 +57,12 @@
|
|||||||
- **P6 去中心化自选**:✅ 已建(`main.py: swarm_dispatch` + `ENABLE_SWARM_DISPATCH`):每个空闲 Agent 感知共享池、按 capability+τ+load+budget **自选**最适任务(统一 #9 可解释打分 + #10 信息素 τ),记录可解释 `dispatch.decision_made`。集成测试 `test-swarm-dispatch.py`。cutover 时成为**唯一**派发,删除 greedy/ACO/scored 与各模式开关。
|
- **P6 去中心化自选**:✅ 已建(`main.py: swarm_dispatch` + `ENABLE_SWARM_DISPATCH`):每个空闲 Agent 感知共享池、按 capability+τ+load+budget **自选**最适任务(统一 #9 可解释打分 + #10 信息素 τ),记录可解释 `dispatch.decision_made`。集成测试 `test-swarm-dispatch.py`。cutover 时成为**唯一**派发,删除 greedy/ACO/scored 与各模式开关。
|
||||||
- **P-cutover 切换为唯一路径**:✅ 已完成。`swarm_dispatch` 为唯一派发(删 greedy/ACO/scored 分支 + `scored_matchmake`);`build_seed_task_specs` 为唯一任务创建(删 planner-fallback `build_planner_task_specs`/`planner_fallback_enabled`);删单 critic Master 评审环(`maybe_run_review_cycle`/`review_loop_enabled`),`run_cross_review` 为唯一评审;收敛/提案/竞争/评审原语全部**无条件**(移除全部 `ENABLE_*` 构建开关);`test-workflow-e2e` 改写为 seed→自选→自主分解→执行→收敛全流程(stub agent 改为感知种子后提案分解),`test-merge-smoke` 的 planner/单评审用例改写为 seeder/cross-review;CI 同步。`master_agent.synthesize` 作为汇总工具保留。
|
- **P-cutover 切换为唯一路径**:✅ 已完成。`swarm_dispatch` 为唯一派发(删 greedy/ACO/scored 分支 + `scored_matchmake`);`build_seed_task_specs` 为唯一任务创建(删 planner-fallback `build_planner_task_specs`/`planner_fallback_enabled`);删单 critic Master 评审环(`maybe_run_review_cycle`/`review_loop_enabled`),`run_cross_review` 为唯一评审;收敛/提案/竞争/评审原语全部**无条件**(移除全部 `ENABLE_*` 构建开关);`test-workflow-e2e` 改写为 seed→自选→自主分解→执行→收敛全流程(stub agent 改为感知种子后提案分解),`test-merge-smoke` 的 planner/单评审用例改写为 seeder/cross-review;CI 同步。`master_agent.synthesize` 作为汇总工具保留。
|
||||||
- **P-guard 守卫(最后)**:✅ 已完成。`orchestrator/guard.py: diagnose`(纯函数)检测 `NO_AGENTS_CONNECTED`/`NO_CAPABLE_AGENT`/`DEPENDENCY_DEADLOCK`/`BUDGET_EXHAUSTED`/`SEED_UNDECOMPOSED` 并给出可读原因;`main.py: assess_swarm_health` 在派发环检测到「有待办却本 tick 无任何分派」时诊断受影响 run,存 `run.metadata["health"]` 并在不健康时发内部 `swarm.health` 事件(仅诊断,不改 run)。测试 `test-swarm-guard.py`。**这是唯一保留的非正常路径处理。**
|
- **P-guard 守卫(最后)**:✅ 已完成。`orchestrator/guard.py: diagnose`(纯函数)检测 `NO_AGENTS_CONNECTED`/`NO_CAPABLE_AGENT`/`DEPENDENCY_DEADLOCK`/`BUDGET_EXHAUSTED`/`SEED_UNDECOMPOSED` 并给出可读原因;`main.py: assess_swarm_health` 在派发环检测到「有待办却本 tick 无任何分派」时诊断受影响 run,存 `run.metadata["health"]` 并在不健康时发内部 `swarm.health` 事件(仅诊断,不改 run)。测试 `test-swarm-guard.py`。**这是唯一保留的非正常路径处理。**
|
||||||
- **P-后续 蜂后收敛闭环(agent_swarm#8,已实现 M2–M4)**:fan-out 后的「收口」由 `orchestrator/queen.py` **蜂后**(不执行/不分配的终态仲裁层)承担——best-of-N 选最优(M2)、质量门打回迭代(M3,`QUEEN_ACCEPTANCE_THRESHOLD` 默认禁用)、失败隔离(M4,单 task 失败不拖垮整个 run)、防跨 run 抢夺(P0,`extract_swarm_from_agent`)。设计与诚实差距见 `convergence-protocol.md §7`。**剩余**:SC-7 git push 最优产物落 `main`(待端到端)、SC-8 convergence 全 authoritative、**北极星 M5**(SWE-bench Pro 50 对比单 Opus 4.8 的 resolved 率,客观验证涌现是否超越)。
|
- **P-后续 协作聚合收敛闭环(重写,agent_swarm#8)**:fan-out 后的「收口」是**协作聚合**——对齐去中心化蜂群范式(掘金《蜂群智能多 Agent 框架》舆情案例)的「**个体简单、群体智能**」:涌现来自**分工协作 + stigmergy 间接协调**,不是多个独立解竞争选优。
|
||||||
|
- **中间产物走共享结果池**(对应文章 `SwarmEnvironment.results`):每个互补子任务产出以 `task.result.files` 写入 run 共享态,**不直接进交付(git)仓**;产物仓只有 `main` 一份,**无 per-agent 工作分支**。
|
||||||
|
- **专门聚合节点(ResultAggregator,扮演文章「报告生成 Agent」,DAG `mode=wait_all`)**:等该种子下所有互补子任务到达终态 → 从共享池读全部产出 → LLM **整合**成一份完整产物 → 跑测试验证 → **单次** push 到 `main` 并发**单一** `artifact.created`。
|
||||||
|
- **两种收敛语义**:互补子任务(自主分解 #7 产生)→**聚合合并**(默认主路径);同一原子任务多个可互换完整解(竞争 #8)→才用**选优**(次要路径)。文章主推前者。
|
||||||
|
- **⚠️ 旧 best-of-N 选优已废弃为主路径**:`orchestrator/queen.py` 的 `aggregate_run`/`score_candidates`/`select_best`(winner 标 `deliverable.selected`)不再作为默认收口,仅保留于「多解选优」次要语义。质量门打回迭代(`QUEEN_ACCEPTANCE_THRESHOLD` 默认禁用)、失败隔离(单 task 失败不拖垮 run)、防跨 run 抢夺(`extract_swarm_from_agent`)仍保留。
|
||||||
|
- 完整设计、与冻结契约一致性、诚实差距见 `convergence-protocol.md §7`。**剩余**:聚合产物 git push 落 `main`(待端到端)、代码级互补整合(非纯文字汇总)、convergence 全 authoritative、**北极星 M5**(SWE-bench Pro 50 对比单 Opus 4.8 的 resolved 率,客观验证涌现是否超越)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# heicode-test 云部署清单(SWE-bench 涌现评测 · 生成段)—— 改自 orchestrator-local.yaml。
|
||||||
|
# 与本地差异:ACR 镜像(非 local)、Redis 集群(REDIS_URL+REDIS_CLUSTER)、隔离沙箱双门控开、
|
||||||
|
# SWARM_EMIT_PATCH 产 diff(不落 main)、Cosmos/Blob 用测试专属 database/container 与生产区分、
|
||||||
|
# Service=LoadBalancer 公网裸入(本地测试式,无鉴权)。
|
||||||
|
# 所有凭据走 Secret(secretKeyRef),manifest 内零明文。创建 Secret 见文件末尾注释(你自行执行)。
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: orchestrator-public
|
||||||
|
namespace: swarm-system
|
||||||
|
labels: { app: orchestrator }
|
||||||
|
spec:
|
||||||
|
type: LoadBalancer # 公网入口(无鉴权,仅限隔离测试集群);建议加 loadBalancerSourceRanges 限 IP
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8000
|
||||||
|
name: http
|
||||||
|
selector: { app: orchestrator }
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: orchestrator-service # 集群内 WS 入口(agent pod 连这个),保持与 local 同名
|
||||||
|
namespace: swarm-system
|
||||||
|
labels: { app: orchestrator }
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
ports:
|
||||||
|
- port: 8000
|
||||||
|
targetPort: 8000
|
||||||
|
name: http
|
||||||
|
selector: { app: orchestrator }
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: orchestrator
|
||||||
|
namespace: swarm-system
|
||||||
|
labels: { app: orchestrator }
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels: { app: orchestrator }
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: { app: orchestrator }
|
||||||
|
spec:
|
||||||
|
serviceAccountName: swarm-orchestrator
|
||||||
|
# ACR 拉取:AKS 已 attach ACR 时无需此项;否则用 imagePullSecret。
|
||||||
|
# imagePullSecrets: [{ name: acr-pull }]
|
||||||
|
containers:
|
||||||
|
- name: orchestrator
|
||||||
|
image: REPLACE_ACR.azurecr.io/swarm-orchestrator:heicode-test # ← 填 ACR
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
# --- Redis 集群(权威存储,非 FAKE)---
|
||||||
|
# REDIS_URL 形如 rediss://:<password>@<host>:<port>/0;含密码 → 走 Secret。
|
||||||
|
- name: REDIS_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: { name: swarm-redis, key: REDIS_URL }
|
||||||
|
- name: REDIS_CLUSTER
|
||||||
|
value: "1" # Azure Redis Enterprise OSSCluster 协议需要
|
||||||
|
- name: LOG_LEVEL
|
||||||
|
value: "INFO"
|
||||||
|
# --- agent pod 启动(ACR 镜像)---
|
||||||
|
- name: AGENT_LAUNCH_BACKEND
|
||||||
|
value: "kubernetes"
|
||||||
|
- name: AGENT_POD_IMAGE
|
||||||
|
value: "REPLACE_ACR.azurecr.io/swarm-agent:heicode-test" # ← 填 ACR
|
||||||
|
- name: AGENT_POD_NAMESPACE
|
||||||
|
value: "swarm-system"
|
||||||
|
- name: ORCHESTRATOR_PUBLIC_URL
|
||||||
|
value: "ws://orchestrator-service.swarm-system.svc.cluster.local:8000"
|
||||||
|
# --- 模型网关(OpenAI 兼容)= 你的叮嘱:qwen3.7-max @ api.heicode.cc ---
|
||||||
|
- name: AGENT_OPENAI_API_BASE
|
||||||
|
value: "https://api.heicode.cc/v1"
|
||||||
|
- name: OPENAI_API_BASE
|
||||||
|
value: "https://api.heicode.cc/v1"
|
||||||
|
- name: OPENAI_MODEL
|
||||||
|
value: "qwen3.7-max"
|
||||||
|
- name: MASTER_REVIEW_MODEL # 聚合器整合也用同模型(锁模型)
|
||||||
|
value: "qwen3.7-max"
|
||||||
|
- name: OPENAI_API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: { name: swarm-model-key, key: OPENAI_API_KEY }
|
||||||
|
# --- agent 池窗口 ---
|
||||||
|
- name: AGENT_LAUNCH_MIN_POOL
|
||||||
|
value: "16"
|
||||||
|
- name: AGENT_LAUNCH_POOL_SIZE
|
||||||
|
value: "16"
|
||||||
|
- name: AGENT_LAUNCH_MAX_POOL
|
||||||
|
value: "64"
|
||||||
|
- name: MAX_AGENTS_PER_USER
|
||||||
|
value: "64"
|
||||||
|
- name: AGENT_PROPOSAL_BUDGET
|
||||||
|
value: "12"
|
||||||
|
# --- 隔离沙箱双门控(heicode-test 已确认隔离集群)---
|
||||||
|
- name: ENABLE_QUALITY_EVAL
|
||||||
|
value: "1"
|
||||||
|
- name: HEICODE_SANDBOX_ISOLATED
|
||||||
|
value: "1"
|
||||||
|
# --- SWE-bench 评测:产 unified diff,不 push 到 main ---
|
||||||
|
- name: SWARM_EMIT_PATCH
|
||||||
|
value: "1"
|
||||||
|
# --- 采集遥测:与生产 Cosmos/Blob 共用底座,但用测试专属 database/container 区分 ---
|
||||||
|
- name: BENCHMARK_COSMOS_DATABASE
|
||||||
|
value: "benchmark-heicodetest" # 区分键(生产默认 benchmark)
|
||||||
|
- name: BENCHMARK_COSMOS_CONTAINER
|
||||||
|
value: "selfcert-heicodetest" # 区分键(生产默认 selfcert)
|
||||||
|
- name: BENCHMARK_BLOB_CONTAINER
|
||||||
|
value: "selfcert-heicodetest" # 区分键(生产默认 benchmark-selfcert)
|
||||||
|
- name: BENCHMARK_COSMOS_CONNECTION_STRING
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: { name: swarm-telemetry, key: COSMOS_CONNECTION_STRING, optional: true }
|
||||||
|
- name: AZURE_STORAGE_CONNECTION_STRING
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: { name: swarm-telemetry, key: AZURE_STORAGE_CONNECTION_STRING, optional: true }
|
||||||
|
resources:
|
||||||
|
requests: { memory: "512Mi", cpu: "300m" }
|
||||||
|
limits: { memory: "1Gi", cpu: "1" }
|
||||||
|
livenessProbe:
|
||||||
|
httpGet: { path: /health, port: 8000 }
|
||||||
|
initialDelaySeconds: 20
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet: { path: /health, port: 8000 }
|
||||||
|
initialDelaySeconds: 8
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 创建 Secret(你自行执行,凭据不进任何文件/日志;用 ! 前缀在会话里跑):
|
||||||
|
# kubectl -n swarm-system create secret generic swarm-model-key \
|
||||||
|
# --from-literal=OPENAI_API_KEY=<模型KEY>
|
||||||
|
# kubectl -n swarm-system create secret generic swarm-redis \
|
||||||
|
# --from-literal=REDIS_URL='rediss://:<password>@<host>:<port>/0'
|
||||||
|
# # 遥测(可选,不重要时可跳过;跳过则 export 自动 no-op):
|
||||||
|
# kubectl -n swarm-system create secret generic swarm-telemetry \
|
||||||
|
# --from-literal=COSMOS_CONNECTION_STRING='<...>' \
|
||||||
|
# --from-literal=AZURE_STORAGE_CONNECTION_STRING='<...>'
|
||||||
|
# 部署:
|
||||||
|
# kubectl create -f k8s/rbac/
|
||||||
|
# kubectl create -f k8s/orchestrator-heicode-test.yaml
|
||||||
|
# kubectl -n swarm-system get svc orchestrator-public -w # 等 EXTERNAL-IP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
+40
-65
@@ -37,7 +37,7 @@ from . import autonomous_tasks as autonomous_mod
|
|||||||
from . import task_competition as competition_mod
|
from . import task_competition as competition_mod
|
||||||
from . import cross_review as cross_review_mod
|
from . import cross_review as cross_review_mod
|
||||||
from . import guard as guard_mod
|
from . import guard as guard_mod
|
||||||
from . import queen as queen_mod
|
from . import result_aggregator
|
||||||
from . import agent_launcher
|
from . import agent_launcher
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -698,12 +698,29 @@ async def refresh_swarm_run_status(run):
|
|||||||
if next_status == "completed":
|
if next_status == "completed":
|
||||||
if await run_cross_review(run, tasks):
|
if await run_cross_review(run, tasks):
|
||||||
return
|
return
|
||||||
# Queen quality gate (M3/SC-9): score the candidates and, if the best fails the acceptance
|
# 协作聚合质量门(①合并 ②沙箱验证 ③不达标打回):聚合节点合并各 agent 互补产出 → 跑测试验证 →
|
||||||
# bar (and the review-cycle cap isn't hit), send work BACK for another round instead of
|
# 不达标且未到轮次上限则 reopen 打回(run 保持 running,下轮重做),达标/达上限则落 main。
|
||||||
# declaring success on substandard output. Disabled by default (no threshold) — keeps
|
# 阈值未设(AGGREGATE_ACCEPTANCE_THRESHOLD)时不打回,保持原完成语义。
|
||||||
# current completion semantics until an operator sets QUEEN_ACCEPTANCE_THRESHOLD.
|
agg_cycles = int(run.metadata.get("review_cycles", 0) or 0)
|
||||||
if await queen_quality_gate(run, tasks):
|
agg_max = int(os.getenv("MAX_REVIEW_CYCLES", "2") or 2)
|
||||||
|
agg = await result_aggregator.finalize_run(
|
||||||
|
run, tasks, run.objective, _accept_threshold(run), agg_cycles, agg_max)
|
||||||
|
run.metadata["aggregation"] = agg
|
||||||
|
if agg.get("decision") == "bounce":
|
||||||
|
from .quality import collect_generated_files
|
||||||
|
reopened = 0
|
||||||
|
for t in tasks:
|
||||||
|
if collect_generated_files([t]).get("impl"):
|
||||||
|
if await task_queue.reopen_task(t.task_id):
|
||||||
|
reopened += 1
|
||||||
|
run.metadata["review_cycles"] = agg_cycles + 1
|
||||||
|
run.metadata["rework_reopens"] = int(run.metadata.get("rework_reopens", 0) or 0) + reopened
|
||||||
|
await swarm_runtime.save_run(run)
|
||||||
|
logger.info("aggregation: bounced run %s — reopened %d for rework (cycle %d, pass_rate=%s)",
|
||||||
|
run.swarm_id, reopened, agg_cycles + 1,
|
||||||
|
(agg.get("validation") or {}).get("pass_rate"))
|
||||||
return
|
return
|
||||||
|
await swarm_runtime.save_run(run)
|
||||||
|
|
||||||
if run.status == next_status:
|
if run.status == next_status:
|
||||||
return
|
return
|
||||||
@@ -734,27 +751,15 @@ async def refresh_swarm_run_status(run):
|
|||||||
results = {t.task_id: {"result": parse_task_result(t) or {}} for t in completed}
|
results = {t.task_id: {"result": parse_task_result(t) or {}} for t in completed}
|
||||||
final_summary = await master_agent.synthesize(run.objective, results)
|
final_summary = await master_agent.synthesize(run.objective, results)
|
||||||
run.metadata["final_summary"] = final_summary
|
run.metadata["final_summary"] = final_summary
|
||||||
# Queen (agent_swarm#8/#12): aggregate the fan-out agents' artifacts, score each by running
|
# 聚合产物已由上面的协作聚合质量门产出并落 main(run.metadata['aggregation']);折进 deliverable。
|
||||||
# its impl against the swarm's shared tests, and SELECT the single best (best-of-N). Records
|
agg = run.metadata.get("aggregation") or {}
|
||||||
# the verdict on the run and marks the winner on the deliverable so the result is one
|
if isinstance(deliverable, dict) and agg.get("finalized"):
|
||||||
# coherent pick, not N scattered branches. Best-effort: never breaks the terminal path.
|
deliverable["aggregated_files"] = agg.get("files")
|
||||||
# The Queen verdict was computed by the quality gate above (run.metadata['queen']); mark the
|
deliverable["merged_file_count"] = agg.get("merged_file_count")
|
||||||
# selected winner on the deliverable so the result is one coherent pick, not N branches.
|
promo = agg.get("promotion") or {}
|
||||||
queen_summary = run.metadata.get("queen") or {}
|
if promo.get("promoted"):
|
||||||
winner = queen_summary.get("winner") if isinstance(queen_summary, dict) else None
|
deliverable["promoted_to_main"] = {
|
||||||
if isinstance(deliverable, dict) and winner:
|
"branch": promo.get("branch"), "commit_sha": promo.get("commit_sha")}
|
||||||
deliverable["selected"] = winner
|
|
||||||
deliverable["candidate_count"] = queen_summary.get("candidate_count")
|
|
||||||
# SC-7: promote the winning artifact to the repo's main → one coherent deliverable on
|
|
||||||
# main, not N scattered agent branches. No-op when the run has no git grant.
|
|
||||||
try:
|
|
||||||
promo = await queen_mod.promote_to_main(run, tasks, winner.get("task_id"))
|
|
||||||
run.metadata["queen_promotion"] = promo
|
|
||||||
if isinstance(deliverable, dict) and promo.get("promoted"):
|
|
||||||
deliverable["promoted_to_main"] = {
|
|
||||||
"branch": promo.get("branch"), "commit_sha": promo.get("commit_sha")}
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("queen promote_to_main failed for run %s: %s", run.swarm_id, exc)
|
|
||||||
await swarm_runtime.save_run(run)
|
await swarm_runtime.save_run(run)
|
||||||
|
|
||||||
# Benchmark Group B: grade the run's generated code against its held-out fixture tests in
|
# Benchmark Group B: grade the run's generated code against its held-out fixture tests in
|
||||||
@@ -1175,48 +1180,18 @@ async def run_cross_review(run, tasks) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _queen_threshold(run) -> Optional[float]:
|
def _accept_threshold(run) -> Optional[float]:
|
||||||
"""Queen acceptance bar (pass_rate 0-100): run.metadata override → QUEEN_ACCEPTANCE_THRESHOLD
|
"""协作聚合验收阈值(pass_rate 0-100):run.metadata 覆盖 → AGGREGATE_ACCEPTANCE_THRESHOLD env →
|
||||||
env → None (gate disabled). None keeps the current task-completion completion semantics."""
|
None(门禁用)。None 保持当前完成语义(不打回);设了才启用质量驱动的"不达标打回迭代"。"""
|
||||||
raw = (run.metadata or {}).get("queen_acceptance_threshold")
|
raw = (run.metadata or {}).get("aggregate_acceptance_threshold")
|
||||||
if raw is None:
|
if raw is None:
|
||||||
raw = os.getenv("QUEEN_ACCEPTANCE_THRESHOLD")
|
raw = os.getenv("AGGREGATE_ACCEPTANCE_THRESHOLD")
|
||||||
try:
|
try:
|
||||||
return float(raw) if raw not in (None, "") else None
|
return float(raw) if raw not in (None, "") else None
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def queen_quality_gate(run, tasks) -> bool:
|
|
||||||
"""M3/SC-9: the Queen scores the fan-out candidates and, if the best fails the acceptance bar
|
|
||||||
and the review-cycle cap isn't hit, sends work BACK (reopen impl tasks) for another round
|
|
||||||
instead of declaring success on substandard output. Stores the verdict on run.metadata['queen']
|
|
||||||
(reused by the deliverable). Returns True if it reopened (caller keeps the run RUNNING).
|
|
||||||
Best-effort: never raises, never bounces on a score it couldn't compute (org rule #9)."""
|
|
||||||
try:
|
|
||||||
summary = await queen_mod.aggregate_run(run, tasks)
|
|
||||||
run.metadata["queen"] = summary
|
|
||||||
cycles = int(run.metadata.get("review_cycles", 0) or 0)
|
|
||||||
max_cycles = int(os.getenv("MAX_REVIEW_CYCLES", "2") or 2)
|
|
||||||
if not queen_mod.should_bounce(summary, _queen_threshold(run), cycles, max_cycles):
|
|
||||||
await swarm_runtime.save_run(run)
|
|
||||||
return False
|
|
||||||
reopened = 0
|
|
||||||
for c in summary.get("candidates", []):
|
|
||||||
if await task_queue.reopen_task(c["task_id"]):
|
|
||||||
reopened += 1
|
|
||||||
run.metadata["review_cycles"] = cycles + 1
|
|
||||||
# SC-10: tally quality-driven reopens for P_rework (reopen_task doesn't bump retry_count).
|
|
||||||
run.metadata["rework_reopens"] = int(run.metadata.get("rework_reopens", 0) or 0) + reopened
|
|
||||||
await swarm_runtime.save_run(run)
|
|
||||||
logger.info("queen: quality gate bounced run %s — reopened %d for rework (cycle %d)",
|
|
||||||
run.swarm_id, reopened, cycles + 1)
|
|
||||||
return reopened > 0
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("queen quality gate failed for run %s: %s", run.swarm_id, exc)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _historical_success_map(agent_role: str, agent_ids) -> Dict[str, float]:
|
async def _historical_success_map(agent_role: str, agent_ids) -> Dict[str, float]:
|
||||||
"""τ (decision_engine pheromone) per agent, normalized to [0,1] for arbitration."""
|
"""τ (decision_engine pheromone) per agent, normalized to [0,1] for arbitration."""
|
||||||
out: Dict[str, float] = {}
|
out: Dict[str, float] = {}
|
||||||
@@ -1237,7 +1212,7 @@ async def handle_task_bid(agent_id: str, message: Dict[str, Any]) -> Dict[str, A
|
|||||||
if not run or not task_id:
|
if not run or not task_id:
|
||||||
return {"recorded": False, "reason": "no_run"}
|
return {"recorded": False, "reason": "no_run"}
|
||||||
if not _agent_belongs_to_run(agent_id, run):
|
if not _agent_belongs_to_run(agent_id, run):
|
||||||
logger.warning("queen: rejected cross-run bid — agent %s ∉ run %s (task %s)",
|
logger.warning("run-boundary: rejected cross-run bid — agent %s ∉ run %s (task %s)",
|
||||||
agent_id, run.swarm_id, task_id)
|
agent_id, run.swarm_id, task_id)
|
||||||
return {"recorded": False, "reason": "cross_run_denied"}
|
return {"recorded": False, "reason": "cross_run_denied"}
|
||||||
bid = competition_mod.TaskBid(
|
bid = competition_mod.TaskBid(
|
||||||
@@ -1296,7 +1271,7 @@ async def handle_task_yield(agent_id: str, message: Dict[str, Any]) -> Dict[str,
|
|||||||
if not run or not task_id:
|
if not run or not task_id:
|
||||||
return {"released": False, "reason": "no_run"}
|
return {"released": False, "reason": "no_run"}
|
||||||
if not _agent_belongs_to_run(agent_id, run):
|
if not _agent_belongs_to_run(agent_id, run):
|
||||||
logger.warning("queen: rejected cross-run yield — agent %s ∉ run %s (task %s)",
|
logger.warning("run-boundary: rejected cross-run yield — agent %s ∉ run %s (task %s)",
|
||||||
agent_id, run.swarm_id, task_id)
|
agent_id, run.swarm_id, task_id)
|
||||||
return {"released": False, "reason": "cross_run_denied"}
|
return {"released": False, "reason": "cross_run_denied"}
|
||||||
yield_msg = competition_mod.TaskYield(
|
yield_msg = competition_mod.TaskYield(
|
||||||
@@ -1325,7 +1300,7 @@ async def handle_task_takeover(agent_id: str, message: Dict[str, Any]) -> Dict[s
|
|||||||
if not task or not run:
|
if not task or not run:
|
||||||
return {"taken_over": False, "reason": "no_task"}
|
return {"taken_over": False, "reason": "no_task"}
|
||||||
if not _agent_belongs_to_run(agent_id, run):
|
if not _agent_belongs_to_run(agent_id, run):
|
||||||
logger.warning("queen: rejected cross-run takeover — agent %s ∉ run %s (task %s)",
|
logger.warning("run-boundary: rejected cross-run takeover — agent %s ∉ run %s (task %s)",
|
||||||
agent_id, run.swarm_id, task_id)
|
agent_id, run.swarm_id, task_id)
|
||||||
return {"taken_over": False, "reason": "cross_run_denied"}
|
return {"taken_over": False, "reason": "cross_run_denied"}
|
||||||
incumbent_id = task.assigned_agent_id
|
incumbent_id = task.assigned_agent_id
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
"""Queen — the swarm's terminal arbitration layer (agent_swarm#8/#12, Queen role).
|
|
||||||
|
|
||||||
The Queen does NOT execute or dispatch tasks. She only judges the FINAL result of a run:
|
|
||||||
- aggregate the candidate artifacts produced by the fan-out agents,
|
|
||||||
- score each candidate by running its tests in the sandbox,
|
|
||||||
- SELECT the single best candidate (best-of-N — the emergence lever a single model lacks),
|
|
||||||
- (M2) promote the winner to the artifact repo's `main` as one coherent deliverable,
|
|
||||||
- (M3) if no candidate meets the quality bar, send work BACK for another round (reopen_task),
|
|
||||||
- (P0, in main.py) arbitrate competition and reject cross-run grabs.
|
|
||||||
|
|
||||||
This module is intentionally import-light and does NOT import orchestrator.main (avoids an import
|
|
||||||
cycle): `aggregate_run` returns a plain summary dict; the caller (refresh_swarm_run_status) folds it
|
|
||||||
into the run's deliverable. `select_best` is a pure function so it can be unit-tested without I/O.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Candidate:
|
|
||||||
"""One agent's contribution to the run, with its sandbox score."""
|
|
||||||
task_id: str
|
|
||||||
agent_id: Optional[str]
|
|
||||||
impl_files: List[Any] = field(default_factory=list) # SandboxFile (impl)
|
|
||||||
score: Optional[float] = None # pass_rate 0-100, None = not scored
|
|
||||||
total: int = 0
|
|
||||||
passed: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
def select_best(candidates: List[Candidate]) -> Optional[Candidate]:
|
|
||||||
"""Pure selection: the candidate with the highest test pass_rate wins. Unscored (None)
|
|
||||||
candidates rank below any scored one; ties and all-unscored fall back to the first candidate
|
|
||||||
that actually carries impl files (deterministic — preserves input order). None if no candidate
|
|
||||||
has impl files."""
|
|
||||||
with_impl = [c for c in candidates if c.impl_files]
|
|
||||||
if not with_impl:
|
|
||||||
return None
|
|
||||||
scored = [c for c in with_impl if c.score is not None]
|
|
||||||
if scored:
|
|
||||||
# max by score; stable on ties (first in input order wins)
|
|
||||||
return max(scored, key=lambda c: (c.score, c.passed, -with_impl.index(c)))
|
|
||||||
return with_impl[0]
|
|
||||||
|
|
||||||
|
|
||||||
def should_bounce(summary: Dict[str, Any], threshold: Optional[float],
|
|
||||||
cycles: int, max_cycles: int) -> bool:
|
|
||||||
"""Pure decision (M3/SC-9): should the run be sent BACK for another round?
|
|
||||||
|
|
||||||
True only when the best candidate WAS scored, fell BELOW `threshold`, and the review-cycle cap
|
|
||||||
isn't hit yet. False (= accept / converge) when: no threshold (gate disabled), not scored
|
|
||||||
(honest — don't bounce on a score we couldn't compute, rule #9), already meets the bar, or the
|
|
||||||
cap is reached (convergence then marks MAX_ROUNDS_REACHED on the best-so-far)."""
|
|
||||||
if threshold is None:
|
|
||||||
return False
|
|
||||||
winner = summary.get("winner")
|
|
||||||
if not winner or winner.get("score") is None:
|
|
||||||
return False
|
|
||||||
if winner["score"] >= threshold:
|
|
||||||
return False
|
|
||||||
return cycles < max_cycles
|
|
||||||
|
|
||||||
|
|
||||||
def _result_of(task) -> Dict[str, Any]:
|
|
||||||
"""task.result → dict (JSON string or dict), {} on failure. Mirrors main.parse_task_result
|
|
||||||
without importing main."""
|
|
||||||
import json
|
|
||||||
raw = getattr(task, "result", None)
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
return raw
|
|
||||||
if isinstance(raw, str):
|
|
||||||
try:
|
|
||||||
return json.loads(raw)
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def collect_candidates(tasks) -> List[Candidate]:
|
|
||||||
"""One Candidate per completed task that produced impl files. Reuses quality.collect_generated_files
|
|
||||||
(per task) to split impl vs test_*.py, so the Queen scores each agent's implementation."""
|
|
||||||
from .quality import collect_generated_files
|
|
||||||
out: List[Candidate] = []
|
|
||||||
for t in tasks:
|
|
||||||
files = collect_generated_files([t])
|
|
||||||
impl = files.get("impl") or []
|
|
||||||
if not impl:
|
|
||||||
continue
|
|
||||||
out.append(Candidate(
|
|
||||||
task_id=getattr(t, "task_id", "?"),
|
|
||||||
agent_id=getattr(t, "assigned_agent_id", None) or getattr(t, "agent_role", None),
|
|
||||||
impl_files=impl,
|
|
||||||
))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _shared_tests(tasks) -> List[Any]:
|
|
||||||
"""All test_*.py the swarm produced this run — the shared yardstick the Queen scores impls against."""
|
|
||||||
from .quality import collect_generated_files
|
|
||||||
seen: Dict[str, Any] = {}
|
|
||||||
for t in tasks:
|
|
||||||
for tf in (collect_generated_files([t]).get("agent_tests") or []):
|
|
||||||
seen[tf.path] = tf # de-dup by path, last writer wins
|
|
||||||
return list(seen.values())
|
|
||||||
|
|
||||||
|
|
||||||
async def score_candidates(candidates: List[Candidate], tests: List[Any]) -> None:
|
|
||||||
"""Score each candidate in-place: run its impl against the shared test set in the sandbox.
|
|
||||||
|
|
||||||
FAIL-CLOSED + honest: if isolation isn't confirmed (sandbox.assert_isolated would refuse) or
|
|
||||||
there are no tests, scores stay None (not 0 — 'not scored' != 'scored zero', org rule #9)."""
|
|
||||||
from .sandbox import run_tests, isolation_confirmed
|
|
||||||
if not tests or not isolation_confirmed():
|
|
||||||
return
|
|
||||||
for c in candidates:
|
|
||||||
try:
|
|
||||||
res = await asyncio.to_thread(run_tests, c.impl_files, tests)
|
|
||||||
c.score = res.pass_rate
|
|
||||||
c.total = res.total
|
|
||||||
c.passed = res.passed
|
|
||||||
except Exception as exc: # a scoring error leaves this candidate unscored, never crashes
|
|
||||||
logger.warning("queen: scoring candidate %s failed: %s", c.task_id, exc)
|
|
||||||
|
|
||||||
|
|
||||||
async def aggregate_run(run, tasks) -> Dict[str, Any]:
|
|
||||||
"""Queen entry point at run terminal. Collect → score → select best. Returns a summary dict the
|
|
||||||
caller folds into the deliverable. Never raises (best-effort; a failure leaves winner=None and
|
|
||||||
the caller keeps the legacy per-task deliverable)."""
|
|
||||||
try:
|
|
||||||
candidates = collect_candidates(tasks)
|
|
||||||
if not candidates:
|
|
||||||
return {"winner": None, "candidate_count": 0, "reason": "no_impl_artifacts"}
|
|
||||||
tests = _shared_tests(tasks)
|
|
||||||
await score_candidates(candidates, tests)
|
|
||||||
best = select_best(candidates)
|
|
||||||
return {
|
|
||||||
"winner": (
|
|
||||||
{"task_id": best.task_id, "agent_id": best.agent_id,
|
|
||||||
"score": best.score, "passed": best.passed, "total": best.total}
|
|
||||||
if best else None
|
|
||||||
),
|
|
||||||
"candidate_count": len(candidates),
|
|
||||||
"scored": sum(1 for c in candidates if c.score is not None),
|
|
||||||
"candidates": [
|
|
||||||
{"task_id": c.task_id, "agent_id": c.agent_id, "score": c.score}
|
|
||||||
for c in candidates
|
|
||||||
],
|
|
||||||
}
|
|
||||||
except Exception as exc: # Queen never breaks the run's terminal path
|
|
||||||
logger.warning("queen: aggregate_run failed for %s: %s", getattr(run, "swarm_id", "?"), exc)
|
|
||||||
return {"winner": None, "candidate_count": 0, "reason": f"error:{exc!r}"}
|
|
||||||
|
|
||||||
|
|
||||||
def _auth_url(repo_url: str, user: Optional[str], pw: Optional[str]) -> str:
|
|
||||||
"""Embed credentials into an http(s) clone URL. URL-encodes the password (handles '@','/', etc).
|
|
||||||
The result is NEVER logged."""
|
|
||||||
import urllib.parse
|
|
||||||
if not user or not pw or "://" not in repo_url:
|
|
||||||
return repo_url
|
|
||||||
scheme, rest = repo_url.split("://", 1)
|
|
||||||
return f"{scheme}://{urllib.parse.quote(user, safe='')}:{urllib.parse.quote(pw, safe='')}@{rest}"
|
|
||||||
|
|
||||||
|
|
||||||
def _git_promote(env: Dict[str, str], base_branch: str, files: List[Any], swarm_id: str) -> Dict[str, Any]:
|
|
||||||
"""Sync git: clone base_branch → overwrite with winner files → commit → push base_branch.
|
|
||||||
Blocking (run via asyncio.to_thread). Credentials live only in the clone URL, never logged."""
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
repo_url = env["GIT_REPO_URL"]
|
|
||||||
auth_url = _auth_url(repo_url, env.get("GIT_USERNAME"), env.get("GIT_PASSWORD"))
|
|
||||||
workdir = tempfile.mkdtemp(prefix="queen-promote-")
|
|
||||||
repo_dir = os.path.join(workdir, "repo")
|
|
||||||
|
|
||||||
def git(*args, cwd=None):
|
|
||||||
return subprocess.run(["git", *args], cwd=cwd or workdir,
|
|
||||||
capture_output=True, text=True, timeout=180)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if git("clone", "--depth", "1", "--branch", base_branch, auth_url, "repo").returncode != 0:
|
|
||||||
return {"promoted": False, "reason": "clone_failed"}
|
|
||||||
for f in files:
|
|
||||||
dest = os.path.join(repo_dir, f.path)
|
|
||||||
os.makedirs(os.path.dirname(dest) or repo_dir, exist_ok=True)
|
|
||||||
with open(dest, "w", encoding="utf-8") as fh:
|
|
||||||
fh.write(f.content)
|
|
||||||
git("config", "user.email", "queen@heicode.swarm", cwd=repo_dir)
|
|
||||||
git("config", "user.name", "HeiCode Queen", cwd=repo_dir)
|
|
||||||
git("add", "-A", cwd=repo_dir)
|
|
||||||
c = git("commit", "-m", f"queen: promote best swarm artifact ({swarm_id})", cwd=repo_dir)
|
|
||||||
if c.returncode != 0:
|
|
||||||
return {"promoted": False, "reason": "no_changes"}
|
|
||||||
if git("push", "origin", base_branch, cwd=repo_dir).returncode != 0:
|
|
||||||
return {"promoted": False, "reason": "push_failed"}
|
|
||||||
sha = git("rev-parse", "HEAD", cwd=repo_dir).stdout.strip()
|
|
||||||
return {"promoted": True, "commit_sha": sha, "branch": base_branch}
|
|
||||||
except Exception as exc:
|
|
||||||
return {"promoted": False, "reason": f"error:{exc!r}"}
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(workdir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def promote_to_main(run, tasks, winner_task_id: str) -> Dict[str, Any]:
|
|
||||||
"""SC-7: push the winning candidate's files to the artifact repo's base branch (main), so the
|
|
||||||
run delivers ONE coherent artifact, not N scattered agent branches. Credentials are resolved
|
|
||||||
from the grant's secret_ref at call time (never stored/logged). Best-effort: never raises."""
|
|
||||||
try:
|
|
||||||
grant = (run.metadata or {}).get("git_grant") or {}
|
|
||||||
repo_url = grant.get("repo_url")
|
|
||||||
if not repo_url:
|
|
||||||
return {"promoted": False, "reason": "no_git_grant"}
|
|
||||||
wtask = next((t for t in tasks if getattr(t, "task_id", None) == winner_task_id), None)
|
|
||||||
if wtask is None:
|
|
||||||
return {"promoted": False, "reason": "winner_not_found"}
|
|
||||||
from .quality import collect_generated_files
|
|
||||||
cf = collect_generated_files([wtask])
|
|
||||||
files = (cf.get("impl") or []) + (cf.get("agent_tests") or [])
|
|
||||||
if not files:
|
|
||||||
return {"promoted": False, "reason": "winner_no_files"}
|
|
||||||
from .agent_launcher import resolve_git_grant
|
|
||||||
env = resolve_git_grant({"resource_grants": [{
|
|
||||||
"resource_type": "git", "secret_ref": grant.get("secret_ref"),
|
|
||||||
"metadata": {"repo_url": repo_url, "base_branch": grant.get("base_branch", "main")},
|
|
||||||
}]})
|
|
||||||
if not env or "GIT_REPO_URL" not in env:
|
|
||||||
return {"promoted": False, "reason": "grant_unresolved"}
|
|
||||||
return await asyncio.to_thread(_git_promote, env, grant.get("base_branch", "main"),
|
|
||||||
files, getattr(run, "swarm_id", "?"))
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("queen: promote_to_main failed for %s: %s", getattr(run, "swarm_id", "?"), exc)
|
|
||||||
return {"promoted": False, "reason": f"error:{exc!r}"}
|
|
||||||
@@ -9,6 +9,9 @@ prometheus-client==0.20.0
|
|||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
# dev/CI only: in-memory Redis emulator for the gated REDIS_FAKE/ALLOW_MEMORY_STORE fallback
|
# dev/CI only: in-memory Redis emulator for the gated REDIS_FAKE/ALLOW_MEMORY_STORE fallback
|
||||||
fakeredis==2.26.1
|
fakeredis==2.26.1
|
||||||
|
# sandbox test runner: pytest natively runs the test styles agents produce (pytest-style classes,
|
||||||
|
# fixtures, parametrize, marks) — the in-pod harness (sandbox_runner.py) prefers it, stdlib fallback.
|
||||||
|
pytest==8.3.3
|
||||||
opentelemetry-api==1.24.0
|
opentelemetry-api==1.24.0
|
||||||
opentelemetry-sdk==1.24.0
|
opentelemetry-sdk==1.24.0
|
||||||
opentelemetry-exporter-otlp==1.24.0
|
opentelemetry-exporter-otlp==1.24.0
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
"""ResultAggregator — 蜂群的终态聚合节点(对应掘金文章的"报告生成 Agent",mode=wait_all)。
|
||||||
|
|
||||||
|
设计依据 https://juejin.cn/post/7603575399255949352 的协作聚合模型,取代旧的"蜂后 best-of-N 选优":
|
||||||
|
- 中间产物存**共享结果池**(task.result.files,经 collect_generated_files 读取),**不进 git 交付仓**;
|
||||||
|
- run 终态(所有子任务完成)→ 从共享池**收集所有 agent 的互补产出**;
|
||||||
|
- 同一文件被多个 agent 各写一部分时,**整合成一份完整产物**:优先 LLM(协作合并),无 LLM 时用
|
||||||
|
**AST 函数级合并**(提取各版本 def/class/import 拼合,而非"取最长版本"糊弄);
|
||||||
|
- 跑测试**验证**(沙箱,fail-closed);
|
||||||
|
- 不达标且未到轮次上限 → **打回迭代**(decision=bounce,调用方 reopen 重做);
|
||||||
|
- 达标/达上限 → **单次**把整合产物 push 到 main —— 产物仓永远只有 main 一份,无 agent 工作分支。
|
||||||
|
|
||||||
|
不 import orchestrator.main(避免循环);finalize_run 返回 plain dict,调用方折进 deliverable 并据
|
||||||
|
decision 决定是否打回。质量驱动闭环 = 聚合合并 → 验证 → 不过打回 → 达标落库。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
AsyncOpenAI = None
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregator_timeout() -> float:
|
||||||
|
try:
|
||||||
|
return float(os.getenv("AGGREGATOR_TIMEOUT_SECONDS", "90") or 90)
|
||||||
|
except ValueError:
|
||||||
|
return 90.0
|
||||||
|
|
||||||
|
|
||||||
|
def _llm():
|
||||||
|
"""(client, model) for LLM file integration, or (None, model) when no key — degrades to AST merge."""
|
||||||
|
api_key = os.getenv("OPENAI_API_KEY") or os.getenv("MODEL_API_KEY")
|
||||||
|
api_base = (os.getenv("OPENAI_API_BASE") or os.getenv("MODEL_API_BASE")
|
||||||
|
or "https://api.openai.com/v1")
|
||||||
|
model = (os.getenv("OPENAI_MODEL") or os.getenv("MODEL_NAME") or os.getenv("MODEL_ID")
|
||||||
|
or "gpt-4o-mini")
|
||||||
|
model = os.getenv("MASTER_REVIEW_MODEL", model)
|
||||||
|
client = AsyncOpenAI(api_key=api_key, base_url=api_base) if (api_key and AsyncOpenAI) else None
|
||||||
|
return client, model
|
||||||
|
|
||||||
|
|
||||||
|
_FENCE = re.compile(r"^```[a-zA-Z0-9_+-]*\n(.*?)\n```$", re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_fence(text: str) -> str:
|
||||||
|
m = _FENCE.match(text.strip())
|
||||||
|
return m.group(1) if m else text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _ast_merge_python(contents: List[str]) -> Optional[str]:
|
||||||
|
"""确定性整合多个 Python 版本:提取各版本的 import 与顶层 def/class,按名字去重合并(同名后者覆盖)。
|
||||||
|
比"取最长版本"强 —— 真把各 agent 写的不同函数拼成一份完整文件。解析全失败 → None(回退最长)。"""
|
||||||
|
imports: Dict[str, str] = {}
|
||||||
|
defs: Dict[str, str] = {}
|
||||||
|
parsed_any = False
|
||||||
|
for content in contents:
|
||||||
|
try:
|
||||||
|
tree = ast.parse(content)
|
||||||
|
except SyntaxError:
|
||||||
|
continue
|
||||||
|
parsed_any = True
|
||||||
|
for node in tree.body:
|
||||||
|
seg = ast.get_source_segment(content, node)
|
||||||
|
if not seg:
|
||||||
|
continue
|
||||||
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||||
|
imports[seg.strip()] = seg
|
||||||
|
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||||
|
defs[node.name] = seg
|
||||||
|
if not parsed_any or not defs:
|
||||||
|
return None
|
||||||
|
parts = list(imports.values())
|
||||||
|
if parts:
|
||||||
|
parts.append("")
|
||||||
|
parts.extend(defs.values())
|
||||||
|
return "\n".join(parts).strip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_fallback(path: str, contents: List[str]) -> str:
|
||||||
|
"""无 LLM 兜底:Python 走 AST 函数级合并,其他文件取信息量最大版本。"""
|
||||||
|
if path.endswith(".py"):
|
||||||
|
merged = _ast_merge_python(contents)
|
||||||
|
if merged:
|
||||||
|
return merged
|
||||||
|
return max(contents, key=len)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 收集共享池 ----------
|
||||||
|
|
||||||
|
def _collect_per_path(tasks) -> Dict[str, List[Any]]:
|
||||||
|
"""从所有子任务的共享池产出,按文件 path 分组保留**每个版本**(不去重 —— 去重会丢互补内容)。
|
||||||
|
返回 {path: [SandboxFile, ...]}。复用 quality.collect_generated_files 逐 task 提取(它已兼容
|
||||||
|
task.result.files 与旧 subtasks 两种结构)。"""
|
||||||
|
from .quality import collect_generated_files
|
||||||
|
by_path: Dict[str, List[Any]] = {}
|
||||||
|
for t in tasks:
|
||||||
|
cf = collect_generated_files([t])
|
||||||
|
for kind in ("impl", "agent_tests"):
|
||||||
|
for f in cf.get(kind) or []:
|
||||||
|
by_path.setdefault(f.path, []).append(f)
|
||||||
|
return by_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 同文件互补整合(协作合并) ----------
|
||||||
|
|
||||||
|
async def _merge_one_path(path: str, versions: List[Any], objective: str) -> Any:
|
||||||
|
"""同一文件多个 agent 版本 → 整合成一份完整产物。
|
||||||
|
单版本直接用;多版本优先 LLM 整合(文章的聚合节点),失败/无 LLM 时用 AST 函数级合并兜底。"""
|
||||||
|
from .sandbox import SandboxFile
|
||||||
|
contents = [v.content for v in versions]
|
||||||
|
if len(contents) == 1:
|
||||||
|
return versions[0]
|
||||||
|
client, model = _llm()
|
||||||
|
if not client:
|
||||||
|
logger.warning("aggregator: no LLM for %s; AST/longest merge of %d versions", path, len(contents))
|
||||||
|
return SandboxFile(path=path, content=_merge_fallback(path, contents))
|
||||||
|
try:
|
||||||
|
joined = "\n\n".join(f"===VERSION {i}===\n{c}" for i, c in enumerate(contents))
|
||||||
|
resp = await client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": (
|
||||||
|
"你是蜂群的结果聚合节点。多个 agent 各自实现了同一个文件的不同部分。"
|
||||||
|
"请把它们整合成一个完整、正确、无重复、可直接运行的文件:合并所有互补的函数/类/导入,"
|
||||||
|
"去掉重复定义,保持一致风格。只输出该文件的最终完整内容,不要任何解释、不要 markdown 围栏。")},
|
||||||
|
{"role": "user", "content": (
|
||||||
|
f"目标:{objective}\n文件路径:{path}\n\n"
|
||||||
|
f"以下是 {len(contents)} 个 agent 各自的版本:\n\n{joined}")},
|
||||||
|
],
|
||||||
|
timeout=_aggregator_timeout(),
|
||||||
|
)
|
||||||
|
out = _strip_fence((resp.choices[0].message.content or "").strip())
|
||||||
|
return SandboxFile(path=path, content=out or _merge_fallback(path, contents))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("aggregator: LLM merge failed for %s (%s); AST/longest fallback", path, exc)
|
||||||
|
return SandboxFile(path=path, content=_merge_fallback(path, contents))
|
||||||
|
|
||||||
|
|
||||||
|
async def merge_artifacts(tasks, objective: str) -> List[Any]:
|
||||||
|
"""收集共享池 → 对每个文件整合互补产出 → 返回完整产物文件集(SandboxFile 列表)。"""
|
||||||
|
by_path = _collect_per_path(tasks)
|
||||||
|
merged: List[Any] = []
|
||||||
|
for path, versions in by_path.items():
|
||||||
|
merged.append(await _merge_one_path(path, versions, objective))
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 验证(沙箱,fail-closed) ----------
|
||||||
|
|
||||||
|
async def _validate(merged: List[Any]) -> Dict[str, Any]:
|
||||||
|
"""跑测试验证整合后的产物。fail-closed:隔离未确认/无测试 → 不评分(诚实 None,非 0)。"""
|
||||||
|
from .sandbox import run_tests, isolation_confirmed
|
||||||
|
from .quality import _is_test_file
|
||||||
|
if not isolation_confirmed():
|
||||||
|
return {"validated": False, "reason": "sandbox_not_isolated", "pass_rate": None}
|
||||||
|
impl = [f for f in merged if not _is_test_file(f.path)]
|
||||||
|
tests = [f for f in merged if _is_test_file(f.path)]
|
||||||
|
if not tests:
|
||||||
|
return {"validated": False, "reason": "no_tests", "pass_rate": None}
|
||||||
|
try:
|
||||||
|
res = await asyncio.to_thread(run_tests, impl, tests)
|
||||||
|
return {"validated": True, "pass_rate": res.pass_rate,
|
||||||
|
"passed": res.passed, "total": res.total}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"validated": False, "reason": f"error:{exc!r}", "pass_rate": None}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 落 main(单次,复用 git helper) ----------
|
||||||
|
|
||||||
|
def _auth_url(repo_url: str, user: Optional[str], pw: Optional[str]) -> str:
|
||||||
|
"""凭据嵌入 http(s) clone URL,URL-encode 密码。结果**绝不记日志**。"""
|
||||||
|
import urllib.parse
|
||||||
|
if not user or not pw or "://" not in repo_url:
|
||||||
|
return repo_url
|
||||||
|
scheme, rest = repo_url.split("://", 1)
|
||||||
|
return f"{scheme}://{urllib.parse.quote(user, safe='')}:{urllib.parse.quote(pw, safe='')}@{rest}"
|
||||||
|
|
||||||
|
|
||||||
|
def _git_push(env: Dict[str, str], base_branch: str, files: List[Any], swarm_id: str) -> Dict[str, Any]:
|
||||||
|
"""同步 git:clone base_branch → 写入整合产物 → commit → push。阻塞,经 to_thread 调用。
|
||||||
|
凭据只活在 clone URL,绝不记日志。"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
repo_url = env["GIT_REPO_URL"]
|
||||||
|
auth_url = _auth_url(repo_url, env.get("GIT_USERNAME"), env.get("GIT_PASSWORD"))
|
||||||
|
workdir = tempfile.mkdtemp(prefix="aggregate-promote-")
|
||||||
|
repo_dir = os.path.join(workdir, "repo")
|
||||||
|
|
||||||
|
def git(*args, cwd=None):
|
||||||
|
return subprocess.run(["git", *args], cwd=cwd or workdir,
|
||||||
|
capture_output=True, text=True, timeout=180)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if git("clone", "--depth", "1", "--branch", base_branch, auth_url, "repo").returncode != 0:
|
||||||
|
return {"promoted": False, "reason": "clone_failed"}
|
||||||
|
for f in files:
|
||||||
|
dest = os.path.join(repo_dir, f.path)
|
||||||
|
os.makedirs(os.path.dirname(dest) or repo_dir, exist_ok=True)
|
||||||
|
with open(dest, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(f.content)
|
||||||
|
git("config", "user.email", "aggregator@heicode.swarm", cwd=repo_dir)
|
||||||
|
git("config", "user.name", "HeiCode Aggregator", cwd=repo_dir)
|
||||||
|
git("add", "-A", cwd=repo_dir)
|
||||||
|
c = git("commit", "-m", f"aggregate: integrated swarm deliverable ({swarm_id})", cwd=repo_dir)
|
||||||
|
if c.returncode != 0:
|
||||||
|
return {"promoted": False, "reason": "no_changes"}
|
||||||
|
if git("push", "origin", base_branch, cwd=repo_dir).returncode != 0:
|
||||||
|
return {"promoted": False, "reason": "push_failed"}
|
||||||
|
sha = git("rev-parse", "HEAD", cwd=repo_dir).stdout.strip()
|
||||||
|
return {"promoted": True, "commit_sha": sha, "branch": base_branch}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"promoted": False, "reason": f"error:{exc!r}"}
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(workdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_patch_mode(run) -> bool:
|
||||||
|
"""Benchmark 模式(SWE-bench 等):产出 unified diff,不 push 到 main。
|
||||||
|
per-run 门控(run.metadata['emit_patch'])或 per-deployment 门控(env SWARM_EMIT_PATCH)。
|
||||||
|
默认关 —— 生产路径仍走 _promote,行为不变。"""
|
||||||
|
if (run.metadata or {}).get("emit_patch"):
|
||||||
|
return True
|
||||||
|
return os.getenv("SWARM_EMIT_PATCH", "").strip().lower() in {"1", "true", "yes"}
|
||||||
|
|
||||||
|
|
||||||
|
def _git_diff(env: Dict[str, str], base_ref: str, files: List[Any], swarm_id: str) -> Dict[str, Any]:
|
||||||
|
"""_git_push 的 benchmark 变体:clone → checkout base_ref → 写入整合产物 → `git add -A` →
|
||||||
|
`git diff --cached`(相对 base 的 unified diff)。**只产 diff,不 commit、不 push**。
|
||||||
|
凭据只活在 clone URL,绝不记日志;diff 本身只含代码,无凭据。阻塞,经 to_thread 调用。"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
repo_url = env["GIT_REPO_URL"]
|
||||||
|
auth_url = _auth_url(repo_url, env.get("GIT_USERNAME"), env.get("GIT_PASSWORD"))
|
||||||
|
workdir = tempfile.mkdtemp(prefix="aggregate-patch-")
|
||||||
|
repo_dir = os.path.join(workdir, "repo")
|
||||||
|
|
||||||
|
def git(*args, cwd=None):
|
||||||
|
return subprocess.run(["git", *args], cwd=cwd or workdir,
|
||||||
|
capture_output=True, text=True, timeout=300)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 全量 clone(非 --depth 1):base_ref 可能是任意 base_commit,浅 clone 不含其历史。
|
||||||
|
if git("clone", auth_url, "repo").returncode != 0:
|
||||||
|
return {"emitted": False, "reason": "clone_failed"}
|
||||||
|
if base_ref:
|
||||||
|
if git("checkout", base_ref, cwd=repo_dir).returncode != 0:
|
||||||
|
return {"emitted": False, "reason": "checkout_failed"}
|
||||||
|
for f in files:
|
||||||
|
dest = os.path.join(repo_dir, f.path)
|
||||||
|
os.makedirs(os.path.dirname(dest) or repo_dir, exist_ok=True)
|
||||||
|
with open(dest, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(f.content)
|
||||||
|
git("add", "-A", cwd=repo_dir)
|
||||||
|
d = git("diff", "--cached", cwd=repo_dir)
|
||||||
|
if d.returncode != 0:
|
||||||
|
return {"emitted": False, "reason": "diff_failed"}
|
||||||
|
if not (d.stdout or "").strip():
|
||||||
|
return {"emitted": False, "reason": "empty_patch"}
|
||||||
|
return {"emitted": True, "patch": d.stdout, "base_ref": base_ref}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"emitted": False, "reason": f"error:{exc!r}"}
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(workdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _emit_patch(run, files: List[Any]) -> Dict[str, Any]:
|
||||||
|
"""Benchmark 入口:把整合产物相对 base_commit 产成 unified diff(SWE-bench patch 格式)。
|
||||||
|
凭据按需从 grant.secret_ref 解析,绝不存储/记录。base_ref 优先 grant.base_commit(SWE-bench
|
||||||
|
精确 commit),回退 base_branch。"""
|
||||||
|
try:
|
||||||
|
grant = (run.metadata or {}).get("git_grant") or {}
|
||||||
|
repo_url = grant.get("repo_url")
|
||||||
|
if not repo_url:
|
||||||
|
return {"emitted": False, "reason": "no_git_grant"}
|
||||||
|
if not files:
|
||||||
|
return {"emitted": False, "reason": "no_files"}
|
||||||
|
from .agent_launcher import resolve_git_grant
|
||||||
|
env = resolve_git_grant({"resource_grants": [{
|
||||||
|
"resource_type": "git", "secret_ref": grant.get("secret_ref"),
|
||||||
|
"metadata": {"repo_url": repo_url, "base_branch": grant.get("base_branch", "main")},
|
||||||
|
}]})
|
||||||
|
if not env or "GIT_REPO_URL" not in env:
|
||||||
|
return {"emitted": False, "reason": "grant_unresolved"}
|
||||||
|
base_ref = grant.get("base_commit") or grant.get("base_branch", "main")
|
||||||
|
return await asyncio.to_thread(_git_diff, env, base_ref, files, getattr(run, "swarm_id", "?"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("aggregator: emit_patch failed for %s: %s", getattr(run, "swarm_id", "?"), exc)
|
||||||
|
return {"emitted": False, "reason": f"error:{exc!r}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _promote(run, files: List[Any]) -> Dict[str, Any]:
|
||||||
|
"""把整合产物 push 到产物仓 base 分支(main)。凭据按需从 grant.secret_ref 解析,绝不存储/记录。"""
|
||||||
|
try:
|
||||||
|
grant = (run.metadata or {}).get("git_grant") or {}
|
||||||
|
repo_url = grant.get("repo_url")
|
||||||
|
if not repo_url:
|
||||||
|
return {"promoted": False, "reason": "no_git_grant"}
|
||||||
|
if not files:
|
||||||
|
return {"promoted": False, "reason": "no_files"}
|
||||||
|
from .agent_launcher import resolve_git_grant
|
||||||
|
env = resolve_git_grant({"resource_grants": [{
|
||||||
|
"resource_type": "git", "secret_ref": grant.get("secret_ref"),
|
||||||
|
"metadata": {"repo_url": repo_url, "base_branch": grant.get("base_branch", "main")},
|
||||||
|
}]})
|
||||||
|
if not env or "GIT_REPO_URL" not in env:
|
||||||
|
return {"promoted": False, "reason": "grant_unresolved"}
|
||||||
|
return await asyncio.to_thread(_git_push, env, grant.get("base_branch", "main"),
|
||||||
|
files, getattr(run, "swarm_id", "?"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("aggregator: promote failed for %s: %s", getattr(run, "swarm_id", "?"), exc)
|
||||||
|
return {"promoted": False, "reason": f"error:{exc!r}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 质量决策 ----------
|
||||||
|
|
||||||
|
def _decide(validation: Dict[str, Any], threshold: Optional[float],
|
||||||
|
cycles: int, max_cycles: int) -> str:
|
||||||
|
"""聚合质量门决策:accept(达标/无门/无法评分/达上限)或 bounce(评分不达标且未到上限)。
|
||||||
|
诚实(规则 #9):无法评分(pass_rate=None)不打回 —— 不在"算不出的分"上重做。"""
|
||||||
|
if threshold is None:
|
||||||
|
return "accept"
|
||||||
|
pass_rate = validation.get("pass_rate")
|
||||||
|
if pass_rate is None:
|
||||||
|
return "accept"
|
||||||
|
if pass_rate >= threshold:
|
||||||
|
return "accept"
|
||||||
|
if cycles >= max_cycles:
|
||||||
|
return "accept" # 达上限:接受当前最好的,交收敛标 MAX_ROUNDS_REACHED
|
||||||
|
return "bounce"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 终态入口 ----------
|
||||||
|
|
||||||
|
async def finalize_run(run, tasks, objective: str = "", threshold: Optional[float] = None,
|
||||||
|
cycles: int = 0, max_cycles: int = 2) -> Dict[str, Any]:
|
||||||
|
"""聚合节点入口(run 终态调用,此时子任务已 wait_all 完成)。
|
||||||
|
收集共享池 → 整合互补产出 → 验证 → 决策。
|
||||||
|
- decision='bounce':不 promote,调用方 reopen 重做(质量驱动迭代);
|
||||||
|
- decision='accept':单次落 main。
|
||||||
|
Best-effort,绝不让终态路径崩。"""
|
||||||
|
try:
|
||||||
|
merged = await merge_artifacts(tasks, objective or "")
|
||||||
|
if not merged:
|
||||||
|
return {"finalized": False, "decision": "accept", "reason": "no_artifacts",
|
||||||
|
"merged_file_count": 0}
|
||||||
|
validation = await _validate(merged)
|
||||||
|
decision = _decide(validation, threshold, cycles, max_cycles)
|
||||||
|
if decision == "bounce":
|
||||||
|
return {"finalized": False, "decision": "bounce", "validation": validation,
|
||||||
|
"merged_file_count": len(merged), "files": [f.path for f in merged]}
|
||||||
|
# Benchmark 模式:产 unified diff 不 push(SWE-bench 等需要 patch,产物仓不能被改);
|
||||||
|
# 生产模式:单次落 main。两条互斥,由 _emit_patch_mode 门控。
|
||||||
|
if _emit_patch_mode(run):
|
||||||
|
patch_res = await _emit_patch(run, merged)
|
||||||
|
return {
|
||||||
|
"finalized": True,
|
||||||
|
"decision": "accept",
|
||||||
|
"mode": "emit_patch",
|
||||||
|
"merged_file_count": len(merged),
|
||||||
|
"files": [f.path for f in merged],
|
||||||
|
"validation": validation,
|
||||||
|
"patch": patch_res.get("patch"),
|
||||||
|
"patch_meta": {k: v for k, v in patch_res.items() if k != "patch"},
|
||||||
|
}
|
||||||
|
promotion = await _promote(run, merged)
|
||||||
|
return {
|
||||||
|
"finalized": True,
|
||||||
|
"decision": "accept",
|
||||||
|
"merged_file_count": len(merged),
|
||||||
|
"files": [f.path for f in merged],
|
||||||
|
"validation": validation,
|
||||||
|
"promotion": promotion,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("aggregator: finalize_run failed for %s: %s",
|
||||||
|
getattr(run, "swarm_id", "?"), exc)
|
||||||
|
return {"finalized": False, "decision": "accept", "reason": f"error:{exc!r}",
|
||||||
|
"merged_file_count": 0}
|
||||||
@@ -1,91 +1,132 @@
|
|||||||
"""In-sandbox test harness — runs INSIDE the isolated workdir as a child process.
|
"""In-sandbox test harness — runs INSIDE the isolated workdir as a child process.
|
||||||
|
|
||||||
Stdlib only (no pytest dependency): collects both `unittest.TestCase` tests and bare
|
Prefers **pytest** (native support for pytest-style classes, fixtures, parametrize, marks AND
|
||||||
`test_*` functions from every `test_*.py` in the working directory, runs them, and writes a
|
unittest.TestCase) — the test styles agents actually produce. Falls back to a stdlib-only
|
||||||
machine-readable `_result.json` ({total, passed, failed, errored, details}). The parent
|
collector (unittest.TestCase + bare module-level ``test_*`` functions) when pytest is absent, so
|
||||||
(orchestrator/sandbox.py) reads that file; it never trusts stdout for counts.
|
the harness still works without the dependency. Writes a machine-readable ``_result.json``
|
||||||
|
({total, passed, failed, errored, details}); the parent (orchestrator/sandbox.py) reads that
|
||||||
|
file and never trusts stdout for counts.
|
||||||
|
|
||||||
This file is copied into the ephemeral sandbox workdir at run time and executed there with the
|
Copied into the ephemeral sandbox workdir at run time and executed there with the workdir as CWD.
|
||||||
workdir as CWD. It must stay self-contained and import nothing outside the stdlib.
|
|
||||||
"""
|
"""
|
||||||
import importlib.util
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
|
||||||
|
|
||||||
RESULT_FILE = "_result.json"
|
RESULT_FILE = "_result.json"
|
||||||
|
|
||||||
|
|
||||||
def _load_module(path: str):
|
def _run_with_pytest(workdir: str) -> dict:
|
||||||
name = "sbx_" + os.path.splitext(os.path.basename(path))[0]
|
"""Run every test under workdir with pytest; collect pass/fail/error via an inline plugin.
|
||||||
spec = importlib.util.spec_from_file_location(name, path)
|
Native support for pytest classes/fixtures/parametrize/marks + unittest.TestCase."""
|
||||||
module = importlib.util.module_from_spec(spec)
|
import pytest
|
||||||
spec.loader.exec_module(module) # may raise on import/collection error
|
|
||||||
return module
|
class _Collector:
|
||||||
|
def __init__(self):
|
||||||
|
self.total = 0
|
||||||
|
self.passed = 0
|
||||||
|
self.failed = 0
|
||||||
|
self.errored = 0
|
||||||
|
self.details = []
|
||||||
|
|
||||||
|
def pytest_runtest_logreport(self, report):
|
||||||
|
text = (getattr(report, "longreprtext", "") or "")[:500]
|
||||||
|
if report.when == "call":
|
||||||
|
self.total += 1
|
||||||
|
if report.outcome == "passed":
|
||||||
|
self.passed += 1
|
||||||
|
self.details.append({"test": report.nodeid, "status": "passed"})
|
||||||
|
else:
|
||||||
|
self.failed += 1
|
||||||
|
self.details.append({"test": report.nodeid, "status": "failed", "error": text})
|
||||||
|
elif report.when in ("setup", "teardown") and report.outcome == "failed":
|
||||||
|
# setup/teardown failure (e.g. fixture error) counts as one errored test
|
||||||
|
self.total += 1
|
||||||
|
self.failed += 1
|
||||||
|
self.errored += 1
|
||||||
|
self.details.append({"test": report.nodeid, "status": "error", "error": text})
|
||||||
|
|
||||||
|
def pytest_collectreport(self, report):
|
||||||
|
# import/collection failure (e.g. missing dependency) counts as one errored test
|
||||||
|
if report.failed:
|
||||||
|
text = (getattr(report, "longreprtext", "") or "")[:500]
|
||||||
|
self.total += 1
|
||||||
|
self.failed += 1
|
||||||
|
self.errored += 1
|
||||||
|
self.details.append({"test": report.nodeid or "collection",
|
||||||
|
"status": "error", "error": text})
|
||||||
|
|
||||||
|
collector = _Collector()
|
||||||
|
# -q quiet, disable cache writes, ignore any repo pytest config so the sandbox is hermetic
|
||||||
|
pytest.main(["-q", "-p", "no:cacheprovider", "--no-header", "-o", "addopts=", workdir],
|
||||||
|
plugins=[collector])
|
||||||
|
return {"total": collector.total, "passed": collector.passed,
|
||||||
|
"failed": collector.failed, "errored": collector.errored, "details": collector.details}
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def _run_with_stdlib(workdir: str) -> dict:
|
||||||
workdir = os.getcwd()
|
"""Fallback when pytest is unavailable: unittest.TestCase + bare module-level test_* functions."""
|
||||||
sys.path.insert(0, workdir)
|
import importlib.util
|
||||||
test_files = sorted(
|
import unittest
|
||||||
f for f in os.listdir(workdir) if f.startswith("test_") and f.endswith(".py")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
def _load_module(path):
|
||||||
|
name = "sbx_" + os.path.splitext(os.path.basename(path))[0]
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
test_files = sorted(f for f in os.listdir(workdir)
|
||||||
|
if f.startswith("test_") and f.endswith(".py"))
|
||||||
total = passed = failed = errored = 0
|
total = passed = failed = errored = 0
|
||||||
details = []
|
details = []
|
||||||
suite = unittest.TestSuite()
|
suite = unittest.TestSuite()
|
||||||
bare_funcs = [] # (label, callable)
|
bare_funcs = []
|
||||||
|
|
||||||
for tf in test_files:
|
for tf in test_files:
|
||||||
try:
|
try:
|
||||||
module = _load_module(os.path.join(workdir, tf))
|
module = _load_module(os.path.join(workdir, tf))
|
||||||
except Exception as exc: # import-time failure counts as one errored test
|
except Exception as exc:
|
||||||
total += 1
|
total += 1; errored += 1; failed += 1
|
||||||
errored += 1
|
|
||||||
failed += 1
|
|
||||||
details.append({"test": tf, "status": "error", "error": repr(exc)})
|
details.append({"test": tf, "status": "error", "error": repr(exc)})
|
||||||
continue
|
continue
|
||||||
# unittest.TestCase-style tests
|
|
||||||
suite.addTests(unittest.defaultTestLoader.loadTestsFromModule(module))
|
suite.addTests(unittest.defaultTestLoader.loadTestsFromModule(module))
|
||||||
# bare pytest-style functions defined in this module
|
|
||||||
for attr in dir(module):
|
for attr in dir(module):
|
||||||
if not attr.startswith("test_"):
|
if not attr.startswith("test_"):
|
||||||
continue
|
continue
|
||||||
obj = getattr(module, attr)
|
obj = getattr(module, attr)
|
||||||
if callable(obj) and not isinstance(obj, type) and getattr(obj, "__module__", None) == module.__name__:
|
if callable(obj) and not isinstance(obj, type) and getattr(obj, "__module__", None) == module.__name__:
|
||||||
bare_funcs.append((f"{tf}::{attr}", obj))
|
bare_funcs.append((f"{tf}::{attr}", obj))
|
||||||
|
|
||||||
# Run the unittest suite (TestCase subclasses).
|
|
||||||
ut_result = unittest.TestResult()
|
ut_result = unittest.TestResult()
|
||||||
suite.run(ut_result)
|
suite.run(ut_result)
|
||||||
ut_total = ut_result.testsRun
|
total += ut_result.testsRun
|
||||||
ut_failed = len(ut_result.failures) + len(ut_result.errors)
|
ut_failed = len(ut_result.failures) + len(ut_result.errors)
|
||||||
total += ut_total
|
|
||||||
failed += ut_failed
|
failed += ut_failed
|
||||||
errored += len(ut_result.errors)
|
errored += len(ut_result.errors)
|
||||||
passed += ut_total - ut_failed
|
passed += ut_result.testsRun - ut_failed
|
||||||
|
|
||||||
# Run bare test_* functions.
|
|
||||||
for label, fn in bare_funcs:
|
for label, fn in bare_funcs:
|
||||||
total += 1
|
total += 1
|
||||||
try:
|
try:
|
||||||
fn()
|
fn(); passed += 1
|
||||||
passed += 1
|
|
||||||
details.append({"test": label, "status": "passed"})
|
details.append({"test": label, "status": "passed"})
|
||||||
except AssertionError as exc:
|
except AssertionError as exc:
|
||||||
failed += 1
|
failed += 1
|
||||||
details.append({"test": label, "status": "failed", "error": str(exc)})
|
details.append({"test": label, "status": "failed", "error": str(exc)})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failed += 1
|
failed += 1; errored += 1
|
||||||
errored += 1
|
|
||||||
details.append({"test": label, "status": "error", "error": repr(exc)})
|
details.append({"test": label, "status": "error", "error": repr(exc)})
|
||||||
|
return {"total": total, "passed": passed, "failed": failed, "errored": errored, "details": details}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
workdir = os.getcwd()
|
||||||
|
sys.path.insert(0, workdir)
|
||||||
|
try:
|
||||||
|
import pytest # noqa: F401
|
||||||
|
result = _run_with_pytest(workdir)
|
||||||
|
except ImportError:
|
||||||
|
result = _run_with_stdlib(workdir)
|
||||||
with open(os.path.join(workdir, RESULT_FILE), "w", encoding="utf-8") as fh:
|
with open(os.path.join(workdir, RESULT_FILE), "w", encoding="utf-8") as fh:
|
||||||
json.dump(
|
json.dump(result, fh)
|
||||||
{"total": total, "passed": passed, "failed": failed, "errored": errored, "details": details},
|
|
||||||
fh,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
"""Queen aggregation (agent_swarm#8/#12, M2).
|
|
||||||
|
|
||||||
Verifies the Queen's best-of-N selection: among the fan-out agents' candidates, the one whose impl
|
|
||||||
passes the most tests wins; selection is deterministic; honest fail-closed (unscored != zero).
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
os.environ.setdefault("REDIS_FAKE", "1")
|
|
||||||
os.environ.setdefault("ALLOW_MEMORY_STORE", "1")
|
|
||||||
os.environ.setdefault("OPENAI_API_KEY", "test-key")
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from orchestrator.queen import Candidate, select_best, should_bounce, _auth_url, promote_to_main
|
|
||||||
|
|
||||||
_failures = []
|
|
||||||
|
|
||||||
|
|
||||||
def check(name, cond):
|
|
||||||
print(("PASS" if cond else "FAIL") + " - " + name)
|
|
||||||
if not cond:
|
|
||||||
_failures.append(name)
|
|
||||||
|
|
||||||
|
|
||||||
F = ["impl.py"] # non-empty impl marker
|
|
||||||
|
|
||||||
# highest pass_rate wins (best-of-N)
|
|
||||||
check("highest score wins",
|
|
||||||
select_best([Candidate("t1", "a1", F, score=40.0), Candidate("t2", "a2", F, score=90.0)]).task_id == "t2")
|
|
||||||
# scored ranks above unscored
|
|
||||||
check("scored beats unscored",
|
|
||||||
select_best([Candidate("t1", "a1", F), Candidate("t2", "a2", F, score=10.0)]).task_id == "t2")
|
|
||||||
# no impl anywhere → None (nothing to deliver)
|
|
||||||
check("no impl → None", select_best([Candidate("t1", "a1", [])]) is None)
|
|
||||||
# all unscored → deterministic fallback to first with impl
|
|
||||||
check("all unscored → first with impl",
|
|
||||||
select_best([Candidate("t1", "a1", F), Candidate("t2", "a2", F)]).task_id == "t1")
|
|
||||||
# tie on score → stable (first input order wins)
|
|
||||||
check("score tie → stable first",
|
|
||||||
select_best([Candidate("t1", "a1", F, score=100.0), Candidate("t2", "a2", F, score=100.0)]).task_id == "t1")
|
|
||||||
# tie on score, more passed wins
|
|
||||||
check("score tie → more passed wins",
|
|
||||||
select_best([Candidate("t1", "a1", F, score=100.0, passed=2, total=2),
|
|
||||||
Candidate("t2", "a2", F, score=100.0, passed=5, total=5)]).task_id == "t2")
|
|
||||||
|
|
||||||
# --- should_bounce (M3/SC-9 quality gate decision) ---
|
|
||||||
def _summary(score):
|
|
||||||
return {"winner": {"task_id": "t1", "score": score}, "candidates": [{"task_id": "t1"}]}
|
|
||||||
|
|
||||||
# below threshold + under cap → bounce
|
|
||||||
check("below bar under cap → bounce", should_bounce(_summary(40.0), 80.0, cycles=0, max_cycles=2) is True)
|
|
||||||
# meets bar → accept
|
|
||||||
check("meets bar → no bounce", should_bounce(_summary(90.0), 80.0, cycles=0, max_cycles=2) is False)
|
|
||||||
# cap reached → accept best-so-far
|
|
||||||
check("cap reached → no bounce", should_bounce(_summary(40.0), 80.0, cycles=2, max_cycles=2) is False)
|
|
||||||
# no threshold (disabled) → never bounce
|
|
||||||
check("no threshold → no bounce", should_bounce(_summary(0.0), None, cycles=0, max_cycles=2) is False)
|
|
||||||
# unscored → honest, don't bounce
|
|
||||||
check("unscored → no bounce", should_bounce(_summary(None), 80.0, cycles=0, max_cycles=2) is False)
|
|
||||||
|
|
||||||
# --- SC-7 promote_to_main ---
|
|
||||||
# _auth_url embeds + URL-encodes creds; passthrough when missing
|
|
||||||
check("auth url embeds + encodes creds", _auth_url("http://h/r.git", "u", "p@ss") == "http://u:p%40ss@h/r.git")
|
|
||||||
check("auth url passthrough w/o creds", _auth_url("http://h/r.git", None, None) == "http://h/r.git")
|
|
||||||
|
|
||||||
|
|
||||||
class _RunNoGrant:
|
|
||||||
def __init__(self):
|
|
||||||
self.metadata = {}
|
|
||||||
self.swarm_id = "s1"
|
|
||||||
|
|
||||||
|
|
||||||
# promote is a no-op (not an error) when the run has no git grant
|
|
||||||
_promo = asyncio.run(promote_to_main(_RunNoGrant(), [], "t1"))
|
|
||||||
check("promote without grant → not promoted", _promo.get("promoted") is False and _promo.get("reason") == "no_git_grant")
|
|
||||||
|
|
||||||
if _failures:
|
|
||||||
print(f"\nFAILED: {len(_failures)} check(s): {_failures}")
|
|
||||||
raise SystemExit(1)
|
|
||||||
print("\nALL PASSED")
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""ResultAggregator(质量驱动协作聚合)测试 — 取代旧 test-queen(best-of-N 选优)。
|
||||||
|
|
||||||
|
覆盖文章模型 + ①②③闭环:收集共享池互补产出、AST 函数级整合(无 LLM 兜底不靠"最长版本")、
|
||||||
|
质量门决策(达标 accept / 不达标 bounce 打回 / 无法评分诚实不打回)、终态聚合 best-effort。
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
os.environ.setdefault("REDIS_FAKE", "1")
|
||||||
|
os.environ.setdefault("ALLOW_MEMORY_STORE", "1")
|
||||||
|
for k in ("OPENAI_API_KEY", "MODEL_API_KEY"): # 无 LLM,走 AST 兜底(确定性,不依赖外部模型)
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
|
||||||
|
from orchestrator.result_aggregator import (
|
||||||
|
_strip_fence, _ast_merge_python, _merge_fallback, _decide,
|
||||||
|
_collect_per_path, merge_artifacts, finalize_run, _emit_patch_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
_failures = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(("PASS" if cond else "FAIL") + " - " + name)
|
||||||
|
if not cond:
|
||||||
|
_failures.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTask:
|
||||||
|
def __init__(self, task_id, files):
|
||||||
|
self.task_id = task_id
|
||||||
|
self.assigned_agent_id = "agent-" + task_id
|
||||||
|
self.agent_role = "impl"
|
||||||
|
self.result = json.dumps({"files": files})
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRun:
|
||||||
|
def __init__(self, metadata=None):
|
||||||
|
self.metadata = metadata or {}
|
||||||
|
self.swarm_id = "swarm-test"
|
||||||
|
|
||||||
|
|
||||||
|
# --- _strip_fence ---
|
||||||
|
check("strip fence python", _strip_fence("```python\nx = 1\n```") == "x = 1")
|
||||||
|
check("strip fence passthrough", _strip_fence("z = 3") == "z = 3")
|
||||||
|
|
||||||
|
# --- ① AST 函数级合并:各 agent 写不同函数 → 合并成含全部函数的一份(不靠最长版本) ---
|
||||||
|
v1 = "def is_palindrome(s):\n return s == s[::-1]\n"
|
||||||
|
v2 = "def count_vowels(s):\n return sum(c in 'aeiou' for c in s)\ndef reverse_words(s):\n return ' '.join(s.split()[::-1])\n"
|
||||||
|
merged_py = _ast_merge_python([v1, v2])
|
||||||
|
check("AST 合并含 is_palindrome", merged_py and "def is_palindrome" in merged_py)
|
||||||
|
check("AST 合并含 count_vowels", merged_py and "def count_vowels" in merged_py)
|
||||||
|
check("AST 合并含 reverse_words", merged_py and "def reverse_words" in merged_py)
|
||||||
|
check("AST 合并去重(同名一次)", merged_py and merged_py.count("def is_palindrome") == 1 if merged_py else False)
|
||||||
|
# 语法垃圾 → None(回退最长)
|
||||||
|
check("AST 解析全失败 → None", _ast_merge_python(["@#$%", "!!!"]) is None)
|
||||||
|
check("merge_fallback 非py取最长", _merge_fallback("a.txt", ["short", "longer text"]) == "longer text")
|
||||||
|
|
||||||
|
# --- _decide 决策门 ---
|
||||||
|
check("decide: 无阈值 → accept", _decide({"pass_rate": 10.0}, None, 0, 2) == "accept")
|
||||||
|
check("decide: 无法评分(None) → accept(诚实)", _decide({"pass_rate": None}, 80.0, 0, 2) == "accept")
|
||||||
|
check("decide: 达标 → accept", _decide({"pass_rate": 90.0}, 80.0, 0, 2) == "accept")
|
||||||
|
check("decide: 不达标且未到上限 → bounce", _decide({"pass_rate": 40.0}, 80.0, 0, 2) == "bounce")
|
||||||
|
check("decide: 不达标但达上限 → accept", _decide({"pass_rate": 40.0}, 80.0, 2, 2) == "accept")
|
||||||
|
|
||||||
|
# --- 收集共享池:同文件多版本都保留 ---
|
||||||
|
t1 = FakeTask("t1", [{"path": "stringutils.py", "content": v1, "action": "write"}])
|
||||||
|
t2 = FakeTask("t2", [{"path": "stringutils.py", "content": v2, "action": "write"}])
|
||||||
|
t3 = FakeTask("t3", [{"path": "test_stringutils.py", "content": "def test_x():\n assert True\n", "action": "write"}])
|
||||||
|
by_path = _collect_per_path([t1, t2, t3])
|
||||||
|
check("collect: 同文件两版本都保留", len(by_path.get("stringutils.py", [])) == 2)
|
||||||
|
|
||||||
|
# --- merge_artifacts:无 LLM 用 AST 整合三函数(不丢互补) ---
|
||||||
|
merged = asyncio.run(merge_artifacts([t1, t2, t3], "实现 stringutils"))
|
||||||
|
su = next(f for f in merged if f.path == "stringutils.py")
|
||||||
|
check("merge: 整合后含全部三函数", all(fn in su.content for fn in ("is_palindrome", "count_vowels", "reverse_words")))
|
||||||
|
|
||||||
|
# --- finalize_run:无阈值 → accept;无 git grant → 诚实不 promote ---
|
||||||
|
res = asyncio.run(finalize_run(FakeRun(), [t1, t2, t3], "实现 stringutils"))
|
||||||
|
check("finalize: decision=accept(无阈值)", res.get("decision") == "accept")
|
||||||
|
check("finalize: finalized=True", res.get("finalized") is True)
|
||||||
|
check("finalize: 无 git grant promoted=False", (res.get("promotion") or {}).get("reason") == "no_git_grant")
|
||||||
|
|
||||||
|
# --- finalize_run:无产物 → finalized=False ---
|
||||||
|
empty = asyncio.run(finalize_run(FakeRun(), [], "空"))
|
||||||
|
check("finalize: 无产物 finalized=False", empty.get("finalized") is False and empty.get("reason") == "no_artifacts")
|
||||||
|
|
||||||
|
# --- emit_patch 模式(SWE-bench):门控 + 无 grant 诚实失败 ---
|
||||||
|
check("emit_patch_mode: 默认关", _emit_patch_mode(FakeRun()) is False)
|
||||||
|
check("emit_patch_mode: per-run flag 开", _emit_patch_mode(FakeRun({"emit_patch": True})) is True)
|
||||||
|
os.environ["SWARM_EMIT_PATCH"] = "1"
|
||||||
|
check("emit_patch_mode: env 开", _emit_patch_mode(FakeRun()) is True)
|
||||||
|
patch_run = asyncio.run(finalize_run(FakeRun(), [t1, t2, t3], "实现 stringutils"))
|
||||||
|
check("emit_patch: mode=emit_patch", patch_run.get("mode") == "emit_patch")
|
||||||
|
check("emit_patch: 无 git grant patch=None", patch_run.get("patch") is None)
|
||||||
|
check("emit_patch: 无 grant reason=no_git_grant",
|
||||||
|
(patch_run.get("patch_meta") or {}).get("reason") == "no_git_grant")
|
||||||
|
os.environ.pop("SWARM_EMIT_PATCH", None)
|
||||||
|
|
||||||
|
if _failures:
|
||||||
|
print(f"\nFAILED: {len(_failures)} check(s): {_failures}")
|
||||||
|
raise SystemExit(1)
|
||||||
|
print("\nALL PASSED")
|
||||||
Reference in New Issue
Block a user