mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24: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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user