diff --git a/web/agent_platform_port.py b/web/agent_platform_port.py index 90a872e..600f503 100644 --- a/web/agent_platform_port.py +++ b/web/agent_platform_port.py @@ -57,6 +57,7 @@ from web.paperclip_client import ( # ── raw imports: wrapped with telemetry below (state-affecting ops, #219) ── cancel_interaction as _cancel_interaction, cancel_run as _cancel_run, + escalate_issue as _escalate_issue, post_comment as _post_comment, reap_stale_interactions as _reap_stale_interactions, reset_agent_session as _reset_agent_session, @@ -107,6 +108,12 @@ pc_post_comment = instrument( pc_cancel_interaction = instrument( "interaction.cancelled", keys=("issue_id", "interaction_id"), )(_cancel_interaction) +# First-class escalation-to-chair (#218): the loop-safe alternative to leaving an +# issue agent-owned+blocked. Emits a severity-tagged event so escalations are +# countable per case in the same stream as the wakeups they replace. +pc_escalate_issue = instrument( + "agent.escalated", keys=("issue_id", "severity", "company_id", "reason"), +)(_escalate_issue) pc_reap_stale_interactions = instrument( "interaction.reaped", result_keys=("cancelled",), )(_reap_stale_interactions) @@ -168,6 +175,7 @@ __all__ = [ "pc_respond_to_interaction", "pc_cancel_interaction", "pc_reap_stale_interactions", + "pc_escalate_issue", # agent-run observability + control (live view + smart management) "pc_list_live_runs", "pc_get_run_log", diff --git a/web/app.py b/web/app.py index c8db7aa..c200bb1 100644 --- a/web/app.py +++ b/web/app.py @@ -61,6 +61,7 @@ from web.agent_platform_port import ( pc_cancel_run, pc_create_project, pc_create_workflow_issue, + pc_escalate_issue, pc_get_agents, pc_get_agents_for_case, pc_get_case_issues, @@ -4651,6 +4652,30 @@ async def api_reset_case_agents(case_number: str): return result +class EscalateRequest(BaseModel): + issue_id: str + severity: Literal["critical", "high", "medium"] + reason: str + + +@app.post("/api/cases/{case_number}/agents/escalate") +async def api_escalate_issue(case_number: str, req: EscalateRequest): + """Escalate a single stuck issue to the chair (#218). + + The loop-safe alternative to leaving an issue agent-owned+blocked: hands the + issue to the human in one atomic transition (in_review + assignee_user_id) + and records a severity note, without re-waking any agent. Emits an + ``agent.escalated`` telemetry event via the Port. + """ + issues = await pc_get_case_issues(case_number) + if not any(i["id"] == req.issue_id for i in issues): + raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}") + result = await pc_escalate_issue(req.issue_id, req.severity, req.reason) + if not result.get("ok"): + raise HTTPException(400, result.get("error", "ההסלמה נכשלה")) + return result + + # ── Settings: MCP Server Configuration ──────────────────────────── # # Source of truth for legal-ai env vars is Coolify (see memory: diff --git a/web/paperclip_client.py b/web/paperclip_client.py index d046033..a1aa5be 100644 --- a/web/paperclip_client.py +++ b/web/paperclip_client.py @@ -856,6 +856,78 @@ async def reset_case_agents(case_number: str) -> dict: } +ESCALATION_SEVERITIES = ("critical", "high", "medium") + + +async def escalate_issue( + issue_id: str, severity: str, reason: str, company_id: str = "", +) -> dict: + """First-class, severity-routed escalation of a stuck issue to the chair (#218). + + Replaces the fragile manual "PATCH dance" (reassign + set in_review + open an + interaction) that repeatedly tripped Paperclip's recovery loops + (``source_scoped_recovery_action`` / ``stranded_assigned_issue`` / + ``issue_reopened_via_comment``; see memory reference_paperclip_recovery_loops). + + Two effects in **one DB transaction**, mirroring the proven loop-safe path of + :func:`reset_case_agents` (raw SQL, not REST): + + 1. **Atomic human-owned transition** — a single ``UPDATE`` to the stable end + state ``{status:'in_review', assignee_agent_id:null, + assignee_user_id:CHAIM_USER_ID}`` (recovery-loops rule #7). Direct-DB on + purpose: it bypasses Paperclip's disposition resolver — the very machinery + whose multi-PATCH/`issue.released` behaviour *causes* the loops — so the + issue lands human-owned in one shot with no ``done→todo`` flip. + 2. **Durable severity note** — an ``author_type='system'`` comment recording + severity + reason. System-authored on purpose: the ``route-pending-comments`` + sweep routes only ``author_type='user'`` (chair) comments to the CEO, so a + system note is inert w.r.t. routing and will **not** re-wake an agent + (project_comment_delivery_guarantee). No wakeup is issued — the whole point + is to hand off to the human, not re-invoke an agent. + + The ``agent.escalated`` telemetry event is emitted by the Port wrapper + (docs/spec/X15) — this is the loop-safe counterpart the CEO/analysts reach + for instead of leaving an issue agent-owned+blocked. + """ + if severity not in ESCALATION_SEVERITIES: + return { + "ok": False, + "error": f"invalid severity {severity!r}; expected one of {ESCALATION_SEVERITIES}", + } + + body = f"🚨 הסלמה [{severity}] לחיים\n\n{reason}" + conn = await asyncpg.connect(PAPERCLIP_DB_URL) + try: + async with conn.transaction(): + row = await conn.fetchrow( + """UPDATE issues + SET status='in_review', assignee_agent_id=null, + assignee_user_id=$1, updated_at=now() + WHERE id=$2::uuid + RETURNING id, identifier, company_id""", + CHAIM_USER_ID, issue_id, + ) + if not row: + return {"ok": False, "error": f"issue {issue_id} not found"} + cid = company_id or str(row["company_id"]) + await conn.execute( + """INSERT INTO issue_comments (id, company_id, issue_id, body, author_type) + VALUES ($1::uuid, $2::uuid, $3::uuid, $4, 'system')""", + str(uuid.uuid4()), cid, issue_id, body, + ) + finally: + await conn.close() + + logger.info("Escalated issue %s to chair [severity=%s]", issue_id, severity) + return { + "ok": True, + "id": str(row["id"]), + "identifier": row["identifier"], + "severity": severity, + "status": "in_review", + } + + async def respond_to_interaction( issue_id: str, interaction_id: str, payload: dict, ) -> dict: diff --git a/web/tests/test_escalate_issue.py b/web/tests/test_escalate_issue.py new file mode 100644 index 0000000..9e05e0a --- /dev/null +++ b/web/tests/test_escalate_issue.py @@ -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