diff --git a/server/src/__tests__/issue-assignee-invokability-routes.test.ts b/server/src/__tests__/issue-assignee-invokability-routes.test.ts new file mode 100644 index 0000000000..8fff514a23 --- /dev/null +++ b/server/src/__tests__/issue-assignee-invokability-routes.test.ts @@ -0,0 +1,252 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const AGENT_ACTOR_ID = "11111111-1111-4111-8111-111111111111"; +const PAUSED_AGENT_ID = "22222222-2222-4222-8222-222222222222"; +const IDLE_AGENT_ID = "33333333-3333-4333-8333-333333333333"; + +const agentStatusById: Record = { + [AGENT_ACTOR_ID]: "idle", + [PAUSED_AGENT_ID]: "paused", + [IDLE_AGENT_ID]: "idle", +}; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), + create: vi.fn(), + createChild: vi.fn(), + addComment: vi.fn(), + findMentionedAgents: vi.fn(async () => []), + getRelationSummaries: vi.fn(async () => ({ blockedBy: [], blocks: [] })), + listWakeableBlockedDependents: vi.fn(async () => []), + getWakeableParentAfterChildCompletion: vi.fn(async () => null), + getCurrentScheduledRetry: vi.fn(async () => null), + getDependencyReadiness: vi.fn(async () => ({ + blockerIssueIds: [], + isDependencyReady: false, + unresolvedBlockerCount: 0, + })), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), +})); + +vi.mock("../services/index.js", () => ({ + companyService: () => ({ + getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })), + }), + accessService: () => ({ + canUser: vi.fn(async () => true), + decide: vi.fn(async (input: { action?: string }) => ({ + allowed: true, + action: input.action, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant.", + })), + hasPermission: vi.fn(async () => true), + }), + agentService: () => ({ + getById: vi.fn(async (id: string) => ({ + id, + companyId: "company-1", + status: agentStatusById[id] ?? "idle", + })), + resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({ + ambiguous: false, + agent: { + id: raw, + companyId: "company-1", + status: agentStatusById[raw] ?? "idle", + orgChainHealth: { status: "healthy" }, + }, + })), + }), + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), + documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueReferenceService: () => ({ + deleteDocumentSource: async () => undefined, + diffIssueReferenceSummary: () => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + }), + emptySummary: () => ({ outbound: [], inbound: [] }), + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + syncComment: async () => undefined, + syncDocument: async () => undefined, + syncIssue: async () => undefined, + }), + issueRecoveryActionService: () => ({ + getActiveForIssue: vi.fn(async () => null), + listActiveForIssues: vi.fn(async () => new Map()), + }), + issueService: () => mockIssueService, + issueThreadInteractionService: () => ({ + expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), + expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), + expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(async () => []), + }), + logActivity: vi.fn(async () => undefined), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), +})); + +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; + +type Actor = Record; + +function boardActor(): Actor { + return { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; +} + +// No runId on purpose: a run-less agent actor exercises the assignment guard +// without engaging watchdog-scope or checkout-ownership lookups. +function agentActor(): Actor { + return { + type: "agent", + agentId: AGENT_ACTOR_ID, + companyId: "company-1", + source: "agent_key", + }; +} + +function createApp(actor: Actor) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +function makeIssue(overrides: Record = {}) { + return { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId: "company-1", + status: "todo", + priority: "medium", + projectId: null, + goalId: null, + parentId: null, + assigneeAgentId: AGENT_ACTOR_ID, + assigneeUserId: null, + createdByUserId: "local-board", + identifier: "PAP-999", + title: "Invokability test", + executionPolicy: null, + executionState: null, + hiddenAt: null, + ...overrides, + }; +} + +describe("issue assignee invokability guard", () => { + beforeEach(() => { + mockIssueService.getById.mockReset(); + mockIssueService.update.mockReset(); + mockIssueService.create.mockReset(); + mockIssueService.createChild.mockReset(); + mockIssueService.addComment.mockReset(); + mockHeartbeatService.wakeup.mockClear(); + }); + + it("refuses an agent assigning an issue to a paused agent", async () => { + const existing = makeIssue(); + mockIssueService.getById.mockResolvedValue(existing); + + const res = await request(createApp(agentActor())) + .patch(`/api/issues/${existing.id}`) + .send({ assigneeAgentId: PAUSED_AGENT_ID }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("paused agent"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }); + + it("refuses an agent creating a child issue assigned to a paused agent", async () => { + const parent = makeIssue(); + mockIssueService.getById.mockResolvedValue(parent); + + const res = await request(createApp(agentActor())) + .post(`/api/issues/${parent.id}/children`) + .send({ + title: "Escalation for the manager", + description: "Needs a decision", + assigneeAgentId: PAUSED_AGENT_ID, + }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("paused agent"); + expect(mockIssueService.createChild).not.toHaveBeenCalled(); + expect(mockIssueService.create).not.toHaveBeenCalled(); + }); + + it("still allows an agent to assign to an invokable agent", async () => { + const existing = makeIssue(); + const updated = makeIssue({ assigneeAgentId: IDLE_AGENT_ID }); + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.update.mockResolvedValue(updated); + + const res = await request(createApp(agentActor())) + .patch(`/api/issues/${existing.id}`) + .send({ assigneeAgentId: IDLE_AGENT_ID }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalled(); + }); + + it("allows a board user to assign to a paused agent deliberately", async () => { + const existing = makeIssue({ assigneeAgentId: null }); + const updated = makeIssue({ assigneeAgentId: PAUSED_AGENT_ID }); + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.update.mockResolvedValue(updated); + + const res = await request(createApp(boardActor())) + .patch(`/api/issues/${existing.id}`) + .send({ assigneeAgentId: PAUSED_AGENT_ID }); + + expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 1bb7c5379a..ad04b3f19d 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -4399,6 +4399,7 @@ export function issueRoutes( async function normalizeIssueAssigneeAgentReference( companyId: string, rawAssigneeAgentId: string | null | undefined, + options: { actorType?: string } = {}, ) { if (rawAssigneeAgentId === undefined || rawAssigneeAgentId === null) { return rawAssigneeAgentId; @@ -4422,6 +4423,18 @@ export function issueRoutes( if (resolved.agent.status === "terminated") { throw conflict("Cannot assign work to terminated agents"); } + // Agents must not route work to a paused peer/manager: the assignment is + // accepted silently, nothing will ever run it, and the issue becomes an + // invisible dead letter (e.g. escalation issues assigned to a paused + // manager via the org chart). Humans may still assign to paused agents + // deliberately — the pause state is visible in the UI and staging work + // for a later unpause is a legitimate workflow. + if (options.actorType === "agent" && resolved.agent.status === "paused") { + throw conflict( + "Cannot assign work to a paused agent. Assign an invokable agent, leave the issue unassigned, or escalate to a board operator instead.", + { assigneeAgentId: resolved.agent.id, assigneeStatus: "paused" }, + ); + } if (resolved.agent.orgChainHealth?.status === "invalid_org_chain") { throw conflict( resolved.agent.orgChainHealth?.repairGuidance ?? @@ -7128,6 +7141,7 @@ export function issueRoutes( const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference( companyId, rawCreateBody.assigneeAgentId as string | null | undefined, + { actorType: req.actor.type }, ); const actor = getActorInfo(req); const runWorkspaceInheritanceSourceIssueId = hasExplicitIssueWorkspaceCreateSelection(rawCreateBody) @@ -7343,6 +7357,7 @@ export function issueRoutes( const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference( parent.companyId, sanitizedBody.assigneeAgentId as string | null | undefined, + { actorType: req.actor.type }, ); const createBody = { ...sanitizedBody, @@ -7519,6 +7534,7 @@ export function issueRoutes( const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference( sourceIssue.companyId, sanitizedChild.assigneeAgentId as string | null | undefined, + { actorType: req.actor.type }, ); const childBody = { ...sanitizedChild, @@ -7768,6 +7784,7 @@ export function issueRoutes( const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference( existing.companyId, req.body.assigneeAgentId as string | null | undefined, + { actorType: req.actor.type }, ); const titleOrDescriptionChanged = req.body.title !== undefined || req.body.description !== undefined; const existingRelations =