810 lines
36 KiB
Python
810 lines
36 KiB
Python
# ============================================================
|
||
# 多智能体编排 Mesh(moduleId: core-agent-mesh, 可重生 ✅)
|
||
# 演示与编排层:按需创建智能体、挂 Goal、按能力边界分发任务、
|
||
# 智能体消息互通、RUNNING 超期自动重建智能体、Goal 看门狗。
|
||
# 诚实边界:任务通过统一工具运行时走真实业务管线(分解/试排/校验/报告),
|
||
# P2/P3 确认卡必须由人工审批;审批完成后恢复 Goal 分发。
|
||
# 落盘:<aps_home>/data/agent_mesh.json(可用 APS_MESH_PATH 覆盖)
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from server.timeutil import fmt_dt
|
||
|
||
logger = logging.getLogger("aps.mesh")
|
||
|
||
# ---------------- 角色模板(能力边界) ----------------
|
||
ROLE_TEMPLATES: dict[str, dict[str, Any]] = {
|
||
"data-analyst": {
|
||
"label": "数据解析智能体",
|
||
"capabilities": ["folder.analyze", "data.analyze", "master.query", "plan.trace"],
|
||
"desc": "解析工程目录/表格,校验排产齐备度",
|
||
},
|
||
"mrp-planner": {
|
||
"label": "MRP分解智能体",
|
||
"capabilities": ["order.decompose", "mrp.release"],
|
||
"desc": "按 BOM/供需净算产出采购/委外建议并下达",
|
||
},
|
||
"scheduler": {
|
||
"label": "柔性排产智能体",
|
||
"capabilities": ["flex.schedule", "flex.capacity", "flex.reschedule"],
|
||
"desc": "能力池组虚拟产线,闭环求解排产",
|
||
},
|
||
"verifier": {
|
||
"label": "结果校验智能体",
|
||
"capabilities": ["readiness.query", "conflict.list", "plan.trace"],
|
||
"desc": "复核排产版本、冲突、齐备度与计划追溯证据",
|
||
},
|
||
"reporter": {
|
||
"label": "报告智能体",
|
||
"capabilities": ["report.generate"],
|
||
"desc": "生成排产分析报告",
|
||
},
|
||
}
|
||
|
||
# 看门狗系统智能体(内置,不可删)
|
||
WATCHDOG_AGENT_ID = "agent-watchdog"
|
||
WATCHDOG_NAME = "Goal看门狗"
|
||
|
||
# Goal 模板:APS 排产全流程(任务带能力意图与依赖)
|
||
GOAL_TEMPLATES: dict[str, dict[str, Any]] = {
|
||
"aps-full-flow": {
|
||
"label": "APS 排产全流程",
|
||
"tasks": [
|
||
{"key": "analyze", "title": "解析工程目录数据", "intent": "data.analyze",
|
||
"role": "data-analyst", "params": {}, "dependsOn": []},
|
||
{"key": "readiness", "title": "复查排产齐备度", "intent": "readiness.query",
|
||
"role": "verifier", "params": {}, "dependsOn": ["analyze"]},
|
||
{"key": "decompose", "title": "执行 MRP 分解", "intent": "order.decompose",
|
||
"role": "mrp-planner", "params": {}, "dependsOn": ["readiness"]},
|
||
{"key": "schedule", "title": "生成一版试排", "intent": "flex.schedule",
|
||
"role": "scheduler", "params": {}, "dependsOn": ["decompose"]},
|
||
{"key": "verify", "title": "核对冲突与计划追溯", "intent": "conflict.list",
|
||
"role": "verifier", "params": {}, "dependsOn": ["schedule"]},
|
||
{"key": "report", "title": "生成排产分析报告", "intent": "report.generate",
|
||
"role": "reporter", "params": {"reportType": "plan"}, "dependsOn": ["verify"]},
|
||
],
|
||
},
|
||
}
|
||
|
||
_DEFAULT_STALL_TIMEOUT_SEC = 45.0
|
||
_DEFAULT_MAX_RETRIES = 1
|
||
|
||
|
||
def _now() -> str:
|
||
return fmt_dt(datetime.now())
|
||
|
||
|
||
def _uid(prefix: str) -> str:
|
||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||
|
||
|
||
def _mesh_path() -> str:
|
||
override = os.environ.get("APS_MESH_PATH")
|
||
if override:
|
||
return override
|
||
try:
|
||
from server.aps_home import aps_home
|
||
return str(aps_home() / "data" / "agent_mesh.json")
|
||
except Exception:
|
||
return os.path.join("server", "data", "agent_mesh.json")
|
||
|
||
|
||
class MeshStore:
|
||
"""多智能体编排仓:线程安全 + 原子落盘。"""
|
||
|
||
def __init__(self, path: str | None = None) -> None:
|
||
self.path = path or _mesh_path()
|
||
self._lock = threading.RLock()
|
||
self.data: dict[str, Any] = self._load()
|
||
self._ensure_watchdog()
|
||
|
||
# ---------------- 持久化 ----------------
|
||
def _load(self) -> dict[str, Any]:
|
||
try:
|
||
with open(self.path, encoding="utf-8") as fh:
|
||
data = json.load(fh)
|
||
if isinstance(data, dict):
|
||
data.setdefault("agents", [])
|
||
data.setdefault("goals", [])
|
||
data.setdefault("messages", [])
|
||
data.setdefault("watchdog", {})
|
||
return data
|
||
except (OSError, json.JSONDecodeError):
|
||
pass
|
||
return {"agents": [], "goals": [], "messages": [], "watchdog": {}}
|
||
|
||
def save(self) -> None:
|
||
with self._lock:
|
||
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path) or ".", suffix=".tmp")
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||
json.dump(self.data, fh, ensure_ascii=False, indent=1)
|
||
os.replace(tmp, self.path)
|
||
except OSError:
|
||
try:
|
||
os.unlink(tmp)
|
||
except OSError:
|
||
pass
|
||
|
||
def _ensure_watchdog(self) -> None:
|
||
with self._lock:
|
||
if not any(a.get("id") == WATCHDOG_AGENT_ID for a in self.data["agents"]):
|
||
self.data["agents"].insert(0, {
|
||
"id": WATCHDOG_AGENT_ID, "name": WATCHDOG_NAME, "role": "watchdog",
|
||
"label": "Goal看门狗(系统)", "capabilities": ["mesh.watchdog"],
|
||
"status": "IDLE", "system": True, "createdBy": "SYSTEM",
|
||
"createdAt": _now(), "heartbeatAt": _now(),
|
||
})
|
||
wd = self.data["watchdog"]
|
||
wd.setdefault("enabled", True)
|
||
wd.setdefault("stallTimeoutSec", _DEFAULT_STALL_TIMEOUT_SEC)
|
||
wd.setdefault("maxRetries", _DEFAULT_MAX_RETRIES)
|
||
wd.setdefault("lastTickAt", None)
|
||
wd.setdefault("alerts", [])
|
||
|
||
# ---------------- 智能体 ----------------
|
||
def create_agent(self, name: str, role: str, capabilities: list[str] | None = None,
|
||
*, created_by: str = "USER", parent_id: str | None = None,
|
||
goal_id: str | None = None) -> dict[str, Any]:
|
||
tpl = ROLE_TEMPLATES.get(role, {})
|
||
caps = capabilities if capabilities is not None else list(tpl.get("capabilities") or [])
|
||
agent = {
|
||
"id": _uid("agent"), "name": name.strip() or tpl.get("label") or role,
|
||
"role": role, "label": tpl.get("label") or role,
|
||
"capabilities": caps, "status": "IDLE", "system": False,
|
||
"createdBy": created_by, "parentId": parent_id,
|
||
"parentGoalId": goal_id,
|
||
"goalIds": [goal_id] if goal_id else [],
|
||
"createdAt": _now(), "heartbeatAt": _now(),
|
||
"currentTaskId": None,
|
||
}
|
||
with self._lock:
|
||
self.data["agents"].append(agent)
|
||
self.save()
|
||
return agent
|
||
|
||
def _capable_idle(self, intent: str, role: str | None = None) -> dict[str, Any] | None:
|
||
for agent in self.data["agents"]:
|
||
if agent.get("system"):
|
||
continue
|
||
if role and agent.get("role") != role:
|
||
continue
|
||
if agent.get("status") == "IDLE" and intent in (agent.get("capabilities") or []):
|
||
return agent
|
||
return None
|
||
|
||
def _agent_for_role(self, role: str | None) -> str | None:
|
||
if not role:
|
||
return None
|
||
agent = next(
|
||
(item for item in self.data["agents"]
|
||
if not item.get("system") and item.get("role") == role),
|
||
None,
|
||
)
|
||
return str(agent.get("id")) if agent else None
|
||
|
||
def _spawn_for_intent(self, intent: str, *, created_by: str, goal_id: str,
|
||
role: str | None = None) -> dict[str, Any] | None:
|
||
"""没有能执行该意图的空闲智能体时,按角色模板自动新建(允许新建智能体)。"""
|
||
for candidate_role, tpl in ROLE_TEMPLATES.items():
|
||
if role and candidate_role != role:
|
||
continue
|
||
if intent in (tpl.get("capabilities") or []):
|
||
seq = sum(1 for a in self.data["agents"] if a.get("role") == candidate_role) + 1
|
||
agent = self.create_agent(
|
||
f"{tpl['label']}-{seq}", candidate_role,
|
||
created_by=created_by, parent_id=None, goal_id=goal_id,
|
||
)
|
||
self.post_message(
|
||
from_agent_id=WATCHDOG_AGENT_ID, to_agent_id=None, goal_id=goal_id,
|
||
kind="INFO",
|
||
text=f"没有空闲的「{tpl['label']}」,已自动新建 {agent['name']} 接管 {intent}。",
|
||
)
|
||
return agent
|
||
return None
|
||
|
||
# ---------------- Goal / 任务 ----------------
|
||
def create_goal(self, title: str, *, template: str | None = "aps-full-flow",
|
||
tasks: list[dict[str, Any]] | None = None,
|
||
created_by: str = "USER", world_key: str | None = None,
|
||
session_id: str | None = None, project_id: str | None = None,
|
||
source_sha256: str | None = None,
|
||
run_id: str | None = None) -> dict[str, Any]:
|
||
tpl = GOAL_TEMPLATES.get(template or "", {})
|
||
raw_tasks = tasks if tasks is not None else tpl.get("tasks") or []
|
||
goal_id = _uid("goal")
|
||
run_id = str(run_id or _uid("run"))
|
||
task_rows = []
|
||
for spec in raw_tasks:
|
||
task_rows.append({
|
||
"id": _uid("task"), "goalId": goal_id,
|
||
"key": spec.get("key") or spec.get("intent"),
|
||
"title": spec.get("title") or spec.get("intent"),
|
||
"intent": spec["intent"], "role": spec.get("role"),
|
||
"params": dict(spec.get("params") or {}),
|
||
"dependsOn": list(spec.get("dependsOn") or []),
|
||
"status": "PENDING", "assigneeAgentId": None, "assigneeName": None,
|
||
"attempts": 0, "result": None, "error": None,
|
||
"startedAt": None, "finishedAt": None,
|
||
})
|
||
goal = {
|
||
"id": goal_id, "title": title.strip() or tpl.get("label") or "未命名 Goal",
|
||
"template": template, "status": "ACTIVE",
|
||
"createdBy": created_by, "worldKey": world_key,
|
||
"sessionId": session_id, "projectId": project_id,
|
||
"sourceSha256": source_sha256, "runId": run_id,
|
||
"createdAt": _now(), "tasks": task_rows,
|
||
"watchdog": {"stallTimeoutSec": float(self.data["watchdog"].get("stallTimeoutSec") or _DEFAULT_STALL_TIMEOUT_SEC),
|
||
"maxRetries": int(self.data["watchdog"].get("maxRetries") or _DEFAULT_MAX_RETRIES)},
|
||
}
|
||
with self._lock:
|
||
self.data["goals"].append(goal)
|
||
self.save()
|
||
sub_agents = self._ensure_goal_agents(goal)
|
||
goal["subAgentIds"] = [agent["id"] for agent in sub_agents]
|
||
self.save()
|
||
self.post_message(from_agent_id=WATCHDOG_AGENT_ID, to_agent_id=None, goal_id=goal_id,
|
||
run_id=run_id, kind="INFO",
|
||
text=(f"Goal「{goal['title']}」已挂载(run {run_id}),"
|
||
f"已拉起 {len(sub_agents)} 个子智能体,"
|
||
f"{len(task_rows)} 个任务进入看门狗监控。"))
|
||
return goal
|
||
|
||
def _ensure_goal_agents(self, goal: dict[str, Any]) -> list[dict[str, Any]]:
|
||
"""Create one bounded sub-agent for every distinct task role in the Goal."""
|
||
roles = list(dict.fromkeys(
|
||
str(task.get("role") or "data-analyst") for task in goal.get("tasks") or []))
|
||
agents: list[dict[str, Any]] = []
|
||
for role in roles:
|
||
tpl = ROLE_TEMPLATES.get(role, {})
|
||
agent = next(
|
||
(item for item in self.data["agents"]
|
||
if not item.get("system") and item.get("role") == role
|
||
and goal["id"] in (item.get("goalIds") or [])),
|
||
None,
|
||
)
|
||
if agent is None:
|
||
seq = sum(1 for item in self.data["agents"] if item.get("role") == role) + 1
|
||
agent = self.create_agent(
|
||
f"{tpl.get('label') or role}-{seq}", role,
|
||
created_by=f"GOAL:{goal['id']}", goal_id=goal["id"],
|
||
)
|
||
elif goal["id"] not in (agent.get("goalIds") or []):
|
||
agent.setdefault("goalIds", []).append(goal["id"])
|
||
self.save()
|
||
agents.append(agent)
|
||
return agents
|
||
|
||
def get_goal(self, goal_id: str) -> dict[str, Any] | None:
|
||
return next((g for g in self.data["goals"] if g.get("id") == goal_id), None)
|
||
|
||
# ---------------- 消息总线 ----------------
|
||
def post_message(self, *, from_agent_id: str, to_agent_id: str | None,
|
||
goal_id: str | None, kind: str, text: str,
|
||
run_id: str | None = None,
|
||
evidence_refs: list[str] | None = None) -> dict[str, Any]:
|
||
msg = {
|
||
"id": _uid("msg"), "fromAgentId": from_agent_id,
|
||
"fromName": self._agent_name(from_agent_id),
|
||
"toAgentId": to_agent_id, "toName": self._agent_name(to_agent_id) if to_agent_id else "全体",
|
||
"goalId": goal_id, "runId": run_id, "kind": kind, "text": text,
|
||
"evidenceRefs": list(evidence_refs or []), "createdAt": _now(),
|
||
}
|
||
with self._lock:
|
||
self.data["messages"].append(msg)
|
||
self.data["messages"] = self.data["messages"][-300:]
|
||
self.save()
|
||
return msg
|
||
|
||
def _agent_name(self, agent_id: str | None) -> str:
|
||
if not agent_id:
|
||
return "?"
|
||
agent = next((a for a in self.data["agents"] if a.get("id") == agent_id), None)
|
||
return (agent or {}).get("name") or agent_id
|
||
|
||
# ---------------- 看门狗 ----------------
|
||
def watchdog_tick(self) -> dict[str, Any]:
|
||
"""扫描 RUNNING 超期任务:标记 STALLED → 自动重试/失败 → Goal 升级。幂等可频繁调用。"""
|
||
wd = self.data["watchdog"]
|
||
wd["lastTickAt"] = _now()
|
||
if not wd.get("enabled", True):
|
||
self.save()
|
||
return {"enabled": False, "stalled": 0}
|
||
stalled = 0
|
||
now = time.time()
|
||
for goal in self.data["goals"]:
|
||
if goal.get("status") not in ("ACTIVE", "ATTENTION"):
|
||
continue
|
||
gwd = goal.get("watchdog") or {}
|
||
timeout = float(gwd.get("stallTimeoutSec") or _DEFAULT_STALL_TIMEOUT_SEC)
|
||
max_retries = int(gwd.get("maxRetries") or _DEFAULT_MAX_RETRIES)
|
||
for task in goal.get("tasks") or []:
|
||
if task.get("status") != "RUNNING" or not task.get("startedAt"):
|
||
continue
|
||
try:
|
||
started = datetime.strptime(task["startedAt"], "%Y-%m-%d %H:%M:%S").timestamp()
|
||
except (ValueError, TypeError):
|
||
continue
|
||
if now - started <= timeout:
|
||
continue
|
||
stalled += 1
|
||
attempts = int(task.get("attempts") or 0)
|
||
assignee = task.get("assigneeName") or "未分配"
|
||
if attempts <= max_retries:
|
||
task["status"] = "PENDING"
|
||
task["startedAt"] = None
|
||
task["assigneeAgentId"] = None
|
||
task["assigneeName"] = None
|
||
self.post_message(
|
||
from_agent_id=WATCHDOG_AGENT_ID, to_agent_id=None, goal_id=goal["id"],
|
||
kind="ALERT",
|
||
text=f"任务「{task['title']}」在 {assignee} 上超过 {timeout:.0f}s 未完成,已收回并重新排队(第 {attempts} 次重试)。")
|
||
else:
|
||
task["status"] = "FAILED"
|
||
task["error"] = f"看门狗:超过 {timeout:.0f}s 未完成且重试耗尽"
|
||
task["finishedAt"] = _now()
|
||
goal["status"] = "ATTENTION"
|
||
self.post_message(
|
||
from_agent_id=WATCHDOG_AGENT_ID, to_agent_id=None, goal_id=goal["id"],
|
||
kind="ALERT",
|
||
text=f"任务「{task['title']}」重试 {max_retries} 次仍超时,标记 FAILED,Goal「{goal['title']}」升级为 ATTENTION。")
|
||
if stalled:
|
||
wd.setdefault("alerts", []).append({"at": _now(), "stalled": stalled})
|
||
wd["alerts"] = wd["alerts"][-50:]
|
||
self.save()
|
||
return {"enabled": True, "stalled": stalled, "lastTickAt": wd["lastTickAt"]}
|
||
|
||
def configure_watchdog(self, *, stall_timeout_sec: float | None = None,
|
||
max_retries: int | None = None, enabled: bool | None = None) -> dict[str, Any]:
|
||
wd = self.data["watchdog"]
|
||
if stall_timeout_sec is not None:
|
||
wd["stallTimeoutSec"] = max(5.0, float(stall_timeout_sec))
|
||
if max_retries is not None:
|
||
wd["maxRetries"] = max(0, int(max_retries))
|
||
if enabled is not None:
|
||
wd["enabled"] = bool(enabled)
|
||
self.save()
|
||
return wd
|
||
|
||
# ---------------- 快照 ----------------
|
||
def reset(self) -> dict[str, Any]:
|
||
"""恢复起始状态:清空 Goal/消息/告警,只保留系统看门狗智能体。"""
|
||
with self._lock:
|
||
self.data["agents"] = [a for a in self.data["agents"] if a.get("system")]
|
||
self.data["goals"] = []
|
||
self.data["messages"] = []
|
||
self.data["watchdog"]["alerts"] = []
|
||
self.save()
|
||
return self.snapshot()
|
||
|
||
def snapshot(self) -> dict[str, Any]:
|
||
with self._lock:
|
||
return json.loads(json.dumps({
|
||
"agents": self.data["agents"],
|
||
"goals": self.data["goals"],
|
||
"messages": self.data["messages"][-100:],
|
||
"watchdog": self.data["watchdog"],
|
||
"roleTemplates": [
|
||
{"role": role, **{k: v for k, v in tpl.items()}}
|
||
for role, tpl in ROLE_TEMPLATES.items()
|
||
],
|
||
"goalTemplates": [
|
||
{"key": key, "label": tpl["label"], "taskCount": len(tpl["tasks"])}
|
||
for key, tpl in GOAL_TEMPLATES.items()
|
||
],
|
||
}))
|
||
|
||
|
||
_MESH: MeshStore | None = None
|
||
_MESH_LOCK = threading.Lock()
|
||
|
||
|
||
def get_mesh() -> MeshStore:
|
||
global _MESH
|
||
with _MESH_LOCK:
|
||
if _MESH is None:
|
||
_MESH = MeshStore()
|
||
return _MESH
|
||
|
||
|
||
# ---------------- 分发执行(真实业务管线) ----------------
|
||
_DISPATCH_THREADS: dict[str, threading.Thread] = {}
|
||
|
||
|
||
def dispatch_goal(goal_id: str, *, session_id: str | None = None) -> dict[str, Any]:
|
||
"""异步分发 Goal:按依赖序把任务分给有能力的空闲智能体,必要时自动新建。"""
|
||
mesh = get_mesh()
|
||
goal = mesh.get_goal(goal_id)
|
||
if goal is None:
|
||
raise ValueError(f"Goal 不存在:{goal_id}")
|
||
# Goal 绑定的项目世界与当前活动世界不一致时先切换,保证任务打在正确的数据边界上
|
||
goal_world = (goal.get("worldKey") or "").strip()
|
||
if goal_world:
|
||
try:
|
||
from server.state.store import get_store, switch_store
|
||
if getattr(get_store(), "world_key", "default") != goal_world:
|
||
switch_store(goal_world)
|
||
except Exception as exc: # noqa: BLE001 - 项目边界不可用必须失败关闭
|
||
logger.exception("mesh: switch store to %s failed", goal_world)
|
||
raise RuntimeError(f"Goal 绑定的项目世界不可用:{goal_world}") from exc
|
||
eff_session = session_id or (goal.get("sessionId") or "") or f"mesh-{goal_id}"
|
||
try:
|
||
from server.auth.context import get_identity
|
||
identity = get_identity()
|
||
except Exception: # noqa: BLE001 - 无请求身份时用系统兜底
|
||
identity = None
|
||
running = DispatchThread(goal_id=goal_id, session_id=eff_session,
|
||
world_key=goal_world or None, identity=identity)
|
||
if goal_id in _DISPATCH_THREADS and _DISPATCH_THREADS[goal_id].is_alive():
|
||
return {"message": "该 Goal 已在分发执行中", "goalId": goal_id, "alreadyRunning": True}
|
||
_DISPATCH_THREADS[goal_id] = running
|
||
running.start()
|
||
return {"message": f"Goal「{goal['title']}」已开始分发执行", "goalId": goal_id, "alreadyRunning": False}
|
||
|
||
|
||
def resume_goal_after_confirmation(
|
||
confirm_id: str, *, approve: bool, actor: str,
|
||
) -> dict[str, Any]:
|
||
"""把人工审批结果回写到 Mesh,并只在批准后恢复 Goal 分发。
|
||
|
||
该函数只消费 Mesh 自己登记过的 confirmationId;普通业务确认卡不会命中,
|
||
因此不会改变既有审批路径。重复调用通过任务内确认历史实现幂等。
|
||
"""
|
||
requested = str(confirm_id or "").strip()
|
||
if not requested:
|
||
return {"matched": False, "resolved": False, "resumed": False}
|
||
|
||
mesh = get_mesh()
|
||
actor_name = str(actor or "unknown")
|
||
matched_goal: dict[str, Any] | None = None
|
||
matched_task: dict[str, Any] | None = None
|
||
|
||
with mesh._lock:
|
||
for goal in mesh.data.get("goals") or []:
|
||
for task in goal.get("tasks") or []:
|
||
pending_ids = [str(item) for item in (task.get("confirmationIds") or [])]
|
||
resolutions = task.get("confirmationResolutions") or []
|
||
if requested in pending_ids:
|
||
if task.get("status") != "AWAITING_APPROVAL":
|
||
return {
|
||
"matched": True,
|
||
"resolved": False,
|
||
"resumed": False,
|
||
"reason": "TASK_NOT_AWAITING_APPROVAL",
|
||
"goalId": goal.get("id"),
|
||
"taskId": task.get("id"),
|
||
"taskStatus": task.get("status"),
|
||
}
|
||
matched_goal, matched_task = goal, task
|
||
break
|
||
if any(str(item.get("confirmId") or "") == requested for item in resolutions):
|
||
return {
|
||
"matched": True,
|
||
"resolved": True,
|
||
"resumed": False,
|
||
"reason": "ALREADY_RESOLVED",
|
||
"goalId": goal.get("id"),
|
||
"taskId": task.get("id"),
|
||
"taskStatus": task.get("status"),
|
||
"alreadyRunning": bool(
|
||
goal.get("id") in _DISPATCH_THREADS
|
||
and _DISPATCH_THREADS[goal["id"]].is_alive()
|
||
),
|
||
}
|
||
if matched_task is not None:
|
||
break
|
||
|
||
if matched_goal is None or matched_task is None:
|
||
return {"matched": False, "resolved": False, "resumed": False}
|
||
|
||
now = _now()
|
||
matched_task["confirmationIds"] = []
|
||
matched_task["finishedAt"] = now
|
||
resolutions = matched_task.setdefault("confirmationResolutions", [])
|
||
resolutions.append({
|
||
"confirmId": requested,
|
||
"approve": bool(approve),
|
||
"actor": actor_name,
|
||
"at": now,
|
||
})
|
||
if approve:
|
||
matched_task["status"] = "DONE"
|
||
matched_task["result"] = f"人工已批准 {requested} by {actor_name}"
|
||
matched_task["error"] = None
|
||
matched_goal["status"] = "ACTIVE"
|
||
mesh.post_message(
|
||
from_agent_id=WATCHDOG_AGENT_ID,
|
||
to_agent_id=mesh._agent_for_role(matched_task.get("role")),
|
||
goal_id=matched_goal.get("id"),
|
||
run_id=matched_goal.get("runId"),
|
||
kind="RESPONSE",
|
||
evidence_refs=[f"confirm:{requested}"],
|
||
text=(f"人工批准 {requested}({actor_name}),任务"
|
||
f"「{matched_task.get('title')}」已完成,Goal 继续分发。"),
|
||
)
|
||
else:
|
||
matched_task["status"] = "FAILED"
|
||
matched_task["error"] = f"人工驳回 {requested} by {actor_name}"
|
||
matched_goal["status"] = "ATTENTION"
|
||
mesh.post_message(
|
||
from_agent_id=WATCHDOG_AGENT_ID,
|
||
to_agent_id=None,
|
||
goal_id=matched_goal.get("id"),
|
||
run_id=matched_goal.get("runId"),
|
||
kind="ALERT",
|
||
evidence_refs=[f"confirm:{requested}"],
|
||
text=(f"人工驳回 {requested}({actor_name}),任务"
|
||
f"「{matched_task.get('title')}」已失败,Goal 停止分发。"),
|
||
)
|
||
mesh.save()
|
||
|
||
if not approve:
|
||
return {
|
||
"matched": True,
|
||
"resolved": True,
|
||
"resumed": False,
|
||
"goalId": matched_goal.get("id"),
|
||
"taskId": matched_task.get("id"),
|
||
"taskStatus": "FAILED",
|
||
}
|
||
|
||
dispatch_result = dispatch_goal(
|
||
str(matched_goal["id"]), session_id=matched_goal.get("sessionId"),
|
||
)
|
||
already_running = bool(dispatch_result.get("alreadyRunning"))
|
||
return {
|
||
"matched": True,
|
||
"resolved": True,
|
||
"resumed": True,
|
||
"started": not already_running,
|
||
"alreadyRunning": already_running,
|
||
"goalId": matched_goal.get("id"),
|
||
"taskId": matched_task.get("id"),
|
||
"taskStatus": "DONE",
|
||
"dispatch": dispatch_result,
|
||
}
|
||
|
||
|
||
class DispatchThread(threading.Thread):
|
||
def __init__(self, *, goal_id: str, session_id: str, world_key: str | None = None,
|
||
identity: Any = None) -> None:
|
||
super().__init__(daemon=True, name=f"mesh-dispatch-{goal_id}")
|
||
self.goal_id = goal_id
|
||
self.session_id = session_id
|
||
self.world_key = world_key
|
||
self.identity = identity
|
||
|
||
def run(self) -> None: # noqa: D102
|
||
mesh = get_mesh()
|
||
goal = mesh.get_goal(self.goal_id)
|
||
if goal is None:
|
||
return
|
||
token = None
|
||
if self.identity is not None:
|
||
try:
|
||
from server.auth.context import bind_identity
|
||
token = bind_identity(self.identity) # 项目仓按用户隔离:绑回创建时身份
|
||
except Exception: # noqa: BLE001
|
||
token = None
|
||
try:
|
||
while True:
|
||
task = self._next_ready(goal)
|
||
if task is None:
|
||
break
|
||
self._run_task(mesh, goal, task)
|
||
self._finish_goal(mesh, goal)
|
||
except Exception as exc: # noqa: BLE001 - 编排兜底:任何异常落 Goal,不炸线程
|
||
logger.exception("mesh dispatch failed")
|
||
goal["status"] = "ATTENTION"
|
||
mesh.post_message(from_agent_id=WATCHDOG_AGENT_ID, to_agent_id=None,
|
||
goal_id=self.goal_id, kind="ALERT",
|
||
text=f"分发执行异常:{exc}")
|
||
mesh.save()
|
||
finally:
|
||
if token is not None:
|
||
try:
|
||
from server.auth.context import reset_identity
|
||
reset_identity(token)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
def _next_ready(self, goal: dict[str, Any]) -> dict[str, Any] | None:
|
||
done_keys = {t["key"] for t in goal["tasks"] if t.get("status") == "DONE"}
|
||
for task in goal["tasks"]:
|
||
if task.get("status") == "PENDING" and all(dep in done_keys for dep in task.get("dependsOn") or []):
|
||
return task
|
||
# 还有 RUNNING(看门狗收回中会重新 PENDING)则等待
|
||
if any(t.get("status") == "RUNNING" for t in goal["tasks"]):
|
||
time.sleep(1.0)
|
||
return self._next_ready(goal)
|
||
return None
|
||
|
||
def _run_task(self, mesh: MeshStore, goal: dict[str, Any], task: dict[str, Any]) -> None:
|
||
intent = task["intent"]
|
||
role = task.get("role")
|
||
agent = mesh._capable_idle(intent, role) or mesh._spawn_for_intent(
|
||
intent, created_by=f"GOAL:{goal['id']}", goal_id=goal["id"], role=role)
|
||
if agent is None:
|
||
task["status"] = "FAILED"
|
||
task["error"] = f"没有任何角色模板能执行 {intent}"
|
||
task["finishedAt"] = _now()
|
||
goal["status"] = "ATTENTION"
|
||
mesh.save()
|
||
return
|
||
task["status"] = "RUNNING"
|
||
task["assigneeAgentId"] = agent["id"]
|
||
task["assigneeName"] = agent["name"]
|
||
task["attempts"] = int(task.get("attempts") or 0) + 1
|
||
task["startedAt"] = _now()
|
||
agent["status"] = "RUNNING"
|
||
agent["currentTaskId"] = task["id"]
|
||
agent["heartbeatAt"] = _now()
|
||
mesh.save()
|
||
mesh.post_message(from_agent_id=WATCHDOG_AGENT_ID, to_agent_id=agent["id"],
|
||
goal_id=goal["id"], run_id=goal.get("runId"), kind="REQUEST",
|
||
text=f"分发任务「{task['title']}」({intent}),第 {task['attempts']} 次尝试。")
|
||
try:
|
||
text, blocks = _execute_intent(self.session_id, intent, task.get("params") or {}, agent,
|
||
world_key=self.world_key, goal=goal, task=task)
|
||
pending_cards = [
|
||
str((block.get("props") or {}).get("confirmId"))
|
||
for block in blocks or []
|
||
if (block.get("props") or {}).get("confirmId")
|
||
]
|
||
if pending_cards:
|
||
task["status"] = "AWAITING_APPROVAL"
|
||
task["confirmationIds"] = pending_cards
|
||
task["result"] = (text or "")[:600]
|
||
task["finishedAt"] = None
|
||
agent["status"] = "IDLE"
|
||
agent["currentTaskId"] = None
|
||
agent["heartbeatAt"] = _now()
|
||
goal["status"] = "ATTENTION"
|
||
mesh.save()
|
||
mesh.post_message(
|
||
from_agent_id=agent["id"], to_agent_id=WATCHDOG_AGENT_ID,
|
||
goal_id=goal["id"], run_id=goal.get("runId"), kind="ALERT",
|
||
evidence_refs=[f"confirm:{cid}" for cid in pending_cards],
|
||
text=(f"「{task['title']}」需要人工审批,执行智能体不会自动批准。"
|
||
f"待确认:{', '.join(pending_cards)}"),
|
||
)
|
||
return
|
||
# 排产/分解被阻断(fail-closed 文案)不算任务成功:落 FAILED 交看门狗升级
|
||
if "没有可下达的草稿建议单" in text and _released_supply_exists(self.world_key):
|
||
text = "供应建议单此前已下达(RELEASED 在账),本次幂等跳过。"
|
||
elif "被阻断" in text or "无法生成" in text or "没有可下达" in text:
|
||
raise RuntimeError(text.strip().splitlines()[0][:200])
|
||
task["status"] = "DONE"
|
||
task["result"] = (text or "")[:600]
|
||
task["finishedAt"] = _now()
|
||
agent["status"] = "IDLE"
|
||
agent["currentTaskId"] = None
|
||
agent["heartbeatAt"] = _now()
|
||
mesh.save()
|
||
next_task = self._peek_next_task(goal, task)
|
||
nxt = (next_task or {}).get("title")
|
||
summary_line = next(
|
||
(ln for ln in reversed((text or "").splitlines()) if ln.strip()), "")
|
||
next_agent = mesh._agent_for_role((next_task or {}).get("role"))
|
||
mesh.post_message(from_agent_id=agent["id"], to_agent_id=next_agent,
|
||
goal_id=goal["id"], run_id=goal.get("runId"), kind="RESPONSE",
|
||
evidence_refs=[f"mesh-task:{task['id']}"],
|
||
text=f"「{task['title']}」完成 ✅ {summary_line[:80]}"
|
||
+ (f",@{nxt} 请接棒。" if nxt else ",Goal 任务全部就绪。"))
|
||
except Exception as exc: # noqa: BLE001 - 任务失败落状态,交给看门狗/重试
|
||
logger.exception("mesh task failed: %s", intent)
|
||
task["status"] = "FAILED"
|
||
task["error"] = str(exc)[:300]
|
||
task["finishedAt"] = _now()
|
||
agent["status"] = "IDLE"
|
||
agent["currentTaskId"] = None
|
||
goal["status"] = "ATTENTION"
|
||
mesh.save()
|
||
mesh.post_message(from_agent_id=agent["id"], to_agent_id=WATCHDOG_AGENT_ID,
|
||
goal_id=goal["id"], run_id=goal.get("runId"), kind="ALERT",
|
||
text=f"「{task['title']}」执行失败:{exc}")
|
||
|
||
@staticmethod
|
||
def _peek_next_task(goal: dict[str, Any], just_done: dict[str, Any]) -> dict[str, Any] | None:
|
||
done_keys = {t["key"] for t in goal["tasks"] if t.get("status") == "DONE"}
|
||
for task in goal["tasks"]:
|
||
if task.get("status") == "PENDING" and all(dep in done_keys for dep in task.get("dependsOn") or []):
|
||
return task
|
||
return None
|
||
|
||
def _finish_goal(self, mesh: MeshStore, goal: dict[str, Any]) -> None:
|
||
statuses = {t.get("status") for t in goal["tasks"]}
|
||
if statuses and statuses <= {"DONE"}:
|
||
goal["status"] = "ACHIEVED"
|
||
mesh.post_message(from_agent_id=WATCHDOG_AGENT_ID, to_agent_id=None,
|
||
goal_id=goal["id"], kind="INFO",
|
||
text=f"Goal「{goal['title']}」全部任务完成 ✅ 看门狗结案。")
|
||
elif goal.get("status") != "ATTENTION":
|
||
goal["status"] = "ATTENTION"
|
||
mesh.save()
|
||
|
||
|
||
def _execute_intent(session_id: str, intent: str, params: dict[str, Any],
|
||
agent: dict[str, Any], *, world_key: str | None = None,
|
||
goal: dict[str, Any] | None = None,
|
||
task: dict[str, Any] | None = None) -> tuple[str, list[dict[str, Any]]]:
|
||
"""通过真实业务管线执行意图(与对话同一入口 handle_intent)。
|
||
|
||
world_key 指定时先切到 Goal 绑定的项目世界:全局活动世界可能被
|
||
前端工作区同步抢占,必须在每个任务执行当下重新钉住数据边界。
|
||
"""
|
||
from server.agent_core.tool_runtime import run_tool_async
|
||
from server.contracts import IntentResult
|
||
from server.state.store import get_store, switch_store
|
||
|
||
if world_key and getattr(get_store(), "world_key", "default") != world_key:
|
||
switch_store(world_key)
|
||
store = get_store()
|
||
enriched = dict(params or {})
|
||
if goal:
|
||
enriched["_mesh"] = {
|
||
"goalId": goal.get("id"),
|
||
"runId": goal.get("runId"),
|
||
"worldKey": goal.get("worldKey"),
|
||
"sessionId": goal.get("sessionId"),
|
||
"sourceSha256": goal.get("sourceSha256"),
|
||
"projectId": goal.get("projectId"),
|
||
"fromAgentId": agent.get("id"),
|
||
"taskId": (task or {}).get("id"),
|
||
}
|
||
reply = asyncio.run(run_tool_async(
|
||
store, session_id,
|
||
IntentResult(intent=intent, params=enriched, confidence=1.0, source="LLM"),
|
||
actor=f"mesh:{agent['name']}",
|
||
))
|
||
blocks = [b.model_dump() for b in (reply.blocks or [])]
|
||
return reply.text or "", blocks
|
||
|
||
|
||
# ---------------- 看门狗后台循环 ----------------
|
||
_WATCHDOG_STARTED = False
|
||
|
||
|
||
def start_watchdog_loop(interval_sec: float = 3.0) -> None:
|
||
"""启动看门狗后台节拍(幂等)。桌面/服务启动时调用一次。"""
|
||
global _WATCHDOG_STARTED
|
||
if _WATCHDOG_STARTED:
|
||
return
|
||
_WATCHDOG_STARTED = True
|
||
|
||
def _loop() -> None:
|
||
while True:
|
||
try:
|
||
get_mesh().watchdog_tick()
|
||
except Exception: # noqa: BLE001 - 看门狗自身绝不能崩
|
||
logger.exception("mesh watchdog tick failed")
|
||
time.sleep(interval_sec)
|
||
|
||
threading.Thread(target=_loop, daemon=True, name="mesh-watchdog").start()
|
||
|
||
def _released_supply_exists(world_key: str | None) -> bool:
|
||
"""目标世界是否已有非草稿(已下达)采购/委外单:幂等判断依据。"""
|
||
try:
|
||
from server.state.store import get_store, switch_store
|
||
if world_key and getattr(get_store(), "world_key", "default") != world_key:
|
||
switch_store(world_key)
|
||
data = get_store().data
|
||
for row in (data.get("purchaseOrders") or []) + (data.get("outsourceOrders") or []):
|
||
if isinstance(row, dict) and row.get("status") and row.get("status") != "DRAFT":
|
||
return True
|
||
except Exception: # noqa: BLE001 - 判断失败保守返回 False(维持失败语义)
|
||
pass
|
||
return False
|