From c8822ffd4baec0ab662738cdef6315f58e806d63 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:06:56 -0600 Subject: [PATCH] fix(runner): deliver a lead's queued requirement to a busy task's next turn (#20028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - In chat, a lead agent routes new requirements to tasks that other agents own > - The lead uses the native `comment_on_task` tool for this > - The tool description only says the comment does not reopen or interrupt the task > - A lead read this as "the busy assignee never receives it" and tried other routes, such as the assignee's workspace files > - The comment API also keeps another agent's comment as a note unless it sends `resume: true`, and the tool could not send it, so the queued change was dropped when the worker completed the task first > - This pull request states the real queued-delivery behavior and adds an optional `resume` flag to the tool > - The benefit is that the lead routes the change once, through the product, and reports it honestly as saved for later ## Linked Issues or Issue Description **What happened** The person asked the chat lead to change the headline of an existing website task. The website assignee was in an active run. The lead read `comment_on_task` ("without reopening or interrupting it") and concluded in its reasoning that the running worker would not see a comment. It then listed the assignee's workspace directory and considered writing the worker's input file directly. It used its full reply window without routing the change. When the lead did post the change with `comment_on_task`, Paperclip queued it behind the active run. The worker then completed the task. The wake queue cancelled the deferred wake ("no longer applies to a terminal task") because an agent comment carries follow-up intent only with `resume: true`, and `comment_on_task` sent only `{ body }`. The requirement never reached the worker. **Expected behavior** The lead posts the change as a comment on the existing task with follow-up intent. Paperclip queues it and delivers it to the assignee's next turn, also when the assignee completes the task first. The lead tells the person that the change is saved for that next turn, not already applied. **Steps to reproduce** 1. Start a task for a worker agent and keep the worker in an active run. 2. In the lead's chat, ask for a change to that task's requirements. 3. Read the lead's reasoning: it cannot tell from the tool description that a comment reaches a busy assignee later. 4. When the lead comments, let the worker finish the task as done: no continuation run starts for the queued comment. ## What Changed - `server/src/services/native-runtime/runner-bridge-tools.ts`: the `comment_on_task` description now says to use it for a new or changed requirement on an existing task, instead of a duplicate task or the task's workspace files. It says that a comment on a running task is queued and delivered at the start of the assignee's next turn on that task, and that the agent reports it as saved, not applied. - The same tool now accepts an optional `resume` boolean. The description tells agents to set it for a requirement change, and explains that without it a comment on finished work is only a note. - `server/src/services/native-runtime/paperclip-runner-tool-authority.ts`: `taskBridgeApiRequest` forwards `resume: true` to `POST /api/issues/{id}/comments` and omits it otherwise. The existing comment-route permission check for follow-up intent on another agent's task still applies. - `server/src/services/native-runtime/runner-bridge-tools.test.ts`: regression tests assert the advertised description and schema, and the forwarded request body. ## Verification - `cd server && pnpm exec vitest run src/services/native-runtime/runner-bridge-tools.test.ts` passes (11 passed, 6 platform-skipped). - The wake-queue rule that keeps a resume-intent comment alive after completion is already covered in `server/src/modules/wake-queue/application/use-cases.test.ts` (`done_no_resume` against resume). - End to end: the `agent-chat.multiple-handoffs` chat eval, run with this commit cherry-picked onto its case branch. Results are added as a comment. ## Risks - Low risk. No route or permission changes. The new `resume` field uses the existing comment API flag and its existing authorization (assignee, manager override, or open-write access). - An agent that sets `resume: true` on a comment to finished work reopens that work. This is the existing API behavior; the description limits it to requirement changes. - A runner with an active session goal can steer a comment into the live turn. The description describes the normal queued path. It does not promise the live path. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), agentic coding with tool use (shell, file edits). ## 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 - [x] 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 --- .../paperclip-runner-tool-authority.ts | 4 ++-- .../runner-bridge-tools.test.ts | 21 +++++++++++++++++++ .../native-runtime/runner-bridge-tools.ts | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 3b57ae63768..2db9f54c878 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -291,11 +291,11 @@ const TASK_API_PATHS: Readonly> = { write_task_document: ["PUT", "/api/issues/{id}/documents/{key}"], }; -function taskBridgeApiRequest(tool: string, input: Record): RunnerApiCall { +export function taskBridgeApiRequest(tool: string, input: Record): RunnerApiCall { const [method, path] = TASK_API_PATHS[tool]!; const operation = runnerApiCatalog().find(operation => operation.method === method && operation.path === path); if (!operation) throw new Error("runner_task_api_operation_unavailable"); - const body = tool === "comment_on_task" ? { body: input.body } : tool === "write_task_document" + const body = tool === "comment_on_task" ? { body: input.body, ...(input.resume === true ? { resume: true } : {}) } : tool === "write_task_document" ? { title: input.title, format: "markdown", body: input.body, baseRevisionId: input.baseRevisionId } : undefined; return { operationId: operation.operationId, pathParams: { id: input.taskId, ...(input.key ? { key: input.key } : {}) }, ...(body ? { body } : {}) }; } diff --git a/server/src/services/native-runtime/runner-bridge-tools.test.ts b/server/src/services/native-runtime/runner-bridge-tools.test.ts index d06839777ac..b46cf65e215 100644 --- a/server/src/services/native-runtime/runner-bridge-tools.test.ts +++ b/server/src/services/native-runtime/runner-bridge-tools.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { materializeAsset } from "./runtime-context.js"; +import { taskBridgeApiRequest } from "./paperclip-runner-tool-authority.js"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; @@ -86,6 +87,26 @@ describe("Runner workspace bridge", () => { expect(await readFile(join(directory, "result.txt"), "utf8")).toBe("hello"); }); + it("tells agents that a comment on a running task waits for the assignee's next turn", () => { + // A chat lead routing a requirement change to busy work otherwise reads "without interrupting" + // as "never delivered" and reaches for the assignee's workspace or a duplicate task instead. + const comment = runnerBridgeDefinitions({ workspace: false, skills: false, api: true, mode: "standard" }).find(tool => tool.name === "comment_on_task"); + expect(comment?.description).toContain("existing task's assignee a new or changed requirement"); + expect(comment?.description).toContain("rather than creating a duplicate task or editing that task's workspace files"); + expect(comment?.description).toContain("delivers it at the start of the assignee's next turn on that task"); + expect(comment?.description).toContain("set resume: true for such a requirement"); + expect(comment?.description).toContain("delivery still happens if the assignee completes the task first"); + expect(comment?.description).toContain("not as already applied"); + expect((comment?.inputSchema as { properties?: Record }).properties?.resume).toEqual({ type: "boolean" }); + }); + it("forwards a comment's explicit resume intent to the comment API and omits it otherwise", () => { + const taskId = "8ba8b5a2-066a-4bb2-b591-e396942ecdf5"; + expect(taskBridgeApiRequest("comment_on_task", { taskId, body: "Set the headline to Launch sooner.", resume: true }).body) + .toEqual({ body: "Set the headline to Launch sooner.", resume: true }); + expect(taskBridgeApiRequest("comment_on_task", { taskId, body: "FYI only." }).body).toEqual({ body: "FYI only." }); + expect(taskBridgeApiRequest("comment_on_task", { taskId, body: "FYI only.", resume: false }).body).toEqual({ body: "FYI only." }); + }); + it.skipIf(process.platform === "linux")("does not expose or execute commands without descendant containment", async () => { const directory = await root(); expect(workspaceCommandSandboxAvailable()).toBe(false); diff --git a/server/src/services/native-runtime/runner-bridge-tools.ts b/server/src/services/native-runtime/runner-bridge-tools.ts index fab6e460af7..f66327e559f 100644 --- a/server/src/services/native-runtime/runner-bridge-tools.ts +++ b/server/src/services/native-runtime/runner-bridge-tools.ts @@ -14,7 +14,7 @@ const specs = [ ["get_identity", "Read your agent identity, responsible person, permissions and currently available tools.", z.object({}).strict(), "read"], ["list_people", "List active company people and their IDs for human assignment; roles identify the owner.", z.object({ after: z.string().optional(), limit: z.number().int().min(1).max(100).default(50) }).strict(), "read"], ["get_task", "Read another task through normal task permissions.", z.object({ taskId: z.uuid() }).strict(), "read"], - ["comment_on_task", "Post an attributed comment on an authorized task, without reopening or interrupting it.", z.object({ taskId: z.uuid(), body: z.string().trim().min(1).max(20000) }).strict(), "mutation"], + ["comment_on_task", "Post an attributed comment on an authorized task, without reopening or interrupting it. Use it to give an existing task's assignee a new or changed requirement, rather than creating a duplicate task or editing that task's workspace files, and set resume: true for such a requirement. If the assignee is running on that task now, the comment does not reach the run in progress: Paperclip saves it in the task's queue and delivers it at the start of the assignee's next turn on that task. With resume: true that delivery still happens if the assignee completes the task first; without it, a comment on finished work is only a note. Report a queued requirement as saved for the next turn, not as already applied.", z.object({ taskId: z.uuid(), body: z.string().trim().min(1).max(20000), resume: z.boolean().optional() }).strict(), "mutation"], ["list_task_documents", "List documents on an authorized task.", z.object({ taskId: z.uuid() }).strict(), "read"], ["read_task_document", "Read a document on an authorized task.", z.object({ taskId: z.uuid(), key: documentKey }).strict(), "read"], ["write_task_document", "Write a document on an authorized task using its current revision. Permissions and document locks apply.", z.object({ taskId: z.uuid(), key: documentKey, title: z.string().min(1).max(200), body: z.string().max(20000), baseRevisionId: z.uuid().nullable() }).strict(), "mutation"],