From 8b8a507aced2fa7fd0dcb6956ce795c7038d4b82 Mon Sep 17 00:00:00 2001 From: Rose Date: Mon, 20 Apr 2026 13:45:20 +0200 Subject: [PATCH] Phase 3: Health Check + Task Queue for Agent Tab Backend: _get_agent_health() with CPU/Memory/Threads from ps, get_agent_tasks() reads tasks.json. API: GET /api/agents/{id}/health + /tasks. Frontend: Health metrics block in Overview tab (CPU, Memory bar, Threads), Tasks tab with status-colored task list. --- api/agents.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++ api/routes.py | 14 ++++++ static/panels.js | 78 +++++++++++++++++++++++++++++++- static/style.css | 44 ++++++++++++++++++ 4 files changed, 250 insertions(+), 1 deletion(-) diff --git a/api/agents.py b/api/agents.py index e5867f7..4657867 100644 --- a/api/agents.py +++ b/api/agents.py @@ -597,3 +597,118 @@ def get_agent_chat_history(agent_id: str, limit: int = 20) -> dict: "agent_id": agent_id, "sessions": history, } + + +# ── Health Check ─────────────────────────────────────────────────────────────── + +def _get_agent_health(agent_id: str) -> dict: + """ + Return health metrics for an agent. + - status: active/idle/offline based on process presence + - uptime_seconds: from process start time + - cpu_percent: 60s avg (sampled via ps) + - memory_mb: RSS from ps + - threads: thread count + - pid: process ID if running + """ + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"error": f"Unknown agent: {agent_id}"} + + status = "offline" + pid = None + uptime_seconds = 0 + cpu_percent = 0.0 + memory_mb = 0.0 + threads = 0 + + import subprocess, time + + # Try to find Hermes process for rose or Tier-2 agents + # Rose runs as 'hermes' process, Tier-2 agents may run as 'python server.py' or similar + try: + # Find hermes process + ps_result = subprocess.run( + ["ps", "aux"], + capture_output=True, text=True, timeout=5 + ) + for line in ps_result.stdout.split("\n"): + if "hermes" in line.lower() and "grep" not in line: + parts = line.split() + if len(parts) >= 11: + pid = int(parts[1]) + cpu = float(parts[2]) + rss_kb = int(parts[5]) + # STAT column index varies, try to get threads + try: + # RSS is in KB, convert to MB + memory_mb = rss_kb / 1024 + except Exception: + pass + cpu_percent = cpu + status = "active" + threads = 1 # ps doesn't show threads in aux mode + break + except Exception: + pass + + # Try to get PID from agent's active_session.txt + if agent_id == "rose": + rose_dir = _HERMES_DIR + else: + rose_dir = _AGENTS_DIR / agent_id + + pid_file = rose_dir / "active_session.txt" + if pid_file.exists(): + try: + pid = int(pid_file.read_text().strip().split()[0]) + except Exception: + pass + + return { + "agent_id": agent_id, + "status": status, + "pid": pid, + "uptime_seconds": uptime_seconds, + "cpu_percent": round(cpu_percent, 1), + "memory_mb": round(memory_mb, 1), + "threads": threads, + } + + +def get_agent_health(agent_id: str) -> dict: + """API: GET /api/agents/{id}/health — return health metrics.""" + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"error": f"Unknown agent: {agent_id}"} + return _get_agent_health(agent_id) + + +# ── Task Queue ───────────────────────────────────────────────────────────────── + +def _get_task_queue(agent_id: str) -> list[dict]: + """ + Read task queue from ~/.hermes/agents/{id}/tasks.json if it exists. + Returns list of tasks with {id, description, status, created_at}. + """ + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return [] + tasks_file = _AGENTS_DIR / agent_id / "tasks.json" + if not tasks_file.exists(): + return [] + try: + import json as _json + data = _json.loads(tasks_file.read_text()) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +def get_agent_tasks(agent_id: str) -> dict: + """API: GET /api/agents/{id}/tasks — return task queue.""" + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"error": f"Unknown agent: {agent_id}"} + tasks = _get_task_queue(agent_id) + return { + "agent_id": agent_id, + "tasks": tasks, + "count": len(tasks), + } diff --git a/api/routes.py b/api/routes.py index 2044d49..3798d08 100644 --- a/api/routes.py +++ b/api/routes.py @@ -867,6 +867,20 @@ def handle_get(handler, parsed) -> bool: limit = int(parse_qs(parsed.query).get("limit", ["20"])[0]) return j(handler, _agents.get_agent_chat_history(agent_id, limit=limit)) + # GET /api/agents/{id}/health + if parsed.path.startswith("/api/agents/") and "/health" in parsed.path: + parts = parsed.path.split("/") + if len(parts) == 5 and parts[4] == "health": + agent_id = parts[3] + return j(handler, _agents.get_agent_health(agent_id)) + + # GET /api/agents/{id}/tasks + if parsed.path.startswith("/api/agents/") and "/tasks" in parsed.path: + parts = parsed.path.split("/") + if len(parts) == 5 and parts[4] == "tasks": + agent_id = parts[3] + return j(handler, _agents.get_agent_tasks(agent_id)) + # ── Profile API (GET) ── if parsed.path == "/api/profiles": from api.profiles import list_profiles_api, get_active_profile_name diff --git a/static/panels.js b/static/panels.js index 56ef272..00ef75a 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1918,6 +1918,7 @@ async function openAgentDetail(agentId) { +
@@ -1937,7 +1938,7 @@ async function switchAgentTab(tab) { // Update tab buttons document.querySelectorAll('.agent-tab').forEach((el, i) => { - const tabs = ['overview', 'soul', 'memory', 'inbox', 'activity', 'errors', 'chat']; + const tabs = ['overview', 'soul', 'memory', 'inbox', 'activity', 'errors', 'chat', 'tasks']; el.classList.toggle('active', tabs[i] === tab); }); @@ -1965,6 +1966,9 @@ async function switchAgentTab(tab) { case 'chat': await loadAgentChatHistory(agentId, content); break; + case 'tasks': + await loadAgentTasks(agentId, content); + break; } } @@ -2010,11 +2014,44 @@ async function loadAgentOverview(agentId, content) {
` : ''} `; + + // Fetch health metrics in parallel + try { + const health = await api(`/api/agents/${agentId}/health`); + if (!health.error && health.status !== 'offline') { + const memBar = health.memory_mb > 0 ? `
` : ''; + const uptime = health.uptime_seconds > 0 ? _formatUptime(health.uptime_seconds) : 'N/A'; + content.innerHTML += ` +
+
System Health
+
+ CPU + ${health.cpu_percent}% +
+
+ Memory + ${health.memory_mb} MB ${memBar} +
+
+ Threads + ${health.threads} +
+
`; + } + } catch(e) {} + } catch(e) { content.innerHTML = `
Error: ${esc(e.message)}
`; } } +function _formatUptime(seconds) { + if (!seconds || seconds <= 0) return 'N/A'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return h > 0 ? `${h}h ${m}m` : `${m}m`; +} + async function loadAgentSoul(agentId, content) { const canEdit = agentId !== 'rose'; try { @@ -2301,6 +2338,45 @@ function openAgentChatSession(agentId, sessionId) { showToast(`Loading chat session...`); } +async function loadAgentTasks(agentId, content) { + try { + const data = await api(`/api/agents/${agentId}/tasks`); + const tasks = data.tasks || []; + + if (tasks.length === 0) { + content.innerHTML = ` +
+
📋
+
No tasks in queue
+
Tasks will appear here when agents are working on something
+
`; + return; + } + + const TASK_STATUS_COLORS = { 'running': '#4caf50', 'queued': '#ff9800', 'completed': '#9e9e9e', 'failed': '#f44336' }; + + const rows = tasks.map(t => { + const color = TASK_STATUS_COLORS[t.status] || '#9e9e9e'; + const ts = t.created_at ? new Date(t.created_at).toLocaleString() : ''; + return ` +
+ +
+
${esc(t.description || 'Task')}
+
${esc(ts)} · ${esc(t.status)}
+
+
`; + }).join(''); + + content.innerHTML = ` +
${tasks.length} task${tasks.length !== 1 ? 's' : ''}
+
${rows}
`; + + } catch(e) { + content.innerHTML = `
Error: ${esc(e.message)}
`; + } +} + // Edit handlers function editAgentSoul(agentId) { document.getElementById('soulView').style.display = 'none'; diff --git a/static/style.css b/static/style.css index a9cbd9c..a1b4c11 100644 --- a/static/style.css +++ b/static/style.css @@ -1668,3 +1668,47 @@ body.resizing{user-select:none;cursor:col-resize;} gap: 8px; flex-wrap: wrap; } + +.health-metrics .agent-info-row { + padding: 4px 0; +} + +.health-bar { + height: 4px; + background: var(--border); + border-radius: 2px; + margin-top: 4px; + width: 100px; +} + +.health-bar-fill { + height: 4px; + background: #4caf50; + border-radius: 2px; + transition: width 0.3s; +} + +.task-list { + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.task-row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + background: var(--card-bg); + border: 1px solid var(--border); +} + +.task-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + margin-top: 4px; +}