feat(case-ui): agents tab v2 + dismiss/reaper for stale interactions (#215)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

מוקאפ 18i v2 (מאושר ב-Claude Design) + תיקון-מקור להצטברות pending interactions.

UI (agent-activity-feed.tsx):
- רוסטר-סוכנים קומפקטי, כל הסוכנים בשורה אחת (grid-cols-9); הוסרה שורת
  "מה הסוכן עושה עכשיו" — היא שייכה תגובה לפי role-fallback, כך שסוכנים
  בעלי אותו תפקיד הציגו טקסט זהה.
- כל אירוע "ממתין לתשובתך" מקופל כאקורדיון (הראשון פתוח).
- כפתור "התעלם" לכל בקשה ממתינה — מבטל שאלה כפולה/מיושנת בלי להעיר את הסוכן.
- ציר-הזמן מקובץ לפי משימה (issue), כל קבוצה אקורדיון עם מזהה+סטטוס+מונה.

Backend (#215 — מניעת הצטברות pending):
- cancel_interaction: ביטול interaction בודד ישירות ל-cancelled, ללא
  wake_assignee (resolve רגיל היה מעיר את הסוכן). אין triggers על הטבלה.
- reap_stale_interactions: reaper שמבטל pending על issues סגורים
  (done/cancelled) — המקור הדומיננטי לערימה. רץ כל 15 דק' ב-lifespan.
- endpoint POST /api/cases/{case}/agents/interaction-dismiss (מאחורי כפתור
  "התעלם"). הכל דרך agent_platform_port (G12).

supersede-at-creation נדחה (תועד ב-#215): ביטול-אוטומטי של שאלה על issue
פעיל אחד על-פני אחר אינו דטרמיניסטי; הליבה הבטוחה = reaper-ליתומים +
כפתור-ביטול, והשורש האמיתי הוא ריסון לולאות-recovery של Paperclip.

Invariants: מקיים G12 (מגע-Paperclip רק דרך הפורט), G2 (אין מסלול מקביל —
מרחיב את endpoints הסוכנים הקיימים), §6 (אין בליעת-שגיאות שקטה — הביטול
מחזיר {ok,error}, ה-reaper מתעד warning).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 20:36:56 +00:00
parent 88977b2f10
commit 581a4ba36f
5 changed files with 375 additions and 130 deletions

View File

@@ -37,6 +37,7 @@ from web.paperclip_client import (
COMPANIES as PAPERCLIP_COMPANIES,
accept_interaction as pc_accept_interaction,
archive_project as pc_archive_project,
cancel_interaction as pc_cancel_interaction,
cancel_run as pc_cancel_run,
create_project as pc_create_project,
create_workflow_issue as pc_create_workflow_issue,
@@ -50,6 +51,7 @@ from web.paperclip_client import (
get_run_log as pc_get_run_log,
list_live_runs as pc_list_live_runs,
post_comment as pc_post_comment,
reap_stale_interactions as pc_reap_stale_interactions,
reject_interaction as pc_reject_interaction,
reset_agent_session as pc_reset_agent_session,
reset_case_agents as pc_reset_case_agents,
@@ -111,6 +113,8 @@ __all__ = [
"pc_accept_interaction",
"pc_reject_interaction",
"pc_respond_to_interaction",
"pc_cancel_interaction",
"pc_reap_stale_interactions",
# agent-run observability + control (live view + smart management)
"pc_list_live_runs",
"pc_get_run_log",

View File

@@ -57,6 +57,7 @@ from web.agent_platform_port import (
get_project_url,
pc_accept_interaction,
pc_archive_project,
pc_cancel_interaction,
pc_cancel_run,
pc_create_project,
pc_create_workflow_issue,
@@ -69,6 +70,7 @@ from web.agent_platform_port import (
pc_get_run_log,
pc_list_live_runs,
pc_post_comment,
pc_reap_stale_interactions,
pc_reject_interaction,
pc_request,
pc_reset_agent_session,
@@ -101,19 +103,38 @@ PROGRESS_TTL_SECONDS = 300
_progress = ProgressStore(config.REDIS_URL, ttl_seconds=PROGRESS_TTL_SECONDS)
async def _interaction_reaper_loop():
"""Safety-net reaper for stranded pending interactions (TaskMaster #215).
Every 15 min, cancel pending interactions on closed (done/cancelled) issues
so the chair's "awaiting you" count never accumulates dead questions.
"""
while True:
try:
await asyncio.sleep(900)
await pc_reap_stale_interactions()
except asyncio.CancelledError:
raise
except Exception:
logger.warning("interaction reaper 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())
try:
yield
finally:
sync_task.cancel()
try:
await sync_task
except asyncio.CancelledError:
pass
reaper_task.cancel()
for _t in (sync_task, reaper_task):
try:
await _t
except asyncio.CancelledError:
pass
await db.close_pool()
await _progress.close()
@@ -4411,6 +4432,30 @@ async def api_post_interaction_response(
raise HTTPException(502, f"שגיאת Paperclip: {e}")
class InteractionDismissRequest(BaseModel):
issue_id: str
interaction_id: str
@app.post("/api/cases/{case_number}/agents/interaction-dismiss")
async def api_dismiss_interaction(
case_number: str, req: InteractionDismissRequest,
):
"""Dismiss a pending interaction WITHOUT waking the issue assignee.
For stale/duplicate questions (TaskMaster #215). Unlike interaction-response,
this does not resolve via Paperclip's wake_assignee path — it cancels the row
so the chair can clear noise without triggering an agent run.
"""
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_cancel_interaction(req.issue_id, req.interaction_id)
if not result.get("ok"):
raise HTTPException(404, result.get("error", "ביטול הבקשה נכשל"))
return result
@app.post("/api/cases/{case_number}/agents/reset")
async def api_reset_case_agents(case_number: str):
"""Reset stuck agents for a case.

View File

@@ -899,6 +899,69 @@ async def reject_interaction(
return resp.json()
async def cancel_interaction(issue_id: str, interaction_id: str) -> dict:
"""Dismiss a pending interaction WITHOUT waking the issue assignee.
For stale/duplicate questions (TaskMaster #215). We flip the row to
``cancelled`` directly rather than calling Paperclip's resolve endpoints:
those carry the interaction's ``wake_assignee`` continuation policy and would
re-wake the agent. A direct status flip has no side effects — the table has
no triggers (verified) and nothing reaps ``cancelled`` rows.
"""
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
row = await conn.fetchrow(
"""UPDATE issue_thread_interactions
SET status='cancelled', resolved_at=now(),
resolved_by_user_id=$3, updated_at=now()
WHERE id=$1::uuid AND issue_id=$2::uuid AND status='pending'
RETURNING id, status""",
interaction_id, issue_id, CHAIM_USER_ID,
)
finally:
await conn.close()
if not row:
return {
"ok": False,
"cancelled": False,
"error": "interaction not found or already resolved",
}
return {"ok": True, "cancelled": True, "id": str(row["id"]), "status": row["status"]}
async def reap_stale_interactions() -> dict:
"""Cancel pending interactions stranded on closed issues (done/cancelled).
Safety-net reaper (TaskMaster #215): a question on a closed issue can never
be answered and only inflates the chair's "awaiting you" count — this was the
dominant source of the pending pile-up. Scoped to the two legal-ai companies.
Direct status flip — no wakeups, no side effects.
"""
company_ids = list(COMPANIES.values())
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
rows = await conn.fetch(
"""UPDATE issue_thread_interactions it
SET status='cancelled', resolved_at=now(),
resolved_by_user_id=$2, updated_at=now()
FROM issues i
WHERE i.id = it.issue_id
AND it.status='pending'
AND it.company_id = ANY($1::uuid[])
AND i.status IN ('done', 'cancelled')
RETURNING it.id""",
company_ids, CHAIM_USER_ID,
)
finally:
await conn.close()
if rows:
logger.info(
"reap_stale_interactions: cancelled %d stranded pending interactions",
len(rows),
)
return {"ok": True, "cancelled": len(rows)}
# Singleton project for the precedent-library extraction queue. One issue per
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"