From cb4aa703ece3bb590c1bd759d913feeaa41cd721 Mon Sep 17 00:00:00 2001 From: Chaim Date: Thu, 23 Jul 2026 12:25:36 +0000 Subject: [PATCH] =?UTF-8?q?feat(agents-panel):=20=D7=94=D7=99=D7=A8=D7=A8?= =?UTF-8?q?=D7=9B=D7=99=D7=94,=20=D7=A1=D7=9E=D7=9F-=D7=9B=D7=94=D7=95?= =?UTF-8?q?=D7=A9=D7=9C=D7=9D=20=D7=99=D7=93=D7=A0=D7=99,=20=D7=95=D7=91?= =?UTF-8?q?=D7=95=D7=A8=D7=A8-=D7=99=D7=A2=D7=93=20=D7=A0=D7=92=D7=9C?= =?UTF-8?q?=D7=9C=20(5=20=D7=AA=D7=99=D7=A7=D7=95=D7=A0=D7=99=20=D7=99?= =?UTF-8?q?=D7=95"=D7=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit טאב הסוכנים בתיק — 5 תיקונים לבקשת היו"ר, מאושרים דרך שער-העיצוב (Claude Design X17, כרטיס 18i4): 1. היררכיה — הרצה שנפתחה תחת השורש (למשל CMP-220 תחת CMP-189) מסומנת בתג "↳ " גם בציר-הזמן וגם בבורר-היעד (בהזחה). הנתון 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) --- .../components/cases/agent-activity-feed.tsx | 225 +++++++++++++----- web-ui/src/lib/api/agents.ts | 19 ++ web/agent_platform_port.py | 8 + web/app.py | 24 ++ web/paperclip_client.py | 64 +++++ 5 files changed, 275 insertions(+), 65 deletions(-) diff --git a/web-ui/src/components/cases/agent-activity-feed.tsx b/web-ui/src/components/cases/agent-activity-feed.tsx index ab11c9a..45e73ab 100644 --- a/web-ui/src/components/cases/agent-activity-feed.tsx +++ b/web-ui/src/components/cases/agent-activity-feed.tsx @@ -10,6 +10,7 @@ import { useSendComment, useSubmitInteraction, useDismissInteraction, + useSetIssueStatus, } from "@/lib/api/agents"; import type { Interaction, @@ -772,31 +773,96 @@ function IssueGroup({ defaultOpen: boolean; }) { const [open, setOpen] = useState(defaultOpen); + const setStatus = useSetIssueStatus(caseNumber); 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 ( -
- + + {issue.identifier} + + + {shortIssueTitle(issue.title)} + + {parentIdentifier && ( + + ↳ {parentIdentifier} + + )} + + {issueStatusLabel(issue.status)} · {items.length} + + + {!closed && ( +
+ + +
+ )} + +
{open && (
{items.map((item) => @@ -878,6 +944,13 @@ function TargetSelector({ const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]); const activeIssues = 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). const effectiveIssue = @@ -925,7 +998,31 @@ function TargetSelector({ onClick={() => setOpen(false)} aria-hidden /> -
+
+ {/* "פתח הרצה חדשה" is the FIRST, most-prominent option so it's always + visible without scrolling to the bottom of a long task list. */} + +
+
משימות פעילות
@@ -933,6 +1030,7 @@ function TargetSelector({ const selected = (target.kind === "issue" && target.id === i.id) || (target.kind === "auto" && defaultIssue?.id === i.id); + const parent = parentTag(i); return (
)} diff --git a/web-ui/src/lib/api/agents.ts b/web-ui/src/lib/api/agents.ts index dcab975..f2e7145 100644 --- a/web-ui/src/lib/api/agents.ts +++ b/web-ui/src/lib/api/agents.ts @@ -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 = { ok: boolean; reassigned_issues: { id: string; identifier: string }[]; diff --git a/web/agent_platform_port.py b/web/agent_platform_port.py index 477e1a7..758c035 100644 --- a/web/agent_platform_port.py +++ b/web/agent_platform_port.py @@ -68,6 +68,7 @@ from web.paperclip_client import ( reap_stale_interactions as _reap_stale_interactions, reset_agent_session as _reset_agent_session, 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_argument_aggregation as _wake_analyst_for_argument_aggregation, wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis, @@ -127,6 +128,12 @@ pc_cancel_interaction = instrument( pc_escalate_issue = instrument( "agent.escalated", keys=("issue_id", "severity", "company_id", "reason"), )(_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( "interaction.reaped", result_keys=("cancelled",), )(_reap_stale_interactions) @@ -192,6 +199,7 @@ __all__ = [ "pc_cancel_interaction", "pc_reap_stale_interactions", "pc_escalate_issue", + "pc_set_issue_status", # agent-run observability + control (live view + smart management) "pc_get_agent_health", "pc_get_recent_escalations", diff --git a/web/app.py b/web/app.py index 772e0c2..1f31920 100644 --- a/web/app.py +++ b/web/app.py @@ -82,6 +82,7 @@ from web.agent_platform_port import ( pc_reset_agent_session, pc_reset_case_agents, pc_respond_to_interaction, + pc_set_issue_status, pc_restore_project, pc_wake_analyst_for_appraiser_facts, pc_wake_analyst_for_argument_aggregation, @@ -4851,6 +4852,29 @@ async def api_escalate_issue(case_number: str, req: EscalateRequest): 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 ──────────────────────────── # # Source of truth for legal-ai env vars is Coolify (see memory: diff --git a/web/paperclip_client.py b/web/paperclip_client.py index e590c8a..8adebcc 100644 --- a/web/paperclip_client.py +++ b/web/paperclip_client.py @@ -988,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( issue_id: str, interaction_id: str, payload: dict, ) -> dict: -- 2.49.1