[codex] prevent invalid agents from receiving assignments and runs (#7663)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The control plane owns agent lifecycle, issue assignment, routine
dispatch, heartbeat wakeups, and recovery paths
> - Terminated, paused, pending-approval, or otherwise invalid agents
should not receive new work or new execution attempts
> - The old behavior left eligibility checks spread across routes and
services, so assignment and run paths could drift apart
> - This pull request centralizes agent lifecycle eligibility and
applies it consistently to assignment, invocation, routines, recovery,
and UI affordances
> - The benefit is safer autonomy: terminated agents stay paused,
invalid org-chain agents are surfaced, and active agents keep receiving
valid work

## Linked Issues or Issue Description

Refs #5103
Related: #1864

Bug fix context:
- What happened: agent assignment and heartbeat/run paths did not share
one eligibility contract, so invalid lifecycle states could still be
considered in some paths.
- Expected behavior: terminated agents must never receive new
assignments or heartbeat runs, and paused or otherwise invalid agents
should be treated as non-invokable consistently.
- Steps to reproduce: create or select an agent in an invalid lifecycle
state, then attempt assignment, routine dispatch, or heartbeat/recovery
wake paths.
- Paperclip version/commit: fixed on top of `paperclipai/paperclip`
`master` at the PR base.
- Deployment mode: applies to the server control plane in local and
authenticated deployments.

## What Changed

- Added shared agent lifecycle eligibility helpers and exported the
related shared types.
- Centralized server-side assignability and invokability checks for
issue assignment, agent routes, heartbeat dispatch, routines, recovery,
and liveness logic.
- Hardened issue assignment so invalid assignees are rejected instead of
queued for work.
- Hardened heartbeat/routine/recovery paths so terminated and otherwise
invalid agents are not woken for new runs.
- Updated board UI affordances to disable invalid agent actions and
surface org-chain warnings where relevant.
- Added targeted shared, server, and UI tests for the new eligibility
behavior.

## Verification

- `pnpm exec vitest run packages/shared/src/agent-eligibility.test.ts
server/src/__tests__/agent-invokability.test.ts
server/src/__tests__/heartbeat-archived-company-guard.test.ts
server/src/__tests__/issue-liveness.test.ts
server/src/__tests__/issues-service.test.ts
server/src/__tests__/routines-service.test.ts
ui/src/lib/company-members.test.ts ui/src/pages/Agents.test.tsx` — 8
files, 144 tests passed.
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/server typecheck && pnpm --filter @paperclipai/ui
typecheck` — passed.
- Checked the PR diff does not include `pnpm-lock.yaml` or
`.github/workflows` changes.
- Checked `ROADMAP.md`; this is a targeted control-plane safety fix and
does not duplicate a planned core feature.
- Searched GitHub for duplicate or related PRs/issues; closest related
items are linked above.
- CI and Greptile verification are pending on the opened PR and will be
followed up before requesting merge.

## Risks

Low to moderate risk. The intended behavioral shift is that invalid
agents are refused earlier and more consistently, which could expose
existing data with paused, pending, terminated, or broken org-chain
assignees. The added tests cover the critical assignment, heartbeat,
routine, recovery, shared helper, and UI paths. No database migrations
are included.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 Codex via the Paperclip `codex_local` adapter, with
shell/git/GitHub CLI tool use. Reasoning mode and context window are
managed by the adapter runtime and not exposed in this environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots (not applicable: no design screenshots requested; UI
behavior is covered by tests)
- [x] I have updated relevant documentation to reflect my changes (not
applicable: no user-facing command or schema docs changed)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending CI)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending Greptile)
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-06-06 12:45:57 -05:00
committed by GitHub
parent 139cdebe51
commit 71a8464fee
35 changed files with 1921 additions and 164 deletions
+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 });