mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +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>
145 lines
9.2 KiB
TypeScript
145 lines
9.2 KiB
TypeScript
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
import { activityLog, agents, heartbeatRuns, issues, projects, routineRuns, type Db } from "@paperclipai/db";
|
|
import { isUuidLike } from "@paperclipai/shared";
|
|
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
|
import { agentRunWritesRevoked } from "../agent-run-cancellation.js";
|
|
import { unauthorized } from "../errors.js";
|
|
|
|
export interface TaskCreationProvenance {
|
|
status: "complete" | "unknown";
|
|
routineIds: string[];
|
|
shareable: boolean | null;
|
|
}
|
|
|
|
export const unknownTaskCreationProvenance = (): TaskCreationProvenance => ({
|
|
status: "unknown", routineIds: [], shareable: null,
|
|
});
|
|
|
|
const MAX_TASKS = 100;
|
|
const MAX_DEPTH = 16;
|
|
const MAX_QUERIES = 32;
|
|
const taskReference = (run: { nativeIssueId?: string | null; issueId?: string | null; contextSnapshot?: Record<string, unknown> | null }) =>
|
|
run.nativeIssueId ?? run.issueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId ?? run.contextSnapshot?.taskKey;
|
|
|
|
export async function authenticateWebhookCreationRun(db: Db, companyId: string, token?: string | null) {
|
|
if (token == null) return null;
|
|
const claims = verifyLocalAgentJwt(token.trim(), { strictRunAuthority: true });
|
|
if (!claims || claims.company_id !== companyId || !isUuidLike(claims.run_id) || !isUuidLike(claims.sub)) throw unauthorized();
|
|
return validateCreationRun(db, companyId, claims.sub, claims.run_id);
|
|
}
|
|
|
|
export async function validateCreationRun(db: Db, companyId: string, agentId: string, runId: string) {
|
|
if (!isUuidLike(runId)) throw unauthorized();
|
|
const [run] = await db.select({ id: heartbeatRuns.id, status: heartbeatRuns.status,
|
|
resultJson: sql<Record<string, unknown>>`jsonb_build_object('executionCancellation', ${heartbeatRuns.resultJson}->'executionCancellation')`,
|
|
agentStatus: agents.status,
|
|
}).from(heartbeatRuns).innerJoin(agents, and(eq(agents.id, heartbeatRuns.agentId), eq(agents.companyId, companyId)))
|
|
.where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId)));
|
|
if (!run || run.status !== "running" || agentRunWritesRevoked(run)
|
|
|| run.agentStatus === "terminated" || run.agentStatus === "pending_approval") throw unauthorized();
|
|
return run.id;
|
|
}
|
|
|
|
export async function readTaskCreationProvenance(db: Db, companyId: string, taskId: string | null, deadlineAt = Infinity): Promise<TaskCreationProvenance> {
|
|
if (!taskId || !isUuidLike(taskId)) return unknownTaskCreationProvenance();
|
|
let queries = 0;
|
|
const read = async <T>(query: () => PromiseLike<T>): Promise<T | null> => {
|
|
if (queries >= MAX_QUERIES || Date.now() >= deadlineAt) return null;
|
|
queries++;
|
|
const rows = await query();
|
|
return Date.now() >= deadlineAt ? null : rows;
|
|
};
|
|
const visited = new Set<string>(), routineIds = new Set<string>();
|
|
const edges = new Map<string, Set<string>>();
|
|
let pending = [taskId], complete = true, shareable: boolean | null = null;
|
|
const connect = (from: string, to: string) => {
|
|
const parents = edges.get(from) ?? new Set<string>();
|
|
parents.add(to); edges.set(from, parents); pending.push(to);
|
|
};
|
|
for (let depth = 0; pending.length; depth++) {
|
|
const batch = [...new Set(pending)].filter(id => !visited.has(id));
|
|
pending = [];
|
|
if (!batch.length) break;
|
|
if (depth >= MAX_DEPTH || visited.size + batch.length > MAX_TASKS) { complete = false; break; }
|
|
batch.forEach(id => visited.add(id));
|
|
const tasks = await read(() => db.select({ id: issues.id, parentId: issues.parentId, originIssueId: issues.originIssueId,
|
|
originKind: issues.originKind, originRunId: issues.originRunId, createdByAgentId: issues.createdByAgentId,
|
|
visibility: issues.visibility, privacyRootIssueId: issues.privacyRootIssueId, projectId: issues.projectId,
|
|
}).from(issues).where(and(eq(issues.companyId, companyId), inArray(issues.id, batch))));
|
|
if (!tasks) { complete = false; break; }
|
|
if (tasks.length !== batch.length) complete = false;
|
|
const projectIds = [...new Set(tasks.flatMap(task => task.projectId ? [task.projectId] : []))];
|
|
const publicProjects = projectIds.length ? await read(() => db.select({ id: projects.id }).from(projects)
|
|
.where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds), eq(projects.visibility, "open")))) : [];
|
|
if (!publicProjects) { complete = false; break; }
|
|
const publicProjectIds = new Set(publicProjects.map(project => project.id));
|
|
for (const task of tasks) {
|
|
const publicTask = task.visibility === "open" && !task.privacyRootIssueId
|
|
&& (!task.projectId || publicProjectIds.has(task.projectId));
|
|
if (task.id === taskId) shareable = publicTask;
|
|
else if (!publicTask) shareable = false;
|
|
if (task.parentId) connect(task.id, task.parentId);
|
|
// A task placed beside its requester keeps the requester as its origin.
|
|
if (task.originIssueId && task.originIssueId !== task.parentId) connect(task.id, task.originIssueId);
|
|
}
|
|
const originIds = [...new Set(tasks.flatMap(task => task.originRunId && isUuidLike(task.originRunId) ? [task.originRunId] : []))];
|
|
const receipts = originIds.length ? await read(() => db.select({ id: routineRuns.id, routineId: routineRuns.routineId,
|
|
linkedIssueId: routineRuns.linkedIssueId, createdByRunId: routineRuns.createdByRunId, source: routineRuns.source,
|
|
}).from(routineRuns).where(and(eq(routineRuns.companyId, companyId), inArray(routineRuns.id, originIds)))) : [];
|
|
if (!receipts) { complete = false; break; }
|
|
const receiptMap = new Map(receipts.map(receipt => [receipt.id, receipt]));
|
|
const creators: Array<{ taskId: string; runId: string; agentId: string | null }> = [];
|
|
const legacyTasks = tasks.filter(task => !task.originRunId && task.createdByAgentId);
|
|
const events = legacyTasks.length ? await read(() => db.select({ taskId: activityLog.entityId, runId: activityLog.runId }).from(activityLog)
|
|
.where(and(eq(activityLog.companyId, companyId), eq(activityLog.entityType, "issue"),
|
|
inArray(activityLog.entityId, legacyTasks.map(task => task.id)), inArray(activityLog.action, ["issue.created", "issue.child_created"])))) : [];
|
|
if (!events) { complete = false; break; }
|
|
for (const task of tasks) {
|
|
const receipt = task.originRunId ? receiptMap.get(task.originRunId) : undefined;
|
|
if (receipt?.linkedIssueId === task.id) {
|
|
routineIds.add(receipt.routineId);
|
|
if (receipt.createdByRunId) creators.push({ taskId: task.id, runId: receipt.createdByRunId, agentId: null });
|
|
else if (task.createdByAgentId || receipt.source === "api") complete = false;
|
|
} else if (task.originKind === "routine_execution") {
|
|
complete = false;
|
|
} else if (task.originRunId) {
|
|
if (!isUuidLike(task.originRunId)) complete = false;
|
|
else creators.push({ taskId: task.id, runId: task.originRunId, agentId: task.createdByAgentId });
|
|
} else if (task.createdByAgentId) {
|
|
const ids = [...new Set(events.filter(event => event.taskId === task.id).map(event => event.runId))];
|
|
if (ids.length !== 1 || !ids[0] || !isUuidLike(ids[0])) complete = false;
|
|
else creators.push({ taskId: task.id, runId: ids[0], agentId: task.createdByAgentId });
|
|
}
|
|
}
|
|
const creatorIds = [...new Set(creators.map(creator => creator.runId))];
|
|
const runs = creatorIds.length ? await read(() => db.select({ id: heartbeatRuns.id, agentId: heartbeatRuns.agentId,
|
|
nativeIssueId: heartbeatRuns.nativeIssueId, issueId: heartbeatRuns.issueId,
|
|
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId',
|
|
'taskId', ${heartbeatRuns.contextSnapshot}->'taskId', 'taskKey', ${heartbeatRuns.contextSnapshot}->'taskKey')`,
|
|
}).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, creatorIds)))) : [];
|
|
if (!runs) { complete = false; break; }
|
|
const runMap = new Map(runs.map(run => [run.id, run]));
|
|
const identifiers = [...new Set(runs.map(taskReference).filter((ref): ref is string => typeof ref === "string" && !isUuidLike(ref)))];
|
|
const identified = identifiers.length ? await read(() => db.select({ id: issues.id, identifier: issues.identifier }).from(issues)
|
|
.where(and(eq(issues.companyId, companyId), inArray(issues.identifier, identifiers)))) : [];
|
|
if (!identified) { complete = false; break; }
|
|
const taskIds = new Map(identified.map(task => [task.identifier, task.id]));
|
|
for (const creator of creators) {
|
|
const run = runMap.get(creator.runId), reference = run && taskReference(run);
|
|
const id = typeof reference === "string" ? (isUuidLike(reference) ? reference : taskIds.get(reference)) : null;
|
|
if (!run || (creator.agentId && run.agentId !== creator.agentId) || !id) complete = false;
|
|
else connect(creator.taskId, id);
|
|
}
|
|
}
|
|
const active = new Set<string>(), done = new Set<string>();
|
|
const acyclic = (id: string): boolean => {
|
|
if (active.has(id)) return false;
|
|
if (done.has(id)) return true;
|
|
active.add(id);
|
|
for (const parent of edges.get(id) ?? []) if (!acyclic(parent)) return false;
|
|
active.delete(id); done.add(id); return true;
|
|
};
|
|
if (!acyclic(taskId)) complete = false;
|
|
return { status: complete ? "complete" : "unknown", routineIds: [...routineIds].sort(), shareable };
|
|
}
|