fix(issues): exclude terminal children from helper cap (#11542)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The issue service lets agents create bounded child work through a
helper.
> - The helper counted completed and cancelled children against its
safety limit.
> - A recurring coordinator could reach the limit even when almost all
historical children were terminal.
> - The safety limit must still bound current delegated work.
> - This pull request counts only non-terminal children when it applies
the limit.
> - The benefit is that recurring parents can keep their lineage without
losing the active-work guardrail.

## Linked Issues or Issue Description

**What happened?**

The child-creation helper rejected a new child after a parent had 25
historical children. It counted children with `done` and `cancelled`
status.

**Expected behavior**

The helper must apply its limit to non-terminal children. Terminal
children must remain attached to the parent for lineage and audit
history.

**Steps to reproduce**

1. Create one parent issue.
2. Create 25 child issues and move them to `done` or `cancelled`.
3. Use the issue-service child helper to create another child.
4. Observe that the current code rejects the new child.

**Paperclip version or commit**

`1a17cbf23`

## What Changed

- Exclude `done` and `cancelled` children from the helper cap query.
- Keep the limit at 25 non-terminal children.
- Add a regression test with terminal history and the active limit
boundary.
- Clarify the limit error so it says `active child issues`.

## Verification

- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t
"counts only non-terminal children toward the helper child limit"`
passed: 1 test passed and 118 skipped.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- `pnpm test:run` completed the general server and UI groups
successfully. The CLI group then reported one unrelated environment
failure because port 3199 was already in use. The failing test was
`doctor > re-runs repairable checks so repaired failures do not remain
blocking`.

```test-evidence:
sha: 071edbf019
command: pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "counts only non-terminal children toward the helper child limit"
exit: 0
totals: 1 test passed; 118 skipped; 1 test file passed
worktree: clean before and after; local == remote == PR head
finished_at: 2026-08-17T14:03:53Z
```

## Risks

- Low risk. The change affects only the helper-specific count query.
- The helper still rejects creation when 25 non-terminal children exist.
- No schema or migration changes are present.

> 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 with reasoning, repository tools, code execution,
and test execution.

## 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 not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Codex Engineer <agent+codex-engineer@agents.techwright.io>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: commitperclip-bot <305974387+commitperclip-bot@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 14:36:48 -07:00
1 parent 7b09e3da73
commit 23e7ee9a2d
2 files changed
+59 -1

No files matched your search

@@ -42,6 +42,7 @@ import { instanceSettingsService } from "../services/instance-settings.ts";
import {
clampIssueListLimit,
deriveIssueCommentRunLogAttribution,
MAX_CHILD_ISSUES_CREATED_BY_HELPER,
ISSUE_LIST_MAX_LIMIT,
issueService,
readIssueCommentRunLogText,
@@ -3922,6 +3923,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", () => {
+2 -1
View File
@@ -9394,11 +9394,12 @@ export function issueService(db: Db) {
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`,
`Parent issue already has the maximum ${MAX_CHILD_ISSUES_CREATED_BY_HELPER} active child issues for this helper`,
);
}