From fa1bc84b7e2c6240dc4e035bec7c6757e54f4cd1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 6 Jun 2026 14:39:32 +0000 Subject: [PATCH] Show invalid org chain agent warnings --- ui/src/components/AgentActionButtons.tsx | 12 +++++- ui/src/components/IssueProperties.tsx | 4 +- ui/src/components/NewIssueDialog.tsx | 4 +- ui/src/components/SidebarAgents.tsx | 9 ++++- ui/src/lib/company-members.test.ts | 29 ++++++++++++++ ui/src/lib/company-members.ts | 12 +++++- ui/src/pages/AgentDetail.tsx | 31 ++++++++++++++ ui/src/pages/Agents.test.tsx | 51 ++++++++++++++++++++++++ ui/src/pages/Agents.tsx | 16 ++++++-- ui/src/pages/IssueDetail.tsx | 4 +- 10 files changed, 158 insertions(+), 14 deletions(-) diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index e43533f4da9..48c12c67d9d 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -126,6 +126,8 @@ export function AgentActionButtons({ runLabel = "Run now", showStatus = true, actionsDisabled = false, + workActionsDisabled = false, + workActionsDisabledReason, navigateToRunOnInvoke = true, onActionError, children, @@ -138,6 +140,8 @@ export function AgentActionButtons({ runLabel?: string; showStatus?: boolean; actionsDisabled?: boolean; + workActionsDisabled?: boolean; + workActionsDisabledReason?: string; navigateToRunOnInvoke?: boolean; /** * Optional inline error reporter. When provided it is used instead of a toast @@ -259,6 +263,8 @@ export function AgentActionButtons({ const isPendingApproval = agent.status === "pending_approval"; const disabled = actionsDisabled || agentAction.isPending; + const assignAndRunDisabled = disabled || isPendingApproval || workActionsDisabled; + const pauseResumeDisabled = disabled || isPendingApproval || (isPaused && workActionsDisabled); return (
@@ -266,13 +272,15 @@ export function AgentActionButtons({ variant="outline" size={size} onClick={() => openNewIssue({ assigneeAgentId: agent.id })} + disabled={assignAndRunDisabled} + title={workActionsDisabled ? workActionsDisabledReason : undefined} > {assignLabel} agentAction.mutate("invoke")} - disabled={disabled || isPendingApproval} + disabled={assignAndRunDisabled} label={runLabel} size={size} /> @@ -280,7 +288,7 @@ export function AgentActionButtons({ isPaused={isPaused} onPause={() => agentAction.mutate("pause")} onResume={() => agentAction.mutate("resume")} - disabled={disabled || isPendingApproval} + disabled={pauseResumeDisabled} size={size} /> {showStatus && ( diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 03e5452c5f4..02f3d1298b6 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -11,7 +11,7 @@ import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; -import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members"; +import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, isAgentTaskTarget } from "../lib/company-members"; import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../lib/issue-assignee-overrides"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { @@ -545,7 +545,7 @@ export function IssueProperties({ const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]); const recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), [assigneeOpen]); const sortedAgents = useMemo( - () => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds), + () => sortAgentsByRecency((agents ?? []).filter(isAgentTaskTarget), recentAssigneeIds), [agents, recentAssigneeIds], ); const recentAssigneeValues = useMemo( diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 4083f4881f1..ac3b92f9c1b 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -13,7 +13,7 @@ import { agentsApi } from "../api/agents"; import { accessApi } from "../api/access"; import { authApi } from "../api/auth"; import { assetsApi } from "../api/assets"; -import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../lib/company-members"; +import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members"; import { queryKeys } from "../lib/queryKeys"; import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces"; import { useProjectOrder } from "../hooks/useProjectOrder"; @@ -1122,7 +1122,7 @@ export function NewIssueDialog() { ...currentUserAssigneeOption(currentUserId), ...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }), ...sortAgentsByRecency( - (agents ?? []).filter((agent) => agent.status !== "terminated"), + (agents ?? []).filter(isAgentTaskTarget), recentAssigneeIds, ).map((agent) => ({ id: assigneeValueFromSelection({ assigneeAgentId: agent.id }), diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 70fe74c2b21..83b324090bb 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -10,6 +10,7 @@ import { PlayCircle, Plus, Users, + AlertTriangle, } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; @@ -114,12 +115,15 @@ function SidebarAgentItem({ const isActive = activeAgentId === routeRef; const isPaused = agent.status === "paused"; const isBudgetPaused = isPaused && agent.pauseReason === "budget"; + const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain"; const pauseResumeLabel = isPaused ? "Resume agent" : "Pause agent"; - const pauseResumeDisabled = disabled || agent.status === "pending_approval" || isBudgetPaused; + const pauseResumeDisabled = disabled || agent.status === "pending_approval" || isBudgetPaused || (isPaused && hasInvalidOrgChain); const pauseResumeDisabledLabel = disabled ? "Updating..." : isBudgetPaused ? "Budget paused" + : isPaused && hasInvalidOrgChain + ? "Invalid org chain" : pauseResumeLabel; return ( @@ -139,6 +143,9 @@ function SidebarAgentItem({ > {agent.name} + {hasInvalidOrgChain ? ( + + ) : null} {(agent.pauseReason === "budget" || runCount > 0) && ( {agent.pauseReason === "budget" ? ( diff --git a/ui/src/lib/company-members.test.ts b/ui/src/lib/company-members.test.ts index 2afc4fa3b82..214769f5167 100644 --- a/ui/src/lib/company-members.test.ts +++ b/ui/src/lib/company-members.test.ts @@ -6,6 +6,7 @@ import { buildCompanyUserProfileMap, buildMarkdownMentionOptions, } from "./company-members"; +import type { AgentOrgChainHealth } from "@paperclipai/shared"; const activeMember = (overrides: Partial): CompanyMember => ({ id: overrides.id ?? "member-1", @@ -22,6 +23,15 @@ const activeMember = (overrides: Partial): CompanyMember => ({ grants: overrides.grants ?? [], }); +const invalidOrgChainHealth: AgentOrgChainHealth = { + status: "invalid_org_chain", + reason: "terminated_ancestor", + fullChain: [], + firstInvalidAncestor: { id: "manager-1", name: "Manager", status: "terminated" }, + invalidAncestors: [{ id: "manager-1", name: "Manager", status: "terminated" }], + repairGuidance: "Repair the reporting chain.", +}; + describe("company-members helpers", () => { it("builds labels from company member profiles", () => { const labels = buildCompanyUserLabelMap([ @@ -82,6 +92,25 @@ describe("company-members helpers", () => { ]); }); + it("omits invalid-org-chain agents from markdown mention options", () => { + const options = buildMarkdownMentionOptions({ + agents: [ + { id: "agent-1", name: "CodexCoder", status: "active", icon: "code" }, + { + id: "agent-2", + name: "InvalidCoder", + status: "active", + icon: "code", + orgChainHealth: invalidOrgChainHealth, + }, + ], + }); + + expect(options).toEqual([ + { id: "agent:agent-1", name: "CodexCoder", kind: "agent", agentId: "agent-1", agentIcon: "code" }, + ]); + }); + it("accepts read-only directory entries for assignee and mention helpers", () => { const users: CompanyUserDirectoryEntry[] = [ { diff --git a/ui/src/lib/company-members.ts b/ui/src/lib/company-members.ts index f0f908f9232..387256e7a8e 100644 --- a/ui/src/lib/company-members.ts +++ b/ui/src/lib/company-members.ts @@ -84,15 +84,23 @@ export function buildCompanyUserMentionOptions( })); } +export function isAgentTaskTarget(agent: Pick): boolean { + return ( + agent.status !== "terminated" && + agent.status !== "pending_approval" && + agent.orgChainHealth?.status !== "invalid_org_chain" + ); +} + export function buildMarkdownMentionOptions(args: { - agents?: Array> | null | undefined; + agents?: Array> | null | undefined; projects?: Array> | null | undefined; members?: CompanyUserRecord[] | null | undefined; }): MentionOption[] { const options: MentionOption[] = [ ...buildCompanyUserMentionOptions(args.members), ...[...(args.agents ?? [])] - .filter((agent) => agent.status !== "terminated") + .filter(isAgentTaskTarget) .sort((left, right) => left.name.localeCompare(right.name)) .map((agent) => ({ id: `agent:${agent.id}`, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 8062a4d9edb..8eedd915511 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -72,6 +72,7 @@ import { ArrowLeft, HelpCircle, FolderOpen, + AlertTriangle, } from "lucide-react"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -123,6 +124,12 @@ const SECRET_ENV_KEY_RE = const COMMAND_ENV_KEY_RE = /(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i; const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/; +function formatOrgChainHealthPath(agent: AgentDetailRecord) { + return agent.orgChainHealth?.fullChain + .map((entry) => `${entry.name}${entry.status !== "active" && entry.status !== "idle" ? ` (${entry.status})` : ""}`) + .join(" -> ") ?? agent.name; +} + function redactPathText(value: string, censorUsernameInLogs: boolean) { return redactHomePathUserSegments(value, { enabled: censorUsernameInLogs }); } @@ -913,6 +920,7 @@ export function AgentDetail() { return ; } const isPendingApproval = agent.status === "pending_approval"; + const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain"; const showConfigActionBar = (activeView === "configuration" || activeView === "instructions") && (configDirty || configSaving); const showLeftAgentNotice = agentMembershipState === "left" && !dismissedLeftAgentIds.has(agent.id); const agentMembershipPending = @@ -956,6 +964,27 @@ export function AgentDetail() {
) : null} + {hasInvalidOrgChain ? ( +
+ +
+

Invalid reporting chain

+

+ {agent.name} cannot accept tasks or start runs until its reporting chain is repaired. +

+

+ {formatOrgChainHealthPath(agent)} +

+ {agent.orgChainHealth?.repairGuidance ? ( +

{agent.orgChainHealth.repairGuidance}

+ ) : ( +

+ Assign this agent to an active manager/root, or explicitly pause or terminate the affected agent/subtree. +

+ )} +
+
+ ) : null} {/* Header */}
@@ -981,6 +1010,8 @@ export function AgentDetail() { assignLabel="Assign Task" runLabel="Run Heartbeat" actionsDisabled={agentAction.isPending} + workActionsDisabled={hasInvalidOrgChain} + workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs" onActionError={setActionError} > {mobileLiveRun && ( diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index caecb777b6b..495fba5c7c1 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -8,6 +8,7 @@ import type { Agent } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ToastProvider } from "../context/ToastContext"; import { Agents } from "./Agents"; +import type { AgentOrgChainHealth } from "@paperclipai/shared"; const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn(), @@ -105,6 +106,34 @@ function makeAgent(overrides: Partial): Agent { }; } +const invalidOrgChainHealth: AgentOrgChainHealth = { + status: "invalid_org_chain", + reason: "terminated_ancestor", + fullChain: [ + { + id: "agent-1", + companyId: "company-1", + name: "Alpha", + status: "active", + reportsTo: "manager-1", + depth: 0, + relation: "self", + }, + { + id: "manager-1", + companyId: "company-1", + name: "Terminated Manager", + status: "terminated", + reportsTo: null, + depth: 1, + relation: "ancestor", + }, + ], + firstInvalidAncestor: { id: "manager-1", name: "Terminated Manager", status: "terminated" }, + invalidAncestors: [{ id: "manager-1", name: "Terminated Manager", status: "terminated" }], + repairGuidance: "Alpha reports through terminated ancestor Terminated Manager.", +}; + async function flushReact() { await act(async () => { await Promise.resolve(); @@ -224,4 +253,26 @@ describe("Agents", () => { expect(titleCell?.textContent).toContain("Alpha"); expect(container.querySelector(".min-w-\\[7rem\\]")).toBeNull(); }); + + it("keeps invalid-org-chain agents visible with a warning marker", async () => { + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ orgChainHealth: invalidOrgChainHealth }), + ]); + + root = createRoot(container); + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Alpha"); + expect(container.querySelector('[aria-label="Invalid reporting chain"]')).not.toBeNull(); + }); }); diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 285b636c65e..4c4f3d023df 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -18,7 +18,7 @@ import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils"; import { PageTabBar } from "../components/PageTabBar"; import { Tabs } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; -import { Bot, Plus, List, GitBranch } from "lucide-react"; +import { AlertTriangle, Bot, Plus, List, GitBranch } from "lucide-react"; import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared"; import { resourceMembershipState, @@ -211,6 +211,7 @@ export function Agents() { {effectiveView === "list" && filtered.length > 0 && (
{filtered.map((agent) => { + const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain"; return ( } + leading={hasInvalidOrgChain ? ( + + ) : ( + + )} meta={
@@ -366,6 +371,7 @@ function OrgTreeNode({ membershipMutation: ReturnType; }) { const agent = agentMap.get(node.id); + const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain"); const membershipState = resourceMembershipState(memberships, "agent", node.id); const pending = membershipMutation.isPending && membershipMutation.variables?.resourceType === "agent" && @@ -381,7 +387,11 @@ function OrgTreeNode({ membershipState === "left" && "text-foreground/55", )} > - + {hasInvalidOrgChain ? ( + + ) : ( + + )}
{node.name} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 6323ba3d273..d622bd81e33 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -19,7 +19,7 @@ import { useSidebar } from "../context/SidebarContext"; import { useToastActions } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees"; -import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions } from "../lib/company-members"; +import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members"; import { extractIssueTimelineEvents } from "../lib/issue-timeline-events"; import { queryKeys } from "../lib/queryKeys"; import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data"; @@ -1603,7 +1603,7 @@ export function IssueDetail() { const options: Array<{ id: string; label: string; searchText?: string }> = []; options.push(...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] })); const activeAgents = [...(agents ?? [])] - .filter((agent) => agent.status !== "terminated") + .filter(isAgentTaskTarget) .sort((a, b) => a.name.localeCompare(b.name)); for (const agent of activeAgents) { options.push({ id: `agent:${agent.id}`, label: agent.name });