feat(escalation): primitive הסלמה-לאדם loop-safe בשער-הפלטפורמה (#218)
מוסיף escalate_issue(issue_id, severity, reason) — חלופה loop-safe להשארת
issue משויך-סוכן+blocked, שהיא מקור לולאות source_scoped_recovery_action /
stranded_assigned_issue / issue_reopened_via_comment (reference_paperclip_recovery_loops).
שני אפקטים בטרנזקציית-DB אחת, במנגנון הקנוני של reset_case_agents (raw SQL, לא REST):
- מעבר-אטומי למצב human-owned {status:in_review, assignee_agent_id:null,
assignee_user_id:CHAIM_USER_ID} (rule #7). direct-DB בכוונה — עוקף את
disposition-resolver של Paperclip שהוא-עצמו מקור-הלולאות; נוחת human-owned בשוט
אחד בלי done→todo flip.
- הערת-severity עמידה author_type='system' — אינרטית ל-route-pending-comments
(מנתב רק author_type='user'), לכן לא מעירה CEO. אפס wakeup — המטרה היא מסירה-לאדם.
חיווט:
- Port: pc_escalate_issue עטוף ב-agent.escalated (מרכיב על טלמטריית #219 —
הסלמות נספרות per-case באותו זרם כמו ה-wakeups שהן מחליפות).
- app.py: POST /api/cases/{case}/agents/escalate (Literal severity).
- 3 בדיקות pytest (fake-asyncpg): severity לא-תקין, happy-path (מעבר+system-note+
אפס-wakeup), issue-לא-נמצא.
Invariants: מקיים G2 (אותו מנגנון-העברה-לאדם כמו reset_case_agents, לא מסלול מקביל),
G12/INV-PORT1 (המגע מהשער בלבד; ליבה נקייה), §6 (אין בליעה שקטה — severity לא-תקין
וissue-חסר מוחזרים מפורשות).
follow-up מוצהר: חיווט-סוכנים (MCP tool + HEARTBEAT §4 להחליף blocked+prose),
auto-escalation מה-reaper, והתראת-מייל (notify.py) לחיים.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
118
web/tests/test_escalate_issue.py
Normal file
118
web/tests/test_escalate_issue.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Tests for #218 — paperclip_client.escalate_issue (loop-safe chair escalation).
|
||||
|
||||
Verifies the primitive that replaces the fragile manual PATCH dance:
|
||||
- invalid severity is rejected before any DB work.
|
||||
- the happy path performs ONE atomic human-owned transition
|
||||
(``status='in_review'``, ``assignee_user_id=CHAIM_USER_ID``) and records a
|
||||
``author_type='system'`` severity note carrying the reason.
|
||||
- **no wakeup / REST call is issued** — escalation hands off to the human, it
|
||||
must never re-invoke an agent (that is what caused the recovery loops).
|
||||
- a missing issue reports ``ok:False`` and writes no comment.
|
||||
|
||||
Uses a fake asyncpg connection — never touches the live Paperclip DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("PAPERCLIP_DB_URL", "postgres://x:x@127.0.0.1:54329/paperclip")
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # web/
|
||||
|
||||
pc = pytest.importorskip("paperclip_client", reason="web deps unavailable")
|
||||
|
||||
|
||||
class _FakeTxn:
|
||||
async def __aenter__(self):
|
||||
return None
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self, row):
|
||||
self._row = row
|
||||
self.updates: list[tuple] = []
|
||||
self.inserts: list[tuple] = []
|
||||
self.closed = False
|
||||
|
||||
def transaction(self):
|
||||
return _FakeTxn()
|
||||
|
||||
async def fetchrow(self, query, *args):
|
||||
self.updates.append((query, args))
|
||||
return self._row
|
||||
|
||||
async def execute(self, query, *args):
|
||||
self.inserts.append((query, args))
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_db(monkeypatch):
|
||||
"""Patch asyncpg.connect + guard that no wakeup/REST is issued."""
|
||||
conns: list[_FakeConn] = []
|
||||
row = {"id": "iss-uuid", "identifier": "CMP-141", "company_id": "cmp-uuid"}
|
||||
holder = {"row": row}
|
||||
|
||||
async def _connect(_url):
|
||||
conn = _FakeConn(holder["row"])
|
||||
conns.append(conn)
|
||||
return conn
|
||||
|
||||
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
|
||||
|
||||
async def _forbidden(*a, **k): # any REST/wakeup would be a loop-risk
|
||||
raise AssertionError("escalate_issue must not issue a wakeup/REST call")
|
||||
|
||||
monkeypatch.setattr(pc, "pc_request", _forbidden, raising=False)
|
||||
return {"conns": conns, "holder": holder}
|
||||
|
||||
|
||||
def test_invalid_severity_rejected_before_db(fake_db):
|
||||
result = asyncio.run(pc.escalate_issue("iss-1", "urgent", "boom"))
|
||||
assert result["ok"] is False
|
||||
assert "invalid severity" in result["error"]
|
||||
assert fake_db["conns"] == [] # never connected
|
||||
|
||||
|
||||
def test_happy_path_atomic_transition_and_system_note(fake_db):
|
||||
result = asyncio.run(
|
||||
pc.escalate_issue("iss-uuid", "high", "analyst wedged on protocol parse")
|
||||
)
|
||||
assert result == {
|
||||
"ok": True,
|
||||
"id": "iss-uuid",
|
||||
"identifier": "CMP-141",
|
||||
"severity": "high",
|
||||
"status": "in_review",
|
||||
}
|
||||
conn = fake_db["conns"][0]
|
||||
# 1) one human-owned UPDATE with the chair user + in_review
|
||||
(update_sql, update_args) = conn.updates[0]
|
||||
assert "status='in_review'" in update_sql
|
||||
assert "assignee_agent_id=null" in update_sql
|
||||
assert update_args[0] == pc.CHAIM_USER_ID
|
||||
assert update_args[1] == "iss-uuid"
|
||||
# 2) a system-authored severity note carrying the reason
|
||||
(insert_sql, insert_args) = conn.inserts[0]
|
||||
assert "issue_comments" in insert_sql
|
||||
assert "'system'" in insert_sql
|
||||
assert "high" in insert_args[3] and "wedged on protocol parse" in insert_args[3]
|
||||
assert conn.closed is True
|
||||
|
||||
|
||||
def test_missing_issue_reports_not_found_and_writes_no_comment(fake_db):
|
||||
fake_db["holder"]["row"] = None
|
||||
result = asyncio.run(pc.escalate_issue("ghost", "medium", "nope"))
|
||||
assert result["ok"] is False and "not found" in result["error"]
|
||||
conn = fake_db["conns"][0]
|
||||
assert conn.inserts == [] # no comment on a non-existent issue
|
||||
Reference in New Issue
Block a user