Show invalid org chain agent warnings

This commit is contained in:
Dotta committed 2026-06-06 16:51:14 +00:00
1 parent 948ffaf274
commit fa1bc84b7e
10 files changed
+158 -14

No files matched your search

+10 -2
View File
@@ -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 (
<div className={className ?? "flex items-center gap-1 sm:gap-2 shrink-0"}>
@@ -266,13 +272,15 @@ export function AgentActionButtons({
variant="outline"
size={size}
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
disabled={assignAndRunDisabled}
title={workActionsDisabled ? workActionsDisabledReason : undefined}
>
<Plus className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{assignLabel}</span>
</Button>
<RunButton
onClick={() => 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 && (
+2 -2
View File
@@ -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(
+2 -2
View File
@@ -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 }),
+8 -1
View File
@@ -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({
>
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
<span className="flex-1 truncate">{agent.name}</span>
{hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
) : null}
{(agent.pauseReason === "budget" || runCount > 0) && (
<span className="ml-auto flex items-center gap-1.5 shrink-0">
{agent.pauseReason === "budget" ? (
+29
View File
@@ -6,6 +6,7 @@ import {
buildCompanyUserProfileMap,
buildMarkdownMentionOptions,
} from "./company-members";
import type { AgentOrgChainHealth } from "@paperclipai/shared";
const activeMember = (overrides: Partial<CompanyMember>): CompanyMember => ({
id: overrides.id ?? "member-1",
@@ -22,6 +23,15 @@ const activeMember = (overrides: Partial<CompanyMember>): 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[] = [
{
+10 -2
View File
@@ -84,15 +84,23 @@ export function buildCompanyUserMentionOptions(
}));
}
export function isAgentTaskTarget(agent: Pick<Agent, "status" | "orgChainHealth">): boolean {
return (
agent.status !== "terminated" &&
agent.status !== "pending_approval" &&
agent.orgChainHealth?.status !== "invalid_org_chain"
);
}
export function buildMarkdownMentionOptions(args: {
agents?: Array<Pick<Agent, "id" | "name" | "status" | "icon">> | null | undefined;
agents?: Array<Pick<Agent, "id" | "name" | "status" | "icon" | "orgChainHealth">> | null | undefined;
projects?: Array<Pick<Project, "id" | "name" | "color">> | 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}`,
+31
View File
@@ -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 <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
}
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() {
</button>
</div>
) : null}
{hasInvalidOrgChain ? (
<div className="flex items-start gap-3 border border-amber-300/35 bg-amber-300/10 px-3 py-2 text-sm text-amber-100">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 space-y-1">
<p className="font-medium">Invalid reporting chain</p>
<p className="text-amber-100/90">
{agent.name} cannot accept tasks or start runs until its reporting chain is repaired.
</p>
<p className="break-words font-mono text-xs text-amber-100/80">
{formatOrgChainHealthPath(agent)}
</p>
{agent.orgChainHealth?.repairGuidance ? (
<p className="text-amber-100/85">{agent.orgChainHealth.repairGuidance}</p>
) : (
<p className="text-amber-100/85">
Assign this agent to an active manager/root, or explicitly pause or terminate the affected agent/subtree.
</p>
)}
</div>
</div>
) : null}
{/* Header */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0">
@@ -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 && (
+51
View File
@@ -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>): 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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Alpha");
expect(container.querySelector('[aria-label="Invalid reporting chain"]')).not.toBeNull();
});
});
+13 -3
View File
@@ -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 && (
<div className="border border-border">
{filtered.map((agent) => {
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
return (
<EntityRow
key={agent.id}
@@ -227,7 +228,11 @@ export function Agents() {
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
)}
leading={<AgentStatusCapsule status={agent.status} />}
leading={hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label="Invalid reporting chain" />
) : (
<AgentStatusCapsule status={agent.status} />
)}
meta={
<div className="hidden xl:flex items-center gap-3">
<AgentMetaColumns agent={agent} />
@@ -366,6 +371,7 @@ function OrgTreeNode({
membershipMutation: ReturnType<typeof useResourceMembershipMutation>;
}) {
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",
)}
>
<AgentStatusCapsule status={node.status} />
{hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
) : (
<AgentStatusCapsule status={node.status} />
)}
<div className="flex-1 min-w-[7rem]">
<span className="text-sm font-medium">{node.name}</span>
<span className="text-xs text-muted-foreground ml-2">
+2 -2
View File
@@ -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 });