diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 825e0048fd1..00ee44729e8 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -34,6 +34,7 @@ import { instanceSettingsService } from "../services/instance-settings.ts"; import { clampIssueListLimit, deriveIssueCommentRunLogAttribution, + MAX_CHILD_ISSUES_CREATED_BY_HELPER, ISSUE_LIST_MAX_LIMIT, issueService, } from "../services/issues.ts"; @@ -3677,6 +3678,62 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { expect(child.requestDepth).toBe(MAX_ISSUE_REQUEST_DEPTH); }); + + it("counts only non-terminal children toward the helper child limit", async () => { + const companyId = randomUUID(); + const parentIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(issues).values({ + id: parentIssueId, + companyId, + title: "Recurring coordinator", + status: "in_progress", + priority: "high", + }); + + const terminalChildren = Array.from( + { length: MAX_CHILD_ISSUES_CREATED_BY_HELPER }, + (_, index) => ({ + id: randomUUID(), + companyId, + parentId: parentIssueId, + title: `Terminal child ${index + 1}`, + status: index % 2 === 0 ? "done" as const : "cancelled" as const, + priority: "medium" as const, + }), + ); + const activeChildren = Array.from( + { length: MAX_CHILD_ISSUES_CREATED_BY_HELPER - 1 }, + (_, index) => ({ + id: randomUUID(), + companyId, + parentId: parentIssueId, + title: `Active child ${index + 1}`, + status: "todo" as const, + priority: "medium" as const, + }), + ); + await db.insert(issues).values([...terminalChildren, ...activeChildren]); + + const { issue: finalActiveChild } = await svc.createChild(parentIssueId, { + title: "Final active child", + status: "todo", + }); + + expect(finalActiveChild.parentId).toBe(parentIssueId); + await expect(svc.createChild(parentIssueId, { + title: "Over active limit", + status: "todo", + })).rejects.toThrow( + `Parent issue already has the maximum ${MAX_CHILD_ISSUES_CREATED_BY_HELPER} active child issues for this helper`, + ); + }); }); describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e7500973b01..34480f21123 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -6595,9 +6595,13 @@ export function issueService(db: Db) { const [{ childCount }] = await db .select({ childCount: sql`count(*)::int` }) .from(issues) - .where(and(eq(issues.companyId, parent.companyId), eq(issues.parentId, parent.id))); + .where(and( + eq(issues.companyId, parent.companyId), + eq(issues.parentId, parent.id), + notInArray(issues.status, ["done", "cancelled"]), + )); if (childCount >= MAX_CHILD_ISSUES_CREATED_BY_HELPER) { - throw unprocessable(`Parent issue already has the maximum ${MAX_CHILD_ISSUES_CREATED_BY_HELPER} child issues for this helper`); + throw unprocessable(`Parent issue already has the maximum ${MAX_CHILD_ISSUES_CREATED_BY_HELPER} active child issues for this helper`); } const {