mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
Address agent lifecycle review feedback
This commit is contained in:
1 parent
5ad85b4686
commit
802dc17784
5 files changed
+84
-17
No files matched your search
@@ -64,6 +64,19 @@ describe("agent work eligibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unknown lifecycle statuses explicitly", () => {
|
||||
const target = agent({ status: "sabbatical" });
|
||||
const manager = agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null });
|
||||
|
||||
expect(getAgentWorkEligibility({ agent: target, agents: [target, manager] })).toMatchObject({
|
||||
assignable: false,
|
||||
invokable: false,
|
||||
assignabilityReason: "unknown_status",
|
||||
invokabilityReason: "unknown_status",
|
||||
orgChainHealth: { status: "healthy" },
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -5,7 +5,8 @@ export type AgentEligibilityLifecycleReason =
|
||||
| "terminated"
|
||||
| "pending_approval"
|
||||
| "paused"
|
||||
| "invalid_org_chain";
|
||||
| "invalid_org_chain"
|
||||
| "unknown_status";
|
||||
|
||||
export interface AgentEligibilityAgent {
|
||||
id: string;
|
||||
@@ -202,7 +203,9 @@ export function getAgentWorkEligibility(input: {
|
||||
const assignabilityReason: AgentEligibilityLifecycleReason = !isAgentStatusAssignableToWork(input.agent.status)
|
||||
? input.agent.status === "terminated"
|
||||
? "terminated"
|
||||
: "pending_approval"
|
||||
: input.agent.status === "pending_approval"
|
||||
? "pending_approval"
|
||||
: "unknown_status"
|
||||
: orgChainHealth.status === "invalid_org_chain"
|
||||
? "invalid_org_chain"
|
||||
: "eligible";
|
||||
@@ -211,7 +214,9 @@ export function getAgentWorkEligibility(input: {
|
||||
? "terminated"
|
||||
: input.agent.status === "pending_approval"
|
||||
? "pending_approval"
|
||||
: "paused"
|
||||
: input.agent.status === "paused"
|
||||
? "paused"
|
||||
: "unknown_status"
|
||||
: orgChainHealth.status === "invalid_org_chain"
|
||||
? "invalid_org_chain"
|
||||
: "eligible";
|
||||
|
||||
@@ -15,6 +15,7 @@ type AssignabilityAgent = AgentEligibilityAgent;
|
||||
type AgentAssignmentConflictReason =
|
||||
| "pending_approval"
|
||||
| "assignee_terminated"
|
||||
| "assignee_unknown_status"
|
||||
| "ancestor_terminated"
|
||||
| "ancestor_missing"
|
||||
| "ancestor_cross_company"
|
||||
@@ -32,6 +33,11 @@ function assignmentMessage(kind: AgentAssignmentKind, reason: AgentAssignmentCon
|
||||
? "Cannot assign routines to terminated agents"
|
||||
: "Cannot assign work to terminated agents";
|
||||
}
|
||||
if (reason === "assignee_unknown_status") {
|
||||
return kind === "routine"
|
||||
? "Cannot assign routines to agents with an unsupported lifecycle status"
|
||||
: "Cannot assign work to agents with an unsupported lifecycle status";
|
||||
}
|
||||
return kind === "routine"
|
||||
? "Cannot assign routines to agents with an invalid org chain"
|
||||
: "Cannot assign work to agents with an invalid org chain";
|
||||
@@ -137,6 +143,14 @@ export async function assertAssignableAgent(
|
||||
chain,
|
||||
}));
|
||||
}
|
||||
if (eligibility.assignabilityReason === "unknown_status") {
|
||||
throw conflict(assignmentMessage(kind, "assignee_unknown_status"), conflictDetails({
|
||||
companyId,
|
||||
assigneeAgentId: agentId,
|
||||
reason: "assignee_unknown_status",
|
||||
chain,
|
||||
}));
|
||||
}
|
||||
|
||||
const reason = assignmentReasonFromHealth(eligibility.orgChainHealth);
|
||||
const firstInvalidAncestor = eligibility.orgChainHealth.firstInvalidAncestor;
|
||||
|
||||
@@ -15,6 +15,7 @@ export type AgentInvokabilityBlockReason =
|
||||
| "paused"
|
||||
| "terminated"
|
||||
| "pending_approval"
|
||||
| "unknown_status"
|
||||
| "manager_missing"
|
||||
| "manager_company_mismatch"
|
||||
| "manager_terminated"
|
||||
@@ -84,7 +85,9 @@ export function evaluateAgentInvokability(
|
||||
|
||||
if (eligibility.invokable) return { invokable: true };
|
||||
|
||||
const directStatusReason = statusBlockReason(agent.status);
|
||||
const directStatusReason = eligibility.invokabilityReason === "unknown_status"
|
||||
? "unknown_status"
|
||||
: statusBlockReason(agent.status);
|
||||
if (directStatusReason) {
|
||||
return blocked(
|
||||
directStatusReason,
|
||||
|
||||
@@ -159,6 +159,7 @@ import {
|
||||
evaluateAgentInvokability,
|
||||
evaluateAgentInvokabilityFromDb,
|
||||
shouldCancelRunsForNonInvokableAgent,
|
||||
type AgentOrgRow,
|
||||
} from "./agent-invokability.js";
|
||||
import {
|
||||
redactQuarantinedBodyForHigherTrust,
|
||||
@@ -3070,6 +3071,42 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
return evaluateAgentInvokabilityFromDb(db, agent);
|
||||
}
|
||||
|
||||
function toAgentOrgRow(agent: Pick<typeof agents.$inferSelect, "id" | "companyId" | "name" | "reportsTo" | "status">): AgentOrgRow {
|
||||
return {
|
||||
id: agent.id,
|
||||
companyId: agent.companyId,
|
||||
name: agent.name,
|
||||
reportsTo: agent.reportsTo,
|
||||
status: agent.status,
|
||||
};
|
||||
}
|
||||
|
||||
async function listCompanyAgentOrgRows(companyId: string): Promise<AgentOrgRow[]> {
|
||||
return db
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
name: agents.name,
|
||||
reportsTo: agents.reportsTo,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId));
|
||||
}
|
||||
|
||||
function groupAgentOrgRowsByCompany(agentRows: AgentOrgRow[]) {
|
||||
const byCompany = new Map<string, AgentOrgRow[]>();
|
||||
for (const agent of agentRows) {
|
||||
const companyAgents = byCompany.get(agent.companyId);
|
||||
if (companyAgents) {
|
||||
companyAgents.push(agent);
|
||||
} else {
|
||||
byCompany.set(agent.companyId, [agent]);
|
||||
}
|
||||
}
|
||||
return byCompany;
|
||||
}
|
||||
|
||||
async function getRun(runId: string, opts?: { unsafeFullResultJson?: boolean }) {
|
||||
const safeForLegacyEncoding = !opts?.unsafeFullResultJson && await hasUnsafeTextProjectionDatabase();
|
||||
return db
|
||||
@@ -6584,14 +6621,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
return Number(count ?? 0);
|
||||
}
|
||||
|
||||
async function claimQueuedRun(run: typeof heartbeatRuns.$inferSelect) {
|
||||
async function claimQueuedRun(run: typeof heartbeatRuns.$inferSelect, companyAgents?: AgentOrgRow[]) {
|
||||
if (run.status !== "queued") return run;
|
||||
const agent = await getAgent(run.agentId);
|
||||
if (!agent) {
|
||||
await cancelRunInternal(run.id, "Cancelled because the agent no longer exists");
|
||||
return null;
|
||||
}
|
||||
const invokability = await getAgentInvokability(agent);
|
||||
const invokability = companyAgents
|
||||
? evaluateAgentInvokability(toAgentOrgRow(agent), companyAgents)
|
||||
: await getAgentInvokability(agent);
|
||||
if (!invokability.invokable) {
|
||||
await cancelRunInternal(run.id, `Cancelled because the agent is not invokable: ${invokability.reason}`);
|
||||
return null;
|
||||
@@ -7542,6 +7581,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
: sql`false`,
|
||||
);
|
||||
const issueById = new Map(issueRows.map((row) => [row.id, row]));
|
||||
const companyAgents = await listCompanyAgentOrgRows(agent.companyId);
|
||||
const prioritizedRuns = [...queuedRuns].sort((left, right) => {
|
||||
const leftIssueId = readNonEmptyString(parseObject(left.contextSnapshot).issueId);
|
||||
const rightIssueId = readNonEmptyString(parseObject(right.contextSnapshot).issueId);
|
||||
@@ -7563,7 +7603,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const claimedRuns: Array<typeof heartbeatRuns.$inferSelect> = [];
|
||||
for (const queuedRun of prioritizedRuns) {
|
||||
if (claimedRuns.length >= availableSlots) break;
|
||||
const claimed = await claimQueuedRun(queuedRun);
|
||||
const claimed = await claimQueuedRun(queuedRun, companyAgents);
|
||||
if (claimed) claimedRuns.push(claimed);
|
||||
}
|
||||
if (claimedRuns.length === 0) return [];
|
||||
@@ -11016,21 +11056,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.from(agents)
|
||||
.innerJoin(companies, eq(companies.id, agents.companyId))
|
||||
.where(eq(companies.status, "active"));
|
||||
const agentsByCompany = groupAgentOrgRowsByCompany(allAgents.map(toAgentOrgRow));
|
||||
let checked = 0;
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const agent of allAgents) {
|
||||
const companyAgents = allAgents
|
||||
.filter((candidate) => candidate.companyId === agent.companyId)
|
||||
.map((candidate) => ({
|
||||
id: candidate.id,
|
||||
companyId: candidate.companyId,
|
||||
name: candidate.name,
|
||||
reportsTo: candidate.reportsTo,
|
||||
status: candidate.status,
|
||||
}));
|
||||
const invokability = evaluateAgentInvokability(agent, companyAgents);
|
||||
const invokability = evaluateAgentInvokability(toAgentOrgRow(agent), agentsByCompany.get(agent.companyId) ?? []);
|
||||
if (!invokability.invokable) continue;
|
||||
const policy = parseHeartbeatPolicy(agent);
|
||||
if (!policy.enabled || policy.intervalSec <= 0) continue;
|
||||
|
||||
Reference in new issue
Block a user