mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## 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>
152 lines
5.8 KiB
TypeScript
152 lines
5.8 KiB
TypeScript
import { and, asc, eq, inArray, isNotNull, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db";
|
|
import { isUuidLike } from "@paperclipai/shared";
|
|
|
|
type IssueRow = typeof issues.$inferSelect;
|
|
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
|
|
type CreationSourceRun = Pick<HeartbeatRunRow, "id" | "companyId" | "agentId" | "scopeKind" | "nativeIssueId" | "issueId"> & {
|
|
contextSnapshot: { issueId: unknown; taskId: unknown; taskKey: unknown; paperclipIssue: { id: unknown } } | null;
|
|
};
|
|
|
|
/** The task that created another task, and the run that did so when one did. */
|
|
export interface IssueCreationSourceRecord {
|
|
run: CreationSourceRun | null;
|
|
sourceIssue: IssueRow;
|
|
}
|
|
|
|
const CREATION_ACTIVITY_ACTIONS = ["issue.created", "issue.child_created"];
|
|
|
|
/** The route supplies a fresh UUID; a different returned issue is reused. */
|
|
export function childCreatedByRequest(proposedIssueId: string, returnedIssueId: string) {
|
|
return proposedIssueId === returnedIssueId;
|
|
}
|
|
|
|
function readNonEmptyString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
/**
|
|
* The issue a heartbeat run is bound to: native run identity first, then the
|
|
* run's issue scope, then the persisted legacy task context. Returns a UUID or
|
|
* an issue identifier such as `PAP-168`; the caller resolves identifiers.
|
|
*/
|
|
export function runBoundIssueReference(run: Pick<HeartbeatRunRow, "nativeIssueId" | "issueId" | "contextSnapshot">): string | null {
|
|
const context = run.contextSnapshot && typeof run.contextSnapshot === "object"
|
|
? (run.contextSnapshot as Record<string, unknown>)
|
|
: null;
|
|
const paperclipIssue = context?.paperclipIssue && typeof context.paperclipIssue === "object"
|
|
? (context.paperclipIssue as Record<string, unknown>)
|
|
: null;
|
|
return (
|
|
run.nativeIssueId ??
|
|
run.issueId ??
|
|
readNonEmptyString(context?.issueId) ??
|
|
readNonEmptyString(context?.taskId) ??
|
|
readNonEmptyString(paperclipIssue?.id) ??
|
|
readNonEmptyString(context?.taskKey)
|
|
);
|
|
}
|
|
|
|
/** Loads the run's bound issue within the run's company, or null. */
|
|
export async function loadRunBoundIssue(
|
|
db: Db,
|
|
run: Pick<HeartbeatRunRow, "companyId" | "nativeIssueId" | "issueId" | "contextSnapshot">,
|
|
): Promise<IssueRow | null> {
|
|
const reference = runBoundIssueReference(run);
|
|
if (!reference) return null;
|
|
const condition = isUuidLike(reference)
|
|
? eq(issues.id, reference)
|
|
: eq(issues.identifier, reference);
|
|
const [issue] = await db
|
|
.select()
|
|
.from(issues)
|
|
.where(and(condition, eq(issues.companyId, run.companyId)))
|
|
.limit(1);
|
|
return issue ?? null;
|
|
}
|
|
|
|
/**
|
|
* Resolves the task from which an issue was created. The recorded
|
|
* `origin_issue_id` wins; otherwise the task the origin run (or, for historical
|
|
* rows, the run on the creation activity) was executing. Company-scoped; a run
|
|
* or source task from another company never resolves. Does not check viewer
|
|
* access.
|
|
*/
|
|
export async function resolveIssueCreationSource(
|
|
db: Db,
|
|
issue: Pick<IssueRow, "id" | "companyId" | "originRunId"> & { originIssueId?: string | null },
|
|
): Promise<IssueCreationSourceRecord | null> {
|
|
const run = await loadIssueCreationRun(db, issue);
|
|
if (issue.originIssueId && issue.originIssueId !== issue.id) {
|
|
const [sourceIssue] = await db
|
|
.select()
|
|
.from(issues)
|
|
.where(and(eq(issues.id, issue.originIssueId), eq(issues.companyId, issue.companyId)))
|
|
.limit(1);
|
|
if (sourceIssue) return { run, sourceIssue };
|
|
}
|
|
if (!run) return null;
|
|
const sourceIssue = await loadRunBoundIssue(db, run);
|
|
if (!sourceIssue || sourceIssue.id === issue.id) return null;
|
|
return { run, sourceIssue };
|
|
}
|
|
|
|
async function loadIssueCreationRun(
|
|
db: Db,
|
|
issue: Pick<IssueRow, "id" | "companyId" | "originRunId">,
|
|
): Promise<CreationSourceRun | null> {
|
|
let runId = issue.originRunId;
|
|
if (!runId) {
|
|
const [creation] = await db
|
|
.select({ runId: activityLog.runId })
|
|
.from(activityLog)
|
|
.where(and(
|
|
eq(activityLog.companyId, issue.companyId),
|
|
eq(activityLog.entityType, "issue"),
|
|
eq(activityLog.entityId, issue.id),
|
|
inArray(activityLog.action, CREATION_ACTIVITY_ACTIONS),
|
|
isNotNull(activityLog.runId),
|
|
))
|
|
.orderBy(asc(activityLog.createdAt))
|
|
.limit(1);
|
|
runId = creation?.runId ?? null;
|
|
}
|
|
if (!runId || !isUuidLike(runId)) return null;
|
|
const [run] = await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
scopeKind: heartbeatRuns.scopeKind,
|
|
nativeIssueId: heartbeatRuns.nativeIssueId,
|
|
issueId: heartbeatRuns.issueId,
|
|
contextSnapshot: sql<CreationSourceRun["contextSnapshot"]>`case
|
|
when coalesce(${heartbeatRuns.nativeIssueId}, ${heartbeatRuns.issueId}) is not null then null
|
|
else jsonb_build_object(
|
|
'issueId', ${heartbeatRuns.contextSnapshot}->'issueId',
|
|
'taskId', ${heartbeatRuns.contextSnapshot}->'taskId',
|
|
'taskKey', ${heartbeatRuns.contextSnapshot}->'taskKey',
|
|
'paperclipIssue', jsonb_build_object('id', ${heartbeatRuns.contextSnapshot}->'paperclipIssue'->'id')
|
|
) end`,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId)))
|
|
.limit(1);
|
|
return run ?? null;
|
|
}
|
|
|
|
/** Company-scoped agent summary for a creation source. */
|
|
export async function loadCreationSourceAgent(
|
|
db: Db,
|
|
companyId: string,
|
|
agentId: string,
|
|
): Promise<{ id: string; name: string } | null> {
|
|
const [agent] = await db
|
|
.select({ id: agents.id, name: agents.name })
|
|
.from(agents)
|
|
.where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)))
|
|
.limit(1);
|
|
return agent ?? null;
|
|
}
|