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:
@@ -57,6 +57,7 @@ from web.paperclip_client import (
|
|||||||
# ── raw imports: wrapped with telemetry below (state-affecting ops, #219) ──
|
# ── raw imports: wrapped with telemetry below (state-affecting ops, #219) ──
|
||||||
cancel_interaction as _cancel_interaction,
|
cancel_interaction as _cancel_interaction,
|
||||||
cancel_run as _cancel_run,
|
cancel_run as _cancel_run,
|
||||||
|
escalate_issue as _escalate_issue,
|
||||||
post_comment as _post_comment,
|
post_comment as _post_comment,
|
||||||
reap_stale_interactions as _reap_stale_interactions,
|
reap_stale_interactions as _reap_stale_interactions,
|
||||||
reset_agent_session as _reset_agent_session,
|
reset_agent_session as _reset_agent_session,
|
||||||
@@ -107,6 +108,12 @@ pc_post_comment = instrument(
|
|||||||
pc_cancel_interaction = instrument(
|
pc_cancel_interaction = instrument(
|
||||||
"interaction.cancelled", keys=("issue_id", "interaction_id"),
|
"interaction.cancelled", keys=("issue_id", "interaction_id"),
|
||||||
)(_cancel_interaction)
|
)(_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(
|
pc_reap_stale_interactions = instrument(
|
||||||
"interaction.reaped", result_keys=("cancelled",),
|
"interaction.reaped", result_keys=("cancelled",),
|
||||||
)(_reap_stale_interactions)
|
)(_reap_stale_interactions)
|
||||||
@@ -168,6 +175,7 @@ __all__ = [
|
|||||||
"pc_respond_to_interaction",
|
"pc_respond_to_interaction",
|
||||||
"pc_cancel_interaction",
|
"pc_cancel_interaction",
|
||||||
"pc_reap_stale_interactions",
|
"pc_reap_stale_interactions",
|
||||||
|
"pc_escalate_issue",
|
||||||
# agent-run observability + control (live view + smart management)
|
# agent-run observability + control (live view + smart management)
|
||||||
"pc_list_live_runs",
|
"pc_list_live_runs",
|
||||||
"pc_get_run_log",
|
"pc_get_run_log",
|
||||||
|
|||||||
25
web/app.py
25
web/app.py
@@ -61,6 +61,7 @@ from web.agent_platform_port import (
|
|||||||
pc_cancel_run,
|
pc_cancel_run,
|
||||||
pc_create_project,
|
pc_create_project,
|
||||||
pc_create_workflow_issue,
|
pc_create_workflow_issue,
|
||||||
|
pc_escalate_issue,
|
||||||
pc_get_agents,
|
pc_get_agents,
|
||||||
pc_get_agents_for_case,
|
pc_get_agents_for_case,
|
||||||
pc_get_case_issues,
|
pc_get_case_issues,
|
||||||
@@ -4651,6 +4652,30 @@ async def api_reset_case_agents(case_number: str):
|
|||||||
return result
|
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 ────────────────────────────
|
# ── Settings: MCP Server Configuration ────────────────────────────
|
||||||
#
|
#
|
||||||
# Source of truth for legal-ai env vars is Coolify (see memory:
|
# Source of truth for legal-ai env vars is Coolify (see memory:
|
||||||
|
|||||||
@@ -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(
|
async def respond_to_interaction(
|
||||||
issue_id: str, interaction_id: str, payload: dict,
|
issue_id: str, interaction_id: str, payload: dict,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
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