From 948ffaf274f14dd294ada61c12d6d9a641568dff Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 6 Jun 2026 14:38:18 +0000 Subject: [PATCH] Centralize agent lifecycle eligibility --- packages/shared/src/types/agent.ts | 2 + .../src/__tests__/agent-invokability.test.ts | 1 + server/src/__tests__/issue-liveness.test.ts | 30 +++++ server/src/routes/agents.ts | 21 +++- server/src/routes/issues.ts | 86 ++++++++++---- server/src/services/agent-invokability.ts | 105 +++++++----------- server/src/services/agents.ts | 87 +++++++++++---- .../services/recovery/issue-graph-liveness.ts | 32 ++++-- 8 files changed, 244 insertions(+), 120 deletions(-) diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 577fcbf7448..806ebfc91f9 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -13,6 +13,7 @@ import type { TrustAuthorizationPolicy, TrustPreset, } from "../trust-policy.js"; +import type { AgentOrgChainHealth } from "../agent-eligibility.js"; export interface AgentPermissions extends Record { canCreateAgents: boolean; @@ -98,6 +99,7 @@ export interface Agent { permissions: AgentPermissions; lastHeartbeatAt: Date | null; metadata: Record | null; + orgChainHealth?: AgentOrgChainHealth; createdAt: Date; updatedAt: Date; } diff --git a/server/src/__tests__/agent-invokability.test.ts b/server/src/__tests__/agent-invokability.test.ts index a2a91024d69..516cf5973f6 100644 --- a/server/src/__tests__/agent-invokability.test.ts +++ b/server/src/__tests__/agent-invokability.test.ts @@ -8,6 +8,7 @@ import { function agent(partial: Partial & Pick): AgentOrgRow { return { companyId: "company-1", + name: partial.id, reportsTo: null, status: "active", ...partial, diff --git a/server/src/__tests__/issue-liveness.test.ts b/server/src/__tests__/issue-liveness.test.ts index c55f89e469f..8dba1eb5831 100644 --- a/server/src/__tests__/issue-liveness.test.ts +++ b/server/src/__tests__/issue-liveness.test.ts @@ -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: [ diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 386a8dde8ef..a44aca543d1 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 9f1cca395e4..40768a74124 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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) { - 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) { + for (const child of requestedChildren) { const executionPolicy = applyActorMonitorScheduledBy( normalizeIssueExecutionPolicy(child.executionPolicy), actor.actorType, diff --git a/server/src/services/agent-invokability.ts b/server/src/services/agent-invokability.ts index 237b05a089b..0c026191082 100644 --- a/server/src/services/agent-invokability.ts +++ b/server/src/services/agent-invokability.ts @@ -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([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, }) diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 0ea972e913f..dd67518d363 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -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): 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(); 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(); 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; diff --git a/server/src/services/recovery/issue-graph-liveness.ts b/server/src/services/recovery/issue-graph-liveness.ts index 090480dc255..91cf2e73dd6 100644 --- a/server/src/services/recovery/issue-graph-liveness.ts +++ b/server/src/services/recovery/issue-graph-liveness.ts @@ -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, +) { + 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, + 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,