mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
Harden task assignment against invalid agents
This commit is contained in:
1 parent
139cdebe51
commit
9a7132bf12
10 files changed
+835
-60
No files matched your search
@@ -0,0 +1,145 @@
|
||||
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("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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { AgentStatus } from "./constants.js";
|
||||
|
||||
export type AgentEligibilityLifecycleReason =
|
||||
| "eligible"
|
||||
| "terminated"
|
||||
| "pending_approval"
|
||||
| "paused"
|
||||
| "invalid_org_chain";
|
||||
|
||||
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"
|
||||
: "pending_approval"
|
||||
: 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"
|
||||
: "paused"
|
||||
: 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -168,6 +168,183 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedAssignableAgentCompany() {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
function agentRow(companyId: string, input: {
|
||||
id: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
reportsTo?: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: input.id,
|
||||
companyId,
|
||||
name: input.name,
|
||||
role: "engineer",
|
||||
status: input.status ?? "active",
|
||||
reportsTo: input.reportsTo ?? null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
};
|
||||
}
|
||||
|
||||
it("rejects direct terminated assignees with structured conflict details", async () => {
|
||||
const companyId = await seedAssignableAgentCompany();
|
||||
const terminatedAgentId = randomUUID();
|
||||
await db.insert(agents).values(agentRow(companyId, {
|
||||
id: terminatedAgentId,
|
||||
name: "TerminatedCoder",
|
||||
status: "terminated",
|
||||
}));
|
||||
|
||||
await expect(svc.create(companyId, {
|
||||
title: "Do not assign this",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: terminatedAgentId,
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
code: "agent_not_assignable",
|
||||
reason: "assignee_terminated",
|
||||
assigneeAgentId: terminatedAgentId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid ancestor-chain assignees and preserves the existing assignment", async () => {
|
||||
const companyId = await seedAssignableAgentCompany();
|
||||
const activeAgentId = randomUUID();
|
||||
const terminatedManagerId = randomUUID();
|
||||
const blockedAgentId = randomUUID();
|
||||
await db.insert(agents).values([
|
||||
agentRow(companyId, { id: activeAgentId, name: "ActiveCoder" }),
|
||||
agentRow(companyId, {
|
||||
id: terminatedManagerId,
|
||||
name: "TerminatedManager",
|
||||
status: "terminated",
|
||||
}),
|
||||
agentRow(companyId, {
|
||||
id: blockedAgentId,
|
||||
name: "BlockedCoder",
|
||||
reportsTo: terminatedManagerId,
|
||||
}),
|
||||
]);
|
||||
const issue = await svc.create(companyId, {
|
||||
title: "Keep current assignment",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: activeAgentId,
|
||||
});
|
||||
|
||||
await expect(svc.update(issue.id, {
|
||||
assigneeAgentId: blockedAgentId,
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
code: "agent_not_assignable",
|
||||
reason: "ancestor_terminated",
|
||||
assigneeAgentId: blockedAgentId,
|
||||
invalidAncestorAgentId: terminatedManagerId,
|
||||
},
|
||||
});
|
||||
|
||||
const persisted = await db
|
||||
.select({ assigneeAgentId: issues.assigneeAgentId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issue.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(persisted?.assigneeAgentId).toBe(activeAgentId);
|
||||
});
|
||||
|
||||
it("rejects checkout by a terminated agent before assigning the issue", async () => {
|
||||
const companyId = await seedAssignableAgentCompany();
|
||||
const terminatedAgentId = randomUUID();
|
||||
await db.insert(agents).values(agentRow(companyId, {
|
||||
id: terminatedAgentId,
|
||||
name: "TerminatedCheckoutCoder",
|
||||
status: "terminated",
|
||||
}));
|
||||
const issue = await svc.create(companyId, {
|
||||
title: "Checkout must stay unassigned",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
});
|
||||
|
||||
await expect(svc.checkout(issue.id, terminatedAgentId, ["todo"], randomUUID()))
|
||||
.rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
code: "agent_not_assignable",
|
||||
reason: "assignee_terminated",
|
||||
assigneeAgentId: terminatedAgentId,
|
||||
},
|
||||
});
|
||||
|
||||
const persisted = await db
|
||||
.select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issue.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(persisted).toMatchObject({
|
||||
assigneeAgentId: null,
|
||||
status: "todo",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects moving an existing terminated assignment into progress without clearing it", async () => {
|
||||
const companyId = await seedAssignableAgentCompany();
|
||||
const assigneeAgentId = randomUUID();
|
||||
await db.insert(agents).values(agentRow(companyId, {
|
||||
id: assigneeAgentId,
|
||||
name: "SoonTerminatedCoder",
|
||||
}));
|
||||
const issue = await svc.create(companyId, {
|
||||
title: "Do not restart after termination",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId,
|
||||
});
|
||||
await db.update(agents).set({ status: "terminated" }).where(eq(agents.id, assigneeAgentId));
|
||||
|
||||
await expect(svc.update(issue.id, {
|
||||
status: "in_progress",
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
code: "agent_not_assignable",
|
||||
reason: "assignee_terminated",
|
||||
assigneeAgentId,
|
||||
},
|
||||
});
|
||||
|
||||
const persisted = await db
|
||||
.select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issue.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(persisted).toMatchObject({
|
||||
assigneeAgentId,
|
||||
status: "todo",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns issues an agent participated in across the supported signals", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
||||
@@ -539,6 +539,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: "Cannot assign routines to terminated agents",
|
||||
details: {
|
||||
code: "agent_not_assignable",
|
||||
reason: "assignee_terminated",
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
});
|
||||
await expect(svc.get(routine.id)).resolves.toMatchObject({
|
||||
description: "revision 2",
|
||||
@@ -546,6 +551,74 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks routine reassignment to agents under terminated managers", async () => {
|
||||
const { agentId, companyId, routine, svc } = await seedFixture();
|
||||
const terminatedManagerId = randomUUID();
|
||||
const blockedAgentId = randomUUID();
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: terminatedManagerId,
|
||||
companyId,
|
||||
name: "TerminatedManager",
|
||||
role: "manager",
|
||||
status: "terminated",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: blockedAgentId,
|
||||
companyId,
|
||||
name: "BlockedRoutineCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
reportsTo: terminatedManagerId,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(svc.update(routine.id, {
|
||||
assigneeAgentId: blockedAgentId,
|
||||
}, { userId: "board-user" })).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
code: "agent_not_assignable",
|
||||
reason: "ancestor_terminated",
|
||||
assigneeAgentId: blockedAgentId,
|
||||
invalidAncestorAgentId: terminatedManagerId,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(svc.get(routine.id)).resolves.toMatchObject({
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks manual routine runs when the persisted assignee is no longer assignable", async () => {
|
||||
const { agentId, routine, svc } = await seedFixture();
|
||||
await db
|
||||
.update(agents)
|
||||
.set({ status: "terminated" })
|
||||
.where(eq(agents.id, agentId));
|
||||
|
||||
await expect(svc.runRoutine(routine.id, {
|
||||
source: "manual",
|
||||
payload: null,
|
||||
variables: null,
|
||||
}, { userId: "board-user" })).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
code: "agent_not_assignable",
|
||||
reason: "assignee_terminated",
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("appends safe trigger metadata revisions without leaking webhook secrets", async () => {
|
||||
const { routine, svc } = await seedFixture();
|
||||
const created = await svc.createTrigger(routine.id, {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { and, eq, inArray, ne, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
companyMemberships,
|
||||
instanceUserRoles,
|
||||
issues,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
} from "@paperclipai/db";
|
||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
import { conflict } from "../errors.js";
|
||||
import { assertAssignableAgent } from "./agent-assignability.js";
|
||||
import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js";
|
||||
import { ensureHumanRoleDefaultGrants } from "./principal-access-compatibility.js";
|
||||
|
||||
@@ -315,21 +315,7 @@ export function accessService(db: Db) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = await tx
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, input.assigneeAgentId!))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!agent || agent.companyId !== companyId) {
|
||||
throw conflict("Replacement agent must belong to the same company");
|
||||
}
|
||||
if (agent.status === "pending_approval" || agent.status === "terminated") {
|
||||
throw conflict("Replacement agent must be assignable");
|
||||
}
|
||||
await assertAssignableAgent(tx as Db, companyId, input.assigneeAgentId, { kind: "work" });
|
||||
}
|
||||
|
||||
async function archiveMember(companyId: string, memberId: string, input: MemberArchiveInput = {}) {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents } from "@paperclipai/db";
|
||||
import {
|
||||
getAgentWorkEligibility,
|
||||
type AgentEligibilityAgent,
|
||||
type AgentOrgChainHealth,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
|
||||
type AgentAssignmentKind = "work" | "routine";
|
||||
|
||||
type AssignabilityAgent = AgentEligibilityAgent;
|
||||
|
||||
type AgentAssignmentConflictReason =
|
||||
| "pending_approval"
|
||||
| "assignee_terminated"
|
||||
| "ancestor_terminated"
|
||||
| "ancestor_missing"
|
||||
| "ancestor_cross_company"
|
||||
| "ancestor_cycle"
|
||||
| "ancestor_depth_exceeded";
|
||||
|
||||
function assignmentMessage(kind: AgentAssignmentKind, reason: AgentAssignmentConflictReason) {
|
||||
if (reason === "pending_approval") {
|
||||
return kind === "routine"
|
||||
? "Cannot assign routines to pending approval agents"
|
||||
: "Cannot assign work to pending approval agents";
|
||||
}
|
||||
if (reason === "assignee_terminated") {
|
||||
return kind === "routine"
|
||||
? "Cannot assign routines to terminated agents"
|
||||
: "Cannot assign work to terminated agents";
|
||||
}
|
||||
return kind === "routine"
|
||||
? "Cannot assign routines to agents with an invalid org chain"
|
||||
: "Cannot assign work to agents with an invalid org chain";
|
||||
}
|
||||
|
||||
function conflictDetails(input: {
|
||||
companyId: string;
|
||||
assigneeAgentId: string;
|
||||
reason: AgentAssignmentConflictReason;
|
||||
chain: AssignabilityAgent[];
|
||||
invalidAncestorAgentId?: string | null;
|
||||
missingAncestorAgentId?: string | null;
|
||||
}) {
|
||||
return {
|
||||
code: "agent_not_assignable",
|
||||
reason: input.reason,
|
||||
companyId: input.companyId,
|
||||
assigneeAgentId: input.assigneeAgentId,
|
||||
invalidAncestorAgentId: input.invalidAncestorAgentId ?? null,
|
||||
missingAncestorAgentId: input.missingAncestorAgentId ?? null,
|
||||
ancestorChain: input.chain.map((agent) => ({
|
||||
id: agent.id,
|
||||
companyId: agent.companyId,
|
||||
status: agent.status,
|
||||
reportsTo: agent.reportsTo,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function getAgent(db: Db, agentId: string): Promise<AssignabilityAgent | null> {
|
||||
return db
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
name: agents.name,
|
||||
status: agents.status,
|
||||
reportsTo: agents.reportsTo,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function listCompanyAgents(db: Db, companyId: string): Promise<AssignabilityAgent[]> {
|
||||
return db
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
name: agents.name,
|
||||
status: agents.status,
|
||||
reportsTo: agents.reportsTo,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId));
|
||||
}
|
||||
|
||||
function assignmentReasonFromHealth(health: AgentOrgChainHealth): AgentAssignmentConflictReason {
|
||||
if (health.reason === "terminated_ancestor") return "ancestor_terminated";
|
||||
if (health.reason === "missing_manager") return "ancestor_missing";
|
||||
if (health.reason === "cycle") return "ancestor_cycle";
|
||||
return "ancestor_missing";
|
||||
}
|
||||
|
||||
export async function assertAssignableAgent(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
agentId: string | null | undefined,
|
||||
options: { kind?: AgentAssignmentKind } = {},
|
||||
) {
|
||||
if (!agentId) return;
|
||||
const kind = options.kind ?? "work";
|
||||
const assignee = await getAgent(db, agentId);
|
||||
if (!assignee) throw notFound("Assignee agent not found");
|
||||
if (assignee.companyId !== companyId) {
|
||||
throw unprocessable("Assignee must belong to same company");
|
||||
}
|
||||
|
||||
const companyAgents = await listCompanyAgents(db, companyId);
|
||||
const eligibility = getAgentWorkEligibility({ agent: assignee, agents: companyAgents });
|
||||
const chain = eligibility.orgChainHealth.fullChain.map((entry) => ({
|
||||
id: entry.id,
|
||||
companyId: entry.companyId,
|
||||
name: entry.name,
|
||||
status: entry.status,
|
||||
reportsTo: entry.reportsTo,
|
||||
}));
|
||||
|
||||
if (eligibility.assignable) return;
|
||||
|
||||
if (eligibility.assignabilityReason === "pending_approval") {
|
||||
throw conflict(assignmentMessage(kind, "pending_approval"), conflictDetails({
|
||||
companyId,
|
||||
assigneeAgentId: agentId,
|
||||
reason: "pending_approval",
|
||||
chain,
|
||||
}));
|
||||
}
|
||||
if (eligibility.assignabilityReason === "terminated") {
|
||||
throw conflict(assignmentMessage(kind, "assignee_terminated"), conflictDetails({
|
||||
companyId,
|
||||
assigneeAgentId: agentId,
|
||||
reason: "assignee_terminated",
|
||||
chain,
|
||||
}));
|
||||
}
|
||||
|
||||
const reason = assignmentReasonFromHealth(eligibility.orgChainHealth);
|
||||
const firstInvalidAncestor = eligibility.orgChainHealth.firstInvalidAncestor;
|
||||
throw conflict(assignmentMessage(kind, reason), conflictDetails({
|
||||
companyId,
|
||||
assigneeAgentId: agentId,
|
||||
reason,
|
||||
chain,
|
||||
invalidAncestorAgentId:
|
||||
firstInvalidAncestor && firstInvalidAncestor.status !== "missing"
|
||||
? firstInvalidAncestor.id
|
||||
: null,
|
||||
missingAncestorAgentId:
|
||||
firstInvalidAncestor?.status === "missing"
|
||||
? firstInvalidAncestor.id
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
@@ -75,6 +75,7 @@ import { redactSensitiveText } from "../redaction.js";
|
||||
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
|
||||
import { getRunLogStore } from "./run-log-store.js";
|
||||
import { getDefaultCompanyGoal } from "./goals.js";
|
||||
import { assertAssignableAgent } from "./agent-assignability.js";
|
||||
import {
|
||||
isVerifiedIssueTreeControlInteractionWake,
|
||||
issueTreeControlService,
|
||||
@@ -3372,29 +3373,6 @@ export function issueService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
async function assertAssignableAgent(companyId: string, agentId: string) {
|
||||
const assignee = await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!assignee) throw notFound("Assignee agent not found");
|
||||
if (assignee.companyId !== companyId) {
|
||||
throw unprocessable("Assignee must belong to same company");
|
||||
}
|
||||
if (assignee.status === "pending_approval") {
|
||||
throw conflict("Cannot assign work to pending approval agents");
|
||||
}
|
||||
if (assignee.status === "terminated") {
|
||||
throw conflict("Cannot assign work to terminated agents");
|
||||
}
|
||||
}
|
||||
|
||||
async function isTreeHoldInteractionCheckoutAllowed(
|
||||
companyId: string,
|
||||
checkoutRunId: string | null,
|
||||
@@ -4733,7 +4711,7 @@ export function issueService(db: Db) {
|
||||
throw unprocessable("Issue can only have one assignee");
|
||||
}
|
||||
if (data.assigneeAgentId) {
|
||||
await assertAssignableAgent(companyId, data.assigneeAgentId);
|
||||
await assertAssignableAgent(db, companyId, data.assigneeAgentId, { kind: "work" });
|
||||
}
|
||||
if (data.assigneeUserId) {
|
||||
await assertAssignableUser(companyId, data.assigneeUserId);
|
||||
@@ -5021,8 +4999,11 @@ export function issueService(db: Db) {
|
||||
throw unprocessable("Issue is blocked by unresolved blockers", { unresolvedBlockerIssueIds });
|
||||
}
|
||||
}
|
||||
if (issueData.assigneeAgentId) {
|
||||
await assertAssignableAgent(existing.companyId, issueData.assigneeAgentId);
|
||||
const shouldValidateNextAssignee =
|
||||
Boolean(nextAssigneeAgentId) &&
|
||||
(issueData.assigneeAgentId !== undefined || patch.status === "in_progress");
|
||||
if (shouldValidateNextAssignee) {
|
||||
await assertAssignableAgent(dbOrTx as Db, existing.companyId, nextAssigneeAgentId, { kind: "work" });
|
||||
}
|
||||
if (issueData.assigneeUserId) {
|
||||
await assertAssignableUser(existing.companyId, issueData.assigneeUserId);
|
||||
@@ -5347,7 +5328,7 @@ export function issueService(db: Db) {
|
||||
.where(eq(issues.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issueCompany) throw notFound("Issue not found");
|
||||
await assertAssignableAgent(issueCompany.companyId, agentId);
|
||||
await assertAssignableAgent(db, issueCompany.companyId, agentId, { kind: "work" });
|
||||
|
||||
const now = new Date();
|
||||
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issueCompany.companyId, id);
|
||||
|
||||
@@ -52,6 +52,7 @@ import { logger } from "../middleware/logger.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { assertAssignableAgent } from "./agent-assignability.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { getSecretProvider } from "../secrets/provider-registry.js";
|
||||
import { parseCron, validateCron } from "./cron.js";
|
||||
@@ -610,25 +611,12 @@ export function routineService(
|
||||
return routine;
|
||||
}
|
||||
|
||||
async function assertAssignableAgent(companyId: string, agentId: string | null | undefined) {
|
||||
if (!agentId) return;
|
||||
const agent = await db
|
||||
.select({ id: agents.id, companyId: agents.companyId, status: agents.status })
|
||||
.from(agents)
|
||||
.where(eq(agents.id, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!agent) throw notFound("Assignee agent not found");
|
||||
if (agent.companyId !== companyId) throw unprocessable("Assignee must belong to same company");
|
||||
if (agent.status === "pending_approval") throw conflict("Cannot assign routines to pending approval agents");
|
||||
if (agent.status === "terminated") throw conflict("Cannot assign routines to terminated agents");
|
||||
}
|
||||
|
||||
async function assertRestorableAssignee(
|
||||
companyId: string,
|
||||
assigneeAgentId: string | null | undefined,
|
||||
actor: Actor,
|
||||
) {
|
||||
await assertAssignableAgent(companyId, assigneeAgentId);
|
||||
await assertAssignableAgent(db, companyId, assigneeAgentId, { kind: "routine" });
|
||||
if (actor.agentId && assigneeAgentId !== actor.agentId) {
|
||||
throw forbidden("Agents can only restore routine revisions assigned to themselves");
|
||||
}
|
||||
@@ -1170,6 +1158,7 @@ export function routineService(
|
||||
if (!assigneeAgentId) {
|
||||
throw unprocessable("Default agent required");
|
||||
}
|
||||
await assertAssignableAgent(db, input.routine.companyId, assigneeAgentId, { kind: "routine" });
|
||||
const automaticVariables: Record<string, string | number | boolean> = {};
|
||||
if (input.executionWorkspaceId && routineUsesWorkspaceBranch(input.routine)) {
|
||||
const workspace = await db
|
||||
@@ -1581,7 +1570,7 @@ export function routineService(
|
||||
|
||||
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
|
||||
await assertProject(companyId, input.projectId ?? null);
|
||||
await assertAssignableAgent(companyId, input.assigneeAgentId ?? null);
|
||||
await assertAssignableAgent(db, companyId, input.assigneeAgentId ?? null, { kind: "routine" });
|
||||
if (input.goalId) await assertGoal(companyId, input.goalId);
|
||||
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
||||
const env = input.env === undefined || input.env === null
|
||||
@@ -1663,7 +1652,9 @@ export function routineService(
|
||||
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
|
||||
);
|
||||
if (patch.projectId !== undefined) await assertProject(existing.companyId, nextProjectId);
|
||||
if (patch.assigneeAgentId !== undefined) await assertAssignableAgent(existing.companyId, nextAssigneeAgentId);
|
||||
if (patch.assigneeAgentId !== undefined || patch.status === "active") {
|
||||
await assertAssignableAgent(db, existing.companyId, nextAssigneeAgentId, { kind: "routine" });
|
||||
}
|
||||
if (patch.goalId) await assertGoal(existing.companyId, patch.goalId);
|
||||
if (patch.parentIssueId) await assertParentIssue(existing.companyId, patch.parentIssueId);
|
||||
assertRoutineVariableDefinitions(nextVariables);
|
||||
@@ -2187,7 +2178,8 @@ export function routineService(
|
||||
if (!routine) throw notFound("Routine not found");
|
||||
if (routine.status === "archived") throw conflict("Routine is archived");
|
||||
await assertProject(routine.companyId, input.projectId ?? null);
|
||||
await assertAssignableAgent(routine.companyId, input.assigneeAgentId ?? null);
|
||||
const assigneeAgentId = input.assigneeAgentId ?? routine.assigneeAgentId ?? null;
|
||||
await assertAssignableAgent(db, routine.companyId, assigneeAgentId, { kind: "routine" });
|
||||
const trigger = input.triggerId ? await getTriggerById(input.triggerId) : null;
|
||||
if (trigger && trigger.routineId !== routine.id) throw forbidden("Trigger does not belong to routine");
|
||||
if (trigger && !trigger.enabled) throw conflict("Routine trigger is not active");
|
||||
|
||||
Reference in new issue
Block a user