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 });