מרכיב את הפרוסות שמוזגו לכלל closed-loop: watchdog שרת-צדי (_zombie_escalation_loop ב-lifespan, כל 15 דק') שגוזר בריאות per-issue (#222) ומסלים כל zombie מתמשך (לולאת-recovery, ≥ZOMBIE_ESCALATE_MIN_RECOVERY=2 יקיצות-שחזור) ל-chair דרך escalate_issue הloop-safe (#218). הטלמטריה agent.escalated (#219) נדלקת מה-Port. - מדוע שרת-צדי: סוכן תקוע-בלולאה לא יכול להסלים-עצמו אמין; ה-watchdog (מקביל ל-Deacon של Gastown, אבל דרך ה-reaper הקיים ולא tier חדש) עושה זאת. - אידמפוטנטי מבנייה: issue מוסלם הופך agent-unowned → לא zombie בsweep הבא. - החלטת-ההסלמה (סף+severity) = helper טהור zombie_escalation ב-agent_health (testable). severity: ≥5 יקיצות→high, אחרת→medium. - kill-switch: AGENT_ZOMBIE_AUTO_ESCALATE=0 להשבתה (הבריאות עדיין נצפית, escalate ידני עדיין זמין ב-endpoint). ברירת-מחדל ON — הלולאות שורפות budget והתרופה מוכחת. - 4 בדיקות policy חדשות (11 סה"כ). dry-run חי: 6 issues→0 hzombies→0 הסלמות (blast-radius מינימלי על deploy). Invariants: מקיים G2 (מרחיב את מנגנון-ה-reaper הקיים, לא sweep מקביל; escalate דרך המסלול הקנוני), G12 (הכל ב-web/, המגע מהשער), §6 (כשל-sweep נלכד ולא-קטלני). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
111 lines
4.7 KiB
Python
111 lines
4.7 KiB
Python
"""Agent health taxonomy (TaskMaster #222) — one crisp state per stuck issue.
|
|
|
|
Classifies each open, agent-assigned issue into a single health state so the
|
|
stranded-child / recovery-loop cases we diagnose by hand today
|
|
([[reference_recovery_loop_stranded_child]], [[reference_paperclip_recovery_loops]])
|
|
surface automatically in the dashboard — the ``gt feed --problems`` idea from
|
|
Gastown, grounded in *our* failure modes.
|
|
|
|
Two layers, split for testability (same shape as web.agent_telemetry):
|
|
- :func:`classify_issue_health` is a **pure function** over three primitives
|
|
(live-run flag + wakeup counts). No DB, no platform symbols — trivially
|
|
unit-tested and platform-agnostic (INV-G12).
|
|
- The Paperclip-specific fetch that derives those primitives from
|
|
``heartbeat_runs`` / ``agent_wakeup_requests`` lives in the shell
|
|
(``web.paperclip_client.get_agent_health``); this module only decides *how to
|
|
label* what the fetch measured, so the taxonomy survives a platform swap.
|
|
|
|
The wakeup/recovery signals it reads are the very ones #219 telemetry now emits
|
|
(``agent.wakeup`` with recovery reasons) — this is the consumer that turns that
|
|
stream into an at-a-glance verdict.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
# Severity order (worst first) — the dashboard sorts problems by this.
|
|
HEALTH_STATES: tuple[str, ...] = ("zombie", "stalled", "working", "idle")
|
|
|
|
# Substrings of Paperclip wakeup ``reason`` values that mark a recovery-loop
|
|
# wake (as opposed to a genuine new task). Sourced from the recovery-loop
|
|
# forensics in memory: an issue that keeps getting these while agent-owned is a
|
|
# zombie — assigned + "active" but nothing is really executing.
|
|
RECOVERY_REASON_MARKERS: tuple[str, ...] = (
|
|
"source_scoped_recovery",
|
|
"stranded_assigned",
|
|
"issue_continuation",
|
|
"issue_reopened",
|
|
"missing_disposition",
|
|
"successful_run_missing_state",
|
|
"waiting_on_review",
|
|
)
|
|
|
|
# ≥ this many wakeups inside the fetch window, with no recovery marker and no
|
|
# live run, reads as "poked repeatedly but not progressing" = stalled.
|
|
STALL_WAKEUP_THRESHOLD = 3
|
|
|
|
|
|
def is_recovery_reason(reason: str | None) -> bool:
|
|
"""True if a wakeup ``reason`` is a recovery-loop marker (not a new task)."""
|
|
if not reason:
|
|
return False
|
|
return any(marker in reason for marker in RECOVERY_REASON_MARKERS)
|
|
|
|
|
|
def classify_issue_health(
|
|
*,
|
|
has_live_run: bool,
|
|
recovery_wakeups: int,
|
|
total_wakeups: int,
|
|
) -> str:
|
|
"""Label one open, agent-assigned issue. Pure — no I/O.
|
|
|
|
- ``working`` — a live run is executing right now.
|
|
- ``zombie`` — no live run but recovery-loop wakeups are firing: the issue
|
|
looks agent-owned + active yet nothing is really running (the stranded
|
|
child we chase manually).
|
|
- ``stalled`` — no live run, no recovery markers, but poked ``>=``
|
|
:data:`STALL_WAKEUP_THRESHOLD` times in-window (repeated wakes, no
|
|
progress).
|
|
- ``idle`` — open + assigned but quiet: benign waiting.
|
|
|
|
Called only for issues that are already open and agent-assigned; unassigned
|
|
or closed issues are not health-tracked (the fetch filters them out).
|
|
"""
|
|
if has_live_run:
|
|
return "working"
|
|
if recovery_wakeups >= 1:
|
|
return "zombie"
|
|
if total_wakeups >= STALL_WAKEUP_THRESHOLD:
|
|
return "stalled"
|
|
return "idle"
|
|
|
|
|
|
# A zombie needs at least this many recovery-marker wakeups before the watchdog
|
|
# auto-escalates it — filters a single transient recovery wake from a genuine,
|
|
# budget-burning loop. Escalation reassigns the issue to the chair (via
|
|
# escalate_issue), so an escalated issue is agent-unowned next sweep and never
|
|
# re-escalates (idempotent by construction).
|
|
ZOMBIE_ESCALATE_MIN_RECOVERY = 2
|
|
|
|
|
|
def zombie_escalation(item: dict) -> tuple[str, str] | None:
|
|
"""Decide whether a health item warrants auto-escalation to the chair.
|
|
|
|
Pure (no I/O) so the watchdog's policy is unit-tested. Returns
|
|
``(severity, reason)`` for a persistent zombie, else ``None``. Severity
|
|
scales with loop intensity — a long-running loop (``>=5`` recovery wakes)
|
|
is ``high``, a fresh one ``medium``. Only ``zombie`` items escalate: a
|
|
``stalled``/``working``/``idle`` issue is not a recovery loop.
|
|
"""
|
|
if item.get("health") != "zombie":
|
|
return None
|
|
recovery = int(item.get("recovery_wakeups", 0))
|
|
if recovery < ZOMBIE_ESCALATE_MIN_RECOVERY:
|
|
return None
|
|
severity = "high" if recovery >= 5 else "medium"
|
|
reason = (
|
|
f"auto-escalation (watchdog): זוהתה לולאת-recovery — {recovery} יקיצות-שחזור "
|
|
f"על {item.get('identifier')} ({item.get('agent_name')}); ה-issue נמסר לחיים "
|
|
f"במקום להמשיך לשרוף budget."
|
|
)
|
|
return severity, reason
|