[codex] prevent invalid agents from receiving assignments and runs (#7663)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The control plane owns agent lifecycle, issue assignment, routine dispatch, heartbeat wakeups, and recovery paths > - Terminated, paused, pending-approval, or otherwise invalid agents should not receive new work or new execution attempts > - The old behavior left eligibility checks spread across routes and services, so assignment and run paths could drift apart > - This pull request centralizes agent lifecycle eligibility and applies it consistently to assignment, invocation, routines, recovery, and UI affordances > - The benefit is safer autonomy: terminated agents stay paused, invalid org-chain agents are surfaced, and active agents keep receiving valid work ## Linked Issues or Issue Description Refs #5103 Related: #1864 Bug fix context: - What happened: agent assignment and heartbeat/run paths did not share one eligibility contract, so invalid lifecycle states could still be considered in some paths. - Expected behavior: terminated agents must never receive new assignments or heartbeat runs, and paused or otherwise invalid agents should be treated as non-invokable consistently. - Steps to reproduce: create or select an agent in an invalid lifecycle state, then attempt assignment, routine dispatch, or heartbeat/recovery wake paths. - Paperclip version/commit: fixed on top of `paperclipai/paperclip` `master` at the PR base. - Deployment mode: applies to the server control plane in local and authenticated deployments. ## What Changed - Added shared agent lifecycle eligibility helpers and exported the related shared types. - Centralized server-side assignability and invokability checks for issue assignment, agent routes, heartbeat dispatch, routines, recovery, and liveness logic. - Hardened issue assignment so invalid assignees are rejected instead of queued for work. - Hardened heartbeat/routine/recovery paths so terminated and otherwise invalid agents are not woken for new runs. - Updated board UI affordances to disable invalid agent actions and surface org-chain warnings where relevant. - Added targeted shared, server, and UI tests for the new eligibility behavior. ## Verification - `pnpm exec vitest run packages/shared/src/agent-eligibility.test.ts server/src/__tests__/agent-invokability.test.ts server/src/__tests__/heartbeat-archived-company-guard.test.ts server/src/__tests__/issue-liveness.test.ts server/src/__tests__/issues-service.test.ts server/src/__tests__/routines-service.test.ts ui/src/lib/company-members.test.ts ui/src/pages/Agents.test.tsx` — 8 files, 144 tests passed. - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` — passed. - Checked the PR diff does not include `pnpm-lock.yaml` or `.github/workflows` changes. - Checked `ROADMAP.md`; this is a targeted control-plane safety fix and does not duplicate a planned core feature. - Searched GitHub for duplicate or related PRs/issues; closest related items are linked above. - CI and Greptile verification are pending on the opened PR and will be followed up before requesting merge. ## Risks Low to moderate risk. The intended behavioral shift is that invalid agents are refused earlier and more consistently, which could expose existing data with paused, pending, terminated, or broken org-chain assignees. The added tests cover the critical assignment, heartbeat, routine, recovery, shared helper, and UI paths. No database migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 Codex via the Paperclip `codex_local` adapter, with shell/git/GitHub CLI tool use. Reasoning mode and context window are managed by the adapter runtime and not exposed in this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (not applicable: no design screenshots requested; UI behavior is covered by tests) - [x] I have updated relevant documentation to reflect my changes (not applicable: no user-facing command or schema docs changed) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (pending CI) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (pending Greptile) - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
evaluateAgentInvokability,
|
||||
listInvalidOrgChainDescendantIds,
|
||||
type AgentOrgRow,
|
||||
} from "../services/agent-invokability.ts";
|
||||
|
||||
function agent(partial: Partial<AgentOrgRow> & Pick<AgentOrgRow, "id">): 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>).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()
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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: () => ({}),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user