diff --git a/packages/shared/src/agent-eligibility.test.ts b/packages/shared/src/agent-eligibility.test.ts index 20b3fff500c..fba9d41d893 100644 --- a/packages/shared/src/agent-eligibility.test.ts +++ b/packages/shared/src/agent-eligibility.test.ts @@ -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({ diff --git a/packages/shared/src/agent-eligibility.ts b/packages/shared/src/agent-eligibility.ts index d1e9c266a81..ae53bb57bfe 100644 --- a/packages/shared/src/agent-eligibility.ts +++ b/packages/shared/src/agent-eligibility.ts @@ -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"; diff --git a/server/src/services/agent-assignability.ts b/server/src/services/agent-assignability.ts index f3b48e3a1a0..13140db7a1a 100644 --- a/server/src/services/agent-assignability.ts +++ b/server/src/services/agent-assignability.ts @@ -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; diff --git a/server/src/services/agent-invokability.ts b/server/src/services/agent-invokability.ts index 0c026191082..75726c92b87 100644 --- a/server/src/services/agent-invokability.ts +++ b/server/src/services/agent-invokability.ts @@ -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, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 90252246511..e3502510de5 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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): AgentOrgRow { + return { + id: agent.id, + companyId: agent.companyId, + name: agent.name, + reportsTo: agent.reportsTo, + status: agent.status, + }; + } + + async function listCompanyAgentOrgRows(companyId: string): Promise { + 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(); + 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 = []; 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;