[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
@@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";
import {
getAgentOrgChainHealth,
getAgentWorkEligibility,
isAgentAssignableToWork,
isAgentInvokable,
type AgentEligibilityAgent,
} from "./agent-eligibility.js";
const companyId = "company-1";
function agent(overrides: Partial<AgentEligibilityAgent> = {}): AgentEligibilityAgent {
return {
id: "agent-1",
companyId,
name: "Coder",
status: "active",
reportsTo: "manager-1",
...overrides,
};
}
describe("agent work eligibility", () => {
it("allows healthy active agents to accept work and be invoked", () => {
const agents = [
agent(),
agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null }),
];
expect(isAgentAssignableToWork({ agent: agents[0]!, agents })).toBe(true);
expect(isAgentInvokable({ agent: agents[0]!, agents })).toBe(true);
expect(getAgentWorkEligibility({ agent: agents[0]!, agents })).toMatchObject({
assignable: true,
invokable: true,
assignabilityReason: "eligible",
invokabilityReason: "eligible",
orgChainHealth: { status: "healthy" },
});
});
it("blocks terminated and pending approval agents from assignment and invocation", () => {
const manager = agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null });
for (const status of ["terminated", "pending_approval"]) {
const target = agent({ status });
const eligibility = getAgentWorkEligibility({ agent: target, agents: [target, manager] });
expect(eligibility.assignable).toBe(false);
expect(eligibility.invokable).toBe(false);
expect(eligibility.assignabilityReason).toBe(status);
expect(eligibility.invokabilityReason).toBe(status);
}
});
it("allows paused agents to keep assignments but blocks invocation", () => {
const target = agent({ status: "paused" });
const manager = agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null });
expect(getAgentWorkEligibility({ agent: target, agents: [target, manager] })).toMatchObject({
assignable: true,
invokable: false,
assignabilityReason: "eligible",
invokabilityReason: "paused",
});
});
it("reports unknown lifecycle statuses explicitly", () => {
const target = agent({ status: "sabbatical" });
const manager = agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null });
expect(getAgentWorkEligibility({ agent: target, agents: [target, manager] })).toMatchObject({
assignable: false,
invokable: false,
assignabilityReason: "unknown_status",
invokabilityReason: "unknown_status",
orgChainHealth: { status: "healthy" },
});
});
it("blocks active descendants of terminated ancestors and reports repair details", () => {
const target = agent({ id: "qa-2", name: "QA 2", status: "active", reportsTo: "cto-2" });
const terminatedManager = agent({
id: "cto-2",
name: "CTO 2",
status: "terminated",
reportsTo: "ceo-2",
});
const terminatedRoot = agent({
id: "ceo-2",
name: "CEO 2",
status: "terminated",
reportsTo: null,
});
const agents = [target, terminatedManager, terminatedRoot];
const health = getAgentOrgChainHealth({ agent: target, agents });
expect(health.status).toBe("invalid_org_chain");
expect(health.reason).toBe("terminated_ancestor");
expect(health.fullChain).toEqual([
expect.objectContaining({ id: "qa-2", name: "QA 2", relation: "self", depth: 0 }),
expect.objectContaining({ id: "cto-2", name: "CTO 2", status: "terminated", relation: "ancestor", depth: 1 }),
expect.objectContaining({ id: "ceo-2", name: "CEO 2", status: "terminated", relation: "ancestor", depth: 2 }),
]);
expect(health.firstInvalidAncestor).toEqual({ id: "cto-2", name: "CTO 2", status: "terminated" });
expect(health.invalidAncestors).toEqual([
{ id: "cto-2", name: "CTO 2", status: "terminated" },
{ id: "ceo-2", name: "CEO 2", status: "terminated" },
]);
expect(health.repairGuidance).toContain("QA 2 reports through terminated ancestor CTO 2");
const eligibility = getAgentWorkEligibility({ agent: target, agents });
expect(eligibility.assignable).toBe(false);
expect(eligibility.invokable).toBe(false);
expect(eligibility.assignabilityReason).toBe("invalid_org_chain");
expect(eligibility.invokabilityReason).toBe("invalid_org_chain");
});
it("blocks agents whose manager is missing from the company org", () => {
const target = agent({ id: "qa-3", name: "QA 3", status: "active", reportsTo: "missing-manager" });
const health = getAgentOrgChainHealth({ agent: target, agents: [target] });
expect(health.status).toBe("invalid_org_chain");
expect(health.reason).toBe("missing_manager");
expect(health.fullChain).toEqual([
expect.objectContaining({ id: "qa-3", relation: "self", depth: 0 }),
expect.objectContaining({ id: "missing-manager", status: "missing", relation: "ancestor", depth: 1 }),
]);
expect(health.repairGuidance).toContain("QA 3 reports to missing manager missing-manager");
const eligibility = getAgentWorkEligibility({ agent: target, agents: [target] });
expect(eligibility.assignable).toBe(false);
expect(eligibility.invokable).toBe(false);
expect(eligibility.assignabilityReason).toBe("invalid_org_chain");
expect(eligibility.invokabilityReason).toBe("invalid_org_chain");
});
it("blocks agents with reporting cycles", () => {
const target = agent({ id: "qa-4", name: "QA 4", status: "active", reportsTo: "cto-4" });
const manager = agent({ id: "cto-4", name: "CTO 4", status: "active", reportsTo: "qa-4" });
const agents = [target, manager];
const health = getAgentOrgChainHealth({ agent: target, agents });
expect(health.status).toBe("invalid_org_chain");
expect(health.reason).toBe("cycle");
expect(health.fullChain).toEqual([
expect.objectContaining({ id: "qa-4", relation: "self", depth: 0 }),
expect.objectContaining({ id: "cto-4", relation: "ancestor", depth: 1 }),
expect.objectContaining({ id: "qa-4", status: "cycle", relation: "ancestor", depth: 2 }),
]);
expect(health.repairGuidance).toContain("QA 4 has a cycle in its reporting chain");
const eligibility = getAgentWorkEligibility({ agent: target, agents });
expect(eligibility.assignable).toBe(false);
expect(eligibility.invokable).toBe(false);
expect(eligibility.assignabilityReason).toBe("invalid_org_chain");
expect(eligibility.invokabilityReason).toBe("invalid_org_chain");
});
});
+245
View File
@@ -0,0 +1,245 @@
import type { AgentStatus } from "./constants.js";
export type AgentEligibilityLifecycleReason =
| "eligible"
| "terminated"
| "pending_approval"
| "paused"
| "invalid_org_chain"
| "unknown_status";
export interface AgentEligibilityAgent {
id: string;
companyId: string;
name: string;
status: AgentStatus | string;
reportsTo?: string | null;
}
export interface AgentOrgChainEntry {
id: string;
companyId: string;
name: string;
status: AgentStatus | string;
reportsTo: string | null;
depth: number;
relation: "self" | "ancestor";
}
export interface AgentInvalidOrgChainAncestor {
id: string;
name: string;
status: AgentStatus | string;
}
export type AgentOrgChainInvalidReason =
| "healthy"
| "terminated_ancestor"
| "missing_manager"
| "cycle";
export interface AgentOrgChainHealth {
status: "healthy" | "invalid_org_chain";
reason: AgentOrgChainInvalidReason;
fullChain: AgentOrgChainEntry[];
firstInvalidAncestor: AgentInvalidOrgChainAncestor | null;
invalidAncestors: AgentInvalidOrgChainAncestor[];
repairGuidance: string | null;
}
export interface AgentWorkEligibility {
assignable: boolean;
invokable: boolean;
assignabilityReason: AgentEligibilityLifecycleReason;
invokabilityReason: AgentEligibilityLifecycleReason;
orgChainHealth: AgentOrgChainHealth;
}
const NON_ASSIGNABLE_AGENT_STATUSES = new Set<string>(["terminated", "pending_approval"]);
const NON_INVOKABLE_AGENT_STATUSES = new Set<string>(["terminated", "pending_approval", "paused"]);
const ASSIGNABLE_AGENT_STATUSES = new Set<string>(["active", "paused", "idle", "running", "error"]);
const INVOKABLE_AGENT_STATUSES = new Set<string>(["active", "idle", "running", "error"]);
export function isAgentStatusAssignableToWork(status: AgentStatus | string): boolean {
return ASSIGNABLE_AGENT_STATUSES.has(status) && !NON_ASSIGNABLE_AGENT_STATUSES.has(status);
}
export function isAgentStatusInvokable(status: AgentStatus | string): boolean {
return INVOKABLE_AGENT_STATUSES.has(status) && !NON_INVOKABLE_AGENT_STATUSES.has(status);
}
function chainEntry(
agent: AgentEligibilityAgent,
depth: number,
relation: AgentOrgChainEntry["relation"],
): AgentOrgChainEntry {
return {
id: agent.id,
companyId: agent.companyId,
name: agent.name,
status: agent.status,
reportsTo: agent.reportsTo ?? null,
depth,
relation,
};
}
function invalidAncestor(agent: AgentEligibilityAgent): AgentInvalidOrgChainAncestor {
return {
id: agent.id,
name: agent.name,
status: agent.status,
};
}
function buildRepairGuidance(
agent: AgentEligibilityAgent,
firstInvalidAncestor: AgentInvalidOrgChainAncestor,
): string {
if (firstInvalidAncestor.status === "missing") {
return [
`${agent.name} reports to missing manager ${firstInvalidAncestor.id}.`,
`Reassign ${agent.name} or the nearest affected ancestor under an active manager/root, or explicitly pause or terminate the invalid subtree before assigning work or starting runs.`,
].join(" ");
}
if (firstInvalidAncestor.status === "cycle") {
return [
`${agent.name} has a cycle in its reporting chain at ${firstInvalidAncestor.name}.`,
`Break the cycle by assigning one affected agent to an active manager/root, or explicitly pause or terminate the invalid subtree before assigning work or starting runs.`,
].join(" ");
}
return [
`${agent.name} reports through terminated ancestor ${firstInvalidAncestor.name}.`,
`Reassign ${agent.name} or the nearest affected ancestor under an active manager/root, or explicitly pause or terminate the invalid subtree before assigning work or starting runs.`,
].join(" ");
}
export function getAgentOrgChainHealth(input: {
agent: AgentEligibilityAgent;
agents: AgentEligibilityAgent[];
}): AgentOrgChainHealth {
const byId = new Map(input.agents.map((agent) => [agent.id, agent]));
const fullChain: AgentOrgChainEntry[] = [chainEntry(input.agent, 0, "self")];
const invalidAncestors: AgentInvalidOrgChainAncestor[] = [];
const seen = new Set<string>([input.agent.id]);
let current = input.agent;
let depth = 1;
while (current.reportsTo) {
if (seen.has(current.reportsTo)) {
const cycleAgent = byId.get(current.reportsTo);
const invalid = {
id: current.reportsTo,
name: cycleAgent?.name ?? current.reportsTo,
status: "cycle",
};
fullChain.push({
id: invalid.id,
companyId: input.agent.companyId,
name: invalid.name,
status: invalid.status,
reportsTo: cycleAgent?.reportsTo ?? null,
depth,
relation: "ancestor",
});
invalidAncestors.push(invalid);
break;
}
seen.add(current.reportsTo);
const parent = byId.get(current.reportsTo);
if (!parent || parent.companyId !== input.agent.companyId) {
const invalid = {
id: current.reportsTo,
name: current.reportsTo,
status: "missing",
};
fullChain.push({
id: invalid.id,
companyId: input.agent.companyId,
name: invalid.name,
status: invalid.status,
reportsTo: null,
depth,
relation: "ancestor",
});
invalidAncestors.push(invalid);
break;
}
fullChain.push(chainEntry(parent, depth, "ancestor"));
if (parent.status === "terminated") {
invalidAncestors.push(invalidAncestor(parent));
}
current = parent;
depth += 1;
}
const firstInvalidAncestor = invalidAncestors[0] ?? null;
return {
status: firstInvalidAncestor ? "invalid_org_chain" : "healthy",
reason: firstInvalidAncestor
? firstInvalidAncestor.status === "missing"
? "missing_manager"
: firstInvalidAncestor.status === "cycle"
? "cycle"
: "terminated_ancestor"
: "healthy",
fullChain,
firstInvalidAncestor,
invalidAncestors,
repairGuidance: firstInvalidAncestor
? buildRepairGuidance(input.agent, firstInvalidAncestor)
: null,
};
}
export function getAgentWorkEligibility(input: {
agent: AgentEligibilityAgent;
agents: AgentEligibilityAgent[];
}): AgentWorkEligibility {
const orgChainHealth = getAgentOrgChainHealth(input);
const assignabilityReason: AgentEligibilityLifecycleReason = !isAgentStatusAssignableToWork(input.agent.status)
? input.agent.status === "terminated"
? "terminated"
: input.agent.status === "pending_approval"
? "pending_approval"
: "unknown_status"
: orgChainHealth.status === "invalid_org_chain"
? "invalid_org_chain"
: "eligible";
const invokabilityReason: AgentEligibilityLifecycleReason = !isAgentStatusInvokable(input.agent.status)
? input.agent.status === "terminated"
? "terminated"
: input.agent.status === "pending_approval"
? "pending_approval"
: input.agent.status === "paused"
? "paused"
: "unknown_status"
: orgChainHealth.status === "invalid_org_chain"
? "invalid_org_chain"
: "eligible";
return {
assignable: assignabilityReason === "eligible",
invokable: invokabilityReason === "eligible",
assignabilityReason,
invokabilityReason,
orgChainHealth,
};
}
export function isAgentAssignableToWork(input: {
agent: AgentEligibilityAgent;
agents: AgentEligibilityAgent[];
}): boolean {
return getAgentWorkEligibility(input).assignable;
}
export function isAgentInvokable(input: {
agent: AgentEligibilityAgent;
agents: AgentEligibilityAgent[];
}): boolean {
return getAgentWorkEligibility(input).invokable;
}
+15
View File
@@ -1,4 +1,19 @@
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
export {
getAgentOrgChainHealth,
getAgentWorkEligibility,
isAgentAssignableToWork,
isAgentInvokable,
isAgentStatusAssignableToWork,
isAgentStatusInvokable,
type AgentEligibilityAgent,
type AgentEligibilityLifecycleReason,
type AgentInvalidOrgChainAncestor,
type AgentOrgChainEntry,
type AgentOrgChainHealth,
type AgentOrgChainInvalidReason,
type AgentWorkEligibility,
} from "./agent-eligibility.js";
export {
asBoolean,
asString,
+2
View File
@@ -13,6 +13,7 @@ import type {
TrustAuthorizationPolicy,
TrustPreset,
} from "../trust-policy.js";
import type { AgentOrgChainHealth } from "../agent-eligibility.js";
export interface AgentPermissions extends Record<string, unknown> {
canCreateAgents: boolean;
@@ -98,6 +99,7 @@ export interface Agent {
permissions: AgentPermissions;
lastHeartbeatAt: Date | null;
metadata: Record<string, unknown> | null;
orgChainHealth?: AgentOrgChainHealth;
createdAt: Date;
updatedAt: Date;
}
+9
View File
@@ -142,6 +142,15 @@ export type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestResult,
} from "./agent.js";
export type {
AgentEligibilityAgent,
AgentEligibilityLifecycleReason,
AgentInvalidOrgChainAncestor,
AgentOrgChainEntry,
AgentOrgChainHealth,
AgentOrgChainInvalidReason,
AgentWorkEligibility,
} from "../agent-eligibility.js";
export type { AssetImage } from "./asset.js";
export type {
CreateDocumentAnnotationCommentRequest,