diff --git a/packages/shared/src/agent-eligibility.test.ts b/packages/shared/src/agent-eligibility.test.ts new file mode 100644 index 00000000..fba9d41d --- /dev/null +++ b/packages/shared/src/agent-eligibility.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { + getAgentOrgChainHealth, + getAgentWorkEligibility, + isAgentAssignableToWork, + isAgentInvokable, + type AgentEligibilityAgent, +} from "./agent-eligibility.js"; + +const companyId = "company-1"; + +function agent(overrides: Partial = {}): AgentEligibilityAgent { + return { + id: "agent-1", + companyId, + name: "Coder", + status: "active", + reportsTo: "manager-1", + ...overrides, + }; +} + +describe("agent work eligibility", () => { + it("allows healthy active agents to accept work and be invoked", () => { + const agents = [ + agent(), + agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null }), + ]; + + expect(isAgentAssignableToWork({ agent: agents[0]!, agents })).toBe(true); + expect(isAgentInvokable({ agent: agents[0]!, agents })).toBe(true); + expect(getAgentWorkEligibility({ agent: agents[0]!, agents })).toMatchObject({ + assignable: true, + invokable: true, + assignabilityReason: "eligible", + invokabilityReason: "eligible", + orgChainHealth: { status: "healthy" }, + }); + }); + + it("blocks terminated and pending approval agents from assignment and invocation", () => { + const manager = agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null }); + + for (const status of ["terminated", "pending_approval"]) { + const target = agent({ status }); + const eligibility = getAgentWorkEligibility({ agent: target, agents: [target, manager] }); + + expect(eligibility.assignable).toBe(false); + expect(eligibility.invokable).toBe(false); + expect(eligibility.assignabilityReason).toBe(status); + expect(eligibility.invokabilityReason).toBe(status); + } + }); + + it("allows paused agents to keep assignments but blocks invocation", () => { + const target = agent({ status: "paused" }); + const manager = agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null }); + + expect(getAgentWorkEligibility({ agent: target, agents: [target, manager] })).toMatchObject({ + assignable: true, + invokable: false, + assignabilityReason: "eligible", + invokabilityReason: "paused", + }); + }); + + 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({ + id: "cto-2", + name: "CTO 2", + status: "terminated", + reportsTo: "ceo-2", + }); + const terminatedRoot = agent({ + id: "ceo-2", + name: "CEO 2", + status: "terminated", + reportsTo: null, + }); + const agents = [target, terminatedManager, terminatedRoot]; + + const health = getAgentOrgChainHealth({ agent: target, agents }); + expect(health.status).toBe("invalid_org_chain"); + expect(health.reason).toBe("terminated_ancestor"); + expect(health.fullChain).toEqual([ + expect.objectContaining({ id: "qa-2", name: "QA 2", relation: "self", depth: 0 }), + expect.objectContaining({ id: "cto-2", name: "CTO 2", status: "terminated", relation: "ancestor", depth: 1 }), + expect.objectContaining({ id: "ceo-2", name: "CEO 2", status: "terminated", relation: "ancestor", depth: 2 }), + ]); + expect(health.firstInvalidAncestor).toEqual({ id: "cto-2", name: "CTO 2", status: "terminated" }); + expect(health.invalidAncestors).toEqual([ + { id: "cto-2", name: "CTO 2", status: "terminated" }, + { id: "ceo-2", name: "CEO 2", status: "terminated" }, + ]); + expect(health.repairGuidance).toContain("QA 2 reports through terminated ancestor CTO 2"); + + const eligibility = getAgentWorkEligibility({ agent: target, agents }); + expect(eligibility.assignable).toBe(false); + expect(eligibility.invokable).toBe(false); + expect(eligibility.assignabilityReason).toBe("invalid_org_chain"); + expect(eligibility.invokabilityReason).toBe("invalid_org_chain"); + }); + + it("blocks agents whose manager is missing from the company org", () => { + const target = agent({ id: "qa-3", name: "QA 3", status: "active", reportsTo: "missing-manager" }); + + const health = getAgentOrgChainHealth({ agent: target, agents: [target] }); + expect(health.status).toBe("invalid_org_chain"); + expect(health.reason).toBe("missing_manager"); + expect(health.fullChain).toEqual([ + expect.objectContaining({ id: "qa-3", relation: "self", depth: 0 }), + expect.objectContaining({ id: "missing-manager", status: "missing", relation: "ancestor", depth: 1 }), + ]); + expect(health.repairGuidance).toContain("QA 3 reports to missing manager missing-manager"); + + const eligibility = getAgentWorkEligibility({ agent: target, agents: [target] }); + expect(eligibility.assignable).toBe(false); + expect(eligibility.invokable).toBe(false); + expect(eligibility.assignabilityReason).toBe("invalid_org_chain"); + expect(eligibility.invokabilityReason).toBe("invalid_org_chain"); + }); + + it("blocks agents with reporting cycles", () => { + const target = agent({ id: "qa-4", name: "QA 4", status: "active", reportsTo: "cto-4" }); + const manager = agent({ id: "cto-4", name: "CTO 4", status: "active", reportsTo: "qa-4" }); + const agents = [target, manager]; + + const health = getAgentOrgChainHealth({ agent: target, agents }); + expect(health.status).toBe("invalid_org_chain"); + expect(health.reason).toBe("cycle"); + expect(health.fullChain).toEqual([ + expect.objectContaining({ id: "qa-4", relation: "self", depth: 0 }), + expect.objectContaining({ id: "cto-4", relation: "ancestor", depth: 1 }), + expect.objectContaining({ id: "qa-4", status: "cycle", relation: "ancestor", depth: 2 }), + ]); + expect(health.repairGuidance).toContain("QA 4 has a cycle in its reporting chain"); + + const eligibility = getAgentWorkEligibility({ agent: target, agents }); + expect(eligibility.assignable).toBe(false); + expect(eligibility.invokable).toBe(false); + expect(eligibility.assignabilityReason).toBe("invalid_org_chain"); + expect(eligibility.invokabilityReason).toBe("invalid_org_chain"); + }); +}); diff --git a/packages/shared/src/agent-eligibility.ts b/packages/shared/src/agent-eligibility.ts new file mode 100644 index 00000000..ae53bb57 --- /dev/null +++ b/packages/shared/src/agent-eligibility.ts @@ -0,0 +1,245 @@ +import type { AgentStatus } from "./constants.js"; + +export type AgentEligibilityLifecycleReason = + | "eligible" + | "terminated" + | "pending_approval" + | "paused" + | "invalid_org_chain" + | "unknown_status"; + +export interface AgentEligibilityAgent { + id: string; + companyId: string; + name: string; + status: AgentStatus | string; + reportsTo?: string | null; +} + +export interface AgentOrgChainEntry { + id: string; + companyId: string; + name: string; + status: AgentStatus | string; + reportsTo: string | null; + depth: number; + relation: "self" | "ancestor"; +} + +export interface AgentInvalidOrgChainAncestor { + id: string; + name: string; + status: AgentStatus | string; +} + +export type AgentOrgChainInvalidReason = + | "healthy" + | "terminated_ancestor" + | "missing_manager" + | "cycle"; + +export interface AgentOrgChainHealth { + status: "healthy" | "invalid_org_chain"; + reason: AgentOrgChainInvalidReason; + fullChain: AgentOrgChainEntry[]; + firstInvalidAncestor: AgentInvalidOrgChainAncestor | null; + invalidAncestors: AgentInvalidOrgChainAncestor[]; + repairGuidance: string | null; +} + +export interface AgentWorkEligibility { + assignable: boolean; + invokable: boolean; + assignabilityReason: AgentEligibilityLifecycleReason; + invokabilityReason: AgentEligibilityLifecycleReason; + orgChainHealth: AgentOrgChainHealth; +} + +const NON_ASSIGNABLE_AGENT_STATUSES = new Set(["terminated", "pending_approval"]); +const NON_INVOKABLE_AGENT_STATUSES = new Set(["terminated", "pending_approval", "paused"]); +const ASSIGNABLE_AGENT_STATUSES = new Set(["active", "paused", "idle", "running", "error"]); +const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]); + +export function isAgentStatusAssignableToWork(status: AgentStatus | string): boolean { + return ASSIGNABLE_AGENT_STATUSES.has(status) && !NON_ASSIGNABLE_AGENT_STATUSES.has(status); +} + +export function isAgentStatusInvokable(status: AgentStatus | string): boolean { + return INVOKABLE_AGENT_STATUSES.has(status) && !NON_INVOKABLE_AGENT_STATUSES.has(status); +} + +function chainEntry( + agent: AgentEligibilityAgent, + depth: number, + relation: AgentOrgChainEntry["relation"], +): AgentOrgChainEntry { + return { + id: agent.id, + companyId: agent.companyId, + name: agent.name, + status: agent.status, + reportsTo: agent.reportsTo ?? null, + depth, + relation, + }; +} + +function invalidAncestor(agent: AgentEligibilityAgent): AgentInvalidOrgChainAncestor { + return { + id: agent.id, + name: agent.name, + status: agent.status, + }; +} + +function buildRepairGuidance( + agent: AgentEligibilityAgent, + firstInvalidAncestor: AgentInvalidOrgChainAncestor, +): string { + if (firstInvalidAncestor.status === "missing") { + return [ + `${agent.name} reports to missing manager ${firstInvalidAncestor.id}.`, + `Reassign ${agent.name} or the nearest affected ancestor under an active manager/root, or explicitly pause or terminate the invalid subtree before assigning work or starting runs.`, + ].join(" "); + } + if (firstInvalidAncestor.status === "cycle") { + return [ + `${agent.name} has a cycle in its reporting chain at ${firstInvalidAncestor.name}.`, + `Break the cycle by assigning one affected agent to an active manager/root, or explicitly pause or terminate the invalid subtree before assigning work or starting runs.`, + ].join(" "); + } + return [ + `${agent.name} reports through terminated ancestor ${firstInvalidAncestor.name}.`, + `Reassign ${agent.name} or the nearest affected ancestor under an active manager/root, or explicitly pause or terminate the invalid subtree before assigning work or starting runs.`, + ].join(" "); +} + +export function getAgentOrgChainHealth(input: { + agent: AgentEligibilityAgent; + agents: AgentEligibilityAgent[]; +}): AgentOrgChainHealth { + const byId = new Map(input.agents.map((agent) => [agent.id, agent])); + const fullChain: AgentOrgChainEntry[] = [chainEntry(input.agent, 0, "self")]; + const invalidAncestors: AgentInvalidOrgChainAncestor[] = []; + const seen = new Set([input.agent.id]); + + let current = input.agent; + let depth = 1; + while (current.reportsTo) { + if (seen.has(current.reportsTo)) { + const cycleAgent = byId.get(current.reportsTo); + const invalid = { + id: current.reportsTo, + name: cycleAgent?.name ?? current.reportsTo, + status: "cycle", + }; + fullChain.push({ + id: invalid.id, + companyId: input.agent.companyId, + name: invalid.name, + status: invalid.status, + reportsTo: cycleAgent?.reportsTo ?? null, + depth, + relation: "ancestor", + }); + invalidAncestors.push(invalid); + break; + } + seen.add(current.reportsTo); + + const parent = byId.get(current.reportsTo); + if (!parent || parent.companyId !== input.agent.companyId) { + const invalid = { + id: current.reportsTo, + name: current.reportsTo, + status: "missing", + }; + fullChain.push({ + id: invalid.id, + companyId: input.agent.companyId, + name: invalid.name, + status: invalid.status, + reportsTo: null, + depth, + relation: "ancestor", + }); + invalidAncestors.push(invalid); + break; + } + + fullChain.push(chainEntry(parent, depth, "ancestor")); + if (parent.status === "terminated") { + invalidAncestors.push(invalidAncestor(parent)); + } + + current = parent; + depth += 1; + } + + const firstInvalidAncestor = invalidAncestors[0] ?? null; + return { + status: firstInvalidAncestor ? "invalid_org_chain" : "healthy", + reason: firstInvalidAncestor + ? firstInvalidAncestor.status === "missing" + ? "missing_manager" + : firstInvalidAncestor.status === "cycle" + ? "cycle" + : "terminated_ancestor" + : "healthy", + fullChain, + firstInvalidAncestor, + invalidAncestors, + repairGuidance: firstInvalidAncestor + ? buildRepairGuidance(input.agent, firstInvalidAncestor) + : null, + }; +} + +export function getAgentWorkEligibility(input: { + agent: AgentEligibilityAgent; + agents: AgentEligibilityAgent[]; +}): AgentWorkEligibility { + const orgChainHealth = getAgentOrgChainHealth(input); + const assignabilityReason: AgentEligibilityLifecycleReason = !isAgentStatusAssignableToWork(input.agent.status) + ? input.agent.status === "terminated" + ? "terminated" + : input.agent.status === "pending_approval" + ? "pending_approval" + : "unknown_status" + : orgChainHealth.status === "invalid_org_chain" + ? "invalid_org_chain" + : "eligible"; + const invokabilityReason: AgentEligibilityLifecycleReason = !isAgentStatusInvokable(input.agent.status) + ? input.agent.status === "terminated" + ? "terminated" + : input.agent.status === "pending_approval" + ? "pending_approval" + : input.agent.status === "paused" + ? "paused" + : "unknown_status" + : orgChainHealth.status === "invalid_org_chain" + ? "invalid_org_chain" + : "eligible"; + + return { + assignable: assignabilityReason === "eligible", + invokable: invokabilityReason === "eligible", + assignabilityReason, + invokabilityReason, + orgChainHealth, + }; +} + +export function isAgentAssignableToWork(input: { + agent: AgentEligibilityAgent; + agents: AgentEligibilityAgent[]; +}): boolean { + return getAgentWorkEligibility(input).assignable; +} + +export function isAgentInvokable(input: { + agent: AgentEligibilityAgent; + agents: AgentEligibilityAgent[]; +}): boolean { + return getAgentWorkEligibility(input).invokable; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 559e5ddd..7744af39 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,19 @@ export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js"; +export { + getAgentOrgChainHealth, + getAgentWorkEligibility, + isAgentAssignableToWork, + isAgentInvokable, + isAgentStatusAssignableToWork, + isAgentStatusInvokable, + type AgentEligibilityAgent, + type AgentEligibilityLifecycleReason, + type AgentInvalidOrgChainAncestor, + type AgentOrgChainEntry, + type AgentOrgChainHealth, + type AgentOrgChainInvalidReason, + type AgentWorkEligibility, +} from "./agent-eligibility.js"; export { asBoolean, asString, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 577fcbf7..806ebfc9 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/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 6b72c752..b1ea7661 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -142,6 +142,15 @@ export type { AdapterEnvironmentCheck, AdapterEnvironmentTestResult, } from "./agent.js"; +export type { + AgentEligibilityAgent, + AgentEligibilityLifecycleReason, + AgentInvalidOrgChainAncestor, + AgentOrgChainEntry, + AgentOrgChainHealth, + AgentOrgChainInvalidReason, + AgentWorkEligibility, +} from "../agent-eligibility.js"; export type { AssetImage } from "./asset.js"; export type { CreateDocumentAnnotationCommentRequest, diff --git a/server/src/__tests__/agent-invokability.test.ts b/server/src/__tests__/agent-invokability.test.ts new file mode 100644 index 00000000..516cf597 --- /dev/null +++ b/server/src/__tests__/agent-invokability.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateAgentInvokability, + listInvalidOrgChainDescendantIds, + type AgentOrgRow, +} from "../services/agent-invokability.ts"; + +function agent(partial: Partial & Pick): AgentOrgRow { + return { + companyId: "company-1", + name: partial.id, + reportsTo: null, + status: "active", + ...partial, + }; +} + +describe("agent invokability", () => { + it("blocks active descendants under a terminated manager as invalid-org-chain", () => { + const rows = [ + agent({ id: "ceo", status: "terminated" }), + agent({ id: "cto", reportsTo: "ceo" }), + agent({ id: "coder", reportsTo: "cto" }), + ]; + + const result = evaluateAgentInvokability(rows[2], rows); + + expect(result).toMatchObject({ + invokable: false, + reason: "manager_terminated", + invalidOrgChain: true, + details: { + managerId: "ceo", + reportingChainAgentIds: ["cto", "ceo"], + }, + }); + }); + + it("reports missing managers and cycles as invalid-org-chain", () => { + const missingManager = [agent({ id: "coder", reportsTo: "missing" })]; + expect(evaluateAgentInvokability(missingManager[0], missingManager)).toMatchObject({ + invokable: false, + reason: "manager_missing", + invalidOrgChain: true, + }); + + const cycle = [ + agent({ id: "a", reportsTo: "b" }), + agent({ id: "b", reportsTo: "a" }), + ]; + expect(evaluateAgentInvokability(cycle[0], cycle)).toMatchObject({ + invokable: false, + reason: "reporting_cycle", + invalidOrgChain: true, + }); + }); + + it("lists non-terminated descendants made invalid by a terminated root", () => { + const rows = [ + agent({ id: "ceo", status: "terminated" }), + agent({ id: "cto", reportsTo: "ceo" }), + agent({ id: "coder", reportsTo: "cto" }), + agent({ id: "old-coder", reportsTo: "cto", status: "terminated" }), + agent({ id: "other-root" }), + ]; + + expect(listInvalidOrgChainDescendantIds("ceo", rows).sort()).toEqual(["coder", "cto"]); + }); +}); diff --git a/server/src/__tests__/heartbeat-archived-company-guard.test.ts b/server/src/__tests__/heartbeat-archived-company-guard.test.ts index b0eb6b54..085f8bfd 100644 --- a/server/src/__tests__/heartbeat-archived-company-guard.test.ts +++ b/server/src/__tests__/heartbeat-archived-company-guard.test.ts @@ -5,6 +5,7 @@ import { agentWakeupRequests, companies, createDb, + heartbeatRunEvents, heartbeatRuns, issues, } from "@paperclipai/db"; @@ -33,6 +34,7 @@ describeEmbeddedPostgres("heartbeat archived-company guard", () => { }, 20_000); afterEach(async () => { + await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); await db.delete(issues); @@ -77,6 +79,60 @@ describeEmbeddedPostgres("heartbeat archived-company guard", () => { return { companyId, agentId }; } + async function insertInvalidOrgChainAgent() { + const companyId = randomUUID(); + const managerId = randomUUID(); + const childId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Invalid Org Co", + status: "active", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values([ + { + id: managerId, + companyId, + name: "Terminated Manager", + role: "cto", + status: "terminated", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + enabled: true, + intervalSec: 60, + wakeOnDemand: true, + }, + }, + permissions: {}, + }, + { + id: childId, + companyId, + name: "Invalid Chain Child", + role: "engineer", + reportsTo: managerId, + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + enabled: true, + intervalSec: 60, + wakeOnDemand: true, + }, + }, + permissions: {}, + }, + ]); + + return { companyId, managerId, childId }; + } + it("does not iterate archived-company agents in tickTimers", async () => { const { agentId } = await insertArchivedAgent(); @@ -205,4 +261,122 @@ describeEmbeddedPostgres("heartbeat archived-company guard", () => { .then((rows) => rows.filter((row) => row.agentId === agentId).length); expect(runCount).toBe(0); }); + + it("rejects explicit user invokes for invalid-org-chain agents", async () => { + const { childId } = await insertInvalidOrgChainAgent(); + + const heartbeat = heartbeatService(db); + + await expect(heartbeat.wakeup(childId, { + source: "on_demand", + triggerDetail: "manual", + requestedByActorType: "user", + requestedByActorId: "board-user", + })).rejects.toMatchObject({ + status: 409, + details: { + reason: "manager_terminated", + invalidOrgChain: true, + }, + }); + + const runCount = await db + .select() + .from(heartbeatRuns) + .then((rows) => rows.filter((row) => row.agentId === childId).length); + expect(runCount).toBe(0); + }); + + it("cancels existing queued runs for invalid-org-chain agents instead of starting them", async () => { + const { companyId, childId } = await insertInvalidOrgChainAgent(); + const wakeupRequestId = randomUUID(); + const runId = randomUUID(); + + await db.insert(agentWakeupRequests).values({ + id: wakeupRequestId, + companyId, + agentId: childId, + source: "assignment", + status: "queued", + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId: childId, + invocationSource: "assignment", + status: "queued", + wakeupRequestId, + }); + + const heartbeat = heartbeatService(db); + await heartbeat.resumeQueuedRuns(); + + const run = await db + .select({ + status: heartbeatRuns.status, + error: heartbeatRuns.error, + }) + .from(heartbeatRuns) + .then((rows) => rows.find((row) => row.status === "cancelled") ?? null); + expect(run).toMatchObject({ + status: "cancelled", + error: "Cancelled because the agent is not invokable: manager_terminated", + }); + + const wakeup = await db + .select({ + status: agentWakeupRequests.status, + error: agentWakeupRequests.error, + }) + .from(agentWakeupRequests) + .then((rows) => rows[0] ?? null); + expect(wakeup).toMatchObject({ + status: "cancelled", + error: "Cancelled because the agent is not invokable: manager_terminated", + }); + }); + + it("suppresses due scheduled retries for invalid-org-chain agents", async () => { + const { companyId, childId } = await insertInvalidOrgChainAgent(); + const wakeupRequestId = randomUUID(); + const runId = randomUUID(); + const now = new Date("2026-06-04T00:10:00Z"); + + await db.insert(agentWakeupRequests).values({ + id: wakeupRequestId, + companyId, + agentId: childId, + source: "automation", + status: "queued", + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId: childId, + invocationSource: "automation", + status: "scheduled_retry", + wakeupRequestId, + scheduledRetryAt: new Date("2026-06-04T00:00:00Z"), + scheduledRetryReason: "transient_failure", + scheduledRetryAttempt: 1, + }); + + const heartbeat = heartbeatService(db); + const promoted = await heartbeat.promoteDueScheduledRetries(now); + + expect(promoted).toEqual({ promoted: 0, runIds: [] }); + const run = await db + .select({ + status: heartbeatRuns.status, + errorCode: heartbeatRuns.errorCode, + error: heartbeatRuns.error, + }) + .from(heartbeatRuns) + .then((rows) => rows[0] ?? null); + expect(run).toMatchObject({ + status: "cancelled", + errorCode: "agent_not_invokable", + error: "Scheduled retry suppressed because the agent is not invokable", + }); + }); }); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 17f01486..ed7ac016 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -957,6 +957,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { it("queues exactly one retry when the recorded local pid is dead", async () => { const { agentId, runId, issueId } = await seedRunFixture({ + agentStatus: "idle", processPid: 999_999_999, contextSnapshot: { modelProfile: "cheap", @@ -988,7 +989,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { timeoutConfigured: false, timeoutFired: false, }); - expect(retryRun?.status).toBe("queued"); + expect(["queued", "running"]).toContain(retryRun?.status); expect(retryRun?.retryOfRunId).toBe(runId); expect(retryRun?.processLossRetryCount).toBe(1); expect(retryRun?.contextSnapshot as Record).not.toHaveProperty("modelProfile"); @@ -1032,6 +1033,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(isPidAlive(orphan.descendantPid)).toBe(true); const { agentId, runId, issueId } = await seedRunFixture({ + agentStatus: "idle", processPid: orphan.processPid, processGroupId: orphan.processGroupId, }); @@ -1055,7 +1057,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(failedRun?.error).toContain("descendant process group"); const retryRun = runs.find((row) => row.id !== runId); - expect(retryRun?.status).toBe("queued"); + expect(["queued", "running"]).toContain(retryRun?.status); const issue = await db .select() diff --git a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts index 01eb43e9..13d8c727 100644 --- a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts +++ b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts @@ -32,6 +32,15 @@ vi.mock("../services/index.js", () => ({ }), agentService: () => ({ getById: vi.fn(async () => null), + resolveByReference: vi.fn(async (_companyId: string, reference: string) => ({ + ambiguous: false, + agent: { + id: reference, + companyId: "company-1", + status: "active", + orgChainHealth: { status: "healthy" }, + }, + })), }), companyService: () => ({ getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })), diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index 8c313c11..cce87c1b 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -65,6 +65,15 @@ function registerModuleMocks() { companyId: "company-1", permissions: null, })), + resolveByReference: vi.fn(async (_companyId: string, reference: string) => ({ + ambiguous: false, + agent: { + id: reference, + companyId: "company-1", + status: "idle", + orgChainHealth: { status: "healthy" }, + }, + })), }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), diff --git a/server/src/__tests__/issue-liveness.test.ts b/server/src/__tests__/issue-liveness.test.ts index c55f89e4..8dba1eb5 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/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 1fdae889..a74b438c 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -168,6 +168,183 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { await tempDb?.cleanup(); }); + async function seedAssignableAgentCompany() { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + return companyId; + } + + function agentRow(companyId: string, input: { + id: string; + name: string; + status?: string; + reportsTo?: string | null; + }) { + return { + id: input.id, + companyId, + name: input.name, + role: "engineer", + status: input.status ?? "active", + reportsTo: input.reportsTo ?? null, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }; + } + + it("rejects direct terminated assignees with structured conflict details", async () => { + const companyId = await seedAssignableAgentCompany(); + const terminatedAgentId = randomUUID(); + await db.insert(agents).values(agentRow(companyId, { + id: terminatedAgentId, + name: "TerminatedCoder", + status: "terminated", + })); + + await expect(svc.create(companyId, { + title: "Do not assign this", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: terminatedAgentId, + })).rejects.toMatchObject({ + status: 409, + details: { + code: "agent_not_assignable", + reason: "assignee_terminated", + assigneeAgentId: terminatedAgentId, + }, + }); + }); + + it("rejects invalid ancestor-chain assignees and preserves the existing assignment", async () => { + const companyId = await seedAssignableAgentCompany(); + const activeAgentId = randomUUID(); + const terminatedManagerId = randomUUID(); + const blockedAgentId = randomUUID(); + await db.insert(agents).values([ + agentRow(companyId, { id: activeAgentId, name: "ActiveCoder" }), + agentRow(companyId, { + id: terminatedManagerId, + name: "TerminatedManager", + status: "terminated", + }), + agentRow(companyId, { + id: blockedAgentId, + name: "BlockedCoder", + reportsTo: terminatedManagerId, + }), + ]); + const issue = await svc.create(companyId, { + title: "Keep current assignment", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: activeAgentId, + }); + + await expect(svc.update(issue.id, { + assigneeAgentId: blockedAgentId, + })).rejects.toMatchObject({ + status: 409, + details: { + code: "agent_not_assignable", + reason: "ancestor_terminated", + assigneeAgentId: blockedAgentId, + invalidAncestorAgentId: terminatedManagerId, + }, + }); + + const persisted = await db + .select({ assigneeAgentId: issues.assigneeAgentId }) + .from(issues) + .where(eq(issues.id, issue.id)) + .then((rows) => rows[0] ?? null); + expect(persisted?.assigneeAgentId).toBe(activeAgentId); + }); + + it("rejects checkout by a terminated agent before assigning the issue", async () => { + const companyId = await seedAssignableAgentCompany(); + const terminatedAgentId = randomUUID(); + await db.insert(agents).values(agentRow(companyId, { + id: terminatedAgentId, + name: "TerminatedCheckoutCoder", + status: "terminated", + })); + const issue = await svc.create(companyId, { + title: "Checkout must stay unassigned", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + }); + + await expect(svc.checkout(issue.id, terminatedAgentId, ["todo"], randomUUID())) + .rejects.toMatchObject({ + status: 409, + details: { + code: "agent_not_assignable", + reason: "assignee_terminated", + assigneeAgentId: terminatedAgentId, + }, + }); + + const persisted = await db + .select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status }) + .from(issues) + .where(eq(issues.id, issue.id)) + .then((rows) => rows[0] ?? null); + expect(persisted).toMatchObject({ + assigneeAgentId: null, + status: "todo", + }); + }); + + it("rejects moving an existing terminated assignment into progress without clearing it", async () => { + const companyId = await seedAssignableAgentCompany(); + const assigneeAgentId = randomUUID(); + await db.insert(agents).values(agentRow(companyId, { + id: assigneeAgentId, + name: "SoonTerminatedCoder", + })); + const issue = await svc.create(companyId, { + title: "Do not restart after termination", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId, + }); + await db.update(agents).set({ status: "terminated" }).where(eq(agents.id, assigneeAgentId)); + + await expect(svc.update(issue.id, { + status: "in_progress", + })).rejects.toMatchObject({ + status: 409, + details: { + code: "agent_not_assignable", + reason: "assignee_terminated", + assigneeAgentId, + }, + }); + + const persisted = await db + .select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status }) + .from(issues) + .where(eq(issues.id, issue.id)) + .then((rows) => rows[0] ?? null); + expect(persisted).toMatchObject({ + assigneeAgentId, + status: "todo", + }); + }); + it("returns issues an agent participated in across the supported signals", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/__tests__/monthly-spend-service.test.ts b/server/src/__tests__/monthly-spend-service.test.ts index 97b213af..a63a2b40 100644 --- a/server/src/__tests__/monthly-spend-service.test.ts +++ b/server/src/__tests__/monthly-spend-service.test.ts @@ -55,6 +55,27 @@ describe("monthly spend hydration", () => { it("recomputes agent spentMonthlyCents from the current utc month instead of returning stale stored values", async () => { const dbStub = createSelectSequenceDb([ + [{ + id: "agent-1", + companyId: "company-1", + name: "Budget Agent", + role: "general", + title: null, + reportsTo: null, + capabilities: null, + adapterType: "claude-local", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 5000, + spentMonthlyCents: 999999, + metadata: null, + permissions: null, + status: "idle", + pauseReason: null, + pausedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }], [{ id: "agent-1", companyId: "company-1", diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index f78ab907..22756ff9 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -539,6 +539,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { ).rejects.toMatchObject({ status: 409, message: "Cannot assign routines to terminated agents", + details: { + code: "agent_not_assignable", + reason: "assignee_terminated", + assigneeAgentId: agentId, + }, }); await expect(svc.get(routine.id)).resolves.toMatchObject({ description: "revision 2", @@ -546,6 +551,74 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { }); }); + it("blocks routine reassignment to agents under terminated managers", async () => { + const { agentId, companyId, routine, svc } = await seedFixture(); + const terminatedManagerId = randomUUID(); + const blockedAgentId = randomUUID(); + await db.insert(agents).values([ + { + id: terminatedManagerId, + companyId, + name: "TerminatedManager", + role: "manager", + status: "terminated", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: blockedAgentId, + companyId, + name: "BlockedRoutineCoder", + role: "engineer", + status: "active", + reportsTo: terminatedManagerId, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + await expect(svc.update(routine.id, { + assigneeAgentId: blockedAgentId, + }, { userId: "board-user" })).rejects.toMatchObject({ + status: 409, + details: { + code: "agent_not_assignable", + reason: "ancestor_terminated", + assigneeAgentId: blockedAgentId, + invalidAncestorAgentId: terminatedManagerId, + }, + }); + + await expect(svc.get(routine.id)).resolves.toMatchObject({ + assigneeAgentId: agentId, + }); + }); + + it("blocks manual routine runs when the persisted assignee is no longer assignable", async () => { + const { agentId, routine, svc } = await seedFixture(); + await db + .update(agents) + .set({ status: "terminated" }) + .where(eq(agents.id, agentId)); + + await expect(svc.runRoutine(routine.id, { + source: "manual", + payload: null, + variables: null, + }, { userId: "board-user" })).rejects.toMatchObject({ + status: 409, + details: { + code: "agent_not_assignable", + reason: "assignee_terminated", + assigneeAgentId: agentId, + }, + }); + }); + it("appends safe trigger metadata revisions without leaking webhook secrets", async () => { const { routine, svc } = await seedFixture(); const created = await svc.createTrigger(routine.id, { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index c63a86eb..a44aca54 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -102,6 +102,7 @@ import { assertEnvironmentSelectionForCompany } from "./environment-selection.js import { recoveryService } from "../services/recovery/service.js"; import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js"; import { readObject } from "../lib/objects.js"; +import { listInvalidOrgChainDescendantIds } from "../services/agent-invokability.js"; const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000; const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024; @@ -2850,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); @@ -2918,7 +2926,21 @@ export function agentRoutes( return; } - await heartbeat.cancelActiveForAgent(id); + const companyAgentRows = await db + .select({ + id: agentsTable.id, + companyId: agentsTable.companyId, + name: agentsTable.name, + reportsTo: agentsTable.reportsTo, + status: agentsTable.status, + }) + .from(agentsTable) + .where(eq(agentsTable.companyId, agent.companyId)); + const invalidOrgChainDescendantIds = listInvalidOrgChainDescendantIds(id, companyAgentRows); + const cancellation = await heartbeat.cancelInvocationsForAgents( + [id, ...invalidOrgChainDescendantIds], + "Cancelled because the agent was terminated or became invalid-org-chain under a terminated manager", + ); await logActivity(db, { companyId: agent.companyId, @@ -2927,6 +2949,18 @@ export function agentRoutes( action: "agent.terminated", entityType: "agent", entityId: agent.id, + details: { + invalidOrgChain: { + descendantCount: invalidOrgChainDescendantIds.length, + descendantIds: invalidOrgChainDescendantIds, + state: invalidOrgChainDescendantIds.length > 0 ? "descendants_invalid_under_terminated_manager" : "none", + }, + cancellation: { + agentIds: cancellation.agentIds, + runsCancelled: cancellation.runsCancelled, + wakeupsCancelled: cancellation.wakeupsCancelled, + }, + }, }); res.json(agent); @@ -3058,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, @@ -3126,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 9f1cca39..40768a74 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/access.ts b/server/src/services/access.ts index ad58db87..1aaa5371 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -1,7 +1,6 @@ import { and, eq, inArray, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { - agents, companyMemberships, instanceUserRoles, issues, @@ -9,6 +8,7 @@ import { } from "@paperclipai/db"; import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; import { conflict } from "../errors.js"; +import { assertAssignableAgent } from "./agent-assignability.js"; import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js"; import { ensureHumanRoleDefaultGrants } from "./principal-access-compatibility.js"; @@ -315,21 +315,7 @@ export function accessService(db: Db) { return; } - const agent = await tx - .select({ - id: agents.id, - companyId: agents.companyId, - status: agents.status, - }) - .from(agents) - .where(eq(agents.id, input.assigneeAgentId!)) - .then((rows) => rows[0] ?? null); - if (!agent || agent.companyId !== companyId) { - throw conflict("Replacement agent must belong to the same company"); - } - if (agent.status === "pending_approval" || agent.status === "terminated") { - throw conflict("Replacement agent must be assignable"); - } + await assertAssignableAgent(tx as Db, companyId, input.assigneeAgentId, { kind: "work" }); } async function archiveMember(companyId: string, memberId: string, input: MemberArchiveInput = {}) { diff --git a/server/src/services/agent-assignability.ts b/server/src/services/agent-assignability.ts new file mode 100644 index 00000000..13140db7 --- /dev/null +++ b/server/src/services/agent-assignability.ts @@ -0,0 +1,171 @@ +import { eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents } from "@paperclipai/db"; +import { + getAgentWorkEligibility, + type AgentEligibilityAgent, + type AgentOrgChainHealth, +} from "@paperclipai/shared"; +import { conflict, notFound, unprocessable } from "../errors.js"; + +type AgentAssignmentKind = "work" | "routine"; + +type AssignabilityAgent = AgentEligibilityAgent; + +type AgentAssignmentConflictReason = + | "pending_approval" + | "assignee_terminated" + | "assignee_unknown_status" + | "ancestor_terminated" + | "ancestor_missing" + | "ancestor_cross_company" + | "ancestor_cycle" + | "ancestor_depth_exceeded"; + +function assignmentMessage(kind: AgentAssignmentKind, reason: AgentAssignmentConflictReason) { + if (reason === "pending_approval") { + return kind === "routine" + ? "Cannot assign routines to pending approval agents" + : "Cannot assign work to pending approval agents"; + } + if (reason === "assignee_terminated") { + return kind === "routine" + ? "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"; +} + +function conflictDetails(input: { + companyId: string; + assigneeAgentId: string; + reason: AgentAssignmentConflictReason; + chain: AssignabilityAgent[]; + invalidAncestorAgentId?: string | null; + missingAncestorAgentId?: string | null; +}) { + return { + code: "agent_not_assignable", + reason: input.reason, + companyId: input.companyId, + assigneeAgentId: input.assigneeAgentId, + invalidAncestorAgentId: input.invalidAncestorAgentId ?? null, + missingAncestorAgentId: input.missingAncestorAgentId ?? null, + ancestorChain: input.chain.map((agent) => ({ + id: agent.id, + companyId: agent.companyId, + status: agent.status, + reportsTo: agent.reportsTo, + })), + }; +} + +async function getAgent(db: Db, agentId: string): Promise { + return db + .select({ + id: agents.id, + companyId: agents.companyId, + name: agents.name, + status: agents.status, + reportsTo: agents.reportsTo, + }) + .from(agents) + .where(eq(agents.id, agentId)) + .then((rows) => rows[0] ?? null); +} + +async function listCompanyAgents(db: Db, companyId: string): Promise { + return db + .select({ + id: agents.id, + companyId: agents.companyId, + name: agents.name, + status: agents.status, + reportsTo: agents.reportsTo, + }) + .from(agents) + .where(eq(agents.companyId, companyId)); +} + +function assignmentReasonFromHealth(health: AgentOrgChainHealth): AgentAssignmentConflictReason { + if (health.reason === "terminated_ancestor") return "ancestor_terminated"; + if (health.reason === "missing_manager") return "ancestor_missing"; + if (health.reason === "cycle") return "ancestor_cycle"; + return "ancestor_missing"; +} + +export async function assertAssignableAgent( + db: Db, + companyId: string, + agentId: string | null | undefined, + options: { kind?: AgentAssignmentKind } = {}, +) { + if (!agentId) return; + const kind = options.kind ?? "work"; + const assignee = await getAgent(db, agentId); + if (!assignee) throw notFound("Assignee agent not found"); + if (assignee.companyId !== companyId) { + throw unprocessable("Assignee must belong to same company"); + } + + const companyAgents = await listCompanyAgents(db, companyId); + const eligibility = getAgentWorkEligibility({ agent: assignee, agents: companyAgents }); + const chain = eligibility.orgChainHealth.fullChain.map((entry) => ({ + id: entry.id, + companyId: entry.companyId, + name: entry.name, + status: entry.status, + reportsTo: entry.reportsTo, + })); + + if (eligibility.assignable) return; + + if (eligibility.assignabilityReason === "pending_approval") { + throw conflict(assignmentMessage(kind, "pending_approval"), conflictDetails({ + companyId, + assigneeAgentId: agentId, + reason: "pending_approval", + chain, + })); + } + if (eligibility.assignabilityReason === "terminated") { + throw conflict(assignmentMessage(kind, "assignee_terminated"), conflictDetails({ + companyId, + assigneeAgentId: agentId, + reason: "assignee_terminated", + 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; + throw conflict(assignmentMessage(kind, reason), conflictDetails({ + companyId, + assigneeAgentId: agentId, + reason, + chain, + invalidAncestorAgentId: + firstInvalidAncestor && firstInvalidAncestor.status !== "missing" + ? firstInvalidAncestor.id + : null, + missingAncestorAgentId: + firstInvalidAncestor?.status === "missing" + ? firstInvalidAncestor.id + : null, + })); +} diff --git a/server/src/services/agent-invokability.ts b/server/src/services/agent-invokability.ts new file mode 100644 index 00000000..75726c92 --- /dev/null +++ b/server/src/services/agent-invokability.ts @@ -0,0 +1,164 @@ +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" | "name" | "reportsTo" | "status" +>; + +export type AgentInvokabilityBlockReason = + | "missing" + | "paused" + | "terminated" + | "pending_approval" + | "unknown_status" + | "manager_missing" + | "manager_company_mismatch" + | "manager_terminated" + | "reporting_cycle" + | "reporting_chain_too_deep"; + +export type AgentInvokability = + | { invokable: true } + | { + invokable: false; + reason: AgentInvokabilityBlockReason; + message: string; + details: Record; + invalidOrgChain: boolean; + }; + +const DIRECT_NON_INVOKABLE_STATUSES = new Set([ + "paused", + "terminated", + "pending_approval", +]); + +function blocked( + reason: AgentInvokabilityBlockReason, + message: string, + details: Record, + invalidOrgChain = false, +): AgentInvokability { + return { invokable: false, reason, message, details, invalidOrgChain }; +} + +function statusBlockReason(status: AgentStatus): AgentInvokabilityBlockReason | null { + if (status === "paused") return "paused"; + if (status === "terminated") return "terminated"; + if (status === "pending_approval") return "pending_approval"; + 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[], +): AgentInvokability { + if (!agent) { + 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 = eligibility.invokabilityReason === "unknown_status" + ? "unknown_status" + : statusBlockReason(agent.status); + if (directStatusReason) { + return blocked( + directStatusReason, + "Agent is not invokable in its current state", + { agentId: agent.id, agentStatus: agent.status }, + false, + ); + } + + 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( + db: Db, + agent: AgentOrgRow | null | undefined, +): Promise { + if (!agent) return evaluateAgentInvokability(agent, []); + const companyAgents = await db + .select({ + id: agents.id, + companyId: agents.companyId, + name: agents.name, + reportsTo: agents.reportsTo, + status: agents.status, + }) + .from(agents) + .where(eq(agents.companyId, agent.companyId)); + return evaluateAgentInvokability(agent, companyAgents); +} + +export function listInvalidOrgChainDescendantIds( + terminatedAgentId: string, + companyAgents: AgentOrgRow[], +): string[] { + const byManager = new Map(); + for (const row of companyAgents) { + const siblings = byManager.get(row.reportsTo ?? null) ?? []; + siblings.push(row); + byManager.set(row.reportsTo ?? null, siblings); + } + + const invalidDescendantIds: string[] = []; + const stack = [...(byManager.get(terminatedAgentId) ?? [])]; + const seen = new Set([terminatedAgentId]); + while (stack.length > 0) { + const current = stack.pop(); + if (!current || seen.has(current.id)) continue; + seen.add(current.id); + if (current.status !== "terminated") { + invalidDescendantIds.push(current.id); + } + stack.push(...(byManager.get(current.id) ?? [])); + } + return invalidDescendantIds; +} + +export function shouldCancelRunsForNonInvokableAgent(result: AgentInvokability) { + return !result.invokable && (result.reason === "terminated" || result.invalidOrgChain); +} diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 0ea972e9..dd67518d 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/heartbeat.ts b/server/src/services/heartbeat.ts index 076f53c5..6a7ec69e 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -155,6 +155,12 @@ import { import { recoveryService } from "./recovery/service.js"; import { productivityReviewService } from "./productivity-review.js"; import { withAgentStartLock } from "./agent-start-lock.js"; +import { + evaluateAgentInvokability, + evaluateAgentInvokabilityFromDb, + shouldCancelRunsForNonInvokableAgent, + type AgentOrgRow, +} from "./agent-invokability.js"; import { redactQuarantinedBodyForHigherTrust, sanitizeQuarantinedCommentForHigherTrust, @@ -3061,6 +3067,46 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .then((rows) => rows[0] ?? null); } + async function getAgentInvokability(agent: typeof agents.$inferSelect | null | undefined) { + 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 @@ -5146,6 +5192,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) agent: typeof agents.$inferSelect, issueId: string, ) { + const invokability = await getAgentInvokability(agent); + if (!invokability.invokable) { + await appendRunEvent(run, await nextRunEventSeq(run.id), { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: "Missing-comment retry suppressed because the agent is not invokable", + payload: { + reason: invokability.reason, + invalidOrgChain: invokability.invalidOrgChain, + ...invokability.details, + }, + }); + return null; + } + const contextSnapshot = parseObject(run.contextSnapshot); const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null); const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); @@ -5366,6 +5428,23 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) agent: typeof agents.$inferSelect, now: Date, ) { + const invokability = await getAgentInvokability(agent); + if (!invokability.invokable) { + await appendRunEvent(run, await nextRunEventSeq(run.id), { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: "Process-loss retry suppressed because the agent is not invokable", + payload: { + reason: invokability.reason, + invalidOrgChain: invokability.invalidOrgChain, + ...invokability.details, + }, + }); + await releaseIssueExecutionAndPromote(run); + return null; + } + const contextSnapshot = parseObject(run.contextSnapshot); const issueId = readNonEmptyString(contextSnapshot.issueId); const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null); @@ -5516,15 +5595,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }; } - if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") { + const agentInvokability = await getAgentInvokability(agent); + if (!agentInvokability.invokable) { return { allowed: false, reason: "Scheduled retry suppressed because the agent is not invokable", errorCode: "agent_not_invokable", issueId, details: { - agentId: agent.id, - agentStatus: agent.status, + ...agentInvokability.details, + invalidOrgChain: agentInvokability.invalidOrgChain, }, }; } @@ -5896,6 +5976,35 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) maxAttempts, }; } + + if (retryReason !== MAX_TURN_CONTINUATION_RETRY_REASON) { + const invokability = await getAgentInvokability(agent); + if (!invokability.invokable) { + const contextSnapshot = parseObject(run.contextSnapshot); + const issueId = readNonEmptyString(contextSnapshot.issueId); + await appendRunEvent(run, await nextRunEventSeq(run.id), { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: "Scheduled retry suppressed because the agent is not invokable", + payload: { + retryReason, + scheduledRetryAttempt: nextAttempt, + maxAttempts, + reason: invokability.reason, + invalidOrgChain: invokability.invalidOrgChain, + ...invokability.details, + }, + }); + return { + outcome: "not_scheduled" as const, + reason: "Scheduled retry suppressed because the agent is not invokable", + errorCode: "agent_not_invokable" as const, + issueId, + }; + } + } + const schedule = transientRetryNotBefore && transientRetryNotBefore.getTime() > baseSchedule.dueAt.getTime() ? { @@ -6514,15 +6623,18 @@ 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; } - if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") { - await cancelRunInternal(run.id, "Cancelled because the agent is not invokable"); + 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; } @@ -7433,7 +7545,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return withAgentStartLock(agentId, async () => { const agent = await getAgent(agentId); if (!agent) return []; - if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") { + const invokability = await getAgentInvokability(agent); + if (!invokability.invokable) { + if (shouldCancelRunsForNonInvokableAgent(invokability)) { + await cancelActiveForAgentInternal(agentId, `Cancelled because the agent is not invokable: ${invokability.reason}`); + } return []; } const policy = parseHeartbeatPolicy(agent); @@ -7467,6 +7583,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); @@ -7488,7 +7605,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 []; @@ -9278,13 +9395,24 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .where(eq(agents.id, deferred.agentId)) .then((rows) => rows[0] ?? null); - if ( - !deferredAgent || - deferredAgent.companyId !== issue.companyId || - deferredAgent.status === "paused" || - deferredAgent.status === "terminated" || - deferredAgent.status === "pending_approval" - ) { + const companyAgents = deferredAgent + ? await tx + .select({ + id: agents.id, + companyId: agents.companyId, + name: agents.name, + reportsTo: agents.reportsTo, + status: agents.status, + }) + .from(agents) + .where(eq(agents.companyId, issue.companyId)) + : []; + const deferredInvokability = + deferredAgent?.companyId === issue.companyId + ? evaluateAgentInvokability(deferredAgent, companyAgents) + : evaluateAgentInvokability(null, companyAgents); + + if (!deferredAgent || deferredAgent.companyId !== issue.companyId || !deferredInvokability.invokable) { await tx .update(agentWakeupRequests) .set({ @@ -9767,12 +9895,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }); } - if ( - agent.status === "paused" || - agent.status === "terminated" || - agent.status === "pending_approval" - ) { - throw conflict("Agent is not invokable in its current state", { status: agent.status }); + const invokability = await getAgentInvokability(agent); + if (!invokability.invokable) { + if (opts.requestedByActorType !== "user") { + await writeSkippedRequest("agent.not_invokable", { + error: invokability.message, + }); + } + throw conflict(invokability.message, { + status: agent.status, + reason: invokability.reason, + invalidOrgChain: invokability.invalidOrgChain, + ...invokability.details, + }); } const policy = parseHeartbeatPolicy(agent); @@ -10576,6 +10711,52 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return runs.length; } + async function cancelPendingWakeupsForAgentsInternal(agentIds: string[], reason: string) { + const uniqueAgentIds = [...new Set(agentIds)].filter((agentId) => agentId.length > 0); + if (uniqueAgentIds.length === 0) return 0; + + const now = new Date(); + const wakeupIds = await db + .select({ id: agentWakeupRequests.id }) + .from(agentWakeupRequests) + .where( + and( + inArray(agentWakeupRequests.agentId, uniqueAgentIds), + inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"]), + sql`${agentWakeupRequests.runId} is null`, + ), + ) + .then((rows) => rows.map((row) => row.id)); + + if (wakeupIds.length === 0) return 0; + + await db + .update(agentWakeupRequests) + .set({ + status: "cancelled", + finishedAt: now, + error: reason, + updatedAt: now, + }) + .where(inArray(agentWakeupRequests.id, wakeupIds)); + + return wakeupIds.length; + } + + async function cancelInvocationsForAgentsInternal(agentIds: string[], reason: string) { + const uniqueAgentIds = [...new Set(agentIds)].filter((agentId) => agentId.length > 0); + let runsCancelled = 0; + for (const agentId of uniqueAgentIds) { + runsCancelled += await cancelActiveForAgentInternal(agentId, reason); + } + const wakeupsCancelled = await cancelPendingWakeupsForAgentsInternal(uniqueAgentIds, reason); + return { + agentIds: uniqueAgentIds, + runsCancelled, + wakeupsCancelled, + }; + } + async function cancelBudgetScopeWork(scope: BudgetEnforcementScope) { if (scope.scopeType === "agent") { await cancelActiveForAgentInternal(scope.scopeId, "Cancelled due to budget pause"); @@ -10877,12 +11058,14 @@ 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) { - if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") continue; + const invokability = evaluateAgentInvokability(toAgentOrgRow(agent), agentsByCompany.get(agent.companyId) ?? []); + if (!invokability.invokable) continue; const policy = parseHeartbeatPolicy(agent); if (!policy.enabled || policy.intervalSec <= 0) continue; @@ -10920,6 +11103,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) cancelActiveForAgent: (agentId: string, reason?: string) => cancelActiveForAgentInternal(agentId, reason), + cancelInvocationsForAgents: (agentIds: string[], reason: string) => + cancelInvocationsForAgentsInternal(agentIds, reason), + cancelBudgetScopeWork, getRunIssueSummary: async (runId: string) => { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index b7f91508..e4c01818 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -75,6 +75,7 @@ import { redactSensitiveText } from "../redaction.js"; import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js"; import { getRunLogStore } from "./run-log-store.js"; import { getDefaultCompanyGoal } from "./goals.js"; +import { assertAssignableAgent } from "./agent-assignability.js"; import { isVerifiedIssueTreeControlInteractionWake, issueTreeControlService, @@ -3372,29 +3373,6 @@ export function issueService(db: Db) { }); } - async function assertAssignableAgent(companyId: string, agentId: string) { - const assignee = await db - .select({ - id: agents.id, - companyId: agents.companyId, - status: agents.status, - }) - .from(agents) - .where(eq(agents.id, agentId)) - .then((rows) => rows[0] ?? null); - - if (!assignee) throw notFound("Assignee agent not found"); - if (assignee.companyId !== companyId) { - throw unprocessable("Assignee must belong to same company"); - } - if (assignee.status === "pending_approval") { - throw conflict("Cannot assign work to pending approval agents"); - } - if (assignee.status === "terminated") { - throw conflict("Cannot assign work to terminated agents"); - } - } - async function isTreeHoldInteractionCheckoutAllowed( companyId: string, checkoutRunId: string | null, @@ -4733,7 +4711,7 @@ export function issueService(db: Db) { throw unprocessable("Issue can only have one assignee"); } if (data.assigneeAgentId) { - await assertAssignableAgent(companyId, data.assigneeAgentId); + await assertAssignableAgent(db, companyId, data.assigneeAgentId, { kind: "work" }); } if (data.assigneeUserId) { await assertAssignableUser(companyId, data.assigneeUserId); @@ -5021,8 +4999,11 @@ export function issueService(db: Db) { throw unprocessable("Issue is blocked by unresolved blockers", { unresolvedBlockerIssueIds }); } } - if (issueData.assigneeAgentId) { - await assertAssignableAgent(existing.companyId, issueData.assigneeAgentId); + const shouldValidateNextAssignee = + Boolean(nextAssigneeAgentId) && + (issueData.assigneeAgentId !== undefined || patch.status === "in_progress"); + if (shouldValidateNextAssignee) { + await assertAssignableAgent(dbOrTx as Db, existing.companyId, nextAssigneeAgentId, { kind: "work" }); } if (issueData.assigneeUserId) { await assertAssignableUser(existing.companyId, issueData.assigneeUserId); @@ -5347,7 +5328,7 @@ export function issueService(db: Db) { .where(eq(issues.id, id)) .then((rows) => rows[0] ?? null); if (!issueCompany) throw notFound("Issue not found"); - await assertAssignableAgent(issueCompany.companyId, agentId); + await assertAssignableAgent(db, issueCompany.companyId, agentId, { kind: "work" }); const now = new Date(); const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issueCompany.companyId, id); diff --git a/server/src/services/recovery/issue-graph-liveness.ts b/server/src/services/recovery/issue-graph-liveness.ts index 090480dc..91cf2e73 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, diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 468d76c9..c9f4b4fd 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -36,6 +36,7 @@ import { instanceSettingsService } from "../instance-settings.js"; import { issueRecoveryActionService } from "../issue-recovery-actions.js"; import { issueTreeControlService } from "../issue-tree-control.js"; import { issueService } from "../issues.js"; +import { evaluateAgentInvokabilityFromDb } from "../agent-invokability.js"; import { getRunLogStore } from "../run-log-store.js"; import { DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS, @@ -327,10 +328,6 @@ function unwrapDatabaseConflictError(error: unknown) { }; } -function isAgentInvokable(agent: typeof agents.$inferSelect | null | undefined) { - return Boolean(agent && !["paused", "terminated", "pending_approval"].includes(agent.status)); -} - function isStrandedIssueRecoveryIssue(issue: Pick) { return isStrandedIssueRecoveryOriginKind(issue.originKind); } @@ -466,6 +463,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return db.select().from(agents).where(eq(agents.id, agentId)).then((rows) => rows[0] ?? null); } + async function isAgentInvokable(agent: typeof agents.$inferSelect | null | undefined) { + return (await evaluateAgentInvokabilityFromDb(db, agent)).invokable; + } + async function getLatestIssueRun(companyId: string, issueId: string): Promise { return db .select({ @@ -697,7 +698,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) continue; } const creatorAgent = await getAgent(creatorAgentId); - if (!creatorAgent || creatorAgent.companyId !== candidate.companyId || !isAgentInvokable(creatorAgent)) { + if (!creatorAgent || creatorAgent.companyId !== candidate.companyId || !(await isAgentInvokable(creatorAgent))) { skipped += 1; continue; } @@ -1249,7 +1250,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) issueId: input.sourceIssue?.id ?? null, projectId: input.sourceIssue?.projectId ?? null, }); - if (isAgentInvokable(candidate) && !budgetBlock) return candidate.id; + if ((await isAgentInvokable(candidate)) && !budgetBlock) return candidate.id; } return null; @@ -1872,7 +1873,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) issueId: issue.id, projectId: issue.projectId, }); - if (isAgentInvokable(candidate) && !budgetBlock) return candidate.id; + if ((await isAgentInvokable(candidate)) && !budgetBlock) return candidate.id; } return null; @@ -2486,7 +2487,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) } const agent = await getAgent(agentId); - if (!agent || agent.companyId !== issue.companyId || !isAgentInvokable(agent)) { + if (!agent || agent.companyId !== issue.companyId || !(await isAgentInvokable(agent))) { result.skipped += 1; continue; } diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 44197312..7081377c 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -52,6 +52,7 @@ import { logger } from "../middleware/logger.js"; import { getTelemetryClient } from "../telemetry.js"; import { getConfiguredSecretProvider } from "../secrets/configured-provider.js"; import { issueService } from "./issues.js"; +import { assertAssignableAgent } from "./agent-assignability.js"; import { secretService } from "./secrets.js"; import { getSecretProvider } from "../secrets/provider-registry.js"; import { parseCron, validateCron } from "./cron.js"; @@ -610,25 +611,12 @@ export function routineService( return routine; } - async function assertAssignableAgent(companyId: string, agentId: string | null | undefined) { - if (!agentId) return; - const agent = await db - .select({ id: agents.id, companyId: agents.companyId, status: agents.status }) - .from(agents) - .where(eq(agents.id, agentId)) - .then((rows) => rows[0] ?? null); - if (!agent) throw notFound("Assignee agent not found"); - if (agent.companyId !== companyId) throw unprocessable("Assignee must belong to same company"); - if (agent.status === "pending_approval") throw conflict("Cannot assign routines to pending approval agents"); - if (agent.status === "terminated") throw conflict("Cannot assign routines to terminated agents"); - } - async function assertRestorableAssignee( companyId: string, assigneeAgentId: string | null | undefined, actor: Actor, ) { - await assertAssignableAgent(companyId, assigneeAgentId); + await assertAssignableAgent(db, companyId, assigneeAgentId, { kind: "routine" }); if (actor.agentId && assigneeAgentId !== actor.agentId) { throw forbidden("Agents can only restore routine revisions assigned to themselves"); } @@ -1170,6 +1158,7 @@ export function routineService( if (!assigneeAgentId) { throw unprocessable("Default agent required"); } + await assertAssignableAgent(db, input.routine.companyId, assigneeAgentId, { kind: "routine" }); const automaticVariables: Record = {}; if (input.executionWorkspaceId && routineUsesWorkspaceBranch(input.routine)) { const workspace = await db @@ -1581,7 +1570,7 @@ export function routineService( create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise => { await assertProject(companyId, input.projectId ?? null); - await assertAssignableAgent(companyId, input.assigneeAgentId ?? null); + await assertAssignableAgent(db, companyId, input.assigneeAgentId ?? null, { kind: "routine" }); if (input.goalId) await assertGoal(companyId, input.goalId); if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId); const env = input.env === undefined || input.env === null @@ -1663,7 +1652,9 @@ export function routineService( patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables), ); if (patch.projectId !== undefined) await assertProject(existing.companyId, nextProjectId); - if (patch.assigneeAgentId !== undefined) await assertAssignableAgent(existing.companyId, nextAssigneeAgentId); + if (patch.assigneeAgentId !== undefined || patch.status === "active") { + await assertAssignableAgent(db, existing.companyId, nextAssigneeAgentId, { kind: "routine" }); + } if (patch.goalId) await assertGoal(existing.companyId, patch.goalId); if (patch.parentIssueId) await assertParentIssue(existing.companyId, patch.parentIssueId); assertRoutineVariableDefinitions(nextVariables); @@ -2187,7 +2178,8 @@ export function routineService( if (!routine) throw notFound("Routine not found"); if (routine.status === "archived") throw conflict("Routine is archived"); await assertProject(routine.companyId, input.projectId ?? null); - await assertAssignableAgent(routine.companyId, input.assigneeAgentId ?? null); + const assigneeAgentId = input.assigneeAgentId ?? routine.assigneeAgentId ?? null; + await assertAssignableAgent(db, routine.companyId, assigneeAgentId, { kind: "routine" }); const trigger = input.triggerId ? await getTriggerById(input.triggerId) : null; if (trigger && trigger.routineId !== routine.id) throw forbidden("Trigger does not belong to routine"); if (trigger && !trigger.enabled) throw conflict("Routine trigger is not active"); diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index e43533f4..48c12c67 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -126,6 +126,8 @@ export function AgentActionButtons({ runLabel = "Run now", showStatus = true, actionsDisabled = false, + workActionsDisabled = false, + workActionsDisabledReason, navigateToRunOnInvoke = true, onActionError, children, @@ -138,6 +140,8 @@ export function AgentActionButtons({ runLabel?: string; showStatus?: boolean; actionsDisabled?: boolean; + workActionsDisabled?: boolean; + workActionsDisabledReason?: string; navigateToRunOnInvoke?: boolean; /** * Optional inline error reporter. When provided it is used instead of a toast @@ -259,6 +263,8 @@ export function AgentActionButtons({ const isPendingApproval = agent.status === "pending_approval"; const disabled = actionsDisabled || agentAction.isPending; + const assignAndRunDisabled = disabled || isPendingApproval || workActionsDisabled; + const pauseResumeDisabled = disabled || isPendingApproval || (isPaused && workActionsDisabled); return (
@@ -266,13 +272,15 @@ export function AgentActionButtons({ variant="outline" size={size} onClick={() => openNewIssue({ assigneeAgentId: agent.id })} + disabled={assignAndRunDisabled} + title={workActionsDisabled ? workActionsDisabledReason : undefined} > {assignLabel} agentAction.mutate("invoke")} - disabled={disabled || isPendingApproval} + disabled={assignAndRunDisabled} label={runLabel} size={size} /> @@ -280,7 +288,7 @@ export function AgentActionButtons({ isPaused={isPaused} onPause={() => agentAction.mutate("pause")} onResume={() => agentAction.mutate("resume")} - disabled={disabled || isPendingApproval} + disabled={pauseResumeDisabled} size={size} /> {showStatus && ( diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 03e5452c..02f3d129 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -11,7 +11,7 @@ import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; -import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members"; +import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, isAgentTaskTarget } from "../lib/company-members"; import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../lib/issue-assignee-overrides"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { @@ -545,7 +545,7 @@ export function IssueProperties({ const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]); const recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), [assigneeOpen]); const sortedAgents = useMemo( - () => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds), + () => sortAgentsByRecency((agents ?? []).filter(isAgentTaskTarget), recentAssigneeIds), [agents, recentAssigneeIds], ); const recentAssigneeValues = useMemo( diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 4083f488..ac3b92f9 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -13,7 +13,7 @@ import { agentsApi } from "../api/agents"; import { accessApi } from "../api/access"; import { authApi } from "../api/auth"; import { assetsApi } from "../api/assets"; -import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../lib/company-members"; +import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members"; import { queryKeys } from "../lib/queryKeys"; import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces"; import { useProjectOrder } from "../hooks/useProjectOrder"; @@ -1122,7 +1122,7 @@ export function NewIssueDialog() { ...currentUserAssigneeOption(currentUserId), ...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }), ...sortAgentsByRecency( - (agents ?? []).filter((agent) => agent.status !== "terminated"), + (agents ?? []).filter(isAgentTaskTarget), recentAssigneeIds, ).map((agent) => ({ id: assigneeValueFromSelection({ assigneeAgentId: agent.id }), diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 70fe74c2..83b32409 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -10,6 +10,7 @@ import { PlayCircle, Plus, Users, + AlertTriangle, } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; @@ -114,12 +115,15 @@ function SidebarAgentItem({ const isActive = activeAgentId === routeRef; const isPaused = agent.status === "paused"; const isBudgetPaused = isPaused && agent.pauseReason === "budget"; + const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain"; const pauseResumeLabel = isPaused ? "Resume agent" : "Pause agent"; - const pauseResumeDisabled = disabled || agent.status === "pending_approval" || isBudgetPaused; + const pauseResumeDisabled = disabled || agent.status === "pending_approval" || isBudgetPaused || (isPaused && hasInvalidOrgChain); const pauseResumeDisabledLabel = disabled ? "Updating..." : isBudgetPaused ? "Budget paused" + : isPaused && hasInvalidOrgChain + ? "Invalid org chain" : pauseResumeLabel; return ( @@ -139,6 +143,9 @@ function SidebarAgentItem({ > {agent.name} + {hasInvalidOrgChain ? ( + + ) : null} {(agent.pauseReason === "budget" || runCount > 0) && ( {agent.pauseReason === "budget" ? ( diff --git a/ui/src/lib/company-members.test.ts b/ui/src/lib/company-members.test.ts index 2afc4fa3..214769f5 100644 --- a/ui/src/lib/company-members.test.ts +++ b/ui/src/lib/company-members.test.ts @@ -6,6 +6,7 @@ import { buildCompanyUserProfileMap, buildMarkdownMentionOptions, } from "./company-members"; +import type { AgentOrgChainHealth } from "@paperclipai/shared"; const activeMember = (overrides: Partial): CompanyMember => ({ id: overrides.id ?? "member-1", @@ -22,6 +23,15 @@ const activeMember = (overrides: Partial): CompanyMember => ({ grants: overrides.grants ?? [], }); +const invalidOrgChainHealth: AgentOrgChainHealth = { + status: "invalid_org_chain", + reason: "terminated_ancestor", + fullChain: [], + firstInvalidAncestor: { id: "manager-1", name: "Manager", status: "terminated" }, + invalidAncestors: [{ id: "manager-1", name: "Manager", status: "terminated" }], + repairGuidance: "Repair the reporting chain.", +}; + describe("company-members helpers", () => { it("builds labels from company member profiles", () => { const labels = buildCompanyUserLabelMap([ @@ -82,6 +92,25 @@ describe("company-members helpers", () => { ]); }); + it("omits invalid-org-chain agents from markdown mention options", () => { + const options = buildMarkdownMentionOptions({ + agents: [ + { id: "agent-1", name: "CodexCoder", status: "active", icon: "code" }, + { + id: "agent-2", + name: "InvalidCoder", + status: "active", + icon: "code", + orgChainHealth: invalidOrgChainHealth, + }, + ], + }); + + expect(options).toEqual([ + { id: "agent:agent-1", name: "CodexCoder", kind: "agent", agentId: "agent-1", agentIcon: "code" }, + ]); + }); + it("accepts read-only directory entries for assignee and mention helpers", () => { const users: CompanyUserDirectoryEntry[] = [ { diff --git a/ui/src/lib/company-members.ts b/ui/src/lib/company-members.ts index f0f908f9..387256e7 100644 --- a/ui/src/lib/company-members.ts +++ b/ui/src/lib/company-members.ts @@ -84,15 +84,23 @@ export function buildCompanyUserMentionOptions( })); } +export function isAgentTaskTarget(agent: Pick): boolean { + return ( + agent.status !== "terminated" && + agent.status !== "pending_approval" && + agent.orgChainHealth?.status !== "invalid_org_chain" + ); +} + export function buildMarkdownMentionOptions(args: { - agents?: Array> | null | undefined; + agents?: Array> | null | undefined; projects?: Array> | null | undefined; members?: CompanyUserRecord[] | null | undefined; }): MentionOption[] { const options: MentionOption[] = [ ...buildCompanyUserMentionOptions(args.members), ...[...(args.agents ?? [])] - .filter((agent) => agent.status !== "terminated") + .filter(isAgentTaskTarget) .sort((left, right) => left.name.localeCompare(right.name)) .map((agent) => ({ id: `agent:${agent.id}`, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 8062a4d9..8eedd915 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -72,6 +72,7 @@ import { ArrowLeft, HelpCircle, FolderOpen, + AlertTriangle, } from "lucide-react"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -123,6 +124,12 @@ const SECRET_ENV_KEY_RE = const COMMAND_ENV_KEY_RE = /(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i; const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/; +function formatOrgChainHealthPath(agent: AgentDetailRecord) { + return agent.orgChainHealth?.fullChain + .map((entry) => `${entry.name}${entry.status !== "active" && entry.status !== "idle" ? ` (${entry.status})` : ""}`) + .join(" -> ") ?? agent.name; +} + function redactPathText(value: string, censorUsernameInLogs: boolean) { return redactHomePathUserSegments(value, { enabled: censorUsernameInLogs }); } @@ -913,6 +920,7 @@ export function AgentDetail() { return ; } const isPendingApproval = agent.status === "pending_approval"; + const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain"; const showConfigActionBar = (activeView === "configuration" || activeView === "instructions") && (configDirty || configSaving); const showLeftAgentNotice = agentMembershipState === "left" && !dismissedLeftAgentIds.has(agent.id); const agentMembershipPending = @@ -956,6 +964,27 @@ export function AgentDetail() {
) : null} + {hasInvalidOrgChain ? ( +
+ +
+

Invalid reporting chain

+

+ {agent.name} cannot accept tasks or start runs until its reporting chain is repaired. +

+

+ {formatOrgChainHealthPath(agent)} +

+ {agent.orgChainHealth?.repairGuidance ? ( +

{agent.orgChainHealth.repairGuidance}

+ ) : ( +

+ Assign this agent to an active manager/root, or explicitly pause or terminate the affected agent/subtree. +

+ )} +
+
+ ) : null} {/* Header */}
@@ -981,6 +1010,8 @@ export function AgentDetail() { assignLabel="Assign Task" runLabel="Run Heartbeat" actionsDisabled={agentAction.isPending} + workActionsDisabled={hasInvalidOrgChain} + workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs" onActionError={setActionError} > {mobileLiveRun && ( diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index caecb777..495fba5c 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -8,6 +8,7 @@ import type { Agent } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ToastProvider } from "../context/ToastContext"; import { Agents } from "./Agents"; +import type { AgentOrgChainHealth } from "@paperclipai/shared"; const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn(), @@ -105,6 +106,34 @@ function makeAgent(overrides: Partial): Agent { }; } +const invalidOrgChainHealth: AgentOrgChainHealth = { + status: "invalid_org_chain", + reason: "terminated_ancestor", + fullChain: [ + { + id: "agent-1", + companyId: "company-1", + name: "Alpha", + status: "active", + reportsTo: "manager-1", + depth: 0, + relation: "self", + }, + { + id: "manager-1", + companyId: "company-1", + name: "Terminated Manager", + status: "terminated", + reportsTo: null, + depth: 1, + relation: "ancestor", + }, + ], + firstInvalidAncestor: { id: "manager-1", name: "Terminated Manager", status: "terminated" }, + invalidAncestors: [{ id: "manager-1", name: "Terminated Manager", status: "terminated" }], + repairGuidance: "Alpha reports through terminated ancestor Terminated Manager.", +}; + async function flushReact() { await act(async () => { await Promise.resolve(); @@ -224,4 +253,26 @@ describe("Agents", () => { expect(titleCell?.textContent).toContain("Alpha"); expect(container.querySelector(".min-w-\\[7rem\\]")).toBeNull(); }); + + it("keeps invalid-org-chain agents visible with a warning marker", async () => { + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ orgChainHealth: invalidOrgChainHealth }), + ]); + + root = createRoot(container); + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Alpha"); + expect(container.querySelector('[aria-label="Invalid reporting chain"]')).not.toBeNull(); + }); }); diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 285b636c..4c4f3d02 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -18,7 +18,7 @@ import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils"; import { PageTabBar } from "../components/PageTabBar"; import { Tabs } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; -import { Bot, Plus, List, GitBranch } from "lucide-react"; +import { AlertTriangle, Bot, Plus, List, GitBranch } from "lucide-react"; import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared"; import { resourceMembershipState, @@ -211,6 +211,7 @@ export function Agents() { {effectiveView === "list" && filtered.length > 0 && (
{filtered.map((agent) => { + const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain"; return ( } + leading={hasInvalidOrgChain ? ( + + ) : ( + + )} meta={
@@ -366,6 +371,7 @@ function OrgTreeNode({ membershipMutation: ReturnType; }) { const agent = agentMap.get(node.id); + const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain"); const membershipState = resourceMembershipState(memberships, "agent", node.id); const pending = membershipMutation.isPending && membershipMutation.variables?.resourceType === "agent" && @@ -381,7 +387,11 @@ function OrgTreeNode({ membershipState === "left" && "text-foreground/55", )} > - + {hasInvalidOrgChain ? ( + + ) : ( + + )}
{node.name} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 6323ba3d..d622bd81 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -19,7 +19,7 @@ import { useSidebar } from "../context/SidebarContext"; import { useToastActions } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees"; -import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions } from "../lib/company-members"; +import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members"; import { extractIssueTimelineEvents } from "../lib/issue-timeline-events"; import { queryKeys } from "../lib/queryKeys"; import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data"; @@ -1603,7 +1603,7 @@ export function IssueDetail() { const options: Array<{ id: string; label: string; searchText?: string }> = []; options.push(...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] })); const activeAgents = [...(agents ?? [])] - .filter((agent) => agent.status !== "terminated") + .filter(isAgentTaskTarget) .sort((a, b) => a.name.localeCompare(b.name)); for (const agent of activeAgents) { options.push({ id: `agent:${agent.id}`, label: agent.name });