feat(issues): limit agent-created task trees to parent and child (#19312)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents split work into tasks. A task can have a parent task.
> - Today agents can nest tasks to any depth: parent, child, grandchild,
and deeper.
> - Deep agent-made trees are hard to follow, and an agent that works on
a child task makes grandchildren by default.
> - This pull request limits agent-created trees to two levels: a
top-level task and its children.
> - A request for a grandchild does not fail. The server puts the new
task beside the requester and tells the agent.
> - Board users keep full control. They can still nest to any depth.
Existing deep trees stay valid.
> - The pull request also records which task created each new task, so
the audit trail stays complete when the parent changes.

## Linked Issues or Issue Description

No public issue exists. This is the feature description. Related: #16853
(budgets that include descendant work) also reasons about task trees;
this change does not alter its traversal.

**Subsystem affected**

Issues (tasks): create, child create and move in the issue service and
routes. Agent tools, chat suggestions and creation provenance use these
paths.

**Problem or motivation**

Agents create subtasks as deep as they want. An agent that runs on a
child task gets that child as the default parent, so its follow-ups
become grandchildren. The trees become deep and hard to read. The parent
link is also the only record of which task created a follow-up, so that
record is lost when the parent changes.

**Proposed solution**

Limit agent-created trees to a parent and its children. When an agent
asks for a grandchild, put the new task under the requester's parent and
return `parentAdjusted`. Do not reject the request. Keep board users
unrestricted. Record the originating task in a new server-set
`origin_issue_id` column. Keep `origin_run_id` as the originating run.

**Alternatives considered**

Reject grandchild requests with an error. We did not do this, because
agents then fail mid-task. Add a database constraint. We did not do
this, because existing deep trees must stay valid and nesting can return
later.

## What Changed

- Add `MAX_AGENT_TASK_HIERARCHY_DEPTH` (2) and the
`IssueParentAdjustment` type in `packages/shared`.
- Add `resolveNewTaskParent` and `planIssueParentChange` in
`server/src/services/issues.ts`. `create`, `createChild` and `update`
use the agent limit by default (`parentDepthPolicy: "flatten"`).
- The issue create, `/children` and PATCH routes pass `allow_nested` for
board users. For agents, the routes check authorization against the
parent that the server actually writes.
- The requester keeps its behavior when the server moves a task beside
it: `blockParentUntilDone` blocks the requester, and the new task
inherits the requester's workspace and privacy.
- The server never moves a task above a low-trust boundary root that
contains the requester.
- These subsystems keep any depth, because they find their work by
parent: watchdog reviews, monitor recovery, email-channel placement, and
routines that a person configured.
- An agent move of a task that has children skips only the parent change
and returns `reason: "has_children"`.
- Add `issues.origin_issue_id` (nullable, `on delete set null`, indexed)
in migration `0434`. Only the server sets it. The `createdFrom` detail
field, the `createdFromIssueId` filter and task-creation provenance read
it first.
- Chat prompts now let a chat agent create one task, a task with
children, or separate tasks. The conversation is never a parent.
- The runner `create_task` tool accepts an optional `parentTaskId`. A
chat agent can create a top-level task and then put subtasks under it.
The run must be able to read and change the parent. A conversation is
never a parent. The depth limit still applies. Project tools map the
field to `parentId` through the issue route. The protocol manifest,
semantic contracts and eval fixture are regenerated.
- Watchdog runs that have an active watchdog keep their requested parent
on all create paths, because their access is limited to the watched
subtree.
- Fix a bug that the test drive found: the chat (MCP) `create_task`
ignored the requested `status`, so a `backlog` task became `todo` and
started to run. It now uses the same rule as the native tool.
- Update `skills/paperclip`, `doc/SPEC-implementation.md`, and add the
plan `doc/plans/2026-11-05-two-level-task-hierarchy.md`.

## Verification

- `cd server && npx vitest run
src/__tests__/agent-task-hierarchy-depth.test.ts
src/__tests__/agent-task-hierarchy-depth-routes.test.ts`: 19 new tests
for service and route behavior, all pass.
- Related suites (64 files: issues service, thread interactions,
privacy, watchdogs, provenance, runner tool authority, decisions,
routines, project tools): 1208 tests pass. Two tests changed because of
the intended behavior and pass now. Two `email-channels.integration`
tests fail locally because the test cannot find a bundled skill file
from this worktree path. This change does not touch that code.
- `packages/db`: migration safety, snapshot drift and journal tests
pass.
- Typecheck passes for `server`, `packages/shared`, `packages/db`, `cli`
and `ui` (local `ui` errors come only from generated Storybook catalog
files that are absent).
- Local test drive on an isolated instance with real Claude agents: 29
of 29 use cases pass, including an agent on a child task (follow-ups
placed beside it, with the notice) and a chat that creates a top-level
task with three subtasks.
- I did not run the full `pnpm test:run` or `pnpm build` locally. CI
runs them.

## Risks

- Behavior change for agents: some follow-ups that were grandchildren
are now siblings. The parent of the requester is woken when all its
children finish, which now includes these tasks.
- The runner `create_task` input contract adds one optional field, so
the protocol manifest and the pack contract change. The tool output does
not change: it shows the actual `parentId`, and the activity log records
`parentAdjusted`.
- Migration `0434` adds a nullable column, a foreign key and an index.
It does not backfill rows.
- `IssueCreationSource.run` is now nullable.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edits, local test runs).

## 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
- [ ] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 21:22:33 -06:00
1 parent d8b0eae687
commit 958f9aa9f3
43 files changed
+2576 -391

No files matched your search

@@ -19,7 +19,10 @@ import {
issueApprovals,
issueComments,
issueThreadInteractions,
issueWatchdogs,
issues,
projectWorkspaces,
projects,
} from "@paperclipai/db";
import { createFullDatabaseResponseCapture, installDatabaseResponseCapture } from "../../__tests__/helpers/full-database-response-capture.js";
import { startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js";
@@ -1379,6 +1382,130 @@ describe("PaperclipRunnerToolAuthority", () => {
});
});
it("places create_task beside a child task, except for watchdog-scoped runs", async () => {
const rootId = randomUUID();
await db.insert(issues).values({ id: rootId, companyId, title: "Depth root", status: "todo", workMode: "standard" });
async function childRun(watchdog: boolean, watchdogStatus = "active") {
const childId = randomUUID();
const childRunId = randomUUID();
await db.insert(issues).values({
id: childId, companyId, parentId: rootId, title: `Depth child ${watchdog ? "watchdog" : "ordinary"}`,
status: "in_progress", workMode: "standard", assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: childRunId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: childId,
invocationSource: "assignment", triggerDetail: "system",
contextSnapshot: watchdog ? { issueId: childId, taskWatchdog: { watchedIssueId: childId } } : { issueId: childId },
});
await db.update(issues).set({ executionRunId: childRunId }).where(eq(issues.id, childId));
if (watchdog) await db.insert(issueWatchdogs).values({ companyId, issueId: childId, watchdogAgentId: agentId, status: watchdogStatus });
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId: childId, runId: childRunId, workMode: "standard" });
const created = await authority.execute({
tool: "create_task",
callId: `depth-${childId}`,
arguments: { idempotencyKey: `depth-${childId}`, title: "Follow-up from the child", status: "backlog" },
}) as { task: { id: string; parentId: string | null } };
const [row] = await db.select({ originIssueId: issues.originIssueId }).from(issues).where(eq(issues.id, created.task.id));
return { childId, parentId: created.task.parentId, originIssueId: row?.originIssueId };
}
const ordinary = await childRun(false);
expect(ordinary).toMatchObject({ parentId: rootId, originIssueId: ordinary.childId });
const watchdog = await childRun(true);
expect(watchdog).toMatchObject({ parentId: watchdog.childId, originIssueId: watchdog.childId });
// A disabled watchdog no longer earns the exception.
const disabled = await childRun(true, "disabled");
expect(disabled).toMatchObject({ parentId: rootId, originIssueId: disabled.childId });
// An active watchdog may not name a parent outside its watched subtree.
const outsideId = randomUUID();
await db.insert(issues).values({ id: outsideId, companyId, title: "Outside the watched subtree", status: "todo", workMode: "standard" });
const [watchdogRun] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns)
.where(eq(heartbeatRuns.nativeIssueId, watchdog.childId));
const scoped = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId: watchdog.childId, runId: watchdogRun!.id, workMode: "standard" });
await expect(scoped.execute({
tool: "create_task", callId: "watchdog-outside",
arguments: { idempotencyKey: "watchdog-outside", title: "Elsewhere", status: "backlog", parentTaskId: outsideId },
})).rejects.toThrow(/watched issue subtree/);
});
it("authorizes the placed parent: a hidden root rejects a named child parent and makes the default standalone", async () => {
const hiddenRootId = randomUUID();
const openChildId = randomUUID();
const projectId = randomUUID();
const featureWorkspaceId = randomUUID();
await db.insert(projects).values({ id: projectId, companyId, name: "Depth workspace project" });
await db.insert(projectWorkspaces).values([
{ companyId, projectId, name: "Default checkout", isPrimary: true },
{ id: featureWorkspaceId, companyId, projectId, name: "Feature checkout" },
]);
await db.insert(issues).values({
id: hiddenRootId, companyId, title: "Private root", status: "todo", workMode: "standard",
visibility: "private", privacyRootIssueId: hiddenRootId, responsibleUserId: "someone-else",
});
await db.insert(issues).values({
id: openChildId, companyId, parentId: hiddenRootId, title: "Open child", status: "in_progress",
workMode: "standard", assigneeAgentId: agentId, projectId, projectWorkspaceId: featureWorkspaceId,
});
const named = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId, workMode: "standard" });
await expect(named.execute({
tool: "create_task", callId: "hidden-root-named",
arguments: { idempotencyKey: "hidden-root-named", title: "Under the open child", status: "backlog", parentTaskId: openChildId },
})).rejects.toThrow();
const childRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: childRunId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: openChildId,
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: openChildId },
});
await db.update(issues).set({ executionRunId: childRunId }).where(eq(issues.id, openChildId));
const fromChild = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId: openChildId, runId: childRunId, workMode: "standard" });
const created = await fromChild.execute({
tool: "create_task", callId: "hidden-root-default",
arguments: { idempotencyKey: "hidden-root-default", title: "Default follow-up", status: "backlog" },
}) as { task: { id: string; parentId: string | null } };
expect(created.task.parentId).toBeNull();
const [row] = await db.select({ originIssueId: issues.originIssueId, projectWorkspaceId: issues.projectWorkspaceId })
.from(issues).where(eq(issues.id, created.task.id));
expect(row?.originIssueId).toBe(openChildId);
// Dropping the structural parent keeps the requester's workspace.
expect(row?.projectWorkspaceId).toBe(featureWorkspaceId);
});
it("lets a conversation create a top-level task and subtasks with parentTaskId", async () => {
const conversationId = randomUUID();
const conversationRunId = randomUUID();
await db.insert(issues).values({
id: conversationId, companyId, title: "Chat with runner agent", status: "in_progress", workMode: "standard",
assigneeAgentId: agentId, conversationAgentId: agentId, conversationUserId: "chat-user", conversationState: "active",
});
await db.insert(heartbeatRuns).values({
id: conversationRunId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: conversationId,
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId: conversationId },
});
await db.update(issues).set({ executionRunId: conversationRunId }).where(eq(issues.id, conversationId));
const authority = new PaperclipRunnerToolAuthority(db, {
companyId, agentId, issueId: conversationId, runId: conversationRunId, workMode: "standard",
});
const create = async (key: string, args: Record<string, unknown>) => await authority.execute({
tool: "create_task", callId: key, arguments: { idempotencyKey: key, status: "backlog", ...args },
}) as { task: { id: string; parentId: string | null } };
const parent = await create("chat-parent", { title: "Referral launch" });
expect(parent.task.parentId).toBeNull();
const subtask = await create("chat-subtask", { title: "Landing page", parentTaskId: parent.task.id });
expect(subtask.task.parentId).toBe(parent.task.id);
const [row] = await db.select({ originIssueId: issues.originIssueId }).from(issues).where(eq(issues.id, subtask.task.id));
// Provenance names the chat whose run created it; the parent is structure.
expect(row?.originIssueId).toBe(conversationId);
// A child of a child is placed beside it.
const deeper = await create("chat-deeper", { title: "Landing copy", parentTaskId: subtask.task.id });
expect(deeper.task.parentId).toBe(parent.task.id);
await expect(create("chat-under-conversation", { title: "Under the chat", parentTaskId: conversationId }))
.rejects.toThrow(/conversation cannot be a parent/i);
});
it("creates ordinary children, preserves blockers, and deduplicates across runs", async () => {
const wakes: Array<{ agentId: string; options: Record<string, unknown> }> =
[];
@@ -1,4 +1,6 @@
import { budgetServiceInTransaction } from "../budgets.js";
import { resolveTaskWatchdogMutationScope, taskWatchdogScopeAllowsIssueMutation } from "../task-watchdog-scope.js";
import { resolveNewTaskParent } from "../issue-hierarchy.js";
import { DOWNLOAD_ARTIFACT, DOWNLOAD_ARTIFACT_DEFINITION, downloadArtifactSchema, createArtifactInstaller, type ArtifactInstaller } from "./artifact-transfer.js";
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
import { nativeRunKeyScope, nativeSkillTestToolAllowed, assertNativeSkillTestTool } from "./native-skill-test-scope.js";
@@ -1698,19 +1700,81 @@ export class PaperclipRunnerToolAuthority {
const inputFingerprint = createHash("sha256")
.update(canonicalJson(input))
.digest("hex");
/** Why this run may not add a child under `parent`, or null when it may. */
const parentWriteDenial = async (tx: Db, run: typeof heartbeatRuns.$inferSelect, parent: typeof issues.$inferSelect) => {
for (const action of ["issue:read", "issue:mutate"] as const) {
const decision = await authorizationService(tx).decide({ actor: this.#privacyActor(run), action, resource: {
type: "issue", companyId: this.binding.companyId, issueId: parent.id, projectId: parent.projectId,
parentIssueId: parent.parentId, assigneeAgentId: parent.assigneeAgentId, assigneeUserId: parent.assigneeUserId, status: parent.status,
}, scope: { issueId: parent.id, projectId: parent.projectId, parentIssueId: parent.parentId } });
if (!decision.allowed) return decision.explanation;
}
return null;
};
const authorizeCreate = async (tx: Db, context: {
run: typeof heartbeatRuns.$inferSelect;
issue: typeof issues.$inferSelect;
actor: typeof agents.$inferSelect;
}) => {
const parentIssueId = context.issue.conversationAgentId ? null : context.issue.id;
const projectId = nullableProviderId(input.projectId) ?? (parentIssueId ? context.issue.projectId : null);
// Default parent: the current task, or none from a conversation. An
// explicit parentTaskId names another task the run may read and change
// (for example a top-level task this conversation just created).
let parentIssueId = context.issue.conversationAgentId ? null : context.issue.id;
let parentProjectId = parentIssueId ? context.issue.projectId : null;
const requestedParentId = nullableProviderId(input.parentTaskId);
const watchdogScope = await resolveTaskWatchdogMutationScope(tx, {
type: "agent", agentId: this.binding.agentId, runId: this.binding.runId, companyId: this.binding.companyId,
});
if (requestedParentId && requestedParentId !== parentIssueId) {
const [parent] = await tx.select().from(issues).where(and(
eq(issues.id, requestedParentId), eq(issues.companyId, this.binding.companyId),
));
if (!parent) throw new Error("paperclip_runner_task_not_found");
if (parent.conversationAgentId) {
throw forbidden("A conversation cannot be a parent; omit parentTaskId to create a top-level task");
}
const denied = await parentWriteDenial(tx, context.run, parent);
if (denied) throw forbidden(denied);
// A watchdog run may only add work inside its watched subtree, as on the
// issues route.
if (watchdogScope.kind !== "none") {
const allowed = await taskWatchdogScopeAllowsIssueMutation(tx, watchdogScope, { id: parent.id, companyId: parent.companyId });
if (allowed.kind === "invalid") throw forbidden(allowed.detail);
}
parentIssueId = parent.id;
parentProjectId = parent.projectId;
}
// Place the edge the way createChild will, and authorize the parent that
// is actually written. Active watchdog runs keep their requested parent.
const parentDepthPolicy = watchdogScope.kind === "watchdog" ? "allow_nested" as const : "flatten" as const;
let placedParentId = parentIssueId;
// Set when the structural parent is dropped; the follow-up still works in
// the requester's workspace.
let workspaceSourceIssueId: string | null = null;
if (parentIssueId) {
placedParentId = (await resolveNewTaskParent(tx, this.binding.companyId, parentIssueId, parentDepthPolicy)).effectiveParentId;
if (placedParentId && placedParentId !== parentIssueId) {
const [placed] = await tx.select().from(issues).where(and(
eq(issues.id, placedParentId), eq(issues.companyId, this.binding.companyId),
));
const denied = placed ? await parentWriteDenial(tx, context.run, placed) : "Parent issue not found";
if (denied) {
// A named parent fails closed; the implicit current-task default
// falls back to standalone work, as the issues route does.
if (requestedParentId) throw forbidden(denied);
workspaceSourceIssueId = parentIssueId;
parentIssueId = null;
placedParentId = null;
}
}
}
const projectId = nullableProviderId(input.projectId) ?? parentProjectId;
if (this.binding.museRuntime && projectId) {
const access = await authorizationService(tx).decide({ actor: this.#privacyActor(context.run), action: "project:read",
resource: { type: "project", companyId: this.binding.companyId, projectId } });
if (!access.allowed) throw forbidden("Project is unavailable to this Muse run");
}
const scope = { projectId, parentIssueId, assigneeAgentId, assigneeUserId };
const scope = { projectId, parentIssueId: placedParentId, assigneeAgentId, assigneeUserId };
const decision = await authorizationService(tx).decide({
actor: this.#privacyActor(context.run),
action: "tasks:assign",
@@ -1728,12 +1792,11 @@ export class PaperclipRunnerToolAuthority {
run: { companyId: this.binding.companyId, executionPolicy: context.run.contextSnapshot?.executionPolicy },
});
if (trust.kind === "denied") throw forbidden(trust.detail);
return { projectId, trust };
return { projectId, trust, parentIssueId, placedParentId, parentDepthPolicy, workspaceSourceIssueId };
};
let publication: Awaited<ReturnType<typeof persistActivity>>["publication"] | null = null;
const result = await this.#withMutationReceipt("create_task", idempotencyKey, input, async (tx, context) => {
const { projectId, trust } = await authorizeCreate(tx, context);
const conversation = Boolean(context.issue.conversationAgentId);
const { projectId, trust, parentIssueId, placedParentId, parentDepthPolicy, workspaceSourceIssueId } = await authorizeCreate(tx, context);
const existingChild = await tx.select().from(issues).where(and(
eq(issues.companyId, this.binding.companyId),
eq(issues.originId, durableIdempotencyKey),
@@ -1787,6 +1850,10 @@ export class PaperclipRunnerToolAuthority {
assigneeUserId,
blockedByIssueIds,
blockParentUntilDone: false,
// Placement was authorized above; reject the write if the tree changed since.
parentDepthPolicy,
...(parentIssueId ? { expectedParentId: placedParentId } : {}),
...(workspaceSourceIssueId ? { inheritExecutionWorkspaceFromIssueId: workspaceSourceIssueId } : {}),
createdByAgentId: this.binding.agentId,
originKind: "manual" as const,
originId: durableIdempotencyKey,
@@ -1799,9 +1866,9 @@ export class PaperclipRunnerToolAuthority {
idempotencyKey: durableIdempotencyKey,
onDeduplicated: () => { deduplicated = true; },
};
const child = conversation
? await issueService(tx).create(this.binding.companyId, createInput)
: (await issueService(tx).createChild(this.binding.issueId, createInput)).issue;
const child = parentIssueId
? (await issueService(tx).createChild(parentIssueId, createInput)).issue
: await issueService(tx).create(this.binding.companyId, createInput);
if (deduplicated && child.originFingerprint !== inputFingerprint) {
throw new Error("paperclip_runner_tool_idempotency_conflict");
}
@@ -1826,7 +1893,8 @@ export class PaperclipRunnerToolAuthority {
agentId: this.binding.agentId, runId: this.binding.runId, issueId: child.id,
action: "issue.created", entityType: "issue", entityId: child.id,
details: { identifier: child.identifier, title: child.title, parentId: child.parentId,
assigneeAgentId: child.assigneeAgentId, assigneeUserId: child.assigneeUserId, status: childStatus, source: "paperclip_runner_protocol" },
assigneeAgentId: child.assigneeAgentId, assigneeUserId: child.assigneeUserId, status: childStatus, source: "paperclip_runner_protocol",
...("parentAdjusted" in child && child.parentAdjusted ? { parentAdjusted: child.parentAdjusted } : {}) },
});
publication = activity.publication;
}