feat(case-ui): agents tab v2 + dismiss/reaper for stale interactions (#215)
מוקאפ 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:
@@ -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<string, string>();
|
||||
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({
|
||||
<b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
{/* 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. */}
|
||||
<div className="grid gap-1.5 grid-cols-3 sm:grid-cols-5 lg:grid-cols-9">
|
||||
{agents.map((a) => {
|
||||
const active = agentIsActive(a.status);
|
||||
const activity = latest.get(a.id);
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`rounded-lg border p-2.5 ${active ? "border-success/40 bg-success-bg/40" : "border-rule bg-surface"}`}
|
||||
className={`rounded-lg border p-2 min-w-0 ${active ? "border-success/40 bg-success-bg/40" : "border-rule bg-surface"}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
|
||||
<span className="text-[0.78rem] font-bold text-navy truncate">{a.name}</span>
|
||||
<span
|
||||
className={`ms-auto text-[0.6rem] font-bold rounded-full px-1.5 py-0.5 whitespace-nowrap ${agentStatusTone(a.status)}`}
|
||||
>
|
||||
{AGENT_STATUS_LABEL[a.status] ?? a.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[0.66rem] text-ink-muted mt-0.5">{roleLabel(a.role)}</div>
|
||||
<div className="text-[0.7rem] text-ink-soft mt-1.5 line-clamp-2 leading-tight">
|
||||
{activity || "—"}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
|
||||
<span className="text-[0.72rem] font-bold text-navy truncate">{a.name}</span>
|
||||
</div>
|
||||
<div className="text-[0.6rem] text-ink-muted mt-0.5 truncate">{roleLabel(a.role)}</div>
|
||||
<span
|
||||
className={`inline-block mt-1 text-[0.55rem] font-bold rounded-full px-1.5 py-0.5 whitespace-nowrap ${agentStatusTone(a.status)}`}
|
||||
>
|
||||
{AGENT_STATUS_LABEL[a.status] ?? a.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -605,15 +584,21 @@ function InteractionCard({
|
||||
interaction,
|
||||
caseNumber,
|
||||
issueMap,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
interaction: Interaction;
|
||||
caseNumber: string;
|
||||
issueMap: Map<string, string>;
|
||||
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<string, unknown>) => {
|
||||
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 (
|
||||
<div
|
||||
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
|
||||
@@ -652,15 +647,30 @@ function InteractionCard({
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-navy">
|
||||
{interaction.title || "שאלה לסוכן"}
|
||||
</span>
|
||||
{isPending ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
|
||||
ממתין לתשובה
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-2 flex-wrap text-start min-w-0"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-navy">
|
||||
{interaction.title || "שאלה לסוכן"}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
|
||||
ממתין לתשובה
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<ResolvedBadge status={interaction.status} />
|
||||
<>
|
||||
<span className="text-sm font-semibold text-navy">
|
||||
{interaction.title || "שאלה לסוכן"}
|
||||
</span>
|
||||
<ResolvedBadge status={interaction.status} />
|
||||
</>
|
||||
)}
|
||||
{identifier && (
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
@@ -671,39 +681,58 @@ function InteractionCard({
|
||||
<Clock className="w-3 h-3" />
|
||||
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
|
||||
</span>
|
||||
{isPending && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleDismiss}
|
||||
disabled={dismiss.isPending}
|
||||
className="h-7 px-2 text-ink-muted hover:text-danger"
|
||||
title="בטל בקשה כפולה/מיושנת — ללא העברה לסוכן"
|
||||
>
|
||||
{dismiss.isPending ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Ban className="w-3.5 h-3.5 me-1" />
|
||||
)}
|
||||
התעלם
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{interaction.summary && (
|
||||
{interaction.summary && (!isPending || open) && (
|
||||
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
|
||||
)}
|
||||
|
||||
{isPending ? (
|
||||
interaction.kind === "ask_user_questions" ? (
|
||||
<AskUserQuestionsForm
|
||||
interaction={interaction}
|
||||
onSubmit={(answers) => send("respond", { answers })}
|
||||
pending={submit.isPending}
|
||||
/>
|
||||
) : interaction.kind === "request_confirmation" ? (
|
||||
<RequestConfirmationForm
|
||||
interaction={interaction}
|
||||
onAccept={() => send("accept", {})}
|
||||
onReject={(reason) =>
|
||||
send("reject", reason ? { reason } : {})
|
||||
}
|
||||
pending={submit.isPending}
|
||||
/>
|
||||
) : interaction.kind === "suggest_tasks" ? (
|
||||
<SuggestTasksForm
|
||||
interaction={interaction}
|
||||
onAccept={(keys) =>
|
||||
send("accept", keys.length ? { selectedClientKeys: keys } : {})
|
||||
}
|
||||
onReject={(reason) =>
|
||||
send("reject", reason ? { reason } : {})
|
||||
}
|
||||
pending={submit.isPending}
|
||||
/>
|
||||
open ? (
|
||||
interaction.kind === "ask_user_questions" ? (
|
||||
<AskUserQuestionsForm
|
||||
interaction={interaction}
|
||||
onSubmit={(answers) => send("respond", { answers })}
|
||||
pending={submit.isPending}
|
||||
/>
|
||||
) : interaction.kind === "request_confirmation" ? (
|
||||
<RequestConfirmationForm
|
||||
interaction={interaction}
|
||||
onAccept={() => send("accept", {})}
|
||||
onReject={(reason) =>
|
||||
send("reject", reason ? { reason } : {})
|
||||
}
|
||||
pending={submit.isPending}
|
||||
/>
|
||||
) : interaction.kind === "suggest_tasks" ? (
|
||||
<SuggestTasksForm
|
||||
interaction={interaction}
|
||||
onAccept={(keys) =>
|
||||
send("accept", keys.length ? { selectedClientKeys: keys } : {})
|
||||
}
|
||||
onReject={(reason) =>
|
||||
send("reject", reason ? { reason } : {})
|
||||
}
|
||||
pending={submit.isPending}
|
||||
/>
|
||||
) : null
|
||||
) : null
|
||||
) : summary ? (
|
||||
<div className="text-sm">
|
||||
@@ -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<string, string>;
|
||||
defaultOpen: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const closed = issue.status === "done" || issue.status === "cancelled";
|
||||
return (
|
||||
<div className="border-b border-rule-soft last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50 text-start"
|
||||
>
|
||||
<Badge
|
||||
variant={closed ? "secondary" : "default"}
|
||||
className="text-[10px] font-mono shrink-0"
|
||||
>
|
||||
{issue.identifier}
|
||||
</Badge>
|
||||
<span className="text-[0.78rem] font-semibold text-navy truncate">
|
||||
{shortIssueTitle(issue.title)}
|
||||
</span>
|
||||
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
|
||||
{issueStatusLabel(issue.status)} · {items.length}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 ms-auto text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="pb-1">
|
||||
{items.map((item) =>
|
||||
item.kind === "comment" ? (
|
||||
<CommentCard
|
||||
key={`c-${item.comment.id}`}
|
||||
comment={item.comment}
|
||||
issueMap={issueMap}
|
||||
/>
|
||||
) : (
|
||||
<InteractionCard
|
||||
key={`i-${item.interaction.id}`}
|
||||
interaction={item.interaction}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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<FeedItem>((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<string, FeedItem[]>();
|
||||
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<FeedItem>((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 (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* agent roster — who's doing what right now (mockup 18i) */}
|
||||
<AgentRoster agents={data.agents ?? []} comments={comments} />
|
||||
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
|
||||
<AgentRoster agents={data.agents ?? []} />
|
||||
|
||||
{/* 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 && (
|
||||
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
|
||||
@@ -834,33 +954,29 @@ export function AgentActivityFeed({
|
||||
{pending.length}
|
||||
</span>
|
||||
</div>
|
||||
{pending.map((i) => (
|
||||
{pending.map((i, idx) => (
|
||||
<InteractionCard
|
||||
key={`pending-${i.id}`}
|
||||
interaction={i}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
defaultOpen={idx === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issue summary bar */}
|
||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-2 flex-wrap">
|
||||
{data.issues.map((iss) => (
|
||||
<Badge
|
||||
key={iss.id}
|
||||
variant={iss.status === "done" ? "secondary" : "default"}
|
||||
className="text-[11px] font-mono"
|
||||
>
|
||||
{iss.identifier} — {issueStatusLabel(iss.status)}
|
||||
</Badge>
|
||||
))}
|
||||
{/* Timeline header */}
|
||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-1">
|
||||
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
|
||||
<span className="text-[0.72rem] text-ink-muted">
|
||||
{data.issues.length} משימות · {totalFeedItems} הודעות
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Comments + interactions stream */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
||||
{feed.length === 0 ? (
|
||||
{/* Per-issue accordions */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[500px]">
|
||||
{totalFeedItems === 0 ? (
|
||||
hasActiveIssue ? (
|
||||
<div className="text-center py-8 text-ink-faint text-sm">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||
@@ -873,22 +989,19 @@ export function AgentActivityFeed({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
feed.map((item) =>
|
||||
item.kind === "comment" ? (
|
||||
<CommentCard
|
||||
key={`c-${item.comment.id}`}
|
||||
comment={item.comment}
|
||||
issueMap={issueMap}
|
||||
/>
|
||||
) : (
|
||||
<InteractionCard
|
||||
key={`i-${item.interaction.id}`}
|
||||
interaction={item.interaction}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
/>
|
||||
),
|
||||
)
|
||||
issueGroups.map(({ iss, items }, idx) => (
|
||||
<IssueGroup
|
||||
key={iss.id}
|
||||
issue={iss}
|
||||
items={items}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
defaultOpen={
|
||||
idx === 0 ||
|
||||
(iss.status !== "done" && iss.status !== "cancelled")
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
@@ -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 }[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
53
web/app.py
53
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.
|
||||
|
||||
@@ -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 = "ספריית פסיקה — תור חילוץ"
|
||||
|
||||
Reference in New Issue
Block a user