93e8a1011b
Co-authored-by: multica-agent <github@multica.ai>
475 lines
15 KiB
Python
475 lines
15 KiB
Python
"""
|
||
heartbeat_helper.py — 高频 Agent 心跳辅助脚本
|
||
|
||
提供心跳脚本中所有通用功能,底层通过 multica_proxy 调用 multica CLI,
|
||
自动享受缓存和限流保护。
|
||
|
||
用法:
|
||
from heartbeat_helper import check_my_tasks, check_timeouts, check_dependencies
|
||
|
||
作者:陆怀瑾(COO)
|
||
日期:2026-06-23
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import time
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
if _SCRIPT_DIR not in sys.path:
|
||
sys.path.insert(0, _SCRIPT_DIR)
|
||
|
||
from multica_proxy import (
|
||
run_multica,
|
||
multica_issue_list_my_todo,
|
||
multica_issue_list_in_progress,
|
||
multica_issue_get,
|
||
openclaw_workboard_list,
|
||
openclaw_workboard_read,
|
||
get_cache_stats,
|
||
clear_cache,
|
||
start_coordinated_poller,
|
||
subscribe_to_poller,
|
||
get_poller_status,
|
||
health_check,
|
||
)
|
||
|
||
|
||
# ============================================================================
|
||
# Agent 配置
|
||
# ============================================================================
|
||
|
||
AGENT_CONFIGS = {
|
||
"coo": {
|
||
"name": "陆怀瑾",
|
||
"multica_uuid": "1c38b437-b54d-4784-bda3-29ce4c8a6722",
|
||
"openclaw_agent_id": "coo",
|
||
"is_coo": True,
|
||
},
|
||
"secretary": {
|
||
"name": "刘诗妮",
|
||
"multica_uuid": "b024fcdc-30ff-420d-b289-498041466e1b",
|
||
"openclaw_agent_id": "secretary",
|
||
"is_coo": False,
|
||
},
|
||
"projectmanager": {
|
||
"name": "胡蓉",
|
||
"multica_uuid": "d877b8c3-b230-4073-b3f7-80e148cfdb71",
|
||
"openclaw_agent_id": "projectmanager",
|
||
"is_coo": False,
|
||
},
|
||
"costcodev": {
|
||
"name": "徐聪",
|
||
"multica_uuid": "46bdd4a6-5c64-475a-92ef-36a763602fa1",
|
||
"openclaw_agent_id": "costcodev",
|
||
"is_coo": False,
|
||
},
|
||
"opengineer": {
|
||
"name": "严维序",
|
||
"multica_uuid": "d3804433-9e2e-4199-a92b-a153049b3bc9",
|
||
"openclaw_agent_id": "opengineer",
|
||
"is_coo": False,
|
||
},
|
||
"productmanager": {
|
||
"name": "沈路明",
|
||
"multica_uuid": "a101fa88-d821-4839-9754-e04580d5fd68",
|
||
"openclaw_agent_id": "productmanager",
|
||
"is_coo": False,
|
||
},
|
||
"architect": {
|
||
"name": "梁思筑",
|
||
"multica_uuid": "40abd41a-62d0-416d-bc44-92c1f758d87a",
|
||
"openclaw_agent_id": "architect",
|
||
"is_coo": False,
|
||
},
|
||
"designer": {
|
||
"name": "苏锦绘",
|
||
"multica_uuid": "13bd8968-cc2a-4934-90c7-957a2d3c09c2",
|
||
"openclaw_agent_id": "designer",
|
||
"is_coo": False,
|
||
},
|
||
"contentspecialist": {
|
||
"name": "文墨言",
|
||
"multica_uuid": "8321b0bf-7d89-4ece-927a-0780f42ad396",
|
||
"openclaw_agent_id": "contentspecialist",
|
||
"is_coo": False,
|
||
},
|
||
"cvexpert": {
|
||
"name": "程伯予",
|
||
"multica_uuid": "4a8696fd-6531-40da-8956-ef84d7ea3c43",
|
||
"openclaw_agent_id": "cvexpert",
|
||
"is_coo": False,
|
||
},
|
||
"prompt-engineer": {
|
||
"name": "许言",
|
||
"multica_uuid": "ece81d8e-8a24-4dd8-a7af-8adfc54b9d01",
|
||
"openclaw_agent_id": "prompt-engineer",
|
||
"is_coo": False,
|
||
},
|
||
"mediaspecialist": {
|
||
"name": "钟帧韵",
|
||
"multica_uuid": "e2b587d4-1d16-447c-8ad9-e2a01358ff0a",
|
||
"openclaw_agent_id": "mediaspecialist",
|
||
"is_coo": False,
|
||
},
|
||
"taobaospecialist": {
|
||
"name": "陆云帆",
|
||
"multica_uuid": "e0f62d8f-9568-4f41-8ad4-b73d79a163a7",
|
||
"openclaw_agent_id": "taobaospecialist",
|
||
"is_coo": False,
|
||
},
|
||
"marketanalysis": {
|
||
"name": "顾析策",
|
||
"multica_uuid": "5ed91729-658f-4654-98f0-3e0313022002",
|
||
"openclaw_agent_id": "marketanalysis",
|
||
"is_coo": False,
|
||
},
|
||
"lawyer": {
|
||
"name": "苏慎",
|
||
"multica_uuid": "6fb0fbd2-16a6-4566-ba7a-d2c136baec25",
|
||
"openclaw_agent_id": "lawyer",
|
||
"is_coo": False,
|
||
},
|
||
}
|
||
|
||
|
||
def get_agent_config(agent_id: str) -> Dict[str, Any]:
|
||
"""获取 Agent 配置"""
|
||
config = AGENT_CONFIGS.get(agent_id)
|
||
if config is None:
|
||
raise ValueError(f"Unknown agent: {agent_id}. Known: {list(AGENT_CONFIGS.keys())}")
|
||
return config
|
||
|
||
|
||
# ============================================================================
|
||
# 三源任务检查
|
||
# ============================================================================
|
||
|
||
def check_workboard_tasks(agent_id: str) -> List[Dict[str, Any]]:
|
||
"""
|
||
检查 WorkBoard 中分配给当前 Agent 的待办卡片
|
||
替代内联 bash 脚本
|
||
"""
|
||
result = openclaw_workboard_list()
|
||
if not result["success"]:
|
||
print(f"[heartbeat] WorkBoard 查询失败: {result['error']}")
|
||
return []
|
||
|
||
data = result["data"]
|
||
my_cards = [
|
||
c for c in data.get("cards", [])
|
||
if c.get("agentId") == agent_id and c.get("status") == "todo"
|
||
]
|
||
return my_cards
|
||
|
||
|
||
def check_multica_tasks(agent_id: str) -> List[Dict[str, Any]]:
|
||
"""
|
||
检查 Multica 中分配给当前 Agent 的待办 Issue
|
||
替代内联 bash 脚本
|
||
"""
|
||
config = get_agent_config(agent_id)
|
||
result = multica_issue_list_my_todo(config["multica_uuid"])
|
||
if not result["success"]:
|
||
print(f"[heartbeat] Multica 查询失败: {result['error']}")
|
||
return []
|
||
|
||
data = result["data"]
|
||
if isinstance(data, list):
|
||
return data
|
||
return []
|
||
|
||
|
||
def check_todo_docs(workspace_dir: str) -> List[str]:
|
||
"""
|
||
检查工作区待办文档中的未完成项
|
||
"""
|
||
items = []
|
||
for filename in ["TODO.md", "AGENTS.md"]:
|
||
filepath = os.path.join(workspace_dir, filename)
|
||
if os.path.exists(filepath):
|
||
try:
|
||
with open(filepath) as f:
|
||
for i, line in enumerate(f, 1):
|
||
if "[ ]" in line:
|
||
items.append(f"{filename}:{i}: {line.strip()}")
|
||
except Exception:
|
||
pass
|
||
return items
|
||
|
||
|
||
def check_my_tasks(agent_id: str, workspace_dir: str) -> Dict[str, Any]:
|
||
"""
|
||
三源合并检查:WorkBoard + Multica + 待办文档
|
||
"""
|
||
wb_tasks = check_workboard_tasks(agent_id)
|
||
mul_tasks = check_multica_tasks(agent_id)
|
||
doc_tasks = check_todo_docs(workspace_dir)
|
||
|
||
return {
|
||
"workboard": wb_tasks,
|
||
"multica": mul_tasks,
|
||
"documents": doc_tasks,
|
||
"total": len(wb_tasks) + len(mul_tasks) + len(doc_tasks),
|
||
}
|
||
|
||
|
||
# ============================================================================
|
||
# 超时检测
|
||
# ============================================================================
|
||
|
||
TIMEOUT_SECONDS = 1200 # 20 分钟
|
||
|
||
|
||
def check_workboard_timeouts() -> List[Dict[str, Any]]:
|
||
"""
|
||
检查 WorkBoard 中超过 20 分钟无进展的进行中任务
|
||
"""
|
||
result = openclaw_workboard_list()
|
||
if not result["success"]:
|
||
print(f"[heartbeat] WorkBoard 超时检测失败: {result['error']}")
|
||
return []
|
||
|
||
data = result["data"]
|
||
now = time.time()
|
||
timeouts = []
|
||
|
||
for c in data.get("cards", []):
|
||
if c.get("status") != "in_progress":
|
||
continue
|
||
updated = c.get("updated_at", "")
|
||
if updated:
|
||
try:
|
||
age = now - time.mktime(time.strptime(updated[:19], "%Y-%m-%dT%H:%M:%S"))
|
||
if age > TIMEOUT_SECONDS:
|
||
timeouts.append(c)
|
||
except (ValueError, OverflowError):
|
||
pass
|
||
|
||
return timeouts
|
||
|
||
|
||
def check_multica_timeouts() -> List[Dict[str, Any]]:
|
||
"""
|
||
检查 Multica 中超过 20 分钟无进展的进行中 Issue
|
||
"""
|
||
result = multica_issue_list_in_progress()
|
||
if not result["success"]:
|
||
print(f"[heartbeat] Multica 超时检测失败: {result['error']}")
|
||
return []
|
||
|
||
data = result["data"]
|
||
now = time.time()
|
||
timeouts = []
|
||
|
||
if isinstance(data, list):
|
||
for issue in data:
|
||
updated = issue.get("updated_at", "")
|
||
if updated:
|
||
try:
|
||
age = now - time.mktime(time.strptime(updated[:19], "%Y-%m-%dT%H:%M:%S"))
|
||
if age > TIMEOUT_SECONDS:
|
||
timeouts.append(issue)
|
||
except (ValueError, OverflowError):
|
||
pass
|
||
|
||
return timeouts
|
||
|
||
|
||
def check_timeouts() -> Dict[str, Any]:
|
||
"""
|
||
跨平台超时检测
|
||
"""
|
||
wb_timeouts = check_workboard_timeouts()
|
||
mul_timeouts = check_multica_timeouts()
|
||
|
||
return {
|
||
"workboard_timeouts": wb_timeouts,
|
||
"multica_timeouts": mul_timeouts,
|
||
"total_timeouts": len(wb_timeouts) + len(mul_timeouts),
|
||
}
|
||
|
||
|
||
# ============================================================================
|
||
# 依赖检查
|
||
# ============================================================================
|
||
|
||
def check_workboard_dependencies(card_id: str) -> Dict[str, Any]:
|
||
"""
|
||
检查 WorkBoard 卡片的依赖是否满足
|
||
"""
|
||
result = openclaw_workboard_read(card_id)
|
||
if not result["success"]:
|
||
return {"satisfied": False, "error": result["error"], "unmet": []}
|
||
|
||
card = result["data"]
|
||
deps = card.get("dependsOn", [])
|
||
unmet = [dep for dep in deps if dep.get("status") != "done"]
|
||
|
||
return {
|
||
"satisfied": len(unmet) == 0,
|
||
"total_deps": len(deps),
|
||
"unmet": unmet,
|
||
}
|
||
|
||
|
||
def check_multica_dependencies(issue_id: str) -> Dict[str, Any]:
|
||
"""
|
||
检查 Multica Issue 的父 Issue 依赖是否满足
|
||
"""
|
||
result = multica_issue_get(issue_id)
|
||
if not result["success"]:
|
||
return {"satisfied": False, "error": result["error"], "unmet": []}
|
||
|
||
issue = result["data"]
|
||
parent_id = issue.get("parent_issue_id")
|
||
if not parent_id:
|
||
return {"satisfied": True, "total_deps": 0, "unmet": []}
|
||
|
||
parent_result = multica_issue_get(parent_id)
|
||
if not parent_result["success"]:
|
||
return {"satisfied": False, "error": f"Failed to check parent {parent_id}", "unmet": [parent_id]}
|
||
|
||
parent = parent_result["data"]
|
||
if parent.get("status") != "done":
|
||
return {"satisfied": False, "total_deps": 1, "unmet": [{"id": parent_id, "identifier": parent.get("identifier"), "status": parent.get("status")}]}
|
||
|
||
return {"satisfied": True, "total_deps": 1, "unmet": []}
|
||
|
||
|
||
# ============================================================================
|
||
# 全局积压巡检(COO 专用)
|
||
# ============================================================================
|
||
|
||
def check_global_backlog() -> Dict[str, Any]:
|
||
"""
|
||
全平台积压巡检:WorkBoard + Multica 全局待办数
|
||
"""
|
||
wb_result = openclaw_workboard_list()
|
||
mul_result = multica_issue_list_in_progress()
|
||
|
||
wb_stats = {"total": 0, "todo": 0, "in_progress": 0, "done": 0}
|
||
if wb_result["success"]:
|
||
cards = wb_result["data"].get("cards", [])
|
||
wb_stats["total"] = len(cards)
|
||
for c in cards:
|
||
status = c.get("status", "")
|
||
if status in wb_stats:
|
||
wb_stats[status] += 1
|
||
|
||
mul_stats = {"total": 0, "in_progress": 0}
|
||
if mul_result["success"] and isinstance(mul_result["data"], list):
|
||
mul_stats["total"] = len(mul_result["data"])
|
||
mul_stats["in_progress"] = mul_stats["total"]
|
||
|
||
return {
|
||
"workboard": wb_stats,
|
||
"multica": mul_stats,
|
||
}
|
||
|
||
|
||
# ============================================================================
|
||
# 心跳主入口
|
||
# ============================================================================
|
||
|
||
def run_heartbeat(agent_id: str, workspace_dir: str) -> Dict[str, Any]:
|
||
"""
|
||
执行完整心跳检查
|
||
|
||
参数:
|
||
agent_id: Agent ID(如 "coo", "secretary")
|
||
workspace_dir: 工作区目录路径
|
||
|
||
返回:
|
||
心跳结果字典
|
||
"""
|
||
config = get_agent_config(agent_id)
|
||
is_coo = config["is_coo"]
|
||
|
||
result = {
|
||
"agent": config["name"],
|
||
"agent_id": agent_id,
|
||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||
"tasks": check_my_tasks(agent_id, workspace_dir),
|
||
"timeouts": check_timeouts(),
|
||
}
|
||
|
||
# COO 额外检查
|
||
if is_coo:
|
||
result["global_backlog"] = check_global_backlog()
|
||
result["cache_stats"] = get_cache_stats()
|
||
result["poller_status"] = get_poller_status()
|
||
|
||
return result
|
||
|
||
|
||
def print_heartbeat_report(result: Dict[str, Any]) -> None:
|
||
"""打印格式化的心跳报告"""
|
||
print(f"\n{'='*60}")
|
||
print(f" 🫀 心跳报告 — {result['agent']} ({result['agent_id']})")
|
||
print(f" ⏰ {result['timestamp']}")
|
||
print(f"{'='*60}")
|
||
|
||
tasks = result["tasks"]
|
||
print(f"\n📋 任务检查:")
|
||
print(f" WorkBoard 待办: {len(tasks['workboard'])}")
|
||
for t in tasks["workboard"]:
|
||
print(f" ⚠️ WB TODO: {t['id'][:8]} → {t.get('agentId','?')} - {t.get('title','?')[:50]}")
|
||
print(f" Multica 待办: {len(tasks['multica'])}")
|
||
for t in tasks["multica"]:
|
||
print(f" ⚠️ MUL TODO: {t.get('identifier','?')} - {t.get('title','?')[:50]}")
|
||
print(f" 文档待办: {len(tasks['documents'])}")
|
||
for d in tasks["documents"]:
|
||
print(f" 📝 {d}")
|
||
|
||
timeouts = result["timeouts"]
|
||
print(f"\n⏱️ 超时检测:")
|
||
print(f" WorkBoard 超时: {len(timeouts['workboard_timeouts'])}")
|
||
for t in timeouts["workboard_timeouts"]:
|
||
print(f" ⏰ WB TIMEOUT: {t['id'][:8]} [{t.get('agentId','?')}] {t.get('title','?')[:50]}")
|
||
print(f" Multica 超时: {len(timeouts['multica_timeouts'])}")
|
||
for t in timeouts["multica_timeouts"]:
|
||
print(f" ⏰ MUL TIMEOUT: {t.get('identifier','?')} {t.get('title','?')[:50]}")
|
||
|
||
if "global_backlog" in result:
|
||
gb = result["global_backlog"]
|
||
print(f"\n📊 全局积压:")
|
||
print(f" WorkBoard: {gb['workboard']}")
|
||
print(f" Multica: {gb['multica']}")
|
||
|
||
if "cache_stats" in result:
|
||
print(f"\n💾 缓存: {result['cache_stats']}")
|
||
|
||
print(f"\n{'='*60}\n")
|
||
|
||
|
||
# ============================================================================
|
||
# CLI 入口
|
||
# ============================================================================
|
||
|
||
if __name__ == "__main__":
|
||
import argparse
|
||
|
||
parser = argparse.ArgumentParser(description="Agent 心跳辅助脚本")
|
||
parser.add_argument("agent_id", help="Agent ID (coo/secretary/projectmanager/costcodev/opengineer)")
|
||
parser.add_argument("--workspace", "-w", default=os.getcwd(), help="工作区目录")
|
||
parser.add_argument("--json", action="store_true", help="JSON 输出")
|
||
parser.add_argument("--health", action="store_true", help="健康检查")
|
||
parser.add_argument("--clear-cache", action="store_true", help="清理缓存")
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.health:
|
||
print(json.dumps(health_check(), indent=2, ensure_ascii=False))
|
||
elif args.clear_cache:
|
||
count = clear_cache()
|
||
print(f"已清理 {count} 条缓存")
|
||
else:
|
||
result = run_heartbeat(args.agent_id, args.workspace)
|
||
if args.json:
|
||
print(json.dumps(result, indent=2, ensure_ascii=False, default=str))
|
||
else:
|
||
print_heartbeat_report(result)
|