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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user