Files
PaperClipAI/packages/shared/src/agent-eligibility.ts
T
Devin Foley 3dec88ce90 feat(agents): warn when an agent's escalation path routes to a paused manager (#10657)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents escalate work up the org chart (`reports_to`), and operators
pause agents — notably, instance imports pause every agent by default
> - A paused manager does not invalidate the chain (subordinates stay
invokable), so nothing surfaces when an operator unpauses workers but
leaves their manager paused
> - Escalations then dead-letter silently: agent-created issues assigned
to the paused manager sit in a queue nothing will ever run
> - This pull request computes paused ancestors in the existing
org-chain health model and surfaces a non-blocking warning on the agent
read models and detail page
> - The benefit is that the operator learns their escalation paths are
dead before work vanishes into them

## Linked Issues or Issue Description

Fixes #10647 (companion to #10648, which refuses agent-initiated
assignment to paused agents at write time — this PR makes the standing
hazard visible)

## What Changed

- `AgentOrgChainHealth` gains two additive, optional fields:
`pausedAncestors` (paused agents in the `reports_to` chain) and
`escalationWarning` (human-readable, only set when the agent itself can
work — a paused/terminated agent's escalation path is moot). Chain
validity, invokability, and assignability are byte-identical.
- No server route changes needed: the fields flow through every existing
agent read model (list, detail, org chart) since they ride the same
`getAgentWorkEligibility` computation.
- Agent detail page shows an amber "Escalation path is paused" banner
(same visual language as the invalid-chain banner, but non-blocking)
with the warning text naming the paused manager and the two remedies.

## Verification

- `pnpm vitest run packages/shared/src/agent-eligibility.test.ts` — 5
new cases: paused direct manager warns; paused grandparent through a
healthy manager warns; the agent itself paused → no warning (but
ancestors still reported); fully active chain → no warning, empty list;
terminated ancestor keeps the invalid-chain classification without
double-counting as paused.
- Full `@paperclipai/shared` suite (392 tests) and
`agent-eligibility-routes` (54) unchanged.
- `tsc --noEmit` in shared, server, and ui.

## Risks

- Low. Purely additive fields plus one UI banner; no behavior gates on
the new data.

## Model Used

Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — extended
thinking, agentic tool use. No other models involved.

## 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 not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-01 17:42:42 -07:00

271 lines
8.8 KiB
TypeScript

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;
/**
* Paused ancestors of a non-paused agent. A paused manager does not make
* the chain invalid (the agent stays invokable), but escalations routed to
* it dead-letter: assigned work never runs and nothing surfaces it. This is
* a warning, not a block.
*/
pausedAncestors?: AgentInvalidOrgChainAncestor[];
/** Human-readable warning when the escalation path routes to a paused agent. */
escalationWarning?: 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 pausedAncestors: 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));
}
if (parent.status === "paused") {
pausedAncestors.push({ id: parent.id, name: parent.name, status: "paused" });
}
current = parent;
depth += 1;
}
const firstInvalidAncestor = invalidAncestors[0] ?? null;
// Only warn for agents that can themselves receive and run work: a paused,
// terminated, or unknown-status agent's escalation path is moot until it is
// invokable again. Allowlist on purpose — a denylist complement would treat
// unrecognized statuses as workable and warn misleadingly.
const agentCanWork = isAgentStatusInvokable(input.agent.status);
const firstPausedAncestor = pausedAncestors[0] ?? null;
const escalationWarning = agentCanWork && firstPausedAncestor
? `Escalations from ${input.agent.name} route to paused agent ${firstPausedAncestor.name}. ` +
`Work assigned to a paused agent never runs; unpause ${firstPausedAncestor.name} or change who this agent reports to.`
: 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,
pausedAncestors,
escalationWarning,
};
}
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;
}