diff --git a/web-ui/src/components/cases/agent-activity-feed.tsx b/web-ui/src/components/cases/agent-activity-feed.tsx index b546b1b..4609d99 100644 --- a/web-ui/src/components/cases/agent-activity-feed.tsx +++ b/web-ui/src/components/cases/agent-activity-feed.tsx @@ -9,6 +9,7 @@ import { useAgentActivity, useSendComment, useSubmitInteraction, + useDismissInteraction, } from "@/lib/api/agents"; import type { Interaction, @@ -17,6 +18,7 @@ import type { InteractionTask, PaperclipComment, PaperclipAgentStatus, + PaperclipIssue, } from "@/lib/api/agents"; import { formatRelative } from "@/lib/format-date"; import { toast } from "sonner"; @@ -30,6 +32,8 @@ import { CheckCircle2, XCircle, HelpCircle, + ChevronDown, + Ban, } from "lucide-react"; /* ── Role → color mapping ────────────────────────────────────── */ @@ -111,34 +115,9 @@ function agentStatusTone(s: string) { : "bg-rule-soft text-ink-muted"; } -function AgentRoster({ - agents, - comments, -}: { - agents: PaperclipAgentStatus[]; - comments: PaperclipComment[]; -}) { +function AgentRoster({ agents }: { agents: PaperclipAgentStatus[] }) { if (!agents.length) return null; - // "current activity" per agent = its most recent comment body (by name/role). - const latest = new Map(); - for (const a of agents) { - let best: PaperclipComment | null = null; - let bestAt = -1; - for (const c of comments) { - const mine = - (c.agent_name && c.agent_name === a.name) || - (c.agent_role && c.agent_role === a.role); - if (!mine) continue; - const at = c.created_at ? new Date(c.created_at).getTime() : 0; - if (at >= bestAt) { - bestAt = at; - best = c; - } - } - if (best) latest.set(a.id, best.body); - } - const activeCount = agents.filter((a) => agentIsActive(a.status)).length; return ( @@ -150,28 +129,28 @@ function AgentRoster({ {activeCount} פעילים מתוך {agents.length} -
+ {/* Compact roster — all agents on one row (mockup 18i v2): status · name · + role only. The per-agent "current activity" line was removed: it + matched comments by role-fallback, so agents sharing a role showed the + same text. */} +
{agents.map((a) => { const active = agentIsActive(a.status); - const activity = latest.get(a.id); return (
-
- - {a.name} - - {AGENT_STATUS_LABEL[a.status] ?? a.status} - -
-
{roleLabel(a.role)}
-
- {activity || "—"} +
+ + {a.name}
+
{roleLabel(a.role)}
+ + {AGENT_STATUS_LABEL[a.status] ?? a.status} +
); })} @@ -605,15 +584,21 @@ function InteractionCard({ interaction, caseNumber, issueMap, + defaultOpen = true, }: { interaction: Interaction; caseNumber: string; issueMap: Map; + defaultOpen?: boolean; }) { const submit = useSubmitInteraction(caseNumber); + const dismiss = useDismissInteraction(caseNumber); const identifier = issueMap.get(interaction.issue_id) ?? ""; const isPending = interaction.status === "pending"; const summary = summaryAnswer(interaction); + // Pending interactions collapse into an accordion (mockup 18i v2); resolved + // ones always render their one-line summary. + const [open, setOpen] = useState(defaultOpen); const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record) => { submit.mutate( @@ -630,6 +615,16 @@ function InteractionCard({ ); }; + const handleDismiss = () => { + dismiss.mutate( + { issue_id: interaction.issue_id, interaction_id: interaction.id }, + { + onSuccess: () => toast.success("הבקשה בוטלה"), + onError: () => toast.error("שגיאה בביטול הבקשה"), + }, + ); + }; + return (
- - {interaction.title || "שאלה לסוכן"} - {isPending ? ( - - ממתין לתשובה - + ) : ( - + <> + + {interaction.title || "שאלה לסוכן"} + + + )} {identifier && ( @@ -671,39 +681,58 @@ function InteractionCard({ {timeAgo(interaction.resolved_at ?? interaction.created_at)} + {isPending && ( + + )}
- {interaction.summary && ( + {interaction.summary && (!isPending || open) && (
{interaction.summary}
)} {isPending ? ( - interaction.kind === "ask_user_questions" ? ( - send("respond", { answers })} - pending={submit.isPending} - /> - ) : interaction.kind === "request_confirmation" ? ( - send("accept", {})} - onReject={(reason) => - send("reject", reason ? { reason } : {}) - } - pending={submit.isPending} - /> - ) : interaction.kind === "suggest_tasks" ? ( - - send("accept", keys.length ? { selectedClientKeys: keys } : {}) - } - onReject={(reason) => - send("reject", reason ? { reason } : {}) - } - pending={submit.isPending} - /> + open ? ( + interaction.kind === "ask_user_questions" ? ( + send("respond", { answers })} + pending={submit.isPending} + /> + ) : interaction.kind === "request_confirmation" ? ( + send("accept", {})} + onReject={(reason) => + send("reject", reason ? { reason } : {}) + } + pending={submit.isPending} + /> + ) : interaction.kind === "suggest_tasks" ? ( + + send("accept", keys.length ? { selectedClientKeys: keys } : {}) + } + onReject={(reason) => + send("reject", reason ? { reason } : {}) + } + pending={submit.isPending} + /> + ) : null ) : null ) : summary ? (
@@ -715,6 +744,80 @@ function InteractionCard({ ); } +/* ── Issue group (timeline grouped by issue, accordion) ──────── */ + +type FeedItem = + | { kind: "comment"; at: number; comment: PaperclipComment } + | { kind: "interaction"; at: number; interaction: Interaction }; + +/** Drop the "[ערר 1043-02-26] " prefix Paperclip puts on every issue title. */ +function shortIssueTitle(title: string): string { + return title.replace(/^\s*\[[^\]]*\]\s*/, "").trim() || title; +} + +function IssueGroup({ + issue, + items, + caseNumber, + issueMap, + defaultOpen, +}: { + issue: PaperclipIssue; + items: FeedItem[]; + caseNumber: string; + issueMap: Map; + defaultOpen: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + const closed = issue.status === "done" || issue.status === "cancelled"; + return ( +
+ + {open && ( +
+ {items.map((item) => + item.kind === "comment" ? ( + + ) : ( + + ), + )} +
+ )} +
+ ); +} + /* ── Main Feed ───────────────────────────────────────────────── */ export function AgentActivityFeed({ @@ -792,25 +895,42 @@ export function AgentActivityFeed({ // NOT repeated in the timeline below. const pending = interactions.filter((i) => i.status === "pending"); - // Unified, time-sorted feed: comments + interactions interleaved. - type FeedItem = - | { kind: "comment"; at: number; comment: PaperclipComment } - | { kind: "interaction"; at: number; interaction: Interaction }; - - const feed: FeedItem[] = [ - ...comments.map((c) => ({ + // Group the timeline by issue, each an accordion (mockup 18i v2). Comments + + // resolved interactions; pending ones are surfaced above and not repeated. + const itemsByIssue = new Map(); + const pushItem = (issueId: string, item: FeedItem) => { + const arr = itemsByIssue.get(issueId); + if (arr) arr.push(item); + else itemsByIssue.set(issueId, [item]); + }; + for (const c of comments) { + pushItem(c.issue_id, { kind: "comment", at: c.created_at ? new Date(c.created_at).getTime() : 0, comment: c, - })), - ...interactions - .filter((i) => i.status !== "pending") - .map((i) => ({ - kind: "interaction", - at: i.created_at ? new Date(i.created_at).getTime() : 0, - interaction: i, - })), - ].sort((a, b) => a.at - b.at); + }); + } + for (const i of interactions) { + if (i.status === "pending") continue; + pushItem(i.issue_id, { + kind: "interaction", + at: i.created_at ? new Date(i.created_at).getTime() : 0, + interaction: i, + }); + } + for (const arr of itemsByIssue.values()) arr.sort((a, b) => a.at - b.at); + + // Order groups by most-recent activity so live work floats to the top. + const issueGroups = data.issues + .map((iss) => { + const items = itemsByIssue.get(iss.id) ?? []; + const maxAt = items.reduce((m, it) => Math.max(m, it.at), 0); + return { iss, items, maxAt }; + }) + .filter((g) => g.items.length > 0) + .sort((a, b) => b.maxAt - a.maxAt); + + const totalFeedItems = issueGroups.reduce((n, g) => n + g.items.length, 0); // An issue is "active" if it's not done/cancelled. When everything is closed // we should NOT show the "agents are working, waiting for report" spinner. @@ -820,11 +940,11 @@ export function AgentActivityFeed({ return (
- {/* agent roster — who's doing what right now (mockup 18i) */} - + {/* agent roster — who's who, compact one-row (mockup 18i v2) */} + - {/* pending interactions surfaced to the top — the actual forms, not a - pointer (mockup 18i — awaiting you). Excluded from the timeline. */} + {/* pending interactions surfaced to the top — each an accordion, first + open (mockup 18i v2 — awaiting you). Excluded from the timeline. */} {pending.length > 0 && (
@@ -834,33 +954,29 @@ export function AgentActivityFeed({ {pending.length}
- {pending.map((i) => ( + {pending.map((i, idx) => ( ))}
)} - {/* Issue summary bar */} -
- {data.issues.map((iss) => ( - - {iss.identifier} — {issueStatusLabel(iss.status)} - - ))} + {/* Timeline header */} +
+ פעילות לפי משימה + + {data.issues.length} משימות · {totalFeedItems} הודעות +
- {/* Comments + interactions stream */} -
- {feed.length === 0 ? ( + {/* Per-issue accordions */} +
+ {totalFeedItems === 0 ? ( hasActiveIssue ? (
@@ -873,22 +989,19 @@ export function AgentActivityFeed({
) ) : ( - feed.map((item) => - item.kind === "comment" ? ( - - ) : ( - - ), - ) + issueGroups.map(({ iss, items }, idx) => ( + + )) )}
diff --git a/web-ui/src/lib/api/agents.ts b/web-ui/src/lib/api/agents.ts index 4079ebe..72909cd 100644 --- a/web-ui/src/lib/api/agents.ts +++ b/web-ui/src/lib/api/agents.ts @@ -180,6 +180,26 @@ export function useSubmitInteraction(caseNumber: string | undefined) { }); } +/** Dismiss a pending interaction WITHOUT waking the issue assignee — used for + * stale/duplicate questions. Backend marks it `cancelled` directly (the + * `wake_assignee` continuation policy means a normal resolve would wake the + * agent; dismiss must not). */ +export function useDismissInteraction(caseNumber: string | undefined) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (vars: { issue_id: string; interaction_id: string }) => + apiRequest<{ ok: boolean; cancelled: boolean }>( + `/api/cases/${caseNumber}/agents/interaction-dismiss`, + { method: "POST", body: vars }, + ), + onSuccess: () => { + if (caseNumber) { + qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) }); + } + }, + }); +} + export type AgentResetResult = { ok: boolean; reassigned_issues: { id: string; identifier: string }[]; diff --git a/web/agent_platform_port.py b/web/agent_platform_port.py index fded0d2..1232d5b 100644 --- a/web/agent_platform_port.py +++ b/web/agent_platform_port.py @@ -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", diff --git a/web/app.py b/web/app.py index aee7189..f00a7e9 100644 --- a/web/app.py +++ b/web/app.py @@ -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. diff --git a/web/paperclip_client.py b/web/paperclip_client.py index 2467ff5..455c009 100644 --- a/web/paperclip_client.py +++ b/web/paperclip_client.py @@ -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 = "ספריית פסיקה — תור חילוץ"