Centralize agent lifecycle eligibility

This commit is contained in:
Dotta committed 2026-06-06 16:47:52 +00:00
1 parent 8f02a7ef97
commit 948ffaf274
8 files changed
+244 -120

No files matched your search

+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;
}
@@ -8,6 +8,7 @@ import {
function agent(partial: Partial<AgentOrgRow> & Pick<AgentOrgRow, "id">): AgentOrgRow {
return {
companyId: "company-1",
name: partial.id,
reportsTo: null,
status: "active",
...partial,
@@ -273,6 +273,36 @@ describe("issue graph liveness classifier", () => {
expect(paused[0]?.state).toBe("blocked_by_uninvokable_assignee");
});
it("detects blocker assignees under terminated org ancestors as uninvokable", () => {
const findings = classifyIssueGraphLiveness({
issues: [
issue(),
issue({
id: blockerId,
identifier: "PAP-1704",
title: "Invalid tree unblock work",
status: "todo",
assigneeAgentId: "qa-2",
}),
],
relations: blocks,
agents: [
agent(),
manager,
agent({ id: "qa-2", name: "QA 2", status: "active", reportsTo: "cto-2" }),
agent({ id: "cto-2", name: "CTO 2", status: "terminated", reportsTo: "ceo-2" }),
agent({ id: "ceo-2", name: "CEO 2", status: "terminated", reportsTo: null }),
],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
state: "blocked_by_uninvokable_assignee",
reason: "PAP-1703 is blocked by PAP-1704, but its assignee is in an invalid org chain.",
recommendedOwnerAgentId: managerId,
});
});
it("detects invalid in_review execution participant", () => {
const findings = classifyIssueGraphLiveness({
issues: [
+20 -1
View File
@@ -2851,7 +2851,14 @@ export function agentRoutes(
router.post("/agents/:id/resume", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await getAccessibleAgent(req, res, id))) {
const existing = await getAccessibleAgent(req, res, id);
if (!existing) {
return;
}
if (existing.orgChainHealth?.status === "invalid_org_chain") {
res.status(409).json({
error: existing.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before resuming it",
});
return;
}
const agent = await svc.resume(id);
@@ -3085,6 +3092,12 @@ export function agentRoutes(
} else {
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
}
if (agent.orgChainHealth?.status === "invalid_org_chain") {
res.status(409).json({
error: agent.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before starting runs",
});
return;
}
const run = await heartbeat.wakeup(id, {
source: opts.source,
@@ -3153,6 +3166,12 @@ export function agentRoutes(
} else {
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
}
if (agent.orgChainHealth?.status === "invalid_org_chain") {
res.status(409).json({
error: agent.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before starting runs",
});
return;
}
const body = (req.body ?? {}) as Partial<{
reason: unknown;
+62 -24
View File
@@ -2166,6 +2166,18 @@ export function issueRoutes(
if (!resolved.agent) {
throw notFound("Agent not found");
}
if (resolved.agent.status === "pending_approval") {
throw conflict("Cannot assign work to pending approval agents");
}
if (resolved.agent.status === "terminated") {
throw conflict("Cannot assign work to terminated agents");
}
if (resolved.agent.orgChainHealth?.status === "invalid_org_chain") {
throw conflict(
resolved.agent.orgChainHealth?.repairGuidance ??
"Cannot assign work to agents with invalid org chains",
);
}
return resolved.agent.id;
}
function toValidTimestamp(value: Date | string | null | undefined) {
@@ -4227,7 +4239,15 @@ export function issueRoutes(
}
if (!(await assertIssueReadAllowed(req, res, parent))) return;
}
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, req.body))) return;
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
companyId,
req.body.assigneeAgentId as string | null | undefined,
);
const createBody = {
...req.body,
...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}),
};
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, createBody))) return;
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
await assertCanAssignTasks(req, companyId, {
projectId: await resolveAssignmentProjectId({
@@ -4236,27 +4256,27 @@ export function issueRoutes(
parentIssueId: req.body.parentId,
}),
parentIssueId: req.body.parentId ?? null,
assigneeAgentId: req.body.assigneeAgentId ?? null,
assigneeAgentId: createBody.assigneeAgentId ?? null,
assigneeUserId: req.body.assigneeUserId ?? null,
});
}
await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId);
await assertIssueEnvironmentSelection(companyId, createBody.executionWorkspaceSettings?.environmentId);
const actor = getActorInfo(req);
const executionPolicy = applyActorMonitorScheduledBy(
normalizeIssueExecutionPolicy(req.body.executionPolicy),
normalizeIssueExecutionPolicy(createBody.executionPolicy),
actor.actorType,
);
await assertCanManageIssueMonitor(access, req, companyId, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
await assertCanManageIssueMonitor(access, req, companyId, createBody.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
const issueId = randomUUID();
const sourceTrust = await sourceTrustForActorWrite({
id: issueId,
companyId,
projectId: req.body.projectId ?? null,
projectId: createBody.projectId ?? null,
executionPolicy,
}, actor);
const issue = await svc.create(companyId, {
...req.body,
...createBody,
id: issueId,
executionPolicy,
...(sourceTrust ? { sourceTrust } : {}),
@@ -4343,32 +4363,40 @@ export function issueRoutes(
if (!(await assertIssueReadAllowed(req, res, parent))) return;
if (await assertLowTrustControlPlaneDenied(req, res, parent.companyId, parent)) return;
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return;
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
parent.companyId,
req.body.assigneeAgentId as string | null | undefined,
);
const createBody = {
...req.body,
...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}),
};
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, createBody))) return;
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
await assertCanAssignTasks(req, parent.companyId, {
projectId: req.body.projectId ?? parent.projectId ?? null,
projectId: createBody.projectId ?? parent.projectId ?? null,
parentIssueId: parent.id,
assigneeAgentId: req.body.assigneeAgentId ?? null,
assigneeUserId: req.body.assigneeUserId ?? null,
assigneeAgentId: createBody.assigneeAgentId ?? null,
assigneeUserId: createBody.assigneeUserId ?? null,
});
}
await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId);
await assertIssueEnvironmentSelection(parent.companyId, createBody.executionWorkspaceSettings?.environmentId);
const actor = getActorInfo(req);
const executionPolicy = applyActorMonitorScheduledBy(
normalizeIssueExecutionPolicy(req.body.executionPolicy),
normalizeIssueExecutionPolicy(createBody.executionPolicy),
actor.actorType,
);
await assertCanManageIssueMonitor(access, req, parent.companyId, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
await assertCanManageIssueMonitor(access, req, parent.companyId, createBody.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
const issueId = randomUUID();
const sourceTrust = await sourceTrustForActorWrite({
id: issueId,
companyId: parent.companyId,
projectId: req.body.projectId ?? parent.projectId ?? null,
projectId: createBody.projectId ?? parent.projectId ?? null,
executionPolicy,
}, actor);
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
...req.body,
...createBody,
id: issueId,
executionPolicy,
...(sourceTrust ? { sourceTrust } : {}),
@@ -4457,23 +4485,33 @@ export function issueRoutes(
assertCompanyAccess(req, sourceIssue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, sourceIssue))) return;
const requestedChildren = [];
for (const child of req.body.children as Array<typeof req.body.children[number]>) {
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(child));
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, sourceIssue, child))) return;
if (child.assigneeAgentId || child.assigneeUserId) {
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
sourceIssue.companyId,
child.assigneeAgentId as string | null | undefined,
);
const childBody = {
...child,
...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}),
};
requestedChildren.push(childBody);
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(childBody));
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, sourceIssue, childBody))) return;
if (childBody.assigneeAgentId || childBody.assigneeUserId) {
await assertCanAssignTasks(req, sourceIssue.companyId, {
projectId: child.projectId ?? sourceIssue.projectId ?? null,
projectId: childBody.projectId ?? sourceIssue.projectId ?? null,
parentIssueId: sourceIssue.id,
assigneeAgentId: child.assigneeAgentId ?? null,
assigneeUserId: child.assigneeUserId ?? null,
assigneeAgentId: childBody.assigneeAgentId ?? null,
assigneeUserId: childBody.assigneeUserId ?? null,
});
}
await assertIssueEnvironmentSelection(sourceIssue.companyId, child.executionWorkspaceSettings?.environmentId);
await assertIssueEnvironmentSelection(sourceIssue.companyId, childBody.executionWorkspaceSettings?.environmentId);
}
const actor = getActorInfo(req);
const normalizedChildren = [];
for (const child of req.body.children as Array<typeof req.body.children[number]>) {
for (const child of requestedChildren) {
const executionPolicy = applyActorMonitorScheduledBy(
normalizeIssueExecutionPolicy(child.executionPolicy),
actor.actorType,
+42 -63
View File
@@ -1,12 +1,13 @@
import type { Db } from "@paperclipai/db";
import { agents } from "@paperclipai/db";
import { getAgentWorkEligibility, type AgentEligibilityAgent, type AgentOrgChainHealth } from "@paperclipai/shared";
import { eq } from "drizzle-orm";
type AgentStatus = (typeof agents.$inferSelect)["status"];
export type AgentOrgRow = Pick<
typeof agents.$inferSelect,
"id" | "companyId" | "reportsTo" | "status"
"id" | "companyId" | "name" | "reportsTo" | "status"
>;
export type AgentInvokabilityBlockReason =
@@ -52,6 +53,22 @@ function statusBlockReason(status: AgentStatus): AgentInvokabilityBlockReason |
return null;
}
function toEligibilityAgent(row: AgentOrgRow): AgentEligibilityAgent {
return {
id: row.id,
companyId: row.companyId,
name: row.name,
status: row.status,
reportsTo: row.reportsTo,
};
}
function invalidChainReason(health: AgentOrgChainHealth): AgentInvokabilityBlockReason {
if (health.reason === "terminated_ancestor") return "manager_terminated";
if (health.reason === "cycle") return "reporting_cycle";
return "manager_missing";
}
export function evaluateAgentInvokability(
agent: AgentOrgRow | null | undefined,
companyAgents: AgentOrgRow[],
@@ -60,6 +77,13 @@ export function evaluateAgentInvokability(
return blocked("missing", "Agent no longer exists", {}, false);
}
const eligibility = getAgentWorkEligibility({
agent: toEligibilityAgent(agent),
agents: companyAgents.map(toEligibilityAgent),
});
if (eligibility.invokable) return { invokable: true };
const directStatusReason = statusBlockReason(agent.status);
if (directStatusReason) {
return blocked(
@@ -70,68 +94,22 @@ export function evaluateAgentInvokability(
);
}
const byId = new Map(companyAgents.map((row) => [row.id, row]));
const visited = new Set<string>([agent.id]);
const reportingChainAgentIds: string[] = [];
let managerId = agent.reportsTo;
while (managerId) {
if (visited.has(managerId)) {
return blocked(
"reporting_cycle",
"Agent is not invokable because its reporting chain is invalid",
{ agentId: agent.id, managerId, reportingChainAgentIds },
true,
);
}
visited.add(managerId);
if (reportingChainAgentIds.length >= 100) {
return blocked(
"reporting_chain_too_deep",
"Agent is not invokable because its reporting chain is invalid",
{ agentId: agent.id, reportingChainAgentIds },
true,
);
}
const manager = byId.get(managerId);
if (!manager) {
return blocked(
"manager_missing",
"Agent is not invokable because its reporting chain is invalid",
{ agentId: agent.id, managerId, reportingChainAgentIds },
true,
);
}
reportingChainAgentIds.push(manager.id);
if (manager.companyId !== agent.companyId) {
return blocked(
"manager_company_mismatch",
"Agent is not invokable because its reporting chain is invalid",
{ agentId: agent.id, managerId: manager.id, managerCompanyId: manager.companyId },
true,
);
}
if (manager.status === "terminated") {
return blocked(
"manager_terminated",
"Agent is not invokable because its reporting chain is invalid",
{
agentId: agent.id,
managerId: manager.id,
managerStatus: manager.status,
reportingChainAgentIds,
},
true,
);
}
managerId = manager.reportsTo;
}
return { invokable: true };
const health = eligibility.orgChainHealth;
const firstInvalidAncestor = health.firstInvalidAncestor;
return blocked(
invalidChainReason(health),
"Agent is not invokable because its reporting chain is invalid",
{
agentId: agent.id,
managerId: firstInvalidAncestor?.id ?? null,
managerStatus: firstInvalidAncestor?.status ?? null,
reportingChainAgentIds: health.fullChain
.filter((entry) => entry.relation === "ancestor")
.map((entry) => entry.id),
orgChainHealth: health,
},
true,
);
}
export async function evaluateAgentInvokabilityFromDb(
@@ -143,6 +121,7 @@ export async function evaluateAgentInvokabilityFromDb(
.select({
id: agents.id,
companyId: agents.companyId,
name: agents.name,
reportsTo: agents.reportsTo,
status: agents.status,
})
+67 -20
View File
@@ -16,7 +16,13 @@ import {
issues,
issueComments,
} from "@paperclipai/db";
import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared";
import {
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
getAgentWorkEligibility,
isUuidLike,
normalizeAgentUrlKey,
type AgentEligibilityAgent,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { normalizeAgentPermissions } from "./agent-permissions.js";
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
@@ -228,13 +234,45 @@ export function agentService(db: Db) {
};
}
function normalizeAgentRow(row: typeof agents.$inferSelect) {
function normalizeAgentBaseRow(row: typeof agents.$inferSelect) {
return withUrlKey({
...row,
permissions: normalizeAgentPermissions(row.permissions, row.role),
});
}
function toEligibilityAgent(row: Pick<typeof agents.$inferSelect, "id" | "companyId" | "name" | "status" | "reportsTo">): AgentEligibilityAgent {
return {
id: row.id,
companyId: row.companyId,
name: row.name,
status: row.status,
reportsTo: row.reportsTo,
};
}
function normalizeAgentRows(rows: (typeof agents.$inferSelect)[], allCompanyRows = rows) {
const eligibilityAgents = allCompanyRows.map(toEligibilityAgent);
return rows.map((row) => {
const base = normalizeAgentBaseRow(row);
return {
...base,
orgChainHealth: getAgentWorkEligibility({
agent: toEligibilityAgent(row),
agents: eligibilityAgents,
}).orgChainHealth,
};
});
}
function normalizeAgentRow(row: typeof agents.$inferSelect, allCompanyRows?: (typeof agents.$inferSelect)[]) {
return normalizeAgentRows([row], allCompanyRows)[0]!;
}
async function listCompanyAgentRows(companyId: string) {
return db.select().from(agents).where(eq(agents.companyId, companyId));
}
async function getMonthlySpendByAgentIds(companyId: string, agentIds: string[]) {
if (agentIds.length === 0) return new Map<string, number>();
const { start, end } = currentUtcMonthWindow();
@@ -274,8 +312,17 @@ export function agentService(db: Db) {
.where(eq(agents.id, id))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [hydrated] = await hydrateAgentSpend([row]);
return normalizeAgentRow(hydrated);
const [companyRows, hydrated] = await Promise.all([
listCompanyAgentRows(row.companyId),
hydrateAgentSpend([row]).then((rows) => rows[0]!),
]);
return normalizeAgentRow(hydrated, companyRows);
}
async function requireGetById(id: string) {
const agent = await getById(id);
if (!agent) throw notFound("Agent not found");
return agent;
}
async function ensureManager(companyId: string, managerId: string) {
@@ -374,7 +421,7 @@ export function agentService(db: Db) {
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
const normalizedUpdated = updated ? normalizeAgentRow(updated) : null;
const normalizedUpdated = updated ? await getById(updated.id) : null;
if (normalizedUpdated && shouldRecordRevision && beforeConfig) {
const afterConfig = buildConfigSnapshot(normalizedUpdated);
@@ -403,9 +450,12 @@ export function agentService(db: Db) {
if (!options?.includeTerminated) {
conditions.push(ne(agents.status, "terminated"));
}
const rows = await db.select().from(agents).where(and(...conditions));
const [rows, allCompanyRows] = await Promise.all([
db.select().from(agents).where(and(...conditions)),
listCompanyAgentRows(companyId),
]);
const hydrated = await hydrateAgentSpend(rows);
return hydrated.map(normalizeAgentRow);
return normalizeAgentRows(hydrated, allCompanyRows);
},
getById,
@@ -430,7 +480,7 @@ export function agentService(db: Db) {
.returning()
.then((rows) => rows[0]);
return normalizeAgentRow(created);
return requireGetById(created.id);
},
update: updateAgent,
@@ -451,7 +501,7 @@ export function agentService(db: Db) {
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return updated ? normalizeAgentRow(updated) : null;
return updated ? getById(updated.id) : null;
},
resume: async (id: string) => {
@@ -473,7 +523,7 @@ export function agentService(db: Db) {
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return updated ? normalizeAgentRow(updated) : null;
return updated ? getById(updated.id) : null;
},
terminate: async (id: string) => {
@@ -540,7 +590,7 @@ export function agentService(db: Db) {
.then((rows) => rows[0] ?? null);
if (updated) {
return { agent: normalizeAgentRow(updated), activated: true };
return { agent: await requireGetById(updated.id), activated: true };
}
const existing = await getById(id);
@@ -561,7 +611,7 @@ export function agentService(db: Db) {
.returning()
.then((rows) => rows[0] ?? null);
return updated ? normalizeAgentRow(updated) : null;
return updated ? getById(updated.id) : null;
},
listConfigRevisions: async (id: string) =>
@@ -670,14 +720,12 @@ export function agentService(db: Db) {
},
orgForCompany: async (companyId: string) => {
const rows = await db
.select()
.from(agents)
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
const normalizedRows = rows.map(normalizeAgentRow);
const allCompanyRows = await listCompanyAgentRows(companyId);
const rows = allCompanyRows.filter((row) => row.status !== "terminated");
const normalizedRows = normalizeAgentRows(rows, allCompanyRows);
const byManager = new Map<string | null, typeof normalizedRows>();
for (const row of normalizedRows) {
const key = row.reportsTo ?? null;
const key = row.reportsTo && rows.some((candidate) => candidate.id === row.reportsTo) ? row.reportsTo : null;
const group = byManager.get(key) ?? [];
group.push(row);
byManager.set(key, group);
@@ -735,8 +783,7 @@ export function agentService(db: Db) {
}
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
const matches = rows
.map(normalizeAgentRow)
const matches = normalizeAgentRows(rows, rows)
.filter((agent) => agent.urlKey === urlKey && agent.status !== "terminated");
if (matches.length === 1) {
return { agent: matches[0] ?? null, ambiguous: false } as const;
@@ -1,3 +1,4 @@
import { getAgentWorkEligibility, isAgentInvokable } from "@paperclipai/shared";
import { buildIssueGraphLivenessIncidentKey } from "./origins.js";
export type IssueLivenessSeverity = "warning" | "critical";
@@ -106,9 +107,6 @@ export interface IssueGraphLivenessInput {
now?: Date | string;
}
const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
const BLOCKING_AGENT_STATUSES = new Set(["paused", "terminated", "pending_approval"]);
function issueLabel(issue: IssueLivenessIssueInput) {
return issue.identifier ?? issue.id;
}
@@ -122,8 +120,11 @@ function pathEntry(issue: IssueLivenessIssueInput): IssueLivenessDependencyPathE
};
}
function isInvokableAgent(agent: IssueLivenessAgentInput | null | undefined) {
return Boolean(agent && INVOKABLE_AGENT_STATUSES.has(agent.status));
function isInvokableAgent(
agent: IssueLivenessAgentInput | null | undefined,
agentsById: Map<string, IssueLivenessAgentInput>,
) {
return Boolean(agent && isAgentInvokable({ agent, agents: [...agentsById.values()] }));
}
function hasActiveExecutionPath(
@@ -209,7 +210,7 @@ function addOwnerCandidate(
) {
if (!agentId || seen.has(agentId)) return;
const agent = agentsById.get(agentId);
if (!agent || agent.companyId !== companyId || !isInvokableAgent(agent)) return;
if (!agent || agent.companyId !== companyId || !isInvokableAgent(agent, agentsById)) return;
seen.add(agentId);
candidates.push({ agentId, reason, sourceIssueId });
}
@@ -236,9 +237,13 @@ function addAgentChainCandidates(
}
}
function orderedInvokableAgents(agents: IssueLivenessAgentInput[], companyId: string) {
function orderedInvokableAgents(
agents: IssueLivenessAgentInput[],
agentsById: Map<string, IssueLivenessAgentInput>,
companyId: string,
) {
return agents
.filter((agent) => agent.companyId === companyId && isInvokableAgent(agent))
.filter((agent) => agent.companyId === companyId && isInvokableAgent(agent, agentsById))
.sort((left, right) => left.id.localeCompare(right.id));
}
@@ -284,7 +289,7 @@ function ownerCandidatesForRecoveryIssue(
issue.id,
);
const invokableAgents = orderedInvokableAgents(agents, issue.companyId);
const invokableAgents = orderedInvokableAgents(agents, agentsById, issue.companyId);
for (const agent of invokableAgents) {
if (!agent.reportsTo) {
addOwnerCandidate(candidates, seen, agentsById, issue.companyId, agent.id, "root_agent", issue.id);
@@ -419,7 +424,7 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
const participantAgentId = readPrincipalAgentId(participant);
if (participantAgentId) {
const participantAgent = agentsById.get(participantAgentId);
if (isInvokableAgent(participantAgent) && participantAgent?.companyId === reviewIssue.companyId) return null;
if (isInvokableAgent(participantAgent, agentsById) && participantAgent?.companyId === reviewIssue.companyId) return null;
return finding({
issue: source,
@@ -532,12 +537,15 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
if (!blocker.assigneeAgentId) return null;
const blockerAgent = agentsById.get(blocker.assigneeAgentId);
if (!blockerAgent || blockerAgent.companyId !== source.companyId || BLOCKING_AGENT_STATUSES.has(blockerAgent.status)) {
const blockerEligibility = blockerAgent
? getAgentWorkEligibility({ agent: blockerAgent, agents: input.agents })
: null;
if (!blockerAgent || blockerAgent.companyId !== source.companyId || !blockerEligibility?.invokable) {
return finding({
issue: source,
state: "blocked_by_uninvokable_assignee",
reason: blockerAgent
? `${issueLabel(source)} is blocked by ${issueLabel(blocker)}, but its assignee is ${blockerAgent.status}.`
? `${issueLabel(source)} is blocked by ${issueLabel(blocker)}, but its assignee is ${blockerEligibility?.invokabilityReason === "invalid_org_chain" ? "in an invalid org chain" : blockerAgent.status}.`
: `${issueLabel(source)} is blocked by ${issueLabel(blocker)}, but its assignee no longer exists.`,
dependencyPath,
recoveryIssue: blocker,