feat(watchdog): auto-escalation של zombie-loops ל-chair — סגירת הלולאה (#218+#222)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 11s

מרכיב את הפרוסות שמוזגו לכלל 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:
2026-07-07 03:37:25 +00:00
parent dede33cdff
commit c7941a63ec
3 changed files with 110 additions and 1 deletions

View File

@@ -124,18 +124,66 @@ async def _interaction_reaper_loop():
logger.warning("interaction reaper sweep failed", exc_info=True)
# Watchdog kill-switch — set AGENT_ZOMBIE_AUTO_ESCALATE=0 to observe health
# without acting (escalation stays available manually via the endpoint).
_ZOMBIE_AUTO_ESCALATE = os.environ.get("AGENT_ZOMBIE_AUTO_ESCALATE", "1") != "0"
async def _zombie_escalation_loop():
"""Watchdog: auto-escalate persistent recovery-loop zombies to the chair (#218+#222).
Every 15 min, derive per-issue health (#222) and hand any persistent zombie
(a recovery loop, ``>=`` ``ZOMBIE_ESCALATE_MIN_RECOVERY`` recovery wakeups)
to the human via the loop-safe escalate primitive (#218) — closing the loop
that today burns budget until a human notices. Server-side on purpose: an
agent stuck in a recovery loop cannot reliably self-escalate. Idempotent —
an escalated issue is agent-unowned next sweep, so it is not re-escalated.
"""
from web.agent_health import zombie_escalation
while True:
try:
await asyncio.sleep(900)
if not _ZOMBIE_AUTO_ESCALATE:
continue
health = await pc_get_agent_health()
for item in health.get("items", []):
decision = zombie_escalation(item)
if decision is None:
continue
severity, reason = decision
result = await pc_escalate_issue(item["issue_id"], severity, reason)
if result.get("ok"):
logger.warning(
"watchdog auto-escalated %s (%s, %d recovery wakeups) to chair [%s]",
item.get("identifier"), item.get("agent_name"),
item.get("recovery_wakeups", 0), severity,
)
else:
logger.warning(
"watchdog escalation failed for %s: %s",
item.get("identifier"), result.get("error"),
)
except asyncio.CancelledError:
raise
except Exception:
logger.warning("zombie escalation sweep failed", exc_info=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
await db.init_schema()
sync_task = asyncio.create_task(git_sync.sweep_loop())
reaper_task = asyncio.create_task(_interaction_reaper_loop())
watchdog_task = asyncio.create_task(_zombie_escalation_loop())
try:
yield
finally:
sync_task.cancel()
reaper_task.cancel()
for _t in (sync_task, reaper_task):
watchdog_task.cancel()
for _t in (sync_task, reaper_task, watchdog_task):
try:
await _t
except asyncio.CancelledError: