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

@@ -9,6 +9,7 @@ import {
useAgentActivity, useAgentActivity,
useSendComment, useSendComment,
useSubmitInteraction, useSubmitInteraction,
useDismissInteraction,
} from "@/lib/api/agents"; } from "@/lib/api/agents";
import type { import type {
Interaction, Interaction,
@@ -17,6 +18,7 @@ import type {
InteractionTask, InteractionTask,
PaperclipComment, PaperclipComment,
PaperclipAgentStatus, PaperclipAgentStatus,
PaperclipIssue,
} from "@/lib/api/agents"; } from "@/lib/api/agents";
import { formatRelative } from "@/lib/format-date"; import { formatRelative } from "@/lib/format-date";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -30,6 +32,8 @@ import {
CheckCircle2, CheckCircle2,
XCircle, XCircle,
HelpCircle, HelpCircle,
ChevronDown,
Ban,
} from "lucide-react"; } from "lucide-react";
/* ── Role → color mapping ────────────────────────────────────── */ /* ── Role → color mapping ────────────────────────────────────── */
@@ -111,34 +115,9 @@ function agentStatusTone(s: string) {
: "bg-rule-soft text-ink-muted"; : "bg-rule-soft text-ink-muted";
} }
function AgentRoster({ function AgentRoster({ agents }: { agents: PaperclipAgentStatus[] }) {
agents,
comments,
}: {
agents: PaperclipAgentStatus[];
comments: PaperclipComment[];
}) {
if (!agents.length) return null; 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; const activeCount = agents.filter((a) => agentIsActive(a.status)).length;
return ( return (
@@ -150,28 +129,28 @@ function AgentRoster({
<b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length} <b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length}
</span> </span>
</div> </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) => { {agents.map((a) => {
const active = agentIsActive(a.status); const active = agentIsActive(a.status);
const activity = latest.get(a.id);
return ( return (
<div <div
key={a.id} 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"> <div className="flex items-center gap-1.5 min-w-0">
<span className={`w-2 h-2 rounded-full shrink-0 ${agentStatusDot(a.status)}`} /> <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
<span className="text-[0.78rem] font-bold text-navy truncate">{a.name}</span> <span className="text-[0.72rem] 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> </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> </div>
); );
})} })}
@@ -605,15 +584,21 @@ function InteractionCard({
interaction, interaction,
caseNumber, caseNumber,
issueMap, issueMap,
defaultOpen = true,
}: { }: {
interaction: Interaction; interaction: Interaction;
caseNumber: string; caseNumber: string;
issueMap: Map<string, string>; issueMap: Map<string, string>;
defaultOpen?: boolean;
}) { }) {
const submit = useSubmitInteraction(caseNumber); const submit = useSubmitInteraction(caseNumber);
const dismiss = useDismissInteraction(caseNumber);
const identifier = issueMap.get(interaction.issue_id) ?? ""; const identifier = issueMap.get(interaction.issue_id) ?? "";
const isPending = interaction.status === "pending"; const isPending = interaction.status === "pending";
const summary = summaryAnswer(interaction); 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>) => { const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
submit.mutate( 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 ( return (
<div <div
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${ 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-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap"> <div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</span>
{isPending ? ( {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"> <button
ממתין לתשובה type="button"
</span> 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 && ( {identifier && (
<Badge variant="outline" className="text-[10px] font-mono"> <Badge variant="outline" className="text-[10px] font-mono">
@@ -671,39 +681,58 @@ function InteractionCard({
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{timeAgo(interaction.resolved_at ?? interaction.created_at)} {timeAgo(interaction.resolved_at ?? interaction.created_at)}
</span> </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> </div>
{interaction.summary && ( {interaction.summary && (!isPending || open) && (
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div> <div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
)} )}
{isPending ? ( {isPending ? (
interaction.kind === "ask_user_questions" ? ( open ? (
<AskUserQuestionsForm interaction.kind === "ask_user_questions" ? (
interaction={interaction} <AskUserQuestionsForm
onSubmit={(answers) => send("respond", { answers })} interaction={interaction}
pending={submit.isPending} onSubmit={(answers) => send("respond", { answers })}
/> pending={submit.isPending}
) : interaction.kind === "request_confirmation" ? ( />
<RequestConfirmationForm ) : interaction.kind === "request_confirmation" ? (
interaction={interaction} <RequestConfirmationForm
onAccept={() => send("accept", {})} interaction={interaction}
onReject={(reason) => onAccept={() => send("accept", {})}
send("reject", reason ? { reason } : {}) onReject={(reason) =>
} send("reject", reason ? { reason } : {})
pending={submit.isPending} }
/> pending={submit.isPending}
) : interaction.kind === "suggest_tasks" ? ( />
<SuggestTasksForm ) : interaction.kind === "suggest_tasks" ? (
interaction={interaction} <SuggestTasksForm
onAccept={(keys) => interaction={interaction}
send("accept", keys.length ? { selectedClientKeys: keys } : {}) onAccept={(keys) =>
} send("accept", keys.length ? { selectedClientKeys: keys } : {})
onReject={(reason) => }
send("reject", reason ? { reason } : {}) onReject={(reason) =>
} send("reject", reason ? { reason } : {})
pending={submit.isPending} }
/> pending={submit.isPending}
/>
) : null
) : null ) : null
) : summary ? ( ) : summary ? (
<div className="text-sm"> <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 ───────────────────────────────────────────────── */ /* ── Main Feed ───────────────────────────────────────────────── */
export function AgentActivityFeed({ export function AgentActivityFeed({
@@ -792,25 +895,42 @@ export function AgentActivityFeed({
// NOT repeated in the timeline below. // NOT repeated in the timeline below.
const pending = interactions.filter((i) => i.status === "pending"); const pending = interactions.filter((i) => i.status === "pending");
// Unified, time-sorted feed: comments + interactions interleaved. // Group the timeline by issue, each an accordion (mockup 18i v2). Comments +
type FeedItem = // resolved interactions; pending ones are surfaced above and not repeated.
| { kind: "comment"; at: number; comment: PaperclipComment } const itemsByIssue = new Map<string, FeedItem[]>();
| { kind: "interaction"; at: number; interaction: Interaction }; const pushItem = (issueId: string, item: FeedItem) => {
const arr = itemsByIssue.get(issueId);
const feed: FeedItem[] = [ if (arr) arr.push(item);
...comments.map<FeedItem>((c) => ({ else itemsByIssue.set(issueId, [item]);
};
for (const c of comments) {
pushItem(c.issue_id, {
kind: "comment", kind: "comment",
at: c.created_at ? new Date(c.created_at).getTime() : 0, at: c.created_at ? new Date(c.created_at).getTime() : 0,
comment: c, comment: c,
})), });
...interactions }
.filter((i) => i.status !== "pending") for (const i of interactions) {
.map<FeedItem>((i) => ({ if (i.status === "pending") continue;
kind: "interaction", pushItem(i.issue_id, {
at: i.created_at ? new Date(i.created_at).getTime() : 0, kind: "interaction",
interaction: i, at: i.created_at ? new Date(i.created_at).getTime() : 0,
})), interaction: i,
].sort((a, b) => a.at - b.at); });
}
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 // 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. // we should NOT show the "agents are working, waiting for report" spinner.
@@ -820,11 +940,11 @@ export function AgentActivityFeed({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* agent roster — who's doing what right now (mockup 18i) */} {/* agent roster — who's who, compact one-row (mockup 18i v2) */}
<AgentRoster agents={data.agents ?? []} comments={comments} /> <AgentRoster agents={data.agents ?? []} />
{/* pending interactions surfaced to the top — the actual forms, not a {/* pending interactions surfaced to the top — each an accordion, first
pointer (mockup 18i — awaiting you). Excluded from the timeline. */} open (mockup 18i v2 — awaiting you). Excluded from the timeline. */}
{pending.length > 0 && ( {pending.length > 0 && (
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2"> <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"> <div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
@@ -834,33 +954,29 @@ export function AgentActivityFeed({
{pending.length} {pending.length}
</span> </span>
</div> </div>
{pending.map((i) => ( {pending.map((i, idx) => (
<InteractionCard <InteractionCard
key={`pending-${i.id}`} key={`pending-${i.id}`}
interaction={i} interaction={i}
caseNumber={caseNumber} caseNumber={caseNumber}
issueMap={issueMap} issueMap={issueMap}
defaultOpen={idx === 0}
/> />
))} ))}
</div> </div>
)} )}
{/* Issue summary bar */} {/* Timeline header */}
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-2 flex-wrap"> <div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-1">
{data.issues.map((iss) => ( <span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
<Badge <span className="text-[0.72rem] text-ink-muted">
key={iss.id} {data.issues.length} משימות · {totalFeedItems} הודעות
variant={iss.status === "done" ? "secondary" : "default"} </span>
className="text-[11px] font-mono"
>
{iss.identifier} {issueStatusLabel(iss.status)}
</Badge>
))}
</div> </div>
{/* Comments + interactions stream */} {/* Per-issue accordions */}
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1"> <div className="flex-1 overflow-y-auto max-h-[500px]">
{feed.length === 0 ? ( {totalFeedItems === 0 ? (
hasActiveIssue ? ( hasActiveIssue ? (
<div className="text-center py-8 text-ink-faint text-sm"> <div className="text-center py-8 text-ink-faint text-sm">
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" /> <Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
@@ -873,22 +989,19 @@ export function AgentActivityFeed({
</div> </div>
) )
) : ( ) : (
feed.map((item) => issueGroups.map(({ iss, items }, idx) => (
item.kind === "comment" ? ( <IssueGroup
<CommentCard key={iss.id}
key={`c-${item.comment.id}`} issue={iss}
comment={item.comment} items={items}
issueMap={issueMap} caseNumber={caseNumber}
/> issueMap={issueMap}
) : ( defaultOpen={
<InteractionCard idx === 0 ||
key={`i-${item.interaction.id}`} (iss.status !== "done" && iss.status !== "cancelled")
interaction={item.interaction} }
caseNumber={caseNumber} />
issueMap={issueMap} ))
/>
),
)
)} )}
<div ref={endRef} /> <div ref={endRef} />
</div> </div>

View File

@@ -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 = { export type AgentResetResult = {
ok: boolean; ok: boolean;
reassigned_issues: { id: string; identifier: string }[]; reassigned_issues: { id: string; identifier: string }[];

View File

@@ -37,6 +37,7 @@ from web.paperclip_client import (
COMPANIES as PAPERCLIP_COMPANIES, COMPANIES as PAPERCLIP_COMPANIES,
accept_interaction as pc_accept_interaction, accept_interaction as pc_accept_interaction,
archive_project as pc_archive_project, archive_project as pc_archive_project,
cancel_interaction as pc_cancel_interaction,
cancel_run as pc_cancel_run, cancel_run as pc_cancel_run,
create_project as pc_create_project, create_project as pc_create_project,
create_workflow_issue as pc_create_workflow_issue, 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, get_run_log as pc_get_run_log,
list_live_runs as pc_list_live_runs, list_live_runs as pc_list_live_runs,
post_comment as pc_post_comment, post_comment as pc_post_comment,
reap_stale_interactions as pc_reap_stale_interactions,
reject_interaction as pc_reject_interaction, reject_interaction as pc_reject_interaction,
reset_agent_session as pc_reset_agent_session, reset_agent_session as pc_reset_agent_session,
reset_case_agents as pc_reset_case_agents, reset_case_agents as pc_reset_case_agents,
@@ -111,6 +113,8 @@ __all__ = [
"pc_accept_interaction", "pc_accept_interaction",
"pc_reject_interaction", "pc_reject_interaction",
"pc_respond_to_interaction", "pc_respond_to_interaction",
"pc_cancel_interaction",
"pc_reap_stale_interactions",
# 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",

View File

@@ -57,6 +57,7 @@ from web.agent_platform_port import (
get_project_url, get_project_url,
pc_accept_interaction, pc_accept_interaction,
pc_archive_project, pc_archive_project,
pc_cancel_interaction,
pc_cancel_run, pc_cancel_run,
pc_create_project, pc_create_project,
pc_create_workflow_issue, pc_create_workflow_issue,
@@ -69,6 +70,7 @@ from web.agent_platform_port import (
pc_get_run_log, pc_get_run_log,
pc_list_live_runs, pc_list_live_runs,
pc_post_comment, pc_post_comment,
pc_reap_stale_interactions,
pc_reject_interaction, pc_reject_interaction,
pc_request, pc_request,
pc_reset_agent_session, pc_reset_agent_session,
@@ -101,19 +103,38 @@ PROGRESS_TTL_SECONDS = 300
_progress = ProgressStore(config.REDIS_URL, ttl_seconds=PROGRESS_TTL_SECONDS) _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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
UPLOAD_DIR.mkdir(parents=True, exist_ok=True) UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
await db.init_schema() await db.init_schema()
sync_task = asyncio.create_task(git_sync.sweep_loop()) sync_task = asyncio.create_task(git_sync.sweep_loop())
reaper_task = asyncio.create_task(_interaction_reaper_loop())
try: try:
yield yield
finally: finally:
sync_task.cancel() sync_task.cancel()
try: reaper_task.cancel()
await sync_task for _t in (sync_task, reaper_task):
except asyncio.CancelledError: try:
pass await _t
except asyncio.CancelledError:
pass
await db.close_pool() await db.close_pool()
await _progress.close() await _progress.close()
@@ -4411,6 +4432,30 @@ async def api_post_interaction_response(
raise HTTPException(502, f"שגיאת Paperclip: {e}") 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") @app.post("/api/cases/{case_number}/agents/reset")
async def api_reset_case_agents(case_number: str): async def api_reset_case_agents(case_number: str):
"""Reset stuck agents for a case. """Reset stuck agents for a case.

View File

@@ -899,6 +899,69 @@ async def reject_interaction(
return resp.json() 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 # Singleton project for the precedent-library extraction queue. One issue per
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor. # uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ" _LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"