Files
2026-07-13 13:32:32 +08:00

650 lines
25 KiB
Python

"""Dispatch Worker — 消费 task.dispatch 事件,执行 OpenClaw agent 调用。
核心解决旧架构痛点:
- 旧: daemon 线程 + subprocess.run → kill -9 丢失一切
- 新: Redis Streams ACK 保证 → 崩溃后自动重新投递
流程:
1. 从 task.dispatch stream 消费事件
2. 组装富上下文 (_build_agent_context)
3. 调用 OpenClaw CLI: `openclaw agent --agent xxx -m "..."`
4. 解析 agent 输出(kanban_update.py 调用结果)
5. ACK 事件
"""
import asyncio
import json
import logging
import os
import pathlib
import re
import signal
import subprocess
import tempfile
import time
import uuid
from datetime import datetime, timezone
from ..config import get_settings
from ..services.event_bus import (
EventBus,
TOPIC_TASK_DISPATCH,
TOPIC_TASK_STALLED,
TOPIC_TASK_STATUS,
TOPIC_AGENT_THOUGHTS,
TOPIC_AGENT_HEARTBEAT,
)
log = logging.getLogger("edict.dispatcher")
GROUP = "dispatcher"
CONSUMER = "disp-1"
class DispatchError(Exception):
"""带分类的派发错误。"""
def __init__(self, msg: str, retryable: bool = True):
super().__init__(msg)
self.retryable = retryable
# Agent 分组映射 — 用于加载 group 级 prompt
_GROUP_MAP = {
"taizi": "sansheng",
"zhongshu": "sansheng",
"menxia": "sansheng",
"shangshu": "sansheng",
"hubu": "liubu",
"libu": "liubu",
"bingbu": "liubu",
"xingbu": "liubu",
"gongbu": "liubu",
"libu_hr": "liubu",
"zaochao": None,
}
def _resolve_agents_dir() -> pathlib.Path:
"""定位 agents/ 目录。"""
settings = get_settings()
if settings.openclaw_project_dir:
return pathlib.Path(settings.openclaw_project_dir) / "agents"
# 默认: 相对于 edict/backend 上溯到项目根
return pathlib.Path(__file__).resolve().parents[4] / "agents"
def _build_soul_context(agent_id: str) -> str:
"""拼装三层 prompt 层级:GLOBAL.md → group/*.md → {agent}/SOUL.md。"""
agents_dir = _resolve_agents_dir()
parts = []
global_md = agents_dir / "GLOBAL.md"
if global_md.exists():
parts.append(global_md.read_text(encoding="utf-8"))
group = _GROUP_MAP.get(agent_id)
if group:
group_md = agents_dir / "groups" / f"{group}.md"
if group_md.exists():
parts.append(group_md.read_text(encoding="utf-8"))
soul_md = agents_dir / agent_id / "SOUL.md"
if soul_md.exists():
parts.append(soul_md.read_text(encoding="utf-8"))
return "\n---\n".join(parts) if parts else ""
def _build_task_context(payload: dict) -> str:
"""从 dispatch 事件 payload 中提取结构化任务上下文。"""
sections = []
task_id = payload.get("task_id", "")
title = payload.get("title", "")
description = payload.get("description", "")
state = payload.get("state", "")
org = payload.get("org", "")
priority = payload.get("priority", "中")
tags = payload.get("tags", [])
sections.append(f"## 当前任务\n- ID: {task_id}\n- 标题: {title}\n- 状态: {state}\n- 部门: {org}\n- 优先级: {priority}")
if tags:
sections.append(f"- 标签: {', '.join(tags)}")
if description:
sections.append(f"\n### 任务描述\n{description}")
# Todos
todos = payload.get("todos", [])
if todos:
todo_lines = []
for t in todos:
status_icon = {"completed": "✅", "in-progress": "🔄"}.get(t.get("status", ""), "⬜")
todo_lines.append(f" {status_icon} {t.get('title', '')}")
sections.append(f"\n### 子任务\n" + "\n".join(todo_lines))
# 最近流转记录 (最多 5 条)
flow_log = payload.get("flow_log", [])
if flow_log:
recent = flow_log[-5:]
flow_lines = [f" - [{e.get('at', '')}] {e.get('from', '')}{e.get('to', '')}: {e.get('remark', '')}" for e in recent]
sections.append(f"\n### 最近流转\n" + "\n".join(flow_lines))
# 最近进展 (最多 3 条)
progress_log = payload.get("progress_log", [])
if progress_log:
recent = progress_log[-3:]
prog_lines = [f" - [{e.get('at', '')}] {e.get('agentLabel', e.get('agent', ''))}: {e.get('text', '')}" for e in recent]
sections.append(f"\n### 最近进展\n" + "\n".join(prog_lines))
# 阻塞信息
block = payload.get("block", "")
if block and block != "无":
sections.append(f"\n### ⚠️ 阻塞\n{block}")
return "\n".join(sections)
def _build_reminder(agent_id: str, payload: dict) -> str:
"""在 prompt 尾部注入动态提醒(借鉴 Claude 的 reminderInstructions)。"""
reminders = []
state = payload.get("state", "")
if state == "Doing":
reminders.append("先创建 todo 分解任务,再开始执行。每完成一步立即用 progress 上报。")
elif state == "Review":
reminders.append("这是复审任务。审核完毕后用 state 命令流转状态,附带审核意见。")
elif state == "Menxia":
reminders.append("门下省审核:通过则流转 Assigned,不通过则退回 Zhongshu 并说明原因。")
# 如果有未完成的 todos,提醒继续
todos = payload.get("todos", [])
in_progress = [t for t in todos if t.get("status") == "in-progress"]
not_started = [t for t in todos if t.get("status") == "not-started"]
if in_progress:
reminders.append(f"有 {len(in_progress)} 个进行中的子任务,优先完成它们。")
elif not_started:
reminders.append(f"有 {len(not_started)} 个待开始的子任务。")
# 阻塞提醒
block = payload.get("block", "")
if block and block != "无":
reminders.append(f"⚠️ 存在阻塞: {block}。如已解除,先更新状态再继续。")
if not reminders:
return ""
return "\n\n## ⚡ Reminder\n" + "\n".join(f"- {r}" for r in reminders)
def _resolve_project_root() -> pathlib.Path:
"""定位项目根目录。"""
settings = get_settings()
if settings.openclaw_project_dir:
return pathlib.Path(settings.openclaw_project_dir)
return pathlib.Path(__file__).resolve().parents[4]
def _build_memory_context(agent_id: str, task_id: str, payload: dict) -> str:
"""分层注入三级记忆:全局规则 → Agent 经验 → 任务上下文。"""
root = _resolve_project_root()
parts = []
# 1. 全局共享记忆 — 始终注入
shared_file = root / "data" / "shared_memory.json"
if shared_file.exists():
try:
shared = json.loads(shared_file.read_text(encoding="utf-8"))
rules = shared.get("rules", [])
if rules:
rule_lines = [r.get("content", "") for r in rules[-20:]]
parts.append("## 全局规则\n" + "\n".join(f"- {r}" for r in rule_lines if r))
except (json.JSONDecodeError, OSError):
pass
# 2. Agent 永久记忆 — 按相关性过滤,最多 50 条
agent_mem_file = root / "data" / "agent_memory" / f"{agent_id}.json"
if agent_mem_file.exists():
try:
agent_data = json.loads(agent_mem_file.read_text(encoding="utf-8"))
memories = agent_data.get("memories", [])
if memories:
# 相关性排序:pinned 优先,其次按 tags 交集匹配当前任务
task_tags = set(payload.get("tags", []))
task_org = payload.get("org", "")
if task_org:
task_tags.add(task_org)
def _relevance(m):
pinned = 1 if m.get("pinned") else 0
overlap = len(task_tags & set(m.get("relevance_tags", [])))
is_feedback = 1 if m.get("type") == "feedback" else 0
return (pinned, overlap, is_feedback)
memories.sort(key=_relevance, reverse=True)
top = memories[:50]
mem_lines = [f"- [{m.get('type', '')}] {m.get('content', '')}" for m in top]
parts.append("## 历史经验\n" + "\n".join(mem_lines))
except (json.JSONDecodeError, OSError):
pass
# 3. 任务上下文记忆 — 完整注入上游 Agent 决策链
task_mem_file = root / "data" / "task_memory" / f"{task_id}.json"
if task_mem_file.exists():
try:
task_data = json.loads(task_mem_file.read_text(encoding="utf-8"))
chain = task_data.get("context_chain", [])
if chain:
chain_lines = []
for c in chain:
decisions = ", ".join(c.get("key_decisions", []))
warnings = ", ".join(c.get("warnings", []))
line = f"- [{c.get('phase', '')}] {c.get('agent', '')}: {decisions}"
if warnings:
line += f" ⚠️ {warnings}"
chain_lines.append(line)
parts.append("## 上游决策链\n" + "\n".join(chain_lines))
except (json.JSONDecodeError, OSError):
pass
if not parts:
return ""
return "\n---\n".join(parts)
# ── Prompt 注入检测 ──
_INJECTION_PATTERNS = [
re.compile(r"忽略.{0,20}(指令|规则|协议)", re.IGNORECASE),
re.compile(r"ignore.{0,20}(instructions|rules|above)", re.IGNORECASE),
re.compile(r"system\s*:\s*", re.IGNORECASE),
re.compile(r"<\s*system\s*>", re.IGNORECASE),
re.compile(r"你(现在)?是.{0,10}(管理员|超级用户)", re.IGNORECASE),
re.compile(r"override|bypass|skip.{0,10}(check|review|approval)", re.IGNORECASE),
]
def _sanitize_agent_output(output: str, agent_id: str) -> tuple[str, list[str]]:
"""检测 Agent 输出中的注入模式。返回 (原始文本, 告警列表)。"""
warnings = []
for pattern in _INJECTION_PATTERNS:
match = pattern.search(output)
if match:
warnings.append(
f"Agent {agent_id} 输出触发注入检测: '{match.group()}' (pattern: {pattern.pattern})"
)
return output, warnings
def _load_agent_skills(agent_id: str, payload: dict) -> str:
"""按任务特征动态加载 Agent Skills(延迟能力加载)。"""
agents_dir = _resolve_agents_dir()
manifest_path = agents_dir / agent_id / "skills" / "manifest.json"
if not manifest_path.exists():
return ""
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return ""
task_tags = set(payload.get("tags", []))
task_org = payload.get("org", "")
matched_skills = []
for skill in manifest.get("skills", []):
tag_match = task_tags & set(skill.get("match_tags", []))
org_match = task_org in skill.get("match_orgs", [])
if tag_match or org_match:
skill_path = agents_dir / agent_id / "skills" / skill["file"]
if skill_path.exists():
try:
matched_skills.append(skill_path.read_text(encoding="utf-8"))
except OSError:
pass
if matched_skills:
return "## 本次任务相关技能\n" + "\n---\n".join(matched_skills)
return ""
class DispatchWorker:
"""Agent 派发 Worker — 快慢 Agent 分桶并发控制。"""
# 快/慢 Agent 分桶 — 互不阻塞
_BUCKET_CONFIG = {
"fast": {"agents": {"taizi", "zhongshu", "menxia", "shangshu", "zaochao"}, "limit": 4},
"slow": {"agents": {"hubu", "libu", "bingbu", "xingbu", "gongbu", "libu_hr"}, "limit": 3},
}
def __init__(self):
self.bus = EventBus()
self._running = False
self._buckets: dict[str, asyncio.Semaphore] = {
name: asyncio.Semaphore(cfg["limit"])
for name, cfg in self._BUCKET_CONFIG.items()
}
self._active_tasks: dict[str, asyncio.Task] = {}
self._inflight: set[str] = set()
# 执行时间记录(仅用于监控告警)
self._durations: dict[str, list[float]] = {}
def _get_bucket(self, agent_id: str) -> asyncio.Semaphore:
"""根据 agent 类型返回对应桶的信号量。"""
for name, cfg in self._BUCKET_CONFIG.items():
if agent_id in cfg["agents"]:
return self._buckets[name]
return self._buckets["slow"] # 未知 Agent 归入慢桶
async def start(self):
await self.bus.connect()
await self.bus.ensure_consumer_group(TOPIC_TASK_DISPATCH, GROUP)
self._running = True
log.info("🚀 Dispatch worker started")
# 恢复崩溃遗留
await self._recover_pending()
while self._running:
try:
await self._poll_cycle()
except Exception as e:
log.error(f"Dispatch poll error: {e}", exc_info=True)
await asyncio.sleep(2)
async def stop(self):
self._running = False
# 等待进行中的 agent 调用完成
if self._active_tasks:
log.info(f"Waiting for {len(self._active_tasks)} active dispatches...")
await asyncio.gather(*self._active_tasks.values(), return_exceptions=True)
await self.bus.close()
log.info("Dispatch worker stopped")
async def _recover_pending(self):
events = await self.bus.claim_stale(
TOPIC_TASK_DISPATCH, GROUP, CONSUMER, min_idle_ms=60000, count=20
)
if events:
log.info(f"Recovering {len(events)} stale dispatch events")
for entry_id, event in events:
await self._dispatch(entry_id, event)
async def _poll_cycle(self):
events = await self.bus.consume(
TOPIC_TASK_DISPATCH, GROUP, CONSUMER, count=3, block_ms=2000
)
for entry_id, event in events:
# 每个派发在独立任务中执行,带并发控制
task = asyncio.create_task(self._dispatch(entry_id, event))
task_id = event.get("payload", {}).get("task_id", entry_id)
self._active_tasks[task_id] = task
task.add_done_callback(lambda t, tid=task_id: self._active_tasks.pop(tid, None))
async def _dispatch(self, entry_id: str, event: dict):
"""执行一次 agent 派发(桶级并发控制)。"""
payload = event.get("payload", {})
task_id = payload.get("task_id", "")
agent = payload.get("agent", "")
message = payload.get("message", "")
trace_id = event.get("trace_id", "")
state = payload.get("state", "")
# 去重:同一任务如果已在派发中,跳过并 ACK
if task_id in self._inflight:
log.warning(f"⚡ Skipping duplicate dispatch for task {task_id} (already in-flight)")
await self.bus.ack(TOPIC_TASK_DISPATCH, GROUP, entry_id)
return
self._inflight.add(task_id)
sem = self._get_bucket(agent)
async with sem:
log.info(f"🔄 Dispatching task {task_id} → agent '{agent}' state={state}")
# 组装富上下文
task_context = _build_task_context(payload)
reminder = _build_reminder(agent, payload)
memory_context = _build_memory_context(agent, task_id, payload)
skills_context = _load_agent_skills(agent, payload)
enriched_message = message
if task_context:
enriched_message = f"{message}\n\n---\n{task_context}"
if memory_context:
enriched_message = f"{enriched_message}\n\n---\n{memory_context}"
if skills_context:
enriched_message = f"{enriched_message}\n\n---\n{skills_context}"
if reminder:
enriched_message = f"{enriched_message}\n{reminder}"
# 发布心跳
await self.bus.publish(
topic=TOPIC_AGENT_HEARTBEAT,
trace_id=trace_id,
event_type="agent.dispatch.start",
producer="dispatcher",
payload={"task_id": task_id, "agent": agent},
)
try:
start_time = time.monotonic()
result = await self._call_openclaw(agent, enriched_message, task_id, trace_id, payload)
elapsed = time.monotonic() - start_time
# 记录执行时间(仅用于监控和告警)
self._durations.setdefault(agent, []).append(elapsed)
if len(self._durations[agent]) > 20:
self._durations[agent] = self._durations[agent][-20:]
avg = sum(self._durations[agent]) / len(self._durations[agent])
if elapsed > 2 * avg and elapsed > 120:
log.warning(f"⚠️ Agent {agent} slowdown: {elapsed:.0f}s (avg: {avg:.0f}s)")
# Prompt 注入检测
stdout = result.get("stdout", "")
stdout, injection_warnings = _sanitize_agent_output(stdout, agent)
if injection_warnings:
for w in injection_warnings:
log.warning(f"🛡️ {w}")
# 发布注入告警事件
await self.bus.publish(
topic=TOPIC_TASK_STALLED,
trace_id=trace_id,
event_type="agent.injection.detected",
producer="dispatcher",
payload={
"task_id": task_id,
"agent": agent,
"warnings": injection_warnings,
},
)
# 发布 agent 输出
await self.bus.publish(
topic=TOPIC_AGENT_THOUGHTS,
trace_id=trace_id,
event_type="agent.output",
producer=f"agent.{agent}",
payload={
"task_id": task_id,
"agent": agent,
"output": stdout,
"return_code": result.get("returncode", -1),
"injection_warnings": injection_warnings or None,
},
)
if result.get("returncode") == 0:
log.info(f"✅ Agent '{agent}' completed task {task_id}")
await self.bus.ack(TOPIC_TASK_DISPATCH, GROUP, entry_id)
return
# 失败分类
stderr = result.get("stderr", "")
if "TIMEOUT" in stderr:
raise DispatchError("Agent timeout", retryable=True)
elif "command not found" in stderr:
raise DispatchError("openclaw binary missing", retryable=False)
elif result["returncode"] in (1, 2):
raise DispatchError(
f"Agent failed: rc={result['returncode']}", retryable=True
)
else:
raise DispatchError(
f"Unknown error: rc={result['returncode']}", retryable=False
)
except DispatchError as e:
delivery_count = await self.bus.get_delivery_count(
TOPIC_TASK_DISPATCH, GROUP, entry_id
)
if e.retryable and delivery_count < 3:
log.warning(
f"🔄 Retryable failure for {task_id}, attempt {delivery_count + 1}/3: {e}"
)
return # 不 ACK → Redis 自动重投递
# 不可重试 or 重试耗尽 → ACK + 发布失败事件 + DLQ
log.error(
f"💀 Dispatch dead-lettered: {task_id}{agent} "
f"(retryable={e.retryable}, attempts={delivery_count + 1}): {e}"
)
await self.bus.publish(
topic=TOPIC_TASK_STALLED,
trace_id=trace_id,
event_type="task.dispatch.failed",
producer="dispatcher",
payload={
"task_id": task_id,
"agent": agent,
"error": str(e),
"retryable": e.retryable,
"attempts": delivery_count + 1,
},
)
await self.bus.publish(
topic="dead_letter",
trace_id=trace_id,
event_type="task.dispatch.dead_letter",
producer="dispatcher",
payload={
"task_id": task_id,
"agent": agent,
"error": str(e),
},
)
await self.bus.ack(TOPIC_TASK_DISPATCH, GROUP, entry_id)
except Exception as e:
log.error(f"❌ Dispatch failed: task {task_id}{agent}: {e}", exc_info=True)
# 不 ACK → Redis 会重新投递给其他消费者
finally:
self._inflight.discard(task_id)
async def _call_openclaw(
self,
agent: str,
message: str,
task_id: str,
trace_id: str,
payload: dict | None = None,
) -> dict:
"""异步调用 OpenClaw CLI — 在线程池中执行,带富上下文注入。"""
settings = get_settings()
cmd = [
settings.openclaw_bin,
"agent",
"--agent", agent,
"-m", message,
]
env = os.environ.copy()
env["EDICT_TASK_ID"] = task_id
env["EDICT_TRACE_ID"] = trace_id
env["EDICT_API_URL"] = f"http://localhost:{settings.port}"
# 注入额外上下文环境变量
if payload:
env["EDICT_TASK_TITLE"] = payload.get("title", "")
env["EDICT_TASK_STATE"] = payload.get("state", "")
env["EDICT_TASK_ORG"] = payload.get("org", "")
env["EDICT_TASK_PRIORITY"] = payload.get("priority", "中")
tags = payload.get("tags", [])
if tags:
env["EDICT_TASK_TAGS"] = ",".join(str(t) for t in tags)
# 写入临时上下文文件(大型上下文通过文件传递,避免命令行参数过长)
context_file = None
if payload:
context_data = {
"task_id": task_id,
"trace_id": trace_id,
"title": payload.get("title", ""),
"description": payload.get("description", ""),
"state": payload.get("state", ""),
"org": payload.get("org", ""),
"priority": payload.get("priority", "中"),
"tags": payload.get("tags", []),
"todos": payload.get("todos", []),
"flow_log": payload.get("flow_log", [])[-10:],
"progress_log": payload.get("progress_log", [])[-5:],
"block": payload.get("block", ""),
"meta": payload.get("meta", {}),
}
try:
fd, context_file = tempfile.mkstemp(suffix=".json", prefix=f"edict_ctx_{task_id}_")
with os.fdopen(fd, "w") as f:
json.dump(context_data, f, ensure_ascii=False, indent=2)
env["EDICT_CONTEXT_FILE"] = context_file
except Exception as e:
log.warning(f"Failed to write context file for {task_id}: {e}")
log.debug(f"Executing: {' '.join(cmd)}")
def _run():
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300,
env=env,
cwd=settings.openclaw_project_dir or None,
)
return {
"returncode": proc.returncode,
"stdout": proc.stdout[-5000:] if proc.stdout else "",
"stderr": proc.stderr[-2000:] if proc.stderr else "",
}
except subprocess.TimeoutExpired:
return {"returncode": -1, "stdout": "", "stderr": "TIMEOUT after 300s"}
except FileNotFoundError:
return {"returncode": -1, "stdout": "", "stderr": "openclaw command not found"}
finally:
# 清理临时上下文文件
if context_file:
try:
os.unlink(context_file)
except OSError:
pass
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _run)
async def run_dispatcher():
"""入口函数 — 用于直接运行 worker。"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
worker = DispatchWorker()
loop = asyncio.get_event_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, lambda: asyncio.create_task(worker.stop()))
await worker.start()
if __name__ == "__main__":
asyncio.run(run_dispatcher())