mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:12:25 +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>
121 lines
8.1 KiB
TypeScript
121 lines
8.1 KiB
TypeScript
import express from "express";
|
|
import request from "supertest";
|
|
import { issueRoutes } from "../routes/issues.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { assertTaskWorkspaceExecutionAccess } from "../services/task-workspace-access.js";
|
|
import { errorHandler } from "../middleware/error-handler.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { agents, authUsers, companyMemberships, createDb, executionWorkspaces, heartbeatRuns, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
|
import { executionWorkspaceService } from "../services/execution-workspaces.js";
|
|
import { issueService } from "../services/issues.js";
|
|
import { createRepositoryTrustFixture } from "./helpers/repository-trust-fixture.js";
|
|
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)("child task workspace trust", () => {
|
|
const context = createRepositoryTrustFixture({ managedAuth: vi.fn(), materialize: vi.fn(), catalog: vi.fn() });
|
|
let db: ReturnType<typeof createDb>;
|
|
beforeAll(async () => { ({ db } = await context.setup()); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
|
afterAll(context.cleanup);
|
|
|
|
async function fixture() {
|
|
const f = await context.fixture();
|
|
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Review source" }).returning();
|
|
const policy = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy,
|
|
trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, projectIds: [project.id] } } };
|
|
await db.update(projects).set({ executionWorkspacePolicy: policy }).where(eq(projects.id, project.id));
|
|
await db.update(executionWorkspaces).set({ projectId: project.id }).where(eq(executionWorkspaces.id, f.workspace.id));
|
|
await db.update(issues).set({ executionPolicy: policy }).where(eq(issues.id, f.issueId));
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId));
|
|
const input = { title: `Child ${randomUUID()}`, parentId: f.issueId as string, assigneeAgentId: f.agentId as string,
|
|
executionPolicy: policy, createdByAgentId: f.agentId, workspaceSelectionActor: f.actor,
|
|
workspaceSelection: { kind: "existing" as const, workspaceId: f.workspace.id } };
|
|
return { ...f, project, policy, input };
|
|
}
|
|
|
|
it.each(["direct", "descendant", "omitted_policy"] as const)("creates a matching low-trust child with an explicit existing workspace (%s)", async scenario => {
|
|
const f = await fixture();
|
|
if (scenario === "descendant") {
|
|
const [parent] = await db.insert(issues).values({ companyId: f.companyId, parentId: f.issueId,
|
|
title: "Intermediate task", assigneeAgentId: f.agentId, executionPolicy: f.policy }).returning();
|
|
f.input.parentId = parent.id;
|
|
}
|
|
const baseInput = scenario === "omitted_policy" ? { ...f.input, executionPolicy: undefined } : f.input;
|
|
// Descendant trust is the subject here, so keep the deeper placement (board-style nesting).
|
|
const input = scenario === "descendant" ? { ...baseInput, parentDepthPolicy: "allow_nested" as const } : baseInput;
|
|
await expect(issueService(db).create(f.companyId, input)).resolves.toMatchObject({
|
|
parentId: f.input.parentId, executionWorkspaceId: f.workspace.id,
|
|
});
|
|
});
|
|
it("allows a parent run to patch an authorized child's existing workspace settings", async () => {
|
|
const f = await fixture();
|
|
const child = await issueService(db).create(f.companyId, { ...f.input, status: "backlog", responsibleUserId: f.userId });
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor = { ...f.actor, onBehalfOfMemberships: [{ companyId: f.companyId, status: "active", membershipRole: "operator" }] };
|
|
next();
|
|
});
|
|
app.use("/api", issueRoutes(db, {} as unknown as Parameters<typeof issueRoutes>[1]));
|
|
app.use(errorHandler);
|
|
const settings = instanceSettingsService(db);
|
|
const previous = await settings.getExperimental();
|
|
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
|
|
try {
|
|
const response = await request(app).patch(`/api/issues/${child.id}`).set("X-Paperclip-Run-Id", f.runId)
|
|
.send({ executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", baseRef: "main" } } });
|
|
expect({ status: response.status, body: response.body }).toMatchObject({ status: 200, body: {
|
|
id: child.id, executionWorkspaceId: f.workspace.id,
|
|
executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", baseRef: "main" } },
|
|
} });
|
|
} finally {
|
|
await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces });
|
|
}
|
|
const [parentRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(parentRun.contextSnapshot?.issueId).toBe(f.issueId);
|
|
await expect(assertTaskWorkspaceExecutionAccess(db, f.actor, f.companyId, {
|
|
issueId: child.id, workspaceId: f.workspace.id,
|
|
})).rejects.toThrow(/run identity/);
|
|
});
|
|
|
|
it.each(["child_policy", "unrelated_parent", "missing_parent", "missing_run", "cross_company_run", "sponsor", "live_parent_policy", "absent_saved_policy", "child_agent_policy"] as const)(
|
|
"rejects incompatible child creation (%s)", async scenario => {
|
|
const f = await fixture();
|
|
const different = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy,
|
|
trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, rootIssueId: randomUUID() } } };
|
|
if (scenario === "child_policy") f.input.executionPolicy = different;
|
|
if (scenario === "unrelated_parent") {
|
|
const [parent] = await db.insert(issues).values({ companyId: f.companyId, title: "Other root" }).returning();
|
|
f.input.parentId = parent.id;
|
|
}
|
|
if (scenario === "missing_parent") f.input.parentId = randomUUID();
|
|
if (scenario === "missing_run") f.input.workspaceSelectionActor = { ...f.actor, runId: randomUUID() };
|
|
if (scenario === "cross_company_run") f.input.workspaceSelectionActor = { ...f.actor, runId: (await fixture()).runId };
|
|
if (scenario === "sponsor") {
|
|
const id = randomUUID();
|
|
await db.insert(authUsers).values({ id, name: "Other operator", email: `${id}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: id, membershipRole: "operator", status: "active" });
|
|
f.input.workspaceSelectionActor = { ...f.actor, onBehalfOfUserId: id };
|
|
}
|
|
if (scenario === "live_parent_policy") await db.update(issues).set({ executionPolicy: different }).where(eq(issues.id, f.issueId));
|
|
if (scenario === "absent_saved_policy") await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId } }).where(eq(heartbeatRuns.id, f.runId));
|
|
if (scenario === "child_agent_policy") {
|
|
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Other boundary", adapterType: "process", permissions: different }).returning();
|
|
f.input.assigneeAgentId = agent.id;
|
|
}
|
|
const before = await db.select().from(issues).where(eq(issues.companyId, f.companyId));
|
|
await expect(issueService(db).create(f.companyId, f.input)).rejects.toThrow();
|
|
expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toEqual(before);
|
|
},
|
|
);
|
|
|
|
it("does not infer child creation from an arbitrary issue-less workspace selection", async () => {
|
|
const f = await fixture();
|
|
await expect(executionWorkspaceService(db).validateSelection({ companyId: f.companyId, actor: f.actor,
|
|
selection: f.input.workspaceSelection, parentIssueId: f.issueId, executionPolicy: f.policy,
|
|
assigneeAgentId: f.agentId })).rejects.toThrow(/Selected workspace source trust/);
|
|
});
|
|
});
|