3 Commits

Author SHA1 Message Date
cb4aa703ec feat(agents-panel): היררכיה, סמן-כהושלם ידני, ובורר-יעד נגלל (5 תיקוני יו"ר)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 36s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
טאב הסוכנים בתיק — 5 תיקונים לבקשת היו"ר, מאושרים דרך שער-העיצוב
(Claude Design X17, כרטיס 18i4):

1. היררכיה — הרצה שנפתחה תחת השורש (למשל CMP-220 תחת CMP-189) מסומנת
   בתג "↳ <parent>" גם בציר-הזמן וגם בבורר-היעד (בהזחה). הנתון parent_id
   כבר הגיע מה-API — תצוגה בלבד.
2. סמן-כהושלם / בטל ידני — כל משימה פתוחה מקבלת פעולות בכותרת. פותר
   "אין אפשרות לסמן ידנית" (superseded runs נתקעו ב-in_review בלי מנגנון
   סגירה). endpoint חדש: POST /api/cases/{n}/agents/issue-status → דרך
   ה-Port (pc_set_issue_status), סגירה loop-safe ישירה ל-DB, בלי wakeup.
3. בורר-היעד נגלל — max-height + overflow פנימי, כך שהתיבה לא בורחת
   מגבולות העמוד. פותר "לא רואים את התחתית / לא מגיעים לפתיחת הליך".
4. "פתח הרצה חדשה" עלה לראש הרשימה, מעל "משימות פעילות", ומודגש.
5. קו-הפרדה בין משימות בציר-הזמן עבה יותר (border-b-2 rule).

G12: כל מגע-פלטפורמה עובר דרך agent_platform_port. אימות: lint + tsc +
next build עוברים.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:25:36 +00:00
4d227a4999 Merge pull request 'fix(routing): הוראת-יו"ר תעבוד בדרך הטבעית — הערה פותחת הרצת-CEO' (#415) from worktree-natural-comment-routing into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m17s
G12 Leak-Guard / leak-guard (push) Successful in 7s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-07-16 12:33:29 +00:00
f58ddba93e fix(routing): הוראת-יו"ר תעבוד בדרך הטבעית — הערה פותחת הרצת-CEO
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 37s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
**התסמין:** היו"ר כותב הוראה על תיק והסוכן "מתעלם". ההרצה מדווחת succeeded/exit 0
ואפילו עולה כסף — ולכן זה שרד חודשים בלי שמישהו שם לב.

**שתי סיבות-שורש בלתי-תלויות, שתיהן מאומתות:**

1. ה-wakeup הישיר על ה-issue של התיק מבוטל תמיד: תיק שממתין ליו"ר הוא `in_review`
   ומשויך *לאדם*, ו-Paperclip עונה `issue_assignee_changed` → "אעיר את הבעלים החדש"
   → הבעלים אדם → אף סוכן לא מתעורר.
2. רשת-הביטחון בפלאגין מוסרת את ההוראה כ-`payload.prompt`, וה-runner קורא רק
   `payload.issueId` — הפרומפט נבלע. 18/18 מדידה ב-DB: 0% מסירה, אי-פעם.

**התיקון:** המסלול הטבעי (הערה בטאב-הסוכנים) מפרסם את ההערה בשרשור כרגיל **וגם**
פותח הרצת-CEO עם ההוראה, דרך `pc_open_ceo_run` — הפרימיטיב היחיד שאומת כמגיע
לסוכן (הוכח היום על 1043-02-26: 3,212→7,697 מילים). אותו תיקון ל-interaction-response,
שסובל מאותה תקלה (החצי השני של #228).

**G2 — הסרת מסלול מקביל:** ה-wakeup הנדון-לביטול הוסר מ-`post_comment`; הוא היה
המסלול השני, זה ששותק. כעת מסלול-מסירה קנוני אחד. `mark_comment_routed` מסמן את
ההערה כמנותבת כדי שה-sweep לא יירה הרצת-סרק על מה שכבר טופל.

**G12 — שער-הפלטפורמה:** המגע החדש (`mark_comment_routed`) נחשף דרך
`agent_platform_port.py` בלבד; leak-guard עובר.

התיקון יושב ב-backend (REST) ולא בפלאגין, ולכן אינו חשוף לבאג ה-invocation-scope
שמפיל את ה-sweep. תועד ב-docs/paperclip-quirks.md §7 עם המלכודות שנתקלנו בהן.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 12:32:20 +00:00
6 changed files with 405 additions and 91 deletions

View File

@@ -275,3 +275,40 @@ WARN [plugin] issue.comment.created event missing issueId in payload, skipping
- **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`). - **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`).
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי. - **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
- TaskMaster: `legal-ai` #149. - TaskMaster: `legal-ai` #149.
---
## 7. `agents.invoke({prompt})` — הפרומפט נבלע; רק `payload.issueId` נמסר
**התסמין (מנקודת-מבט היו"ר):** כותבים הוראה לסוכן — והוא "מתעלם". ההרצה נראית תקינה
לחלוטין: `status=succeeded`, `exit_code=0`, בלי שגיאה, ואפילו עולה כסף. הסוכן מדווח
"כל התורים ריקים — אין עבודה".
**הסיבה:** `ctx.agents.invoke(agentId, companyId, {prompt, reason})` הוא ה-API **המתועד**
של ה-Plugin SDK ("*Invoke an agent with a prompt payload*"), ו-`prompt` הוא שדה חובה.
Paperclip מקבל אותו, שומר אותו ב-`agent_wakeup_requests.payload.prompt` — **וה-runner קורא
רק `payload.issueId`**. הפרומפט לא מגיע למודל. ה-`reason` כן שורד (ל-`context_snapshot.wakeReason`),
ולכן זה נראה כאילו משהו נמסר.
מדידה ב-DB (2026-07-16): **18 מתוך 18** ה-wakeups שאי-פעם נשאו `payload.prompt` לא הגיעו
לאף הרצה — 0% מסירה. מול 107,172 wakeups עם `issueId` שעובדים. אימות ישיר: בלוג-ההרצה
שנבלעה אין ולו אזכור אחד של מספר-התיק, והמודל פותח ב-*"This heartbeat has no scoped wake payload"*.
**מה עושים במקום:** נושאים את ההוראה על **issue**, לא ב-payload — issue-ילד שכבר משויך
ל-CEO, עם ההוראה ב-`description`, ואז wakeup עם `payload.issueId`. זה בדיוק
`open_ceo_run` ב-[`web/paperclip_client.py`](../web/paperclip_client.py), והמסלול היחיד
שאומת כמגיע לסוכן.
**מלכודות שנלוות לזה — אל תיפול בהן שוב:**
- **`issues.create` של ה-SDK עם `assigneeAgentId` *לא* מעיר את הסוכן.** ה-REST המקביל
(`POST /api/issues/:id/children`) *כן* — הוא פולט wakeup `issue_assigned`. אל תכליל
מהתנהגות REST על ה-SDK; זה נבדק ונמצא שונה.
- **`ctx.issues.requestWakeup` (ה-primitive הנכון ב-SDK) נכשל מתוך scheduled job** עם
`missing, expired, or unknown invocation scope` — למרות ש-capability `issues.wakeup`
מוצהר ומותקן. אותה שגיאה מפילה גם `companies.list` / `issues.list` / `issues.listComments`
בתוך ה-sweep, ולכן **רשת-הביטחון `route-pending-comments` אינה אמינה**. לא נחקר לעומק —
לכן ניתוב-ההערות הועבר ל-backend של legal-ai (REST), שאינו חשוף לבעיה.
**סטטוס:** נעקף — `POST /api/cases/{case}/agents/comment` ו-`/agents/interaction-response`
פותחים הרצת-CEO ישירות (REST) ומסמנים את ההערה כמנותבת, כדי שה-sweep השבור לא יירה
הרצת-סרק. TaskMaster `legal-ai` #228.

View File

@@ -10,6 +10,7 @@ import {
useSendComment, useSendComment,
useSubmitInteraction, useSubmitInteraction,
useDismissInteraction, useDismissInteraction,
useSetIssueStatus,
} from "@/lib/api/agents"; } from "@/lib/api/agents";
import type { import type {
Interaction, Interaction,
@@ -772,14 +773,34 @@ function IssueGroup({
defaultOpen: boolean; defaultOpen: boolean;
}) { }) {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const setStatus = useSetIssueStatus(caseNumber);
const closed = issue.status === "done" || issue.status === "cancelled"; const closed = issue.status === "done" || issue.status === "cancelled";
// Show the parent run this issue hangs under (e.g. a "הרצה חדשה" nested under
// the live CEO root) so the hierarchy is visible, not flattened.
const parentIdentifier = issue.parent_id
? issueMap.get(issue.parent_id)
: undefined;
const close = (status: "done" | "cancelled") =>
setStatus.mutate(
{ issue_id: issue.id, status },
{
onSuccess: () =>
toast.success(
status === "done" ? "המשימה סומנה כהושלמה" : "המשימה בוטלה",
),
onError: () => toast.error("שגיאה בעדכון סטטוס המשימה"),
},
);
return ( return (
<div className="border-b border-rule-soft last:border-b-0"> <div className="border-b-2 border-rule last:border-b-0">
<div className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50">
<button <button
type="button" type="button"
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
aria-expanded={open} aria-expanded={open}
className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50 text-start" className="flex-1 flex items-center gap-2 text-start min-w-0"
> >
<Badge <Badge
variant={closed ? "secondary" : "default"} variant={closed ? "secondary" : "default"}
@@ -790,13 +811,58 @@ function IssueGroup({
<span className="text-[0.78rem] font-semibold text-navy truncate"> <span className="text-[0.78rem] font-semibold text-navy truncate">
{shortIssueTitle(issue.title)} {shortIssueTitle(issue.title)}
</span> </span>
{parentIdentifier && (
<span
className="inline-flex items-center gap-0.5 text-[0.6rem] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parentIdentifier}`}
>
{parentIdentifier}
</span>
)}
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap"> <span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
{issueStatusLabel(issue.status)} · {items.length} {issueStatusLabel(issue.status)} · {items.length}
</span> </span>
</button>
{!closed && (
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => close("done")}
disabled={setStatus.isPending}
className="h-7 px-2 text-[0.7rem] text-success border-success/40 hover:bg-success-bg"
title="סמן את המשימה כהושלמה"
>
{setStatus.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<CheckCircle2 className="w-3.5 h-3.5 me-1" />
)}
הושלם
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => close("cancelled")}
disabled={setStatus.isPending}
className="h-7 px-2 text-[0.7rem] text-ink-muted hover:text-danger"
title="בטל את המשימה"
>
בטל
</Button>
</div>
)}
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label={open ? "כווץ" : "הרחב"}
className="shrink-0"
>
<ChevronDown <ChevronDown
className={`w-4 h-4 ms-auto text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`} className={`w-4 h-4 text-ink-faint transition-transform ${open ? "rotate-180" : ""}`}
/> />
</button> </button>
</div>
{open && ( {open && (
<div className="pb-1"> <div className="pb-1">
{items.map((item) => {items.map((item) =>
@@ -878,6 +944,13 @@ function TargetSelector({
const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]); const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]);
const activeIssues = issues.filter((i) => isOpenStatus(i.status)); const activeIssues = issues.filter((i) => isOpenStatus(i.status));
const closedIssues = issues.filter((i) => !isOpenStatus(i.status)); const closedIssues = issues.filter((i) => !isOpenStatus(i.status));
// id → identifier, to label a run with the parent it hangs under (↳ CMP-189).
const idToIdentifier = useMemo(
() => new Map(issues.map((i) => [i.id, i.identifier])),
[issues],
);
const parentTag = (i: PaperclipIssue) =>
i.parent_id ? idToIdentifier.get(i.parent_id) : undefined;
// The issue the pill currently represents (explicit pick, or the auto default). // The issue the pill currently represents (explicit pick, or the auto default).
const effectiveIssue = const effectiveIssue =
@@ -925,7 +998,31 @@ function TargetSelector({
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
aria-hidden aria-hidden
/> />
<div className="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] bg-white border border-rule rounded-xl shadow-lg p-1.5"> <div className="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] max-h-[62vh] overflow-y-auto overscroll-contain bg-white border border-rule rounded-xl shadow-lg p-1.5">
{/* "פתח הרצה חדשה" is the FIRST, most-prominent option so it's always
visible without scrolling to the bottom of a long task list. */}
<button
type="button"
onClick={() => {
onChange({ kind: "new_run" });
setOpen(false);
}}
className="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-start bg-gold/10 border border-rule hover:border-gold/60"
>
<span className="w-6 h-6 rounded-md bg-navy text-white flex items-center justify-center shrink-0">
<Plus className="w-4 h-4" />
</span>
<span className="min-w-0">
<span className="block text-xs font-semibold text-navy">
פתח הרצה חדשה
</span>
<span className="block text-[10px] text-ink-muted">
ה-CEO ייצור משימה חדשה ויטפל בהוראה מאפס
</span>
</span>
</button>
<div className="h-px bg-rule-soft my-1.5 mx-1" />
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide"> <div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
משימות פעילות משימות פעילות
</div> </div>
@@ -933,6 +1030,7 @@ function TargetSelector({
const selected = const selected =
(target.kind === "issue" && target.id === i.id) || (target.kind === "issue" && target.id === i.id) ||
(target.kind === "auto" && defaultIssue?.id === i.id); (target.kind === "auto" && defaultIssue?.id === i.id);
const parent = parentTag(i);
return ( return (
<button <button
key={i.id} key={i.id}
@@ -941,12 +1039,20 @@ function TargetSelector({
onChange({ kind: "issue", id: i.id }); onChange({ kind: "issue", id: i.id });
setOpen(false); setOpen(false);
}} }}
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${selected ? "bg-gold/10" : ""}`} className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${parent ? "ps-6" : ""} ${selected ? "bg-gold/10" : ""}`}
> >
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" /> <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" />
<Badge variant="outline" className="text-[10px] font-mono shrink-0"> <Badge variant="outline" className="text-[10px] font-mono shrink-0">
{i.identifier} {i.identifier}
</Badge> </Badge>
{parent && (
<span
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parent}`}
>
{parent}
</span>
)}
<span className="text-xs text-ink-soft truncate flex-1"> <span className="text-xs text-ink-soft truncate flex-1">
{shortIssueTitle(i.title)} {shortIssueTitle(i.title)}
</span> </span>
@@ -968,16 +1074,26 @@ function TargetSelector({
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide"> <div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
משימות סגורות · שליחה אליהן לא תעיר סוכן משימות סגורות · שליחה אליהן לא תעיר סוכן
</div> </div>
{closedIssues.map((i) => ( {closedIssues.map((i) => {
const parent = parentTag(i);
return (
<div <div
key={i.id} key={i.id}
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed" className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed ${parent ? "ps-6" : ""}`}
title="משימה סגורה — שליחה אליה לא תעיר סוכן" title="משימה סגורה — שליחה אליה לא תעיר סוכן"
> >
<span className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" /> <span className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" />
<Badge variant="outline" className="text-[10px] font-mono shrink-0"> <Badge variant="outline" className="text-[10px] font-mono shrink-0">
{i.identifier} {i.identifier}
</Badge> </Badge>
{parent && (
<span
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parent}`}
>
{parent}
</span>
)}
<span className="text-xs text-ink-faint truncate flex-1"> <span className="text-xs text-ink-faint truncate flex-1">
{shortIssueTitle(i.title)} {shortIssueTitle(i.title)}
</span> </span>
@@ -987,31 +1103,10 @@ function TargetSelector({
{issueStatusLabel(i.status)} {issueStatusLabel(i.status)}
</span> </span>
</div> </div>
))} );
})}
</> </>
)} )}
<div className="h-px bg-rule-soft my-1.5 mx-1" />
<button
type="button"
onClick={() => {
onChange({ kind: "new_run" });
setOpen(false);
}}
className="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-start hover:bg-sand-soft"
>
<span className="w-6 h-6 rounded-md bg-navy text-white flex items-center justify-center shrink-0">
<Plus className="w-4 h-4" />
</span>
<span className="min-w-0">
<span className="block text-xs font-semibold text-navy">
פתח הרצה חדשה
</span>
<span className="block text-[10px] text-ink-muted">
ה-CEO ייצור משימה חדשה ויטפל בהוראה מאפס
</span>
</span>
</button>
</div> </div>
</> </>
)} )}

View File

@@ -208,6 +208,25 @@ export function useDismissInteraction(caseNumber: string | undefined) {
}); });
} }
/** Manually close a board issue to done/cancelled — the chair tidying the agents
* board (e.g. superseded runs left in in_review). Backend does a loop-safe
* direct close and issues no wakeup; only the two terminal statuses are allowed. */
export function useSetIssueStatus(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { issue_id: string; status: "done" | "cancelled" }) =>
apiRequest<{ ok: boolean; id: string; identifier: string; status: string }>(
`/api/cases/${caseNumber}/agents/issue-status`,
{ 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

@@ -63,10 +63,12 @@ from web.paperclip_client import (
cancel_interaction as _cancel_interaction, cancel_interaction as _cancel_interaction,
cancel_run as _cancel_run, cancel_run as _cancel_run,
escalate_issue as _escalate_issue, escalate_issue as _escalate_issue,
mark_comment_routed as _mark_comment_routed,
post_comment as _post_comment, post_comment as _post_comment,
reap_stale_interactions as _reap_stale_interactions, reap_stale_interactions as _reap_stale_interactions,
reset_agent_session as _reset_agent_session, reset_agent_session as _reset_agent_session,
reset_case_agents as _reset_case_agents, reset_case_agents as _reset_case_agents,
set_issue_status as _set_issue_status,
wake_analyst_for_appraiser_facts as _wake_analyst_for_appraiser_facts, wake_analyst_for_appraiser_facts as _wake_analyst_for_appraiser_facts,
wake_analyst_for_argument_aggregation as _wake_analyst_for_argument_aggregation, wake_analyst_for_argument_aggregation as _wake_analyst_for_argument_aggregation,
wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis, wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis,
@@ -114,6 +116,9 @@ pc_wake_analyst_for_protocol_analysis = instrument(
pc_post_comment = instrument( pc_post_comment = instrument(
"agent.comment", keys=("issue_id", "company_id"), "agent.comment", keys=("issue_id", "company_id"),
)(_post_comment) )(_post_comment)
pc_mark_comment_routed = instrument(
"agent.comment", keys=("issue_id", "comment_id"),
)(_mark_comment_routed)
pc_cancel_interaction = instrument( pc_cancel_interaction = instrument(
"interaction.cancelled", keys=("issue_id", "interaction_id"), "interaction.cancelled", keys=("issue_id", "interaction_id"),
)(_cancel_interaction) )(_cancel_interaction)
@@ -123,6 +128,12 @@ pc_cancel_interaction = instrument(
pc_escalate_issue = instrument( pc_escalate_issue = instrument(
"agent.escalated", keys=("issue_id", "severity", "company_id", "reason"), "agent.escalated", keys=("issue_id", "severity", "company_id", "reason"),
)(_escalate_issue) )(_escalate_issue)
# Manual chair close of a board issue (done/cancelled) — the loop-safe primitive
# behind the agents-board "סמן כהושלם / בטל" actions. Countable per case in the
# same telemetry stream as the escalations/wakeups it sits beside.
pc_set_issue_status = instrument(
"issue.status_set", keys=("issue_id", "status", "company_id"),
)(_set_issue_status)
pc_reap_stale_interactions = instrument( pc_reap_stale_interactions = instrument(
"interaction.reaped", result_keys=("cancelled",), "interaction.reaped", result_keys=("cancelled",),
)(_reap_stale_interactions) )(_reap_stale_interactions)
@@ -179,6 +190,7 @@ __all__ = [
"pc_get_generation_run_status", "pc_get_generation_run_status",
# comments / interactions # comments / interactions
"pc_post_comment", "pc_post_comment",
"pc_mark_comment_routed",
"pc_get_issue_comments", "pc_get_issue_comments",
"pc_get_issue_interactions", "pc_get_issue_interactions",
"pc_accept_interaction", "pc_accept_interaction",
@@ -187,6 +199,7 @@ __all__ = [
"pc_cancel_interaction", "pc_cancel_interaction",
"pc_reap_stale_interactions", "pc_reap_stale_interactions",
"pc_escalate_issue", "pc_escalate_issue",
"pc_set_issue_status",
# agent-run observability + control (live view + smart management) # agent-run observability + control (live view + smart management)
"pc_get_agent_health", "pc_get_agent_health",
"pc_get_recent_escalations", "pc_get_recent_escalations",

View File

@@ -82,6 +82,7 @@ from web.agent_platform_port import (
pc_reset_agent_session, pc_reset_agent_session,
pc_reset_case_agents, pc_reset_case_agents,
pc_respond_to_interaction, pc_respond_to_interaction,
pc_set_issue_status,
pc_restore_project, pc_restore_project,
pc_wake_analyst_for_appraiser_facts, pc_wake_analyst_for_appraiser_facts,
pc_wake_analyst_for_argument_aggregation, pc_wake_analyst_for_argument_aggregation,
@@ -91,6 +92,7 @@ from web.agent_platform_port import (
pc_wake_ceo, pc_wake_ceo,
pc_wake_ceo_for_action, pc_wake_ceo_for_action,
pc_open_ceo_run, pc_open_ceo_run,
pc_mark_comment_routed,
pc_wake_ceo_for_feedback_fold, pc_wake_ceo_for_feedback_fold,
pc_wake_curator_for_final, pc_wake_curator_for_final,
pc_wake_for_precedent_extraction, pc_wake_for_precedent_extraction,
@@ -4660,8 +4662,9 @@ async def api_case_agents(case_number: str):
class AgentCommentRequest(BaseModel): class AgentCommentRequest(BaseModel):
body: str body: str
issue_id: str | None = None issue_id: str | None = None
# "פתח הרצה חדשה" — open a fresh CEO-owned run for this instruction instead of # "פתח הרצה חדשה" — skip the comment entirely and only open a CEO run. Kept for
# appending to an existing issue (agents-tab target selector). Overrides issue_id. # the agents-tab target selector; the default path below now opens a run too, so
# this only differs in *not* recording the instruction on the parent thread.
new_run: bool = False new_run: bool = False
@@ -4705,10 +4708,31 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
result = await pc_post_comment(target["id"], target["company_id"], req.body) result = await pc_post_comment(target["id"], target["company_id"], req.body)
# The comment alone reaches nobody: a case issue awaiting the chair is
# `in_review` and owned by a human, so Paperclip cancels any wakeup on it
# (`issue_assignee_changed` → "wake the new owner" → the owner is a person).
# The plugin sweep that was meant to catch this delivers the instruction as
# `payload.prompt`, which the runner drops. So the comment is recorded for the
# thread, and the instruction is carried by a CEO-owned child run — the one
# path verified to reach the agent (TaskMaster #228; see paperclip-quirks.md).
run = await pc_open_ceo_run(case_number, req.body)
if run.get("status") != "ok":
raise HTTPException(
502,
f"ההערה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא יטפל בה: "
f"{run.get('reason', 'unknown')}",
)
# Claim the comment so the sweep does not re-route it: the CEO answers on the
# child issue, leaving the parent with no agent reply, which the sweep reads
# as "still pending" forever.
await pc_mark_comment_routed(target["id"], result.get("comment_id", ""))
# Echo the resolved target so the UI can show where it landed and flag it # Echo the resolved target so the UI can show where it landed and flag it
# when the chair explicitly targeted a closed issue (whose wakeup is skipped). # when the chair explicitly targeted a closed issue (whose wakeup is skipped).
result["issue_identifier"] = target.get("identifier", "") result["issue_identifier"] = target.get("identifier", "")
result["issue_status"] = target.get("status", "") result["issue_status"] = target.get("status", "")
result["run_issue_id"] = run["issue_id"]
result["run_issue_identifier"] = run.get("identifier", "")
return result return result
@@ -4725,8 +4749,10 @@ async def api_post_interaction_response(
): ):
"""Submit a user's answer to a Paperclip issue-thread interaction. """Submit a user's answer to a Paperclip issue-thread interaction.
Routes to /respond | /accept | /reject based on `action`. Paperclip Routes to /respond | /accept | /reject based on `action`, then opens a CEO run
auto-wakes the issue assignee after a successful submission. carrying the answer. Paperclip's own `wake_assignee` cannot deliver it: the
issue is owned by the chair while it waits for her, so the "assignee" it wakes
is a person and no agent ever runs (TaskMaster #228).
""" """
issues = await pc_get_case_issues(case_number) issues = await pc_get_case_issues(case_number)
if not any(i["id"] == req.issue_id for i in issues): if not any(i["id"] == req.issue_id for i in issues):
@@ -4738,7 +4764,7 @@ async def api_post_interaction_response(
"reject": pc_reject_interaction, "reject": pc_reject_interaction,
} }
try: try:
return await handlers[req.action]( result = await handlers[req.action](
req.issue_id, req.interaction_id, req.payload, req.issue_id, req.interaction_id, req.payload,
) )
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
@@ -4747,6 +4773,23 @@ async def api_post_interaction_response(
except Exception as e: except Exception as e:
raise HTTPException(502, f"שגיאת Paperclip: {e}") raise HTTPException(502, f"שגיאת Paperclip: {e}")
answer = json.dumps(req.payload, ensure_ascii=False)
run = await pc_open_ceo_run(
case_number,
f'תשובת היו"ר לשאלה שהוצגה לה ({req.action}):\n\n{answer}\n\n'
f"קרא את ה-interaction המקורי על ה-issue והמשך משם.",
)
if run.get("status") != "ok":
raise HTTPException(
502,
f"התשובה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא ימשיך: "
f"{run.get('reason', 'unknown')}",
)
if isinstance(result, dict):
result["run_issue_id"] = run["issue_id"]
result["run_issue_identifier"] = run.get("identifier", "")
return result
class InteractionDismissRequest(BaseModel): class InteractionDismissRequest(BaseModel):
issue_id: str issue_id: str
@@ -4809,6 +4852,29 @@ async def api_escalate_issue(case_number: str, req: EscalateRequest):
return result return result
class IssueStatusRequest(BaseModel):
issue_id: str
status: Literal["done", "cancelled"]
@app.post("/api/cases/{case_number}/agents/issue-status")
async def api_set_issue_status(case_number: str, req: IssueStatusRequest):
"""Manually close a board issue to done/cancelled from the chair UI.
Lets the chair tidy the agents board by hand — superseded "הרצה חדשה" runs
left in ``in_review`` had no manual close control and piled up. Only the two
terminal statuses are accepted; the loop-safe direct-DB close lives behind the
Port (``pc_set_issue_status``) and issues no wakeup.
"""
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_set_issue_status(req.issue_id, req.status)
if not result.get("ok"):
raise HTTPException(400, result.get("error", "עדכון הסטטוס נכשל"))
return result
# ── Settings: MCP Server Configuration ──────────────────────────── # ── Settings: MCP Server Configuration ────────────────────────────
# #
# Source of truth for legal-ai env vars is Coolify (see memory: # Source of truth for legal-ai env vars is Coolify (see memory:

View File

@@ -327,6 +327,40 @@ async def _create_issue(
return issue_id, identifier return issue_id, identifier
async def mark_comment_routed(issue_id: str, comment_id: str) -> dict:
"""Claim a chair comment as already routed, so the plugin sweep skips it.
The plugin's `route-pending-comments` sweep re-routes any user comment newer
than the newest agent comment on the same issue. A chair instruction answered
on a CEO **child** issue leaves the parent with no agent reply, so the sweep
would keep re-routing it forever. Writing the sweep's own marker
(`last-routed-comment-id`, plugin state, issue scope) is what tells it the
comment is handled — this is the same key `markCommentRouted` sets in
`plugin-legal-ai/src/worker.ts`.
"""
if not comment_id:
return {"ok": False, "error": "no_comment_id"}
try:
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
await conn.execute(
"""INSERT INTO plugin_state (plugin_id, scope_kind, scope_id, namespace, state_key, value_json)
VALUES ($1::uuid, 'issue', $2, 'default', 'last-routed-comment-id', $3::jsonb)
ON CONFLICT (plugin_id, scope_kind, scope_id, namespace, state_key)
DO UPDATE SET value_json = $3::jsonb""",
PLUGIN_ID, issue_id, json.dumps(comment_id),
)
finally:
await conn.close()
return {"ok": True, "issue_id": issue_id, "comment_id": comment_id}
except Exception as e:
logger.warning(
"mark_comment_routed failed for issue %s comment %s: %s",
issue_id, comment_id, e,
)
return {"ok": False, "error": str(e)}
async def _link_case_to_issue(conn: asyncpg.Connection, issue_id: str, case_number: str) -> None: async def _link_case_to_issue(conn: asyncpg.Connection, issue_id: str, case_number: str) -> None:
"""Store the legal-ai case number in plugin state, linked to the issue.""" """Store the legal-ai case number in plugin state, linked to the issue."""
await conn.execute( await conn.execute(
@@ -655,10 +689,14 @@ async def get_agents_for_case(company_id: str, issue_ids: list[str]) -> list[dic
async def post_comment(issue_id: str, company_id: str, body: str) -> dict: async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
"""Post a comment on a Paperclip issue. """Post a comment on a Paperclip issue. Records only — wakes nobody.
Tries the Board API first (triggers plugin events for CEO routing). Delivering the instruction is the caller's job, via :func:`open_ceo_run`. This
Falls back to direct DB insert + CEO wakeup if API fails. function used to also wake the CEO on ``issue_id``, but that wakeup is dead on
arrival for the case issues the chair actually comments on: they are
`in_review` and owned by her, so Paperclip cancels the run with
`issue_assignee_changed` before it starts. Keeping it would leave two parallel
delivery paths — one that works and one that silently doesn't (INV-G2).
""" """
# Try Board API first — this triggers the event bus # Try Board API first — this triggers the event bus
if PAPERCLIP_BOARD_API_KEY: if PAPERCLIP_BOARD_API_KEY:
@@ -675,7 +713,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
except Exception: except Exception:
logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id) logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id)
# Fallback: direct DB insert + explicit CEO wakeup
comment_id = str(uuid.uuid4()) comment_id = str(uuid.uuid4())
conn = await asyncpg.connect(PAPERCLIP_DB_URL) conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try: try:
@@ -688,23 +725,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
finally: finally:
await conn.close() await conn.close()
# Wake the correct CEO for this company
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
try:
await pc_request(
"POST",
f"/api/agents/{ceo_id}/wakeup",
json={
"source": "on_demand",
"triggerDetail": "manual",
"reason": f"user_comment_{issue_id}",
"payload": {"issueId": issue_id, "mutation": "comment"},
},
raise_on_error=True,
)
except Exception:
logger.warning("Failed to wake CEO after DB comment on issue %s", issue_id)
return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"} return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"}
@@ -968,6 +988,70 @@ async def escalate_issue(
} }
async def set_issue_status(
issue_id: str, status: str, company_id: str = "",
) -> dict:
"""Manually close a case issue to ``done`` or ``cancelled`` from the chair UI.
The chair needs to tidy the agents board by hand: superseded "הרצה חדשה" runs
that were replaced by a later run pile up in ``in_review`` with no way to close
them — there was no manual control, so they had to be closed out-of-band. This
is the loop-safe primitive behind the board's "סמן כהושלם / בטל" actions.
Only the two **terminal** statuses are accepted (``CLOSED_ISSUE_STATUSES``) —
this tidies the board, it does not drive the workflow (agents own the
todo/in_progress/in_review transitions). Direct-DB in one transaction,
mirroring :func:`escalate_issue`: bypasses Paperclip's disposition resolver
(whose multi-PATCH/``issue.released`` behaviour *causes* the recovery loops),
sets the matching close timestamp, clears any agent assignee so no recovery
sweep re-acts on it, and records an ``author_type='system'`` audit note (inert
w.r.t. the user-comment routing sweep, so it will not wake an agent). No wakeup.
"""
if status not in CLOSED_ISSUE_STATUSES:
return {
"ok": False,
"error": f"invalid status {status!r}; expected one of {tuple(CLOSED_ISSUE_STATUSES)}",
}
# status is validated against the frozenset above, so the interpolated column
# name is one of exactly two literals — no injection surface.
ts_col = "completed_at" if status == "done" else "cancelled_at"
note = (
"✓ סומן ידנית כהושלם ע\"י היו\"ר"
if status == "done"
else "✕ בוטל ידנית ע\"י היו\"ר"
)
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
async with conn.transaction():
row = await conn.fetchrow(
f"""UPDATE issues
SET status=$1, {ts_col}=now(),
assignee_agent_id=null, updated_at=now()
WHERE id=$2::uuid
RETURNING id, identifier, company_id""",
status, 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, note,
)
finally:
await conn.close()
logger.info("Chair set issue %s%s", issue_id, status)
return {
"ok": True,
"id": str(row["id"]),
"identifier": row["identifier"],
"status": status,
}
async def respond_to_interaction( async def respond_to_interaction(
issue_id: str, interaction_id: str, payload: dict, issue_id: str, interaction_id: str, payload: dict,
) -> dict: ) -> dict: