feat(watchdog): auto-escalation של zombie-loops ל-chair — סגירת הלולאה (#218+#222)
מרכיב את הפרוסות שמוזגו לכלל 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>
This commit is contained in:
@@ -19,7 +19,9 @@ from agent_health import ( # noqa: E402
|
||||
STALL_WAKEUP_THRESHOLD,
|
||||
classify_issue_health,
|
||||
is_recovery_reason,
|
||||
zombie_escalation,
|
||||
)
|
||||
from agent_health import ZOMBIE_ESCALATE_MIN_RECOVERY # noqa: E402
|
||||
|
||||
|
||||
# ── pure classifier ────────────────────────────────────────────────────────
|
||||
@@ -45,6 +47,34 @@ def test_quiet_open_issue_is_idle():
|
||||
assert classify_issue_health(has_live_run=False, recovery_wakeups=0, total_wakeups=1) == "idle"
|
||||
|
||||
|
||||
# ── watchdog escalation policy (pure) ───────────────────────────────────────
|
||||
def _item(health, recovery):
|
||||
return {"health": health, "recovery_wakeups": recovery, "identifier": "CMP-141", "agent_name": "writer"}
|
||||
|
||||
|
||||
def test_zombie_escalation_medium_for_fresh_loop():
|
||||
decision = zombie_escalation(_item("zombie", ZOMBIE_ESCALATE_MIN_RECOVERY))
|
||||
assert decision is not None
|
||||
severity, reason = decision
|
||||
assert severity == "medium"
|
||||
assert "CMP-141" in reason and "recovery" in reason.lower()
|
||||
|
||||
|
||||
def test_zombie_escalation_high_for_long_loop():
|
||||
severity, _ = zombie_escalation(_item("zombie", 5))
|
||||
assert severity == "high"
|
||||
|
||||
|
||||
def test_zombie_below_threshold_is_not_escalated():
|
||||
assert zombie_escalation(_item("zombie", ZOMBIE_ESCALATE_MIN_RECOVERY - 1)) is None
|
||||
|
||||
|
||||
def test_non_zombie_never_escalates():
|
||||
assert zombie_escalation(_item("stalled", 9)) is None
|
||||
assert zombie_escalation(_item("idle", 0)) is None
|
||||
assert zombie_escalation(_item("working", 9)) is None
|
||||
|
||||
|
||||
def test_is_recovery_reason_markers():
|
||||
assert is_recovery_reason("source_scoped_recovery_action")
|
||||
assert is_recovery_reason("issue_continuation_needed")
|
||||
|
||||
Reference in New Issue
Block a user