feat(health): טקסונומיית-בריאות לסוכן — zombie/stalled/working/idle (#222)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 31s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

מוסיף web/agent_health.py — מסווג-טהור classify_issue_health (agent-agnostic,
testable) שממפה כל issue פתוח+משויך-סוכן למצב-בריאות אחד, ו-get_agent_health
ב-paperclip_client שגוזר את הפרימיטיבים מ-heartbeat_runs (run חי=finished_at null
בחלון) + agent_wakeup_requests (total + recovery-markers בחלון). מרכיב על אירועי
#219 (אותם reasons של recovery).

- zombie = פתוח+משויך-סוכן, אין run חי, יש wakeup-recovery → הstranded-child שאובחן
  ידנית (reference_recovery_loop_stranded_child) עולה אוטומטית.
- stalled = ננער חוזר בלי progress; working = run חי; idle = המתנה בנונית.
- Port: pc_get_agent_health (read-only, לא עטוף-טלמטריה לפי הכלל); endpoint
  GET /api/operations/agents/health (worst-first, +counts).
- 7 בדיקות pytest (מסווג-טהור + fetch fake-asyncpg). אומת חי read-only מול
  Paperclip DB: 6 issues→idle נכון, אפס zombie.

Invariants: מקיים G12 (מסווג אגנוסטי; fetch מהמעטפת; המגע מהשער), G2 (מקור-בריאות
יחיד). UI ב-/operations = follow-up מגודר-שער-עיצוב (feedback_claude_design_gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 03:05:17 +00:00
parent 653b951d30
commit bfc352c7b5
5 changed files with 307 additions and 0 deletions

View File

@@ -1034,6 +1034,104 @@ async def reap_stale_interactions() -> dict:
return {"ok": True, "cancelled": len(rows)}
# Windows for the agent-health taxonomy (#222). A run with no finished_at older
# than the live window is treated as dead (not "working"); wakeups are counted
# over the recovery window to spot loops.
_HEALTH_LIVE_RUN_WINDOW = "30 minutes"
_HEALTH_WAKEUP_WINDOW = "2 hours"
async def get_agent_health() -> dict:
"""Classify every open, agent-assigned issue into a health state (#222).
Read-only. Derives, per issue, the three primitives the pure classifier
(:func:`web.agent_health.classify_issue_health`) needs, from Paperclip's
``heartbeat_runs`` (a live run = ``finished_at IS NULL`` within the live
window) and ``agent_wakeup_requests`` (total + recovery-marker counts within
the recovery window). Scoped to the two legal-ai companies.
Surfaces the stranded-child / recovery-loop cases (``zombie``) automatically
instead of by hand-querying the DB. Returns items sorted worst-first.
"""
from web.agent_health import ( # local import: keep the shell → agnostic dep inward
HEALTH_STATES,
classify_issue_health,
is_recovery_reason,
)
company_ids = list(COMPANIES.values())
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
issues = await conn.fetch(
"""SELECT i.id, i.identifier, i.status, i.assignee_agent_id,
a.name AS agent_name
FROM issues i
JOIN agents a ON a.id = i.assignee_agent_id
WHERE i.company_id = ANY($1::uuid[])
AND i.assignee_agent_id IS NOT NULL
AND i.status IN ('backlog','todo','in_progress','blocked','in_review')""",
company_ids,
)
if not issues:
return {"ok": True, "items": [], "counts": {s: 0 for s in HEALTH_STATES}}
agent_ids = list({str(r["assignee_agent_id"]) for r in issues})
live_rows = await conn.fetch(
f"""SELECT DISTINCT agent_id FROM heartbeat_runs
WHERE agent_id = ANY($1::uuid[])
AND finished_at IS NULL
AND started_at > now() - interval '{_HEALTH_LIVE_RUN_WINDOW}'""",
agent_ids,
)
live_agents = {str(r["agent_id"]) for r in live_rows}
wake_rows = await conn.fetch(
f"""SELECT agent_id, payload->>'issueId' AS issue_id, reason
FROM agent_wakeup_requests
WHERE agent_id = ANY($1::uuid[])
AND requested_at > now() - interval '{_HEALTH_WAKEUP_WINDOW}'""",
agent_ids,
)
finally:
await conn.close()
total_by_issue: dict[str, int] = {}
recovery_by_issue: dict[str, int] = {}
for w in wake_rows:
iid = w["issue_id"]
if not iid:
continue
total_by_issue[iid] = total_by_issue.get(iid, 0) + 1
if is_recovery_reason(w["reason"]):
recovery_by_issue[iid] = recovery_by_issue.get(iid, 0) + 1
items = []
counts = {s: 0 for s in HEALTH_STATES}
for r in issues:
iid = str(r["id"])
state = classify_issue_health(
has_live_run=str(r["assignee_agent_id"]) in live_agents,
recovery_wakeups=recovery_by_issue.get(iid, 0),
total_wakeups=total_by_issue.get(iid, 0),
)
counts[state] += 1
items.append({
"issue_id": iid,
"identifier": r["identifier"],
"status": r["status"],
"agent_id": str(r["assignee_agent_id"]),
"agent_name": r["agent_name"],
"health": state,
"wakeups": total_by_issue.get(iid, 0),
"recovery_wakeups": recovery_by_issue.get(iid, 0),
})
order = {s: i for i, s in enumerate(HEALTH_STATES)}
items.sort(key=lambda it: (order[it["health"]], -it["recovery_wakeups"]))
return {"ok": True, "items": items, "counts": counts}
# Singleton project for the precedent-library extraction queue. One issue per
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"