mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
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:
1 parent
d8b0eae687
commit
958f9aa9f3
43 files changed
+2576
-391
No files matched your search
@@ -0,0 +1,192 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { activityLog, agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
|
||||
describePostgres("agent task hierarchy depth through the issue routes", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const rootId = randomUUID();
|
||||
const childId = randomUUID();
|
||||
const conversationId = randomUUID();
|
||||
const runOnChild = randomUUID();
|
||||
const runOnConversation = randomUUID();
|
||||
const legacyConversationChildId = randomUUID();
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-agent-task-depth-routes-");
|
||||
db = createDb(database.connectionString);
|
||||
await db.insert(companies).values({
|
||||
id: companyId, name: "Paperclip", issuePrefix: "DEP", defaultResponsibleUserId: "board-user",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Coder", role: "engineer", adapterType: "codex_local", status: "active" });
|
||||
await db.insert(issues).values([
|
||||
{ id: rootId, companyId, title: "Ship the feature", identifier: "DEP-1", issueNumber: 1, status: "in_progress", assigneeAgentId: agentId },
|
||||
{ id: childId, companyId, parentId: rootId, title: "Build the API", identifier: "DEP-2", issueNumber: 2, status: "in_progress", assigneeAgentId: agentId },
|
||||
{ id: conversationId, companyId, title: "Chat", identifier: "DEP-3", issueNumber: 3, status: "in_review", assigneeAgentId: agentId,
|
||||
conversationAgentId: agentId, conversationUserId: "board-user", conversationState: "waiting" },
|
||||
]);
|
||||
await db.insert(issues).values({
|
||||
id: legacyConversationChildId, companyId, parentId: conversationId, title: "Legacy chat child",
|
||||
identifier: "DEP-4", issueNumber: 4, status: "todo", assigneeAgentId: agentId,
|
||||
});
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{ id: runOnChild, companyId, agentId, scopeKind: "issue", issueId: childId, nativeIssueId: childId, status: "running", contextSnapshot: { issueId: childId } },
|
||||
{ id: runOnConversation, companyId, agentId, scopeKind: "issue", issueId: conversationId, status: "running", contextSnapshot: { issueId: conversationId } },
|
||||
]);
|
||||
}, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
|
||||
const boardActor = { type: "board", userId: "board-user", source: "local_implicit", isInstanceAdmin: true } as Express.Request["actor"];
|
||||
function agentActor(runId: string): Express.Request["actor"] {
|
||||
return { type: "agent", companyId, agentId, runId, source: "agent_jwt", onBehalfOfUserId: null, onBehalfOfMemberships: [] } as Express.Request["actor"];
|
||||
}
|
||||
function app(actor: Express.Request["actor"]) {
|
||||
const instance = express();
|
||||
instance.use(express.json());
|
||||
instance.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
instance.use("/api", issueRoutes(db, {} as never));
|
||||
instance.use(errorHandler);
|
||||
return instance;
|
||||
}
|
||||
async function activity(issueId: string, action: string) {
|
||||
const [row] = await db.select().from(activityLog)
|
||||
.where(and(eq(activityLog.entityId, issueId), eq(activityLog.action, action)));
|
||||
return row ?? null;
|
||||
}
|
||||
const expectedAdjustment = {
|
||||
reason: "max_agent_depth",
|
||||
applied: true,
|
||||
requestedParent: { id: childId, identifier: "DEP-2" },
|
||||
effectiveParent: { id: rootId, identifier: "DEP-1" },
|
||||
};
|
||||
|
||||
it("places a run's default follow-up beside the child task it is working on", async () => {
|
||||
const created = await request(app(agentActor(runOnChild)))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Write the migration" });
|
||||
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
||||
expect(created.body.parentId).toBe(rootId);
|
||||
expect(created.body.originIssueId).toBe(childId);
|
||||
expect(created.body.originRunId).toBe(runOnChild);
|
||||
expect(created.body.parentAdjusted).toEqual(expectedAdjustment);
|
||||
expect((await activity(created.body.id, "issue.created"))?.details).toMatchObject({
|
||||
parentId: rootId,
|
||||
parentAdjusted: expectedAdjustment,
|
||||
parentDefaultedFromRunIssue: true,
|
||||
});
|
||||
|
||||
const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`);
|
||||
expect(detail.body.createdFrom).toMatchObject({ issue: { id: childId, identifier: "DEP-2" } });
|
||||
});
|
||||
|
||||
it("places an explicit agent grandchild beside the requester instead of failing", async () => {
|
||||
const created = await request(app(agentActor(runOnChild)))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Add tests", parentId: childId });
|
||||
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
||||
expect(created.body.parentId).toBe(rootId);
|
||||
expect(created.body.parentAdjusted).toEqual(expectedAdjustment);
|
||||
});
|
||||
|
||||
it("places an agent child created through the children route beside the requester", async () => {
|
||||
const created = await request(app(agentActor(runOnChild)))
|
||||
.post(`/api/issues/${childId}/children`)
|
||||
.send({ title: "Update the docs" });
|
||||
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
||||
expect(created.body.parentId).toBe(rootId);
|
||||
expect(created.body.originIssueId).toBe(childId);
|
||||
expect(created.body.parentAdjusted).toEqual(expectedAdjustment);
|
||||
expect((await activity(created.body.id, "issue.child_created"))?.details).toMatchObject({
|
||||
parentId: rootId,
|
||||
requestedParentId: childId,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets board users create grandchildren", async () => {
|
||||
const created = await request(app(boardActor))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Board grandchild", parentId: childId });
|
||||
expect(created.status, JSON.stringify(created.body)).toBe(201);
|
||||
expect(created.body.parentId).toBe(childId);
|
||||
expect(created.body).not.toHaveProperty("parentAdjusted");
|
||||
|
||||
const viaChildren = await request(app(boardActor))
|
||||
.post(`/api/issues/${childId}/children`)
|
||||
.send({ title: "Board grandchild via children route" });
|
||||
expect(viaChildren.status, JSON.stringify(viaChildren.body)).toBe(201);
|
||||
expect(viaChildren.body.parentId).toBe(childId);
|
||||
});
|
||||
|
||||
it("flattens agent moves into a child and lets board users move freely", async () => {
|
||||
const [agentMoved, boardMoved] = [randomUUID(), randomUUID()];
|
||||
await db.insert(issues).values([
|
||||
{ id: agentMoved, companyId, title: "Loose task", status: "todo", assigneeAgentId: agentId },
|
||||
{ id: boardMoved, companyId, title: "Board loose task", status: "todo", assigneeAgentId: agentId },
|
||||
]);
|
||||
|
||||
const byAgent = await request(app(agentActor(runOnChild)))
|
||||
.patch(`/api/issues/${agentMoved}`)
|
||||
.send({ parentId: childId });
|
||||
expect(byAgent.status, JSON.stringify(byAgent.body)).toBe(200);
|
||||
expect(byAgent.body.parentId).toBe(rootId);
|
||||
expect(byAgent.body.parentAdjusted).toEqual(expectedAdjustment);
|
||||
expect((await activity(agentMoved, "issue.updated"))?.details).toMatchObject({
|
||||
parentId: rootId,
|
||||
requestedParentId: childId,
|
||||
parentAdjusted: expectedAdjustment,
|
||||
});
|
||||
|
||||
const byBoard = await request(app(boardActor))
|
||||
.patch(`/api/issues/${boardMoved}`)
|
||||
.send({ parentId: childId });
|
||||
expect(byBoard.status, JSON.stringify(byBoard.body)).toBe(200);
|
||||
expect(byBoard.body.parentId).toBe(childId);
|
||||
expect(byBoard.body).not.toHaveProperty("parentAdjusted");
|
||||
});
|
||||
|
||||
it("records a top-level placement when an agent moves under a legacy conversation child", async () => {
|
||||
const movingId = randomUUID();
|
||||
await db.insert(issues).values({ id: movingId, companyId, parentId: rootId, title: "Move to top", status: "todo", assigneeAgentId: agentId });
|
||||
|
||||
const moved = await request(app(agentActor(runOnChild)))
|
||||
.patch(`/api/issues/${movingId}`)
|
||||
.send({ parentId: legacyConversationChildId });
|
||||
expect(moved.status, JSON.stringify(moved.body)).toBe(200);
|
||||
expect(moved.body.parentId).toBeNull();
|
||||
expect(moved.body.parentAdjusted).toMatchObject({ reason: "max_agent_depth", effectiveParent: null });
|
||||
expect((await activity(movingId, "issue.updated"))?.details).toMatchObject({
|
||||
parentId: null,
|
||||
requestedParentId: legacyConversationChildId,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a chat create a top-level task and a child under it", async () => {
|
||||
const top = await request(app(agentActor(runOnConversation)))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Plan the launch", assigneeAgentId: agentId });
|
||||
expect(top.status, JSON.stringify(top.body)).toBe(201);
|
||||
expect(top.body.parentId).toBeNull();
|
||||
expect(top.body.originIssueId).toBe(conversationId);
|
||||
|
||||
const child = await request(app(agentActor(runOnConversation)))
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Draft the announcement", parentId: top.body.id, assigneeAgentId: agentId });
|
||||
expect(child.status, JSON.stringify(child.body)).toBe(201);
|
||||
expect(child.body.parentId).toBe(top.body.id);
|
||||
// Both originate from the chat that created them.
|
||||
expect(child.body.originIssueId).toBe(conversationId);
|
||||
expect(child.body).not.toHaveProperty("parentAdjusted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,484 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRuns, issueRelations, issues } from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
import { planIssueParentChange, resolveNewTaskParent } from "../services/issue-hierarchy.ts";
|
||||
import { resolveIssueCreationSource } from "../services/issue-creation-source.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
describeEmbeddedPostgres("agent task hierarchy depth", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-agent-task-depth-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedCompany() {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `D${companyId.replace(/-/g, "").slice(0, 5).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
const agentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
return { companyId, agentId };
|
||||
}
|
||||
|
||||
async function seedIssue(
|
||||
companyId: string,
|
||||
values: Partial<typeof issues.$inferInsert> = {},
|
||||
) {
|
||||
const id = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id,
|
||||
companyId,
|
||||
title: `Task ${id.slice(0, 6)}`,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
...values,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async function seedTree(companyId: string) {
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
const rootIdentifier = `ROOT-${suffix}`;
|
||||
const childIdentifier = `CHILD-${suffix}`;
|
||||
const rootId = await seedIssue(companyId, { identifier: rootIdentifier });
|
||||
const childId = await seedIssue(companyId, { parentId: rootId, identifier: childIdentifier });
|
||||
return { rootId, childId, rootIdentifier, childIdentifier };
|
||||
}
|
||||
|
||||
it("places an agent's child-of-a-child beside the requester and keeps it as the origin", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { rootId, childId, rootIdentifier, childIdentifier } = await seedTree(companyId);
|
||||
|
||||
// expectedParentId: the route authorized the placed ancestor.
|
||||
const created = await issueService(db).create(companyId, {
|
||||
title: "Follow-up from the child",
|
||||
parentId: childId,
|
||||
expectedParentId: rootId,
|
||||
});
|
||||
|
||||
expect(created.parentId).toBe(rootId);
|
||||
expect(created.originIssueId).toBe(childId);
|
||||
expect(created.privacyParentIssueId).toBe(childId);
|
||||
expect(created.parentAdjusted).toEqual({
|
||||
reason: "max_agent_depth",
|
||||
applied: true,
|
||||
requestedParent: { id: childId, identifier: childIdentifier },
|
||||
effectiveParent: { id: rootId, identifier: rootIdentifier },
|
||||
});
|
||||
});
|
||||
|
||||
it("lets board users create grandchildren", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { childId } = await seedTree(companyId);
|
||||
|
||||
const created = await issueService(db).create(companyId, {
|
||||
title: "Board grandchild",
|
||||
parentId: childId,
|
||||
parentDepthPolicy: "allow_nested",
|
||||
});
|
||||
|
||||
expect(created.parentId).toBe(childId);
|
||||
expect(created.originIssueId).toBe(childId);
|
||||
expect(created.parentAdjusted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates direct children of a top-level task unchanged", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { rootId } = await seedTree(companyId);
|
||||
|
||||
const created = await issueService(db).create(companyId, { title: "Second child", parentId: rootId });
|
||||
|
||||
expect(created.parentId).toBe(rootId);
|
||||
expect(created.originIssueId).toBe(rootId);
|
||||
expect(created.parentAdjusted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("records the creating run's task as the origin of a top-level task", async () => {
|
||||
const { companyId, agentId } = await seedCompany();
|
||||
const sourceId = await seedIssue(companyId);
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", issueId: sourceId });
|
||||
|
||||
const created = await issueService(db).create(companyId, {
|
||||
title: "Standalone follow-up",
|
||||
parentId: null,
|
||||
createdByAgentId: agentId,
|
||||
actorRunId: runId,
|
||||
});
|
||||
|
||||
expect(created.parentId).toBeNull();
|
||||
expect(created.originIssueId).toBe(sourceId);
|
||||
expect(created.originRunId).toBe(runId);
|
||||
});
|
||||
|
||||
it("flattens legacy deep trees to their root", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { rootId, childId } = await seedTree(companyId);
|
||||
const grandchildId = await seedIssue(companyId, { parentId: childId });
|
||||
|
||||
const created = await issueService(db).create(companyId, { title: "Under legacy grandchild", parentId: grandchildId, expectedParentId: rootId });
|
||||
|
||||
expect(created.parentId).toBe(rootId);
|
||||
expect(created.originIssueId).toBe(grandchildId);
|
||||
expect(created.parentAdjusted?.requestedParent?.id).toBe(grandchildId);
|
||||
});
|
||||
|
||||
it("blocks the requester, not the root, when a flattened child should block its parent", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { rootId, childId } = await seedTree(companyId);
|
||||
|
||||
const { issue, parentBlockerAdded } = await issueService(db).createChild(childId, {
|
||||
title: "Blocking follow-up",
|
||||
blockParentUntilDone: true,
|
||||
expectedParentId: rootId,
|
||||
});
|
||||
|
||||
expect(issue.parentId).toBe(rootId);
|
||||
expect(issue.originIssueId).toBe(childId);
|
||||
expect(issue.parentAdjusted?.reason).toBe("max_agent_depth");
|
||||
expect(parentBlockerAdded).toBe(true);
|
||||
const blockers = await db
|
||||
.select({ relatedIssueId: issueRelations.relatedIssueId })
|
||||
.from(issueRelations)
|
||||
.where(and(eq(issueRelations.issueId, issue.id), eq(issueRelations.type, "blocks")));
|
||||
expect(blockers.map((row) => row.relatedIssueId)).toEqual([childId]);
|
||||
});
|
||||
|
||||
it("reuses a flattened child for the same idempotency key", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { childId } = await seedTree(companyId);
|
||||
const idempotencyKey = `flatten-${randomUUID()}`;
|
||||
|
||||
const first = await issueService(db).createChild(childId, { title: "Once", idempotencyKey });
|
||||
const second = await issueService(db).createChild(childId, { title: "Once", idempotencyKey });
|
||||
|
||||
expect(second.issue.id).toBe(first.issue.id);
|
||||
});
|
||||
|
||||
it("places under the real root of a legacy tree deeper than 64 levels", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const rootId = await seedIssue(companyId);
|
||||
let deepestId = rootId;
|
||||
for (let level = 1; level < 70; level += 1) deepestId = await seedIssue(companyId, { parentId: deepestId });
|
||||
|
||||
const created = await issueService(db).create(companyId, { title: "From the 70th level", parentId: deepestId, expectedParentId: rootId });
|
||||
|
||||
expect(created.parentId).toBe(rootId);
|
||||
expect(created.originIssueId).toBe(deepestId);
|
||||
});
|
||||
|
||||
it("does not move a task above a low-trust boundary root", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const rootId = await seedIssue(companyId);
|
||||
const boundaryId = randomUUID();
|
||||
await seedIssue(companyId, {
|
||||
id: boundaryId,
|
||||
parentId: rootId,
|
||||
executionPolicy: {
|
||||
authorizationPolicy: {
|
||||
trustBoundary: { mode: "low_trust_review", companyId, rootIssueId: boundaryId },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const placement = await resolveNewTaskParent(db, companyId, boundaryId, "flatten");
|
||||
|
||||
expect(placement).toEqual({ effectiveParentId: boundaryId, adjustment: null });
|
||||
});
|
||||
|
||||
it("falls back to top level when a legacy tree is rooted at a conversation", async () => {
|
||||
const { companyId, agentId } = await seedCompany();
|
||||
const conversationId = await seedIssue(companyId, { conversationAgentId: agentId, conversationUserId: "user-1", conversationState: "active", assigneeAgentId: agentId,
|
||||
});
|
||||
const legacyChildId = await seedIssue(companyId, { parentId: conversationId });
|
||||
|
||||
const created = await issueService(db).create(companyId, { title: "From legacy chat child", parentId: legacyChildId });
|
||||
|
||||
expect(created.parentId).toBeNull();
|
||||
expect(created.originIssueId).toBe(legacyChildId);
|
||||
expect(created.parentAdjusted).toMatchObject({ reason: "max_agent_depth", effectiveParent: null });
|
||||
});
|
||||
|
||||
it("rejects a create whose locked placement differs from the authorized one", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { rootId, childId } = await seedTree(companyId);
|
||||
const otherRootId = await seedIssue(companyId);
|
||||
// The route authorized placement under rootId; the child then moved.
|
||||
await db.update(issues).set({ parentId: otherRootId }).where(eq(issues.id, childId));
|
||||
|
||||
await expect(issueService(db).create(companyId, {
|
||||
title: "Stale placement",
|
||||
parentId: childId,
|
||||
expectedParentId: rootId,
|
||||
})).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it("dedupes recent titles per requester, not across siblings placed under the same root", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { rootId, childId } = await seedTree(companyId);
|
||||
const otherChildId = await seedIssue(companyId, { parentId: rootId });
|
||||
|
||||
const first = await issueService(db).create(companyId, { title: "Write tests", parentId: childId, allowDuplicate: false });
|
||||
const second = await issueService(db).create(companyId, { title: "Write tests", parentId: otherChildId, allowDuplicate: false });
|
||||
const repeat = await issueService(db).create(companyId, { title: "Write tests", parentId: childId, allowDuplicate: false });
|
||||
|
||||
expect(second.id).not.toBe(first.id);
|
||||
expect(second.originIssueId).toBe(otherChildId);
|
||||
expect(repeat.id).toBe(first.id);
|
||||
});
|
||||
|
||||
it("ignores caller-supplied originIssueId", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { childId } = await seedTree(companyId);
|
||||
const forgedId = await seedIssue(companyId);
|
||||
|
||||
const created = await issueService(db).create(companyId, {
|
||||
title: "Provenance is server-derived",
|
||||
parentId: childId,
|
||||
...({ originIssueId: forgedId } as Record<string, unknown>),
|
||||
});
|
||||
|
||||
expect(created.originIssueId).toBe(childId);
|
||||
});
|
||||
|
||||
it("rejects a parent from another company on create and move", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { companyId: otherCompanyId } = await seedCompany();
|
||||
const foreignParentId = await seedIssue(otherCompanyId);
|
||||
|
||||
await expect(issueService(db).create(companyId, { title: "Foreign parent", parentId: foreignParentId }))
|
||||
.rejects.toMatchObject({ status: 404 });
|
||||
await expect(issueService(db).create(companyId, {
|
||||
title: "Foreign parent, board", parentId: foreignParentId, parentDepthPolicy: "allow_nested",
|
||||
})).rejects.toMatchObject({ status: 404 });
|
||||
|
||||
const movingId = await seedIssue(companyId);
|
||||
await expect(issueService(db).update(movingId, { parentId: foreignParentId, parentDepthPolicy: "allow_nested" }))
|
||||
.rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it("originates from the creating run's task, not the named parent, in detail and lists", async () => {
|
||||
const { companyId, agentId } = await seedCompany();
|
||||
const { childId } = await seedTree(companyId);
|
||||
const runTaskId = await seedIssue(companyId);
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", issueId: runTaskId });
|
||||
|
||||
const created = await issueService(db).create(companyId, {
|
||||
title: "Named parent from another run", parentId: childId, createdByAgentId: agentId, actorRunId: runId,
|
||||
});
|
||||
// A run working on runTask filed this under childId: it originates from runTask.
|
||||
expect(created.originIssueId).toBe(runTaskId);
|
||||
expect(created.privacyParentIssueId).toBe(childId);
|
||||
|
||||
const fromRunTask = await issueService(db).list(companyId, { createdFromIssueId: runTaskId });
|
||||
const fromChild = await issueService(db).list(companyId, { createdFromIssueId: childId });
|
||||
expect(fromRunTask.map((row) => row.id)).toContain(created.id);
|
||||
expect(fromChild.map((row) => row.id)).not.toContain(created.id);
|
||||
expect((await resolveIssueCreationSource(db, created))?.sourceIssue.id).toBe(runTaskId);
|
||||
});
|
||||
|
||||
it("originates from a legacy run bound only by a task identifier", async () => {
|
||||
const { companyId, agentId } = await seedCompany();
|
||||
const { childId } = await seedTree(companyId);
|
||||
const identifier = `LEG-${randomUUID().slice(0, 6)}`;
|
||||
const runTaskId = await seedIssue(companyId, { identifier });
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: { taskKey: identifier } });
|
||||
|
||||
const created = await issueService(db).create(companyId, {
|
||||
title: "From a legacy-bound run", parentId: childId, createdByAgentId: agentId, actorRunId: runId,
|
||||
});
|
||||
|
||||
expect(created.originIssueId).toBe(runTaskId);
|
||||
});
|
||||
|
||||
it("makes unconfirmed placements standalone instead of writing under an unchecked ancestor", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { childId } = await seedTree(companyId);
|
||||
|
||||
// Internal callers authorize only the parent they request.
|
||||
const created = await issueService(db).create(companyId, { title: "Internal follow-up", parentId: childId });
|
||||
expect(created.parentId).toBeNull();
|
||||
expect(created.privacyParentIssueId).toBe(childId);
|
||||
expect(created.originIssueId).toBe(childId);
|
||||
expect(created.parentAdjusted).toMatchObject({ reason: "max_agent_depth", applied: true, effectiveParent: null });
|
||||
|
||||
const { issue } = await issueService(db).createChild(childId, { title: "Internal child helper" });
|
||||
expect(issue.parentId).toBeNull();
|
||||
|
||||
const movingId = await seedIssue(companyId);
|
||||
const moved = await issueService(db).update(movingId, { parentId: childId, title: "Renamed anyway" });
|
||||
expect(moved?.parentId).toBeNull();
|
||||
expect(moved?.title).toBe("Renamed anyway");
|
||||
expect(moved?.parentAdjusted).toMatchObject({ reason: "max_agent_depth", applied: false, effectiveParent: null });
|
||||
|
||||
// An already-parented task keeps its parent, and the notice names it.
|
||||
const otherRootId = await seedIssue(companyId, { identifier: `KEEP-${randomUUID().slice(0, 6)}` });
|
||||
const parentedId = await seedIssue(companyId, { parentId: otherRootId });
|
||||
const kept = await issueService(db).update(parentedId, { parentId: childId });
|
||||
expect(kept?.parentId).toBe(otherRootId);
|
||||
expect(kept?.parentAdjusted).toMatchObject({ applied: false, effectiveParent: { id: otherRootId } });
|
||||
});
|
||||
|
||||
it("does not expose a private origin on a task its readers may see", async () => {
|
||||
const { companyId, agentId } = await seedCompany();
|
||||
const openParentId = await seedIssue(companyId);
|
||||
const privateTaskId = randomUUID();
|
||||
await seedIssue(companyId, { id: privateTaskId, visibility: "private", privacyRootIssueId: privateTaskId });
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", issueId: privateTaskId });
|
||||
|
||||
const created = await issueService(db).create(companyId, {
|
||||
title: "Open work filed from a private task", parentId: openParentId, createdByAgentId: agentId, actorRunId: runId,
|
||||
});
|
||||
|
||||
expect(created.visibility).toBe("open");
|
||||
expect(created.originIssueId).toBeNull();
|
||||
// The audit link stays server-side through the run, checked for access on read.
|
||||
expect(created.originRunId).toBe(runId);
|
||||
expect((await resolveIssueCreationSource(db, created))?.sourceIssue.id).toBe(privateTaskId);
|
||||
});
|
||||
|
||||
it("does not expose a hidden origin or a private sibling's id", async () => {
|
||||
const { companyId, agentId } = await seedCompany();
|
||||
const runOn = async (taskId: string) => {
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", issueId: taskId });
|
||||
return runId;
|
||||
};
|
||||
|
||||
const openParentId = await seedIssue(companyId);
|
||||
const hiddenTaskId = await seedIssue(companyId, { hiddenAt: new Date() });
|
||||
const fromHidden = await issueService(db).create(companyId, {
|
||||
title: "From a hidden task", parentId: openParentId, createdByAgentId: agentId, actorRunId: await runOn(hiddenTaskId),
|
||||
});
|
||||
expect(fromHidden.originIssueId).toBeNull();
|
||||
|
||||
const rootId = randomUUID();
|
||||
await seedIssue(companyId, { id: rootId, visibility: "private", privacyRootIssueId: rootId });
|
||||
const siblingA = await seedIssue(companyId, { parentId: rootId, visibility: "private", privacyRootIssueId: rootId });
|
||||
const siblingB = await seedIssue(companyId, { parentId: rootId, visibility: "private", privacyRootIssueId: rootId });
|
||||
const fromSibling = await issueService(db).create(companyId, {
|
||||
title: "Filed under a private sibling", parentId: siblingB, createdByAgentId: agentId,
|
||||
actorRunId: await runOn(siblingA), expectedParentId: rootId,
|
||||
});
|
||||
expect(fromSibling.privacyParentIssueId).toBe(siblingB);
|
||||
expect(fromSibling.originIssueId).toBeNull();
|
||||
expect((await resolveIssueCreationSource(db, fromSibling))?.sourceIssue.id).toBe(siblingA);
|
||||
});
|
||||
|
||||
describe("moves", () => {
|
||||
it("keeps the requested private parent as the privacy source of a placed move", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const rootId = await seedIssue(companyId);
|
||||
const privateChildId = randomUUID();
|
||||
await seedIssue(companyId, { id: privateChildId, parentId: rootId, visibility: "private", privacyRootIssueId: privateChildId });
|
||||
const movingId = await seedIssue(companyId);
|
||||
|
||||
const updated = await issueService(db).update(movingId, { parentId: privateChildId, expectedParentId: rootId });
|
||||
|
||||
expect(updated?.parentId).toBe(rootId);
|
||||
expect(updated?.privacyParentIssueId).toBe(privateChildId);
|
||||
expect(updated?.visibility).toBe("private");
|
||||
expect(updated?.privacyRootIssueId).toBe(privateChildId);
|
||||
});
|
||||
|
||||
it("rejects a move whose placement differs from the authorized one", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { childId } = await seedTree(companyId);
|
||||
const movingId = await seedIssue(companyId);
|
||||
|
||||
await expect(issueService(db).update(movingId, { parentId: childId, expectedParentId: childId }))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it("places an agent move into a child beside it", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { rootId, childId } = await seedTree(companyId);
|
||||
const movingId = await seedIssue(companyId);
|
||||
|
||||
const updated = await issueService(db).update(movingId, { parentId: childId, expectedParentId: rootId });
|
||||
|
||||
expect(updated?.parentId).toBe(rootId);
|
||||
expect(updated?.parentAdjusted).toMatchObject({ reason: "max_agent_depth", applied: true });
|
||||
});
|
||||
|
||||
it("skips only the parent change when the moved task has children", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { rootId } = await seedTree(companyId);
|
||||
const movingId = await seedIssue(companyId);
|
||||
await seedIssue(companyId, { parentId: movingId });
|
||||
|
||||
const updated = await issueService(db).update(movingId, { parentId: rootId, title: "Renamed" });
|
||||
|
||||
expect(updated?.parentId).toBeNull();
|
||||
expect(updated?.title).toBe("Renamed");
|
||||
expect(updated?.parentAdjusted).toMatchObject({
|
||||
reason: "has_children",
|
||||
applied: false,
|
||||
requestedParent: { id: rootId },
|
||||
effectiveParent: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets board users move to any depth", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { childId } = await seedTree(companyId);
|
||||
const movingId = await seedIssue(companyId);
|
||||
await seedIssue(companyId, { parentId: movingId });
|
||||
|
||||
const plan = await planIssueParentChange(
|
||||
db,
|
||||
{ id: movingId, companyId, parentId: null },
|
||||
childId,
|
||||
"allow_nested",
|
||||
);
|
||||
expect(plan).toEqual({ parentId: childId, adjustment: null });
|
||||
|
||||
const updated = await issueService(db).update(movingId, { parentId: childId, parentDepthPolicy: "allow_nested" });
|
||||
expect(updated?.parentId).toBe(childId);
|
||||
expect(updated?.parentAdjusted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves creation provenance from the recorded origin task", async () => {
|
||||
const { companyId } = await seedCompany();
|
||||
const { childId } = await seedTree(companyId);
|
||||
const created = await issueService(db).create(companyId, { title: "Flattened", parentId: childId });
|
||||
|
||||
const source = await resolveIssueCreationSource(db, created);
|
||||
|
||||
expect(source?.sourceIssue.id).toBe(childId);
|
||||
expect(source?.run).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,23 @@ import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["native", "mcp"])("honors backlog status and parentTaskId through %s creation", async (transport) => {
|
||||
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
||||
const create = async (args: Record<string, unknown>) => {
|
||||
const result = transport === "native" ? await call(f, "create_task", args) as any : await callProjectTool({
|
||||
name: "create_task", arguments: args, apiUrl: server.apiUrl,
|
||||
token: createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!,
|
||||
companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true,
|
||||
});
|
||||
const [task] = await server.db.select().from(issues).where(eq(issues.id, transport === "native" ? result.task.id : result.id));
|
||||
return task;
|
||||
};
|
||||
const parent = await create({ title: `Backlog parent ${randomUUID()}`, projectId: f.projectId, idempotencyKey: randomUUID(), status: "backlog" });
|
||||
expect(parent).toMatchObject({ status: "backlog", parentId: null });
|
||||
const child = await create({ title: `Backlog child ${randomUUID()}`, projectId: f.projectId, idempotencyKey: randomUUID(), status: "backlog", parentTaskId: parent.id });
|
||||
expect(child).toMatchObject({ status: "backlog", parentId: parent.id });
|
||||
});
|
||||
|
||||
it("persists required output review supplied through MCP creation", async () => {
|
||||
const f = await server.fixture({ conversation: true });
|
||||
const requirement = { version: 1, deliverables: [{ key: "copy", label: "Launch copy" }] };
|
||||
|
||||
@@ -64,7 +64,7 @@ describe.skipIf(!support.supported)("creation source run projections", () => {
|
||||
expect(reads.reduce((count, event) => count + Number(event.calls), 0)).toBe(1);
|
||||
expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(8192);
|
||||
expect(result).toMatchObject({ run: { id: run.id, companyId, agentId }, sourceIssue: { id: sourceId } });
|
||||
expect(runBoundIssueReference(result!.run)).toBe(binding === "scoped" ? sourceId : "CSR-1");
|
||||
expect(runBoundIssueReference(result!.run!)).toBe(binding === "scoped" ? sourceId : "CSR-1");
|
||||
for (const key of ["runnerProfileJson", "stdoutExcerpt", "stderrExcerpt", "resultJson"]) expect(result!.run).not.toHaveProperty(key);
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]).toEqual(run);
|
||||
expect(JSON.stringify(events)).not.toContain("retained-source-context");
|
||||
|
||||
@@ -1334,14 +1334,18 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
]),
|
||||
);
|
||||
|
||||
// Agent-authored suggestions nest one level: the nested suggestion would be
|
||||
// a grandchild, so it lands beside its suggested parent and records it as origin.
|
||||
const children = await issuesSvc.list(companyId, { parentId: issueId });
|
||||
expect(children).toHaveLength(1);
|
||||
expect(children[0]?.title).toBe("Create the root follow-up");
|
||||
|
||||
const nestedChildren = await issuesSvc.list(companyId, { parentId: children[0]!.id });
|
||||
expect(nestedChildren).toHaveLength(1);
|
||||
expect(nestedChildren[0]?.title).toBe("Create the nested follow-up");
|
||||
expect(nestedChildren[0]?.requestDepth).toBe(4);
|
||||
expect(children.map((child) => child.title).sort()).toEqual([
|
||||
"Create the nested follow-up",
|
||||
"Create the root follow-up",
|
||||
]);
|
||||
const rootFollowUp = children.find((child) => child.title === "Create the root follow-up")!;
|
||||
const nestedFollowUp = children.find((child) => child.title === "Create the nested follow-up")!;
|
||||
expect(nestedFollowUp.originIssueId).toBe(rootFollowUp.id);
|
||||
expect(nestedFollowUp.requestDepth).toBe(4);
|
||||
expect(await issuesSvc.list(companyId, { parentId: rootFollowUp.id })).toHaveLength(0);
|
||||
|
||||
const listed = await interactionsSvc.listForIssue(issueId);
|
||||
expect(listed).toHaveLength(1);
|
||||
@@ -1352,7 +1356,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
})).rejects.toThrow("Interaction has already been resolved");
|
||||
|
||||
const childrenAfterDuplicateAccept = await issuesSvc.list(companyId, { parentId: issueId });
|
||||
expect(childrenAfterDuplicateAccept).toHaveLength(1);
|
||||
expect(childrenAfterDuplicateAccept).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("accepts a selected subset of suggested tasks and records the skipped drafts", async () => {
|
||||
|
||||
@@ -513,6 +513,18 @@ describeEmbeddedPostgres("issue watchdog routes", () => {
|
||||
.where(and(eq(issueWatchdogs.companyId, companyId), eq(issueWatchdogs.issueId, watchedChildId)));
|
||||
expect(nestedWatchdogs).toHaveLength(0);
|
||||
|
||||
// Watchdog runs are authorized for the watched subtree, so the agent depth
|
||||
// limit does not move their tasks above the requested parent. Remove the
|
||||
// task afterwards so the watched subtree returns to its stopped state.
|
||||
const allowedCreate = await request(app)
|
||||
.post(`/api/companies/${companyId}/issues`)
|
||||
.send({ title: "Allowed watched grandchild", parentId: watchedChildId });
|
||||
expect(allowedCreate.status, JSON.stringify(allowedCreate.body)).toBe(201);
|
||||
expect(allowedCreate.body.parentId).toBe(watchedChildId);
|
||||
expect(allowedCreate.body).not.toHaveProperty("parentAdjusted");
|
||||
await db.delete(activityLog).where(eq(activityLog.entityId, allowedCreate.body.id));
|
||||
await db.delete(issues).where(eq(issues.id, allowedCreate.body.id));
|
||||
|
||||
const allowedChild = await request(app)
|
||||
.post(`/api/issues/${watchedChildId}/children`)
|
||||
.send({ title: "Allowed watched child" });
|
||||
|
||||
@@ -42,7 +42,9 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
title: "Intermediate task", assigneeAgentId: f.agentId, executionPolicy: f.policy }).returning();
|
||||
f.input.parentId = parent.id;
|
||||
}
|
||||
const input = scenario === "omitted_policy" ? { ...f.input, executionPolicy: undefined } : f.input;
|
||||
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,
|
||||
});
|
||||
|
||||
+203
-20
@@ -148,6 +148,7 @@ import {
|
||||
isMarkdownAttachmentContent,
|
||||
isUuidLike,
|
||||
type IssueCreationSource,
|
||||
type IssueParentAdjustment,
|
||||
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
|
||||
type CompactIssue,
|
||||
type CompanySearchExtractQuery,
|
||||
@@ -326,6 +327,13 @@ import {
|
||||
readAcceptedPlanConfirmationTarget,
|
||||
type IssuePostCommitAction,
|
||||
} from "../services/issues.js";
|
||||
import {
|
||||
loadParentChainWith,
|
||||
placeTaskParent,
|
||||
planParentChange,
|
||||
type IssueParentDepthPolicy,
|
||||
type ParentPlacement,
|
||||
} from "../services/issue-hierarchy.js";
|
||||
import {
|
||||
authorizationDeniedDetails,
|
||||
issueReadSqlCondition,
|
||||
@@ -3816,6 +3824,39 @@ export function issueRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
/** A requested parent's ancestor chain, read through the issue service (company-scoped). */
|
||||
function loadRouteParentChain(companyId: string, parentId: string) {
|
||||
return loadParentChainWith(async (id) => {
|
||||
const row = await svc.getById(id);
|
||||
if (!row || row.companyId !== companyId) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
identifier: row.identifier ?? null,
|
||||
parentId: row.parentId ?? null,
|
||||
conversationAgentId: row.conversationAgentId ?? null,
|
||||
executionPolicy: row.executionPolicy ?? null,
|
||||
};
|
||||
}, parentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a new parent edge lands for this actor. Routes authorize this
|
||||
* placement and pass it to the service as `expectedParentId`, which rejects
|
||||
* the write if the tree changed before the service's locked placement.
|
||||
*/
|
||||
async function resolveRouteParentPlacement(
|
||||
companyId: string,
|
||||
requestedParentId: string | null | undefined,
|
||||
policy: IssueParentDepthPolicy,
|
||||
): Promise<ParentPlacement> {
|
||||
if (!requestedParentId || policy === "allow_nested") {
|
||||
return { effectiveParentId: requestedParentId ?? null, adjustment: null };
|
||||
}
|
||||
const chain = await loadRouteParentChain(companyId, requestedParentId);
|
||||
if (chain.length === 0) return { effectiveParentId: requestedParentId, adjustment: null };
|
||||
return placeTaskParent(chain, policy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default structural parent for a delegated follow-up: the task the agent's
|
||||
* run is executing. Returns null when the new task should stay standalone:
|
||||
@@ -3870,13 +3911,26 @@ export function issueRoutes(
|
||||
// parent in scope. A protected parent (or project it supplies) denies
|
||||
// child creation without a grant; that must fall back to standalone work
|
||||
// rather than fail a request that succeeded before the default existed.
|
||||
// Under the agent depth limit a run on a child task delegates beside it.
|
||||
// The placed parent must also accept the edge, or the default falls back
|
||||
// to standalone work.
|
||||
const { effectiveParentId: placedParentId } = await resolveRouteParentPlacement(
|
||||
companyId, source.id, "flatten",
|
||||
);
|
||||
if (!placedParentId) return null;
|
||||
if (placedParentId !== source.id) {
|
||||
const placedParent = await svc.getById(placedParentId);
|
||||
if (!placedParent || isConversation(placedParent)) return null;
|
||||
const placedDecision = await decideIssueAccess(req, placedParent, "issue:mutate");
|
||||
if (!placedDecision.allowed) return null;
|
||||
}
|
||||
const assignmentScope: TaskAssignmentAuthorizationScope = {
|
||||
projectId: await resolveCreateAssignmentProjectId({
|
||||
...rawCreateBody,
|
||||
companyId,
|
||||
parentId: source.id,
|
||||
}),
|
||||
parentIssueId: source.id,
|
||||
parentIssueId: placedParentId,
|
||||
assigneeAgentId,
|
||||
assigneeUserId:
|
||||
typeof rawCreateBody.assigneeUserId === "string"
|
||||
@@ -3891,7 +3945,7 @@ export function issueRoutes(
|
||||
companyId,
|
||||
issueId: null,
|
||||
projectId: assignmentScope.projectId ?? null,
|
||||
parentIssueId: source.id,
|
||||
parentIssueId: placedParentId,
|
||||
assigneeAgentId,
|
||||
assigneeUserId: assignmentScope.assigneeUserId ?? null,
|
||||
},
|
||||
@@ -3908,20 +3962,22 @@ export function issueRoutes(
|
||||
*/
|
||||
async function resolveVisibleIssueCreationSource(
|
||||
req: Request,
|
||||
issue: { id: string; companyId: string; originRunId: string | null },
|
||||
issue: { id: string; companyId: string; originRunId: string | null; originIssueId?: string | null },
|
||||
): Promise<IssueCreationSource | null> {
|
||||
const source = await resolveIssueCreationSource(db, issue);
|
||||
if (!source) return null;
|
||||
const { run, sourceIssue } = source;
|
||||
const readable = await decideIssueAccess(req, sourceIssue, "issue:read");
|
||||
if (!readable.allowed) return null;
|
||||
const runVisible = await canActorReadHeartbeatRun(db, access, req.actor, {
|
||||
// A recorded origin task stands on its own; the run is shown only when visible.
|
||||
const runVisible = run ? await canActorReadHeartbeatRun(db, access, req.actor, {
|
||||
companyId: run.companyId,
|
||||
scopeKind: run.scopeKind,
|
||||
issueId: run.issueId ?? run.nativeIssueId ?? sourceIssue.id,
|
||||
});
|
||||
if (!runVisible) return null;
|
||||
const agent = await loadCreationSourceAgent(db, issue.companyId, run.agentId);
|
||||
}) : false;
|
||||
if (!runVisible && !issue.originIssueId) return null;
|
||||
const visibleRun = run && runVisible ? run : null;
|
||||
const agent = visibleRun ? await loadCreationSourceAgent(db, issue.companyId, visibleRun.agentId) : null;
|
||||
return {
|
||||
issue: {
|
||||
id: sourceIssue.id,
|
||||
@@ -3929,7 +3985,7 @@ export function issueRoutes(
|
||||
title: sourceIssue.title,
|
||||
status: sourceIssue.status,
|
||||
},
|
||||
run: { id: run.id, agentId: run.agentId },
|
||||
run: visibleRun ? { id: visibleRun.id, agentId: visibleRun.agentId } : null,
|
||||
agent,
|
||||
};
|
||||
}
|
||||
@@ -12675,6 +12731,22 @@ export function issueRoutes(
|
||||
const effectiveParentId = watchdogProductBugFollowUp
|
||||
? null
|
||||
: (runDelegationParentId ?? rawCreateBody.parentId);
|
||||
// Board users nest freely. Agents build at most parent → child trees: a
|
||||
// deeper request is placed beside the requester, never rejected. The
|
||||
// service applies the same placement; authorization below checks both the
|
||||
// requested parent and the parent that is actually written.
|
||||
// Watchdog-scoped runs are authorized for the watched subtree, so their
|
||||
// tasks keep the requested parent, as on the /children route.
|
||||
const parentDepthPolicy: IssueParentDepthPolicy =
|
||||
req.actor.type === "board" ||
|
||||
(Boolean(effectiveParentId) && (await resolveTaskWatchdogMutationScope(db, req.actor)).kind === "watchdog")
|
||||
? "allow_nested"
|
||||
: "flatten";
|
||||
const parentPlacement = await resolveRouteParentPlacement(
|
||||
companyId,
|
||||
effectiveParentId ?? null,
|
||||
parentDepthPolicy,
|
||||
);
|
||||
let createParent: Awaited<ReturnType<typeof svc.getById>> | null = null;
|
||||
if (req.actor.type === "agent" && effectiveParentId) {
|
||||
createParent = await svc.getById(effectiveParentId);
|
||||
@@ -12687,6 +12759,18 @@ export function issueRoutes(
|
||||
!(await assertIssueWriteInfluenceAllowed(req, res, createParent))
|
||||
)
|
||||
return;
|
||||
const placedParentId = parentPlacement.effectiveParentId;
|
||||
if (placedParentId && placedParentId !== createParent.id) {
|
||||
const placedParent = await svc.getById(placedParentId);
|
||||
if (
|
||||
!placedParent ||
|
||||
(!isTaskBridgeKeyActor(req) &&
|
||||
!(await assertIssueWriteInfluenceAllowed(req, res, placedParent)))
|
||||
) {
|
||||
if (!res.headersSent) res.status(404).json({ error: "Parent issue not found" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
!watchdogProductBugFollowUp &&
|
||||
@@ -12733,6 +12817,10 @@ export function issueRoutes(
|
||||
const createBody = {
|
||||
...rawCreateBody,
|
||||
parentId: effectiveParentId,
|
||||
parentDepthPolicy,
|
||||
...(parentDepthPolicy === "flatten" && effectiveParentId
|
||||
? { expectedParentId: parentPlacement.effectiveParentId }
|
||||
: {}),
|
||||
...(normalizedAssigneeAgentId !== undefined
|
||||
? { assigneeAgentId: normalizedAssigneeAgentId }
|
||||
: {}),
|
||||
@@ -12814,7 +12902,7 @@ export function issueRoutes(
|
||||
}
|
||||
const createAssignmentScope = {
|
||||
projectId: await resolveCreateAssignmentProjectId({ ...createBody, companyId }),
|
||||
parentIssueId: createBody.parentId ?? null,
|
||||
parentIssueId: parentPlacement.effectiveParentId ?? null,
|
||||
assigneeAgentId: createBody.assigneeAgentId ?? null,
|
||||
assigneeUserId: rawCreateBody.assigneeUserId ?? null,
|
||||
};
|
||||
@@ -12951,6 +13039,13 @@ export function issueRoutes(
|
||||
...(runDelegationParentId && issue.parentId === runDelegationParentId
|
||||
? { parentId: issue.parentId, parentDefaultedFromRunIssue: true }
|
||||
: {}),
|
||||
...(issue.parentAdjusted
|
||||
? {
|
||||
parentId: issue.parentId,
|
||||
parentAdjusted: issue.parentAdjusted,
|
||||
...(runDelegationParentId ? { parentDefaultedFromRunIssue: true } : {}),
|
||||
}
|
||||
: {}),
|
||||
...(watchdogProductBugFollowUp
|
||||
? {
|
||||
watchdogDiscovery: {
|
||||
@@ -13173,9 +13268,38 @@ export function issueRoutes(
|
||||
? { assigneeAgentId: normalizedAssigneeAgentId }
|
||||
: {}),
|
||||
};
|
||||
// Board users nest freely, and watchdog follow-ups are located by parent.
|
||||
// Other agents build at most parent → child trees: a child of a child is
|
||||
// placed beside it instead (never rejected).
|
||||
const childSerializationContext =
|
||||
await resolveWatchdogFollowUpSerializationContext(req, parent);
|
||||
const childParentDepthPolicy: IssueParentDepthPolicy =
|
||||
req.actor.type === "board" ||
|
||||
childSerializationContext ||
|
||||
(await resolveTaskWatchdogMutationScope(db, req.actor)).kind === "watchdog"
|
||||
? "allow_nested"
|
||||
: "flatten";
|
||||
const childPlacement = await resolveRouteParentPlacement(
|
||||
parent.companyId, parent.id, childParentDepthPolicy,
|
||||
);
|
||||
if (
|
||||
req.actor.type === "agent" &&
|
||||
childPlacement.effectiveParentId &&
|
||||
childPlacement.effectiveParentId !== parent.id
|
||||
) {
|
||||
const placedParent = await svc.getById(childPlacement.effectiveParentId);
|
||||
if (
|
||||
!placedParent ||
|
||||
(!isTaskBridgeKeyActor(req) &&
|
||||
!(await assertIssueWriteInfluenceAllowed(req, res, placedParent)))
|
||||
) {
|
||||
if (!res.headersSent) res.status(404).json({ error: "Parent issue not found" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const childAssignmentScope = {
|
||||
projectId: await resolveCreateAssignmentProjectId({ ...createBody, companyId: parent.companyId, parentId: parent.id }),
|
||||
parentIssueId: parent.id,
|
||||
parentIssueId: childPlacement.effectiveParentId,
|
||||
assigneeAgentId: createBody.assigneeAgentId ?? null,
|
||||
assigneeUserId: createBody.assigneeUserId ?? null,
|
||||
};
|
||||
@@ -13197,8 +13321,7 @@ export function issueRoutes(
|
||||
);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const serializationContext =
|
||||
await resolveWatchdogFollowUpSerializationContext(req, parent);
|
||||
const serializationContext = childSerializationContext;
|
||||
const currentSerializedChild = serializationContext
|
||||
? await findCurrentSerializedWatchdogChild(parent)
|
||||
: null;
|
||||
@@ -13230,6 +13353,8 @@ export function issueRoutes(
|
||||
);
|
||||
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
|
||||
...createBody,
|
||||
parentDepthPolicy: childParentDepthPolicy,
|
||||
...(childParentDepthPolicy === "flatten" ? { expectedParentId: childPlacement.effectiveParentId } : {}),
|
||||
workspaceSelectionActor: req.actor,
|
||||
projectId: childAssignmentScope.projectId,
|
||||
...(taskBridgeOriginForActor(req) ?? {}),
|
||||
@@ -13268,7 +13393,10 @@ export function issueRoutes(
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
parentId: parent.id,
|
||||
parentId: issue.parentId,
|
||||
...(issue.parentAdjusted
|
||||
? { requestedParentId: parent.id, parentAdjusted: issue.parentAdjusted }
|
||||
: {}),
|
||||
createdByRequest: childCreatedByRequest(issueId, issue.id),
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
@@ -14176,6 +14304,56 @@ export function issueRoutes(
|
||||
onBehalfOfUserId: _requestedOnBehalfOfUserId,
|
||||
...updateFields
|
||||
} = req.body;
|
||||
// Board users move tasks to any depth. An agent move into a child is
|
||||
// placed beside it, and an agent move of a task with children is skipped,
|
||||
// both with a notice instead of an error.
|
||||
// The service places the move again and rejects it if the tree changed
|
||||
// after this check (`expectedParentId`); it also keeps the requested
|
||||
// parent as the privacy source when the move is placed beside it.
|
||||
const parentDepthPolicy: IssueParentDepthPolicy = req.actor.type === "board" ? "allow_nested" : "flatten";
|
||||
let parentAdjusted: IssueParentAdjustment | null = null;
|
||||
let expectedParentId: string | null | undefined;
|
||||
if (
|
||||
parentDepthPolicy === "flatten" &&
|
||||
typeof updateFields.parentId === "string" &&
|
||||
updateFields.parentId !== existing.parentId
|
||||
) {
|
||||
const requestedParentId = updateFields.parentId;
|
||||
const [existingChild] = await db
|
||||
.select({ id: issueRows.id })
|
||||
.from(issueRows)
|
||||
.where(and(eq(issueRows.companyId, existing.companyId), eq(issueRows.parentId, existing.id)))
|
||||
.limit(1);
|
||||
const currentParent = existingChild && existing.parentId ? await svc.getById(existing.parentId) : null;
|
||||
const parentPlan = planParentChange(
|
||||
existing,
|
||||
await loadRouteParentChain(existing.companyId, requestedParentId),
|
||||
requestedParentId,
|
||||
Boolean(existingChild),
|
||||
currentParent ? { id: currentParent.id, identifier: currentParent.identifier ?? null } : null,
|
||||
parentDepthPolicy,
|
||||
);
|
||||
parentAdjusted = parentPlan.adjustment;
|
||||
// `undefined` means the move is skipped; `null` is a valid top-level placement.
|
||||
expectedParentId = parentPlan.parentId === undefined ? existing.parentId : parentPlan.parentId;
|
||||
if (
|
||||
req.actor.type === "agent" &&
|
||||
parentPlan.parentId &&
|
||||
parentPlan.parentId !== requestedParentId
|
||||
) {
|
||||
const placedParent = await svc.getById(parentPlan.parentId);
|
||||
if (!placedParent || !(await assertIssueWriteInfluenceAllowed(req, res, placedParent))) {
|
||||
if (!res.headersSent) res.status(404).json({ error: "Parent issue not found" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/** The parent this update will actually write, for checks that depend on it. */
|
||||
const plannedParentId = expectedParentId !== undefined ? expectedParentId : updateFields.parentId;
|
||||
// Activity records the parent actually written and why it differs from the request.
|
||||
const parentAdjustedActivityDetails = parentAdjusted
|
||||
? { parentId: plannedParentId ?? null, requestedParentId: parentAdjusted.requestedParent?.id ?? null, parentAdjusted }
|
||||
: {};
|
||||
if (req.body.visibility === "private" && !existing.projectId && updateFields.projectId === undefined) {
|
||||
const responsibleUserId = existing.responsibleUserId ?? (req.actor.type === "board"
|
||||
? req.actor.userId ?? null
|
||||
@@ -14787,13 +14965,13 @@ export function issueRoutes(
|
||||
updateFields.projectId === undefined
|
||||
? existing.projectId
|
||||
: (updateFields.projectId as string | null | undefined),
|
||||
parentIssueId: (updateFields.parentId === undefined
|
||||
parentIssueId: (plannedParentId === undefined
|
||||
? existing.parentId
|
||||
: updateFields.parentId) as string | null | undefined,
|
||||
: plannedParentId) as string | null | undefined,
|
||||
}),
|
||||
parentIssueId: (updateFields.parentId === undefined
|
||||
parentIssueId: (plannedParentId === undefined
|
||||
? existing.parentId
|
||||
: updateFields.parentId) as string | null | undefined,
|
||||
: plannedParentId) as string | null | undefined,
|
||||
assigneeAgentId: nextAssigneeAgentId,
|
||||
assigneeUserId: nextAssigneeUserId,
|
||||
});
|
||||
@@ -14927,9 +15105,9 @@ export function issueRoutes(
|
||||
}
|
||||
|
||||
const nextParentId =
|
||||
updateFields.parentId === undefined
|
||||
plannedParentId === undefined
|
||||
? existing.parentId
|
||||
: (updateFields.parentId as string | null);
|
||||
: (plannedParentId as string | null);
|
||||
const shouldRelayStop =
|
||||
Boolean(nextParentId) &&
|
||||
existing.status !== updateFields.status &&
|
||||
@@ -14956,6 +15134,8 @@ export function issueRoutes(
|
||||
const postCommitIssueActions: IssuePostCommitAction[] = [];
|
||||
const issueUpdateData = {
|
||||
...updateFields,
|
||||
...(updateFields.parentId !== undefined ? { parentDepthPolicy } : {}),
|
||||
...(expectedParentId !== undefined ? { expectedParentId } : {}),
|
||||
workspaceSelectionActor: req.actor,
|
||||
expectedExecutionPolicy,
|
||||
expectedStatusVersion,
|
||||
@@ -15045,6 +15225,7 @@ export function issueRoutes(
|
||||
entityId: updated.id,
|
||||
details: {
|
||||
...updateFields,
|
||||
...parentAdjustedActivityDetails,
|
||||
identifier: updated.identifier,
|
||||
authorizationReason: issueMutationAuthorizationReason,
|
||||
changes,
|
||||
@@ -15431,6 +15612,7 @@ export function issueRoutes(
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
...updateFields,
|
||||
...parentAdjustedActivityDetails,
|
||||
identifier: issue.identifier,
|
||||
authorizationReason: issueMutationAuthorizationReason,
|
||||
changes: issueChanges,
|
||||
@@ -16403,10 +16585,11 @@ export function issueRoutes(
|
||||
updatedAt: issueResponse.updatedAt,
|
||||
changes,
|
||||
comment,
|
||||
...(parentAdjusted ? { parentAdjusted } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json({ ...issueResponse, changes, comment });
|
||||
res.json({ ...issueResponse, changes, comment, ...(parentAdjusted ? { parentAdjusted } : {}) });
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ When a user answers a pending confirmation in chat, read the latest cards and us
|
||||
|
||||
Before handing off work, inspect available projects and repositories. Every task you create from this chat must belong to a suitable project. Reuse an appropriate existing project; otherwise use create_project. Consider all relevant available repositories and pass repositoryIds for one or multiple repositories when the work spans them. For existing GitHub repositories you can access that are absent from the catalog, pass their HTTPS repositoryUrls; this registers them with the project without creating remote GitHub repositories. You may combine known IDs and URLs and attach multiple repositories. The direct HTTP equivalent is POST /api/companies/{companyId}/projects with name, repositoryIds and/or repositoryUrls arrays, and an idempotencyKey. Include all selected repositories in that creation; do not combine these arrays with workspace. Never invent repository IDs or substitute inaccessible repositories. Ask when the choice is materially ambiguous or required access is missing. Non-code projects may need no repository.
|
||||
|
||||
Create ordinary assigned tasks, never subtasks of this conversation. Give each task a clear outcome, context, acceptance criteria, and project. Default the assignee to the agent in this conversation (yourself). Assign a different agent or person only when the user explicitly specifies that owner. A task requested "for me" does not by itself mean assigning it to the user. Do not choose a different agent based on perceived suitability. Use create_task with initialPlan to copy the relevant plan into the new task before execution starts. If using the HTTP API directly, POST /api/companies/{companyId}/issues with projectId, assigneeAgentId, status: "todo", initialPlan containing the relevant plan Markdown, and an idempotencyKey; omit parentId. Putting a plan in description does not create the task's plan document. Verify the new task's plan document before claiming the handoff is complete. Preserve the original plan here. Carry forward only the remaining execution steps, not completed planning, approval, project creation, or task creation steps. Include the source conversation ID, approved plan revision ID, and accepted interaction ID so the worker can verify the recorded approval. State the approved scope and what is already done; do not claim the new plan document has its own approval. The worker should execute that authorized scope, and ask again only if scope changes or another applicable gate requires it. When splitting work, include the relevant part of the plan in each task. Create and link each task before claiming it exists.
|
||||
Create ordinary assigned tasks; this conversation is never a parent. Choose the shape that fits the work: one task for one coherent outcome, a top-level task with child tasks under it when one owner coordinates several parts, or separate top-level tasks for unrelated outcomes. With create_task, create the top-level task first, then pass its ID as parentTaskId for each child. Nest only one level: a child of a child is placed beside it as a sibling. Use blockers, not nesting, for ordering. Give each task a clear outcome, context, acceptance criteria, and project. Default the assignee to the agent in this conversation (yourself). Assign a different agent or person only when the user explicitly specifies that owner. A task requested "for me" does not by itself mean assigning it to the user. Do not choose a different agent based on perceived suitability. Use create_task with initialPlan to copy the relevant plan into the new task before execution starts. If using the HTTP API directly, POST /api/companies/{companyId}/issues with projectId, assigneeAgentId, status: "todo", initialPlan containing the relevant plan Markdown, and an idempotencyKey; omit parentId for a top-level task, or set it to a top-level task you created from this conversation for a child. Putting a plan in description does not create the task's plan document. Verify the new task's plan document before claiming the handoff is complete. Preserve the original plan here. Carry forward only the remaining execution steps, not completed planning, approval, project creation, or task creation steps. Include the source conversation ID, approved plan revision ID, and accepted interaction ID so the worker can verify the recorded approval. State the approved scope and what is already done; do not claim the new plan document has its own approval. The worker should execute that authorized scope, and ask again only if scope changes or another applicable gate requires it. When splitting work, include the relevant part of the plan in each task. Create and link each task before claiming it exists.
|
||||
|
||||
For review or follow-up tasks, include the existing source materials the assignee needs, not just task identifiers or a rubric. Read the source task's saved documents and comments, then copy the relevant existing content into the handoff or attach resources the recipient can actually read. A new task's tools may only expose its own documents and history. Supplying existing source material is coordination, not creating the assigned deliverable.
|
||||
|
||||
@@ -116,7 +116,7 @@ For status updates, distinguish recorded task status from active execution and v
|
||||
Keep discussion here and leave the conversation available for the next message. Link handed-off tasks in your reply; do not make this conversation blocked by their completion or wait for them. Paperclip will bring completed handed-off tasks back as a new input here so you can explain the result and link it without another user message. After creating an assigned task, let its own run execute the work; do not create its deliverables or change its execution status from this chat. Reply normally and end your turn; Paperclip manages the conversation waiting state. Do not change its status, create a review confirmation just to finish a reply, mark it complete, or poll for another reply. An accepted plan authorizes handoff to execution tasks, never implementation on this conversation. Honor normal approvals. Ask mode is non-mutating. Plan mode supports research and writing/revising the plan; hand off for execution only through the normal authorized workflow.`;
|
||||
|
||||
/** Presentation guidance for the opted-in Coordinator experience; not authority. */
|
||||
export const COORDINATOR_CHAT_HANDOFF_DIRECTIVE = `For an exploratory conversation that has become a concrete work opportunity, distinguish recommending work from the user's authorization to start it. Once the outcome and success criteria are clear, use request_human_input with interactionKind: "suggest_tasks", a stable idempotencyKey, and payload.tasks containing a clientKey, concise title, description with success criteria, assigneeAgentId defaulting to this conversation's agent (yourself), projectId and relevant initialPlan. Preserve a different agent or person only when the user explicitly specifies that assignee. Omit parentId: the accepted task is standalone. The suggestion card is a proposal, not a created task. Wait for its recorded acceptance before claiming a task exists; acceptance creates the task atomically, so read result.createdTasks instead of creating it again. Revise or clarify an ambiguous proposal in chat. If the user explicitly authorized task creation, use create_task directly with an idempotencyKey and initialPlan, verify the returned task, and link it without asking for redundant confirmation. An existing approved plan also retains its normal handoff authority. Ask mode remains non-mutating.`;
|
||||
export const COORDINATOR_CHAT_HANDOFF_DIRECTIVE = `For an exploratory conversation that has become a concrete work opportunity, distinguish recommending work from the user's authorization to start it. Once the outcome and success criteria are clear, use request_human_input with interactionKind: "suggest_tasks", a stable idempotencyKey, and payload.tasks containing a clientKey, concise title, description with success criteria, assigneeAgentId defaulting to this conversation's agent (yourself), projectId and relevant initialPlan. Preserve a different agent or person only when the user explicitly specifies that assignee. Accepted tasks are standalone by default. For several related parts one owner coordinates, propose one top-level task plus child tasks that set parentClientKey to it; for unrelated outcomes, propose separate tasks. Nest only one level. The suggestion card is a proposal, not a created task. Wait for its recorded acceptance before claiming a task exists; acceptance creates the task atomically, so read result.createdTasks instead of creating it again. Revise or clarify an ambiguous proposal in chat. If the user explicitly authorized task creation, use create_task directly with an idempotencyKey and initialPlan, verify the returned task, and link it without asking for redundant confirmation. An existing approved plan also retains its normal handoff authority. Ask mode remains non-mutating.`;
|
||||
|
||||
/** A reset keeps history visible, but parked input from a stopped session cannot become a new turn. */
|
||||
export function currentConversationCommentCondition() {
|
||||
|
||||
@@ -1355,6 +1355,8 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
|
||||
{
|
||||
...(defaults.projectId !== undefined ? { projectId: defaults.projectId } : {}),
|
||||
...placement,
|
||||
// Boundary placement is a trust decision; the agent depth limit must not move it.
|
||||
parentDepthPolicy: "allow_nested",
|
||||
...(defaults.workspace ? { workspaceSelection: defaults.workspace, workspaceSelectionSource: "channel" as const } : {}),
|
||||
title: message.subject.slice(0, 200),
|
||||
description: `Email conversation for ${current.botExternalId}`,
|
||||
@@ -1585,6 +1587,8 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) {
|
||||
{
|
||||
title: input.subject!,
|
||||
parentId: input.parentIssueId,
|
||||
// Channel tasks stay inside their source/boundary subtree.
|
||||
parentDepthPolicy: "allow_nested",
|
||||
projectId: sourceTask.projectId,
|
||||
executionWorkspaceSettings: sourceTask.executionWorkspaceSettings,
|
||||
description: `Email conversation from ${endpoint.botExternalId}`,
|
||||
|
||||
@@ -337,6 +337,8 @@ export function createHeartbeatScheduling(
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
parentId: input.claimed.id,
|
||||
// Recovery work is structural system machinery tied to the stranded task.
|
||||
parentDepthPolicy: "allow_nested",
|
||||
projectId: input.claimed.projectId,
|
||||
goalId: input.claimed.goalId,
|
||||
assigneeAgentId: input.claimed.assigneeAgentId,
|
||||
|
||||
@@ -24,11 +24,14 @@ export function createdFromIssueCondition(companyId: string, sourceIssueId: stri
|
||||
nullif(${heartbeatRuns.contextSnapshot}->>'taskKey', '')
|
||||
) IN (${source.id}::text, ${source.identifier})
|
||||
`;
|
||||
// A recorded origin task wins; run and activity fallbacks apply only to rows
|
||||
// without one, so the list agrees with the detail's createdFrom.
|
||||
return sql<boolean>`
|
||||
${issues.id} <> ${sourceIssueId}
|
||||
AND (
|
||||
${issues.originRunId} IN (${originatingRuns})
|
||||
OR (${issues.originRunId} IS NULL AND EXISTS (
|
||||
${issues.originIssueId} = ${sourceIssueId}
|
||||
OR (${issues.originIssueId} IS NULL AND ${issues.originRunId} IN (${originatingRuns}))
|
||||
OR (${issues.originIssueId} IS NULL AND ${issues.originRunId} IS NULL AND EXISTS (
|
||||
SELECT 1 FROM ${activityLog}
|
||||
WHERE ${activityLog.companyId} = ${companyId}
|
||||
AND ${activityLog.entityType} = 'issue'
|
||||
|
||||
@@ -9,9 +9,9 @@ type CreationSourceRun = Pick<HeartbeatRunRow, "id" | "companyId" | "agentId" |
|
||||
contextSnapshot: { issueId: unknown; taskId: unknown; taskKey: unknown; paperclipIssue: { id: unknown } } | null;
|
||||
};
|
||||
|
||||
/** The task a run was executing when it created another task. */
|
||||
/** The task that created another task, and the run that did so when one did. */
|
||||
export interface IssueCreationSourceRecord {
|
||||
run: CreationSourceRun;
|
||||
run: CreationSourceRun | null;
|
||||
sourceIssue: IssueRow;
|
||||
}
|
||||
|
||||
@@ -67,15 +67,35 @@ export async function loadRunBoundIssue(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the task from which an issue was created, using recorded run
|
||||
* provenance: the issue's origin run, or for historical rows without one, the
|
||||
* run on its creation activity. Company-scoped; a run or source task from
|
||||
* another company never resolves. Does not check viewer access.
|
||||
* 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">,
|
||||
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
|
||||
@@ -113,10 +133,7 @@ export async function resolveIssueCreationSource(
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId)))
|
||||
.limit(1);
|
||||
if (!run) return null;
|
||||
const sourceIssue = await loadRunBoundIssue(db, run);
|
||||
if (!sourceIssue || sourceIssue.id === issue.id) return null;
|
||||
return { run, sourceIssue };
|
||||
return run ?? null;
|
||||
}
|
||||
|
||||
/** Company-scoped agent summary for a creation source. */
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { issues, type Db } from "@paperclipai/db";
|
||||
import { MAX_AGENT_TASK_HIERARCHY_DEPTH, type IssueParentAdjustment } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `flatten` (the default) applies the agent hierarchy limit: a new parent edge
|
||||
* may not make the task deeper than MAX_AGENT_TASK_HIERARCHY_DEPTH. Board users
|
||||
* pass `allow_nested` and may build trees of any depth.
|
||||
*/
|
||||
export type IssueParentDepthPolicy = "flatten" | "allow_nested";
|
||||
|
||||
/** One issue on the path from a requested parent up to its tree root. */
|
||||
export type ParentChainRow = {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
parentId: string | null;
|
||||
conversationAgentId: string | null;
|
||||
executionPolicy: unknown;
|
||||
};
|
||||
|
||||
export type ParentPlacement = { effectiveParentId: string | null; adjustment: IssueParentAdjustment | null };
|
||||
|
||||
// Ancestor walks are not depth-capped: placement needs the real root even for
|
||||
// legacy trees deeper than any fixed bound. Both loaders stop on a cycle.
|
||||
|
||||
/** Root of a low-trust authorization boundary recorded on an issue's execution policy. */
|
||||
function lowTrustBoundaryRootIssueId(executionPolicy: unknown): string | null {
|
||||
const policy = executionPolicy as { authorizationPolicy?: { trustBoundary?: { rootIssueId?: unknown } | null } | null } | null;
|
||||
const root = policy?.authorizationPolicy?.trustBoundary?.rootIssueId;
|
||||
return typeof root === "string" && root.length > 0 ? root : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places a new parent edge given the requested parent's ancestor chain
|
||||
* (`chain[0]` is the requested parent, the last entry its root). Under
|
||||
* `flatten`, a parent already at the maximum depth is replaced by its ancestor
|
||||
* at the allowed depth, which makes the task a sibling of the requester. Never
|
||||
* throws for depth.
|
||||
*/
|
||||
export function placeTaskParent(chain: ParentChainRow[], policy: IssueParentDepthPolicy): ParentPlacement {
|
||||
const requested = chain[0];
|
||||
if (!requested || policy === "allow_nested") {
|
||||
return { effectiveParentId: requested?.id ?? null, adjustment: null };
|
||||
}
|
||||
// A parent at depth d (root = 1) gives the new task depth d + 1.
|
||||
const maxParentDepth = MAX_AGENT_TASK_HIERARCHY_DEPTH - 1;
|
||||
if (chain.length <= maxParentDepth) return { effectiveParentId: requested.id, adjustment: null };
|
||||
const targetIndex = chain.length - maxParentDepth;
|
||||
// Low-trust boundaries are subtrees. Never move a task above the root of a
|
||||
// boundary that contains the requester; keep the requested parent instead.
|
||||
const boundaryRoots = new Set(chain.flatMap((row) => lowTrustBoundaryRootIssueId(row.executionPolicy) ?? []));
|
||||
if (chain.slice(0, Math.max(targetIndex, 0)).some((row) => boundaryRoots.has(row.id))) {
|
||||
return { effectiveParentId: requested.id, adjustment: null };
|
||||
}
|
||||
const target = maxParentDepth > 0 ? chain[targetIndex] : null;
|
||||
// Conversations never take new children; legacy trees under one fall back to top level.
|
||||
const effective = target && !target.conversationAgentId ? target : null;
|
||||
return {
|
||||
effectiveParentId: effective?.id ?? null,
|
||||
adjustment: {
|
||||
reason: "max_agent_depth",
|
||||
applied: true,
|
||||
requestedParent: { id: requested.id, identifier: requested.identifier },
|
||||
effectiveParent: effective ? { id: effective.id, identifier: effective.identifier } : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Callers authorize the parent they request. When depth placement moves a task
|
||||
* above that parent, the ancestor is written only if the caller also authorized
|
||||
* it and says so with `expectedParentId` (the routes and the runner do). Other
|
||||
* callers get standalone work instead: no structural parent, never an edge
|
||||
* under an ancestor nobody checked.
|
||||
*/
|
||||
export function confirmPlacement(placement: ParentPlacement, expectedParentId: string | null | undefined): ParentPlacement {
|
||||
if (expectedParentId !== undefined || !placement.adjustment?.applied || placement.effectiveParentId === null) {
|
||||
return placement;
|
||||
}
|
||||
return { effectiveParentId: null, adjustment: { ...placement.adjustment, effectiveParent: null } };
|
||||
}
|
||||
|
||||
/** Loads a requested parent's ancestor chain in one recursive query. Empty when it does not exist. */
|
||||
export async function loadParentChain(
|
||||
db: Pick<Db, "execute">,
|
||||
companyId: string,
|
||||
parentId: string,
|
||||
): Promise<ParentChainRow[]> {
|
||||
const result = await db.execute(sql`
|
||||
WITH RECURSIVE chain AS (
|
||||
SELECT ${issues.id} AS id, ${issues.identifier} AS identifier, ${issues.parentId} AS parent_id,
|
||||
${issues.conversationAgentId} AS conversation_agent_id, ${issues.executionPolicy} AS execution_policy,
|
||||
1 AS depth, ARRAY[${issues.id}] AS path
|
||||
FROM ${issues}
|
||||
WHERE ${issues.companyId} = ${companyId} AND ${issues.id} = ${parentId}
|
||||
UNION ALL
|
||||
SELECT parent.id, parent.identifier, parent.parent_id, parent.conversation_agent_id, parent.execution_policy,
|
||||
chain.depth + 1, chain.path || parent.id
|
||||
FROM ${issues} AS parent
|
||||
JOIN chain ON parent.id = chain.parent_id
|
||||
WHERE parent.company_id = ${companyId}
|
||||
AND NOT parent.id = ANY(chain.path)
|
||||
)
|
||||
SELECT id, identifier, parent_id, conversation_agent_id, execution_policy FROM chain ORDER BY depth
|
||||
`);
|
||||
const rows = (Array.isArray(result) ? result : (result as { rows?: unknown[] }).rows ?? []) as Array<{
|
||||
id: string; identifier: string | null; parent_id: string | null; conversation_agent_id: string | null; execution_policy: unknown;
|
||||
}>;
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
identifier: row.identifier,
|
||||
parentId: row.parent_id,
|
||||
conversationAgentId: row.conversation_agent_id,
|
||||
executionPolicy: row.execution_policy,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Loads an ancestor chain one issue at a time through a caller-supplied, company-scoped reader. */
|
||||
export async function loadParentChainWith(
|
||||
load: (id: string) => Promise<ParentChainRow | null>,
|
||||
parentId: string,
|
||||
): Promise<ParentChainRow[]> {
|
||||
const chain: ParentChainRow[] = [];
|
||||
const visited = new Set<string>();
|
||||
let nextId: string | null = parentId;
|
||||
while (nextId && !visited.has(nextId)) {
|
||||
visited.add(nextId);
|
||||
const row = await load(nextId);
|
||||
if (!row) break;
|
||||
chain.push(row);
|
||||
nextId = row.parentId;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the parent a new edge should use. Unknown parents pass through
|
||||
* unchanged so the caller's own existence and authorization checks still apply.
|
||||
*/
|
||||
export async function resolveNewTaskParent(
|
||||
db: Pick<Db, "execute">,
|
||||
companyId: string,
|
||||
requestedParentId: string | null | undefined,
|
||||
policy: IssueParentDepthPolicy = "flatten",
|
||||
): Promise<ParentPlacement> {
|
||||
if (!requestedParentId || policy === "allow_nested") {
|
||||
return { effectiveParentId: requestedParentId ?? null, adjustment: null };
|
||||
}
|
||||
const chain = await loadParentChain(db, companyId, requestedParentId);
|
||||
if (chain.length === 0) return { effectiveParentId: requestedParentId, adjustment: null };
|
||||
return placeTaskParent(chain, policy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plans a parent change for an existing issue from the requested parent's
|
||||
* chain. Under `flatten`, a move into a child is placed beside it, and a move
|
||||
* of an issue that has its own children is skipped (`parentId: undefined`),
|
||||
* since either would create grandchildren. Never throws for depth.
|
||||
*/
|
||||
export function planParentChange(
|
||||
existing: { parentId: string | null },
|
||||
chain: ParentChainRow[],
|
||||
requestedParentId: string,
|
||||
hasChildren: boolean,
|
||||
currentParent: { id: string; identifier: string | null } | null,
|
||||
policy: IssueParentDepthPolicy,
|
||||
): { parentId: string | null | undefined; adjustment: IssueParentAdjustment | null } {
|
||||
const requested = chain[0];
|
||||
if (policy === "allow_nested" || requestedParentId === existing.parentId || !requested) {
|
||||
return { parentId: requestedParentId, adjustment: null };
|
||||
}
|
||||
if (hasChildren) {
|
||||
return {
|
||||
parentId: undefined,
|
||||
adjustment: {
|
||||
reason: "has_children",
|
||||
applied: false,
|
||||
requestedParent: { id: requested.id, identifier: requested.identifier },
|
||||
effectiveParent: currentParent,
|
||||
},
|
||||
};
|
||||
}
|
||||
const placement = placeTaskParent(chain, "flatten");
|
||||
return { parentId: placement.effectiveParentId, adjustment: placement.adjustment };
|
||||
}
|
||||
|
||||
/** Database-backed {@link planParentChange} for the issue service. */
|
||||
export async function planIssueParentChange(
|
||||
db: Pick<Db, "execute" | "select">,
|
||||
existing: { id: string; companyId: string; parentId: string | null },
|
||||
requestedParentId: string,
|
||||
policy: IssueParentDepthPolicy = "flatten",
|
||||
) {
|
||||
if (policy === "allow_nested" || requestedParentId === existing.parentId) {
|
||||
return { parentId: requestedParentId as string | null | undefined, adjustment: null as IssueParentAdjustment | null };
|
||||
}
|
||||
const chain = await loadParentChain(db, existing.companyId, requestedParentId);
|
||||
const [child] = await db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, existing.companyId), eq(issues.parentId, existing.id)))
|
||||
.limit(1);
|
||||
const [currentParent] = existing.parentId && child ? await db
|
||||
.select({ id: issues.id, identifier: issues.identifier })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, existing.companyId), eq(issues.id, existing.parentId))) : [];
|
||||
return planParentChange(existing, chain, requestedParentId, Boolean(child), currentParent ?? null, policy);
|
||||
}
|
||||
|
||||
/** Adds `parentAdjusted` to a create/update result only when the parent was adjusted. */
|
||||
export function withParentAdjustment<T extends object>(
|
||||
issue: T,
|
||||
adjustment: IssueParentAdjustment | null,
|
||||
): T & { parentAdjusted?: IssueParentAdjustment } {
|
||||
return adjustment ? { ...issue, parentAdjusted: adjustment } : issue;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveNewTaskParent } from "./issue-hierarchy.js";
|
||||
import { notifyStatusCardIssueChange } from "./status-card-notifications.js";
|
||||
import { notifyMergeConfirmationWork, type MergeConfirmationScope } from "./merged-pr-confirmation-notifications.js";
|
||||
import { notifyNativeStatusReassessment } from "./native-status-reassessment-notifications.js";
|
||||
@@ -4371,8 +4372,18 @@ export function issueThreadInteractionService(
|
||||
} } : {}),
|
||||
} satisfies Parameters<ReturnType<typeof issueService>["createChild"]>[1];
|
||||
const service = issueService(tx as unknown as Db);
|
||||
// A nested suggestion may be placed beside its suggested parent, under
|
||||
// that parent's own requested parent. The accept scope authorized that
|
||||
// parent, so confirm the placement only when it lands exactly there.
|
||||
const nestedUnder = task.parentClientKey ? createdByClientKey.get(task.parentClientKey) : undefined;
|
||||
const placement = parentIssueId && nestedUnder
|
||||
? await resolveNewTaskParent(tx as unknown as Db, issue.companyId, parentIssueId, "flatten")
|
||||
: null;
|
||||
const confirmedParent = placement && placement.effectiveParentId === nestedUnder?.parentIssueId
|
||||
? { expectedParentId: placement.effectiveParentId }
|
||||
: {};
|
||||
const createdIssue = parentIssueId
|
||||
? (await service.createChild(parentIssueId, createInput)).issue
|
||||
? (await service.createChild(parentIssueId, { ...createInput, ...confirmedParent })).issue
|
||||
: await service.create(issue.companyId, createInput);
|
||||
|
||||
const parentIdentifier =
|
||||
|
||||
+169
-14
@@ -52,8 +52,17 @@ import { externalConversationStateSql, nonIdleSlackIssueCondition, resumeSlackCo
|
||||
import { documentService } from "./documents.js";
|
||||
import { parseTaskSearch, taskSearchCtes, taskSearchScore } from "./task-search.js";
|
||||
import { createdFromIssueCondition } from "./issue-creation-origin.js";
|
||||
import { loadRunBoundIssue } from "./issue-creation-source.js";
|
||||
import { executionProjectionsForRuns } from "./execution-projection.js";
|
||||
import type { ExecutionProjection } from "@paperclipai/shared";
|
||||
import type { IssueParentAdjustment } from "@paperclipai/shared";
|
||||
import {
|
||||
confirmPlacement,
|
||||
planIssueParentChange,
|
||||
resolveNewTaskParent,
|
||||
withParentAdjustment,
|
||||
type IssueParentDepthPolicy,
|
||||
} from "./issue-hierarchy.js";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
@@ -2043,6 +2052,18 @@ type IssueUserContextInput = {
|
||||
};
|
||||
type ProjectGoalReader = Pick<Db, "select">;
|
||||
type DbReader = Pick<Db, "select">;
|
||||
/**
|
||||
* A new or moved parent edge must point at an issue in the same company. The
|
||||
* foreign key alone checks only existence, so a parent deleted or moved after
|
||||
* a route's check could otherwise land as a cross-company edge.
|
||||
*/
|
||||
async function assertParentInCompany(db: Pick<Db, "select">, companyId: string, parentId: string | null | undefined) {
|
||||
if (!parentId) return;
|
||||
const [parent] = await db.select({ id: issues.id }).from(issues)
|
||||
.where(and(eq(issues.id, parentId), eq(issues.companyId, companyId)));
|
||||
if (!parent) throw notFound("Parent issue not found");
|
||||
}
|
||||
|
||||
/** Conversation containers cannot acquire new child edges, even with the experiment disabled. */
|
||||
async function assertExecutionTaskParent(db: Db, companyId: string, parentId?: string | null) {
|
||||
if (!parentId) return;
|
||||
@@ -2052,7 +2073,15 @@ async function assertExecutionTaskParent(db: Db, companyId: string, parentId?: s
|
||||
}
|
||||
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId" | "title" | "titleNeedsGeneration" | "workspaceSelection"> & {
|
||||
// `originIssueId` is server-derived provenance; callers cannot supply it.
|
||||
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId" | "title" | "titleNeedsGeneration" | "workspaceSelection" | "originIssueId"> & {
|
||||
/** Defaults to `flatten` (agent/automation limit). Board-user routes pass `allow_nested`. */
|
||||
parentDepthPolicy?: IssueParentDepthPolicy;
|
||||
/**
|
||||
* The placed parent the caller authorized. If the tree changed and placement
|
||||
* under the privacy-tree lock differs, the create is rejected, not written.
|
||||
*/
|
||||
expectedParentId?: string | null;
|
||||
workspaceSelection?: TaskWorkspaceSelection;
|
||||
workspaceSelectionSource?: "explicit" | "channel";
|
||||
workspaceSelectionActor?: AuthorizationActor;
|
||||
@@ -5097,6 +5126,7 @@ const issueListSelect = {
|
||||
originKind: issues.originKind,
|
||||
originId: issues.originId,
|
||||
originRunId: issues.originRunId,
|
||||
originIssueId: issues.originIssueId,
|
||||
originIdentityContextId: issues.originIdentityContextId,
|
||||
continuationIdentityContextId: issues.continuationIdentityContextId,
|
||||
originFingerprint: issues.originFingerprint,
|
||||
@@ -9677,7 +9707,8 @@ export function issueService(db: Db) {
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]?.issue ?? null);
|
||||
if (existingChild) {
|
||||
if (existingChild.parentId !== parent.id) {
|
||||
// A placed child keeps the requester as its privacy parent.
|
||||
if (existingChild.parentId !== parent.id && existingChild.privacyParentIssueId !== parent.id) {
|
||||
throw conflict(
|
||||
"Child creation idempotency key belongs to another parent issue",
|
||||
);
|
||||
@@ -9692,19 +9723,30 @@ export function issueService(db: Db) {
|
||||
db,
|
||||
);
|
||||
return {
|
||||
issue: withRelations,
|
||||
issue: withParentAdjustment(withRelations, null),
|
||||
parentBlockerAdded: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Under the agent depth limit a child of a child lands beside it; count
|
||||
// the requester's own fan-out either way.
|
||||
const { effectiveParentId } = confirmPlacement(
|
||||
await resolveNewTaskParent(db, parent.companyId, parent.id, data.parentDepthPolicy ?? "flatten"),
|
||||
data.expectedParentId,
|
||||
);
|
||||
const [{ childCount }] = await db
|
||||
.select({ childCount: sql<number>`count(*)::int` })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, parent.companyId),
|
||||
eq(issues.parentId, parent.id),
|
||||
effectiveParentId === parent.id
|
||||
? eq(issues.parentId, parent.id)
|
||||
: and(
|
||||
effectiveParentId ? eq(issues.parentId, effectiveParentId) : isNull(issues.parentId),
|
||||
eq(issues.privacyParentIssueId, parent.id),
|
||||
),
|
||||
notInArray(issues.status, ["done", "cancelled"]),
|
||||
),
|
||||
);
|
||||
@@ -9803,11 +9845,13 @@ export function issueService(db: Db) {
|
||||
tx,
|
||||
);
|
||||
});
|
||||
const parentAdjusted = child.parentAdjusted;
|
||||
[child] = await withIssueRelationSummaries(
|
||||
parent.companyId,
|
||||
[child],
|
||||
db,
|
||||
);
|
||||
child = withParentAdjustment(child, parentAdjusted ?? null);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -10204,8 +10248,14 @@ export function issueService(db: Db) {
|
||||
allowDuplicate,
|
||||
assertCanReuseIssue,
|
||||
onDeduplicated,
|
||||
parentDepthPolicy = "flatten",
|
||||
expectedParentId,
|
||||
...issueData
|
||||
} = data;
|
||||
// The parent the caller asked for. It still drives provenance, privacy and
|
||||
// workspace inheritance when the depth policy places the task elsewhere.
|
||||
const requestedParentId = issueData.parentId ?? null;
|
||||
let parentAdjustment: IssueParentAdjustment | null = null;
|
||||
const explicitTitle = issueData.title?.trim();
|
||||
const provisionalTitle = issueData.description
|
||||
? provisionalTitleFromDescription(issueData.description)
|
||||
@@ -10244,6 +10294,18 @@ export function issueService(db: Db) {
|
||||
}
|
||||
const persist = async (tx: DbTransaction) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${companyId}`}, 0))`);
|
||||
const parentResolution = confirmPlacement(
|
||||
await resolveNewTaskParent(tx, companyId, requestedParentId, parentDepthPolicy),
|
||||
expectedParentId,
|
||||
);
|
||||
if (expectedParentId !== undefined && parentResolution.effectiveParentId !== expectedParentId) {
|
||||
throw conflict("The task tree changed while this task was checked. Read the tasks again and retry.", {
|
||||
code: "issue_parent_placement_changed",
|
||||
});
|
||||
}
|
||||
parentAdjustment = parentResolution.adjustment;
|
||||
issueData.parentId = parentResolution.effectiveParentId;
|
||||
await assertParentInCompany(tx, companyId, issueData.parentId);
|
||||
await assertExecutionTaskParent(tx as unknown as Db, companyId, issueData.parentId);
|
||||
if (issueData.conversationAgentId && issueData.conversationUserId) {
|
||||
const identity = `conversation:${companyId}:${issueData.conversationAgentId}:${issueData.conversationUserId}`;
|
||||
@@ -10259,7 +10321,10 @@ export function issueService(db: Db) {
|
||||
const idempotencyKey = rawIdempotencyKey?.trim() || null;
|
||||
const normalizedTitle = normalizeCreateIssueTitle(resolvedTitle);
|
||||
if (deduplicateByTitle) {
|
||||
const titleGuardKey = `issue-create:title:${companyId}:${issueData.parentId ?? "root"}:${normalizedTitle}`;
|
||||
// A task placed beside its requester dedupes within that requester's
|
||||
// follow-ups, not across every requester under the same root.
|
||||
const titleScope = parentAdjustment?.applied ? `origin:${requestedParentId}` : (issueData.parentId ?? "root");
|
||||
const titleGuardKey = `issue-create:title:${companyId}:${titleScope}:${normalizedTitle}`;
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${titleGuardKey}, 0))`,
|
||||
);
|
||||
@@ -10317,6 +10382,9 @@ export function issueService(db: Db) {
|
||||
issueData.parentId
|
||||
? eq(issues.parentId, issueData.parentId)
|
||||
: isNull(issues.parentId),
|
||||
...(parentAdjustment?.applied && requestedParentId
|
||||
? [eq(issues.privacyParentIssueId, requestedParentId)]
|
||||
: []),
|
||||
isNull(issues.hiddenAt),
|
||||
notInArray(issues.status, ["done", "cancelled"]),
|
||||
gte(
|
||||
@@ -10372,7 +10440,7 @@ export function issueService(db: Db) {
|
||||
const workspaceInheritanceIssueId = skipExecutionWorkspaceInheritance
|
||||
? null
|
||||
: (inheritExecutionWorkspaceFromIssueId ??
|
||||
issueData.parentId ??
|
||||
requestedParentId ??
|
||||
null);
|
||||
const hasExplicitExecutionWorkspaceOverride =
|
||||
(workspaceSelection !== undefined && workspaceSelectionSource === "explicit") ||
|
||||
@@ -10618,14 +10686,19 @@ export function issueService(db: Db) {
|
||||
companyId, ...conversationHandoff,
|
||||
userId: issueData.createdByUserId, agentId: issueData.createdByAgentId,
|
||||
}) : null;
|
||||
let privacyParentIssueId = issueData.parentId ?? handoffSource?.id ?? null;
|
||||
// The task the creating run was bound to, when a run created this task.
|
||||
let runSourceIssueId: string | null = null;
|
||||
const sourceRunId = issueData.originRunId ?? actorRunId;
|
||||
if (!privacyParentIssueId && sourceRunId) {
|
||||
if (sourceRunId && isUuidLike(sourceRunId)) {
|
||||
const [sourceRun] = await tx.select().from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, sourceRunId), eq(heartbeatRuns.companyId, companyId)));
|
||||
const sourceId = sourceRun?.nativeIssueId ?? sourceRun?.issueId ?? sourceRun?.contextSnapshot?.issueId;
|
||||
if (typeof sourceId === "string") privacyParentIssueId = sourceId;
|
||||
// Same binding rules as creation-source reads: native identity, issue
|
||||
// scope, then legacy task context (including identifiers), company-scoped.
|
||||
if (sourceRun) runSourceIssueId = (await loadRunBoundIssue(tx as unknown as Db, sourceRun))?.id ?? null;
|
||||
}
|
||||
// Privacy follows structure: the requested parent (even when placed
|
||||
// beside it), the chat that handed this off, or the run's task.
|
||||
let privacyParentIssueId = requestedParentId ?? handoffSource?.id ?? runSourceIssueId;
|
||||
let inheritedPrivateSubtree = false;
|
||||
if (privacyParentIssueId) {
|
||||
const [source] = await tx.select().from(issues)
|
||||
@@ -10667,12 +10740,33 @@ export function issueService(db: Db) {
|
||||
issueData.projectId = personalProject.id;
|
||||
}
|
||||
|
||||
// origin_issue_id is returned with the task, so record it only when it
|
||||
// reveals nothing new: the task's own parent or privacy parent (already
|
||||
// exposed on the task), or a fully public origin (open, outside private
|
||||
// projects and subtrees, not hidden). Otherwise leave it null; createdFrom
|
||||
// then resolves the source through the run, which checks access on read.
|
||||
let originIssueId: string | null = runSourceIssueId ?? handoffSource?.id ?? privacyParentIssueId;
|
||||
if (originIssueId && originIssueId !== issueData.parentId && originIssueId !== privacyParentIssueId) {
|
||||
const [origin] = await tx.select({
|
||||
visibility: issues.visibility, privacyRootIssueId: issues.privacyRootIssueId,
|
||||
projectId: issues.projectId, hiddenAt: issues.hiddenAt,
|
||||
}).from(issues).where(and(eq(issues.id, originIssueId), eq(issues.companyId, companyId)));
|
||||
const originPublic = Boolean(origin) && origin!.visibility !== "private" && !origin!.privacyRootIssueId &&
|
||||
!origin!.hiddenAt && !(origin!.projectId && await tx.select({ id: projects.id }).from(projects)
|
||||
.where(and(eq(projects.id, origin!.projectId), eq(projects.visibility, "private"))).limit(1)
|
||||
.then((rows) => rows.length > 0));
|
||||
if (!originPublic) originIssueId = null;
|
||||
}
|
||||
const values = {
|
||||
...issueData,
|
||||
workspaceSelection: workspaceIntent,
|
||||
visibility,
|
||||
privacyRootIssueId,
|
||||
privacyParentIssueId,
|
||||
// Audit-only provenance; never read for hierarchy or authorization.
|
||||
// Audit-only provenance: the task whose work created this one. A run
|
||||
// working on task A that files work under task B originates from A.
|
||||
originIssueId,
|
||||
titleNeedsGeneration,
|
||||
originRunId: issueData.originRunId ?? actorRunId ?? null,
|
||||
responsibleUserId,
|
||||
@@ -10789,10 +10883,15 @@ export function issueService(db: Db) {
|
||||
[enriched],
|
||||
tx,
|
||||
);
|
||||
insertedNewIssue = true;
|
||||
return withRelations;
|
||||
};
|
||||
if (dbOrTx === db) return db.transaction(persist);
|
||||
return persist(dbOrTx as DbTransaction);
|
||||
let insertedNewIssue = false;
|
||||
const created = dbOrTx === db
|
||||
? await db.transaction(persist)
|
||||
: await persist(dbOrTx as DbTransaction);
|
||||
// Deduplicated returns are existing rows; only a fresh insert reports an adjustment.
|
||||
return withParentAdjustment(created, insertedNewIssue ? parentAdjustment : null);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -11139,6 +11238,13 @@ export function issueService(db: Db) {
|
||||
workspaceSelectionActor?: AuthorizationActor;
|
||||
expectedExecutionPolicy?: typeof issues.$inferInsert.executionPolicy;
|
||||
expectedStatusVersion?: number;
|
||||
/** Defaults to `flatten` for parent changes. Board-user routes pass `allow_nested`. */
|
||||
parentDepthPolicy?: IssueParentDepthPolicy;
|
||||
/**
|
||||
* The placed parent the caller authorized. A different placement (the
|
||||
* tree changed since that check) is rejected instead of written.
|
||||
*/
|
||||
expectedParentId?: string | null;
|
||||
},
|
||||
dbOrTx: any = db,
|
||||
postCommitActivityPublications?: ActivityPublication[],
|
||||
@@ -11166,6 +11272,40 @@ export function issueService(db: Db) {
|
||||
.where(idPredicate)
|
||||
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
let parentAdjustment: IssueParentAdjustment | null = null;
|
||||
// A move placed beside its requested parent still inherits that parent's privacy.
|
||||
let privacySourceParentId: string | null = null;
|
||||
// Re-checked under the privacy-tree lock: a concurrent child create can
|
||||
// change whether this move is allowed after the unlocked plan.
|
||||
let lockedParentPlan: { requestedParentId: string; policy: IssueParentDepthPolicy; plannedParentId: string | null } | null = null;
|
||||
if (data.parentId && data.parentId !== existing.parentId) {
|
||||
// A skipped move drops only `parentId`; the rest of the patch applies.
|
||||
const requestedParentId = data.parentId;
|
||||
let plan = await planIssueParentChange(dbOrTx, existing, requestedParentId, data.parentDepthPolicy ?? "flatten");
|
||||
// A placement above the requested parent needs the caller's confirmed
|
||||
// authorization (expectedParentId); otherwise only the move is skipped.
|
||||
if (plan.adjustment?.applied && plan.parentId !== null && data.expectedParentId === undefined) {
|
||||
// The task keeps its current parent; report that one.
|
||||
const [currentParent] = existing.parentId ? await dbOrTx
|
||||
.select({ id: issues.id, identifier: issues.identifier })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, existing.companyId), eq(issues.id, existing.parentId))) : [];
|
||||
plan = { parentId: undefined, adjustment: { ...plan.adjustment, applied: false, effectiveParent: currentParent ?? null } };
|
||||
}
|
||||
const plannedParentId = plan.parentId === undefined ? existing.parentId : plan.parentId;
|
||||
if (data.expectedParentId !== undefined && plannedParentId !== data.expectedParentId) {
|
||||
throw conflict("The task tree changed while this move was checked. Read the tasks again and retry.", {
|
||||
code: "issue_parent_placement_changed",
|
||||
});
|
||||
}
|
||||
parentAdjustment = plan.adjustment;
|
||||
if (plan.adjustment?.applied) privacySourceParentId = requestedParentId;
|
||||
lockedParentPlan = { requestedParentId, policy: data.parentDepthPolicy ?? "flatten", plannedParentId };
|
||||
const { parentId: _requestedParentId, ...rest } = data;
|
||||
data = plan.parentId === undefined || plan.parentId === existing.parentId
|
||||
? rest
|
||||
: { ...rest, parentId: plan.parentId };
|
||||
}
|
||||
if (data.parentId !== undefined && data.parentId !== existing.parentId) {
|
||||
await assertExecutionTaskParent(dbOrTx, existing.companyId, data.parentId);
|
||||
}
|
||||
@@ -11190,6 +11330,10 @@ export function issueService(db: Db) {
|
||||
workspaceSelectionActor,
|
||||
expectedExecutionPolicy,
|
||||
expectedStatusVersion,
|
||||
parentDepthPolicy: _parentDepthPolicy,
|
||||
expectedParentId: _expectedParentId,
|
||||
// Creation provenance never changes after create.
|
||||
originIssueId: _originIssueId,
|
||||
...issueData
|
||||
} = data;
|
||||
if (expectedStatusVersion !== undefined && (
|
||||
@@ -11440,6 +11584,16 @@ export function issueService(db: Db) {
|
||||
const runUpdate = async (tx: any) => {
|
||||
const changesPrivacy = issueData.visibility !== undefined || issueData.parentId !== undefined || issueData.projectId !== undefined;
|
||||
if (changesPrivacy) await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${existing.companyId}`}, 0))`);
|
||||
if (issueData.parentId) await assertParentInCompany(tx, existing.companyId, issueData.parentId);
|
||||
if (lockedParentPlan && issueData.parentId !== undefined) {
|
||||
const relocked = await planIssueParentChange(tx, existing, lockedParentPlan.requestedParentId, lockedParentPlan.policy);
|
||||
const relockedParentId = relocked.parentId === undefined ? existing.parentId : relocked.parentId;
|
||||
if (relockedParentId !== lockedParentPlan.plannedParentId) {
|
||||
throw conflict("The task tree changed while this move was checked. Read the tasks again and retry.", {
|
||||
code: "issue_parent_placement_changed",
|
||||
});
|
||||
}
|
||||
}
|
||||
// The receipt baseline must be read under the same row lock as the
|
||||
// write. Otherwise a concurrent update can be mistaken for a change
|
||||
// made by this request.
|
||||
@@ -11512,7 +11666,8 @@ export function issueService(db: Db) {
|
||||
patch.privacyRootIssueId = receiptExisting.privacyRootIssueId ?? receiptExisting.id;
|
||||
}
|
||||
}
|
||||
const privacyParentId = issueData.parentId !== undefined ? issueData.parentId : receiptExisting.privacyParentIssueId;
|
||||
const privacyParentId = privacySourceParentId
|
||||
?? (issueData.parentId !== undefined ? issueData.parentId : receiptExisting.privacyParentIssueId);
|
||||
const [privacyParent] = privacyParentId ? await tx.select().from(issues)
|
||||
.where(and(eq(issues.id, privacyParentId), eq(issues.companyId, existing.companyId))) : [];
|
||||
patch.privacyParentIssueId = privacyParent?.id ?? null;
|
||||
@@ -12043,7 +12198,7 @@ export function issueService(db: Db) {
|
||||
if (dbOrTx === db && !postCommitActions) {
|
||||
await executeIssuePostCommitActions(db, ownedPostCommitActions);
|
||||
}
|
||||
return result;
|
||||
return result ? withParentAdjustment(result, parentAdjustment) : result;
|
||||
},
|
||||
|
||||
clearExecutionWorkspaceEnvironmentSelection: async (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -47,8 +47,15 @@ export async function callProjectTool(input: {
|
||||
} : {}),
|
||||
assigneeAgentId: args.assigneeActorId ?? (args.assigneeUserId ? null : input.agentId),
|
||||
assigneeUserId: args.assigneeUserId,
|
||||
parentId: input.conversation ? null : input.issueId,
|
||||
status: Array.isArray(args.blockedByTaskIds) && args.blockedByTaskIds.length ? "blocked" : "todo",
|
||||
// An explicit parent goes through the route's depth placement and
|
||||
// authorization; a conversation itself is never a parent.
|
||||
parentId: typeof args.parentTaskId === "string" && args.parentTaskId
|
||||
? args.parentTaskId
|
||||
: (input.conversation ? null : input.issueId),
|
||||
// Same rule as the native tool: backlog is honored and never wakes an
|
||||
// agent; otherwise unresolved blockers make the task blocked.
|
||||
status: args.status === "backlog" ? "backlog"
|
||||
: Array.isArray(args.blockedByTaskIds) && args.blockedByTaskIds.length ? "blocked" : "todo",
|
||||
blockedByIssueIds: args.blockedByTaskIds,
|
||||
idempotencyKey: `chat-handoff:${input.issueId}:${key}`,
|
||||
});
|
||||
|
||||
@@ -1911,6 +1911,9 @@ export function routineService(
|
||||
projectWorkspaceId,
|
||||
goalId: input.routine.goalId,
|
||||
parentId: input.routine.parentIssueId,
|
||||
// A routine's parent is configured, not chosen per run. Human-configured
|
||||
// routines keep any depth; agent-authored ones follow the agent limit.
|
||||
parentDepthPolicy: input.routine.createdByAgentId ? "flatten" : "allow_nested",
|
||||
title,
|
||||
description,
|
||||
status: "todo",
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function readTaskCreationProvenance(db: Db, companyId: string, task
|
||||
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,
|
||||
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))));
|
||||
@@ -79,6 +79,8 @@ export async function readTaskCreationProvenance(db: Db, companyId: string, task
|
||||
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,
|
||||
|
||||
@@ -1403,6 +1403,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {})
|
||||
status: "todo",
|
||||
assigneeAgentId: input.watchdog.watchdogAgentId,
|
||||
parentId: input.sourceIssue.id,
|
||||
parentDepthPolicy: "allow_nested",
|
||||
projectId: input.sourceIssue.projectId,
|
||||
goalId: input.sourceIssue.goalId,
|
||||
billingCode: input.sourceIssue.billingCode,
|
||||
@@ -1455,6 +1456,9 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {})
|
||||
status: "todo",
|
||||
priority: input.sourceIssue.priority,
|
||||
parentId: input.sourceIssue.id,
|
||||
// Watchdog review issues and their serialized follow-ups are located by
|
||||
// parent; they are system machinery, not agent-authored decomposition.
|
||||
parentDepthPolicy: "allow_nested",
|
||||
projectId: input.sourceIssue.projectId,
|
||||
goalId: input.sourceIssue.goalId,
|
||||
assigneeAgentId: input.watchdog.watchdogAgentId,
|
||||
|
||||
Reference in new issue
Block a user