mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 21:02:34 +01:00
Centralize agent lifecycle eligibility
This commit is contained in:
1 parent
8f02a7ef97
commit
948ffaf274
8 files changed
+244
-120
No files matched your search
@@ -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: [
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user