Harden task assignment against invalid agents

This commit is contained in:
Dotta committed 2026-06-06 16:47:52 +00:00
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");
});
});
+240
View File
@@ -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;
}
+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,
+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,
+177
View File
@@ -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, {
+2 -16
View File
@@ -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 = {}) {
+157
View File
@@ -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,
}));
}
+8 -27
View File
@@ -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);
+9 -17
View File
@@ -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");