mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(runner): deliver a lead's queued requirement to a busy task's next turn (#20028)
## 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3a941372d
commit
c8822ffd4b
3 files changed
+24
-3
No files matched your search
@@ -291,11 +291,11 @@ const TASK_API_PATHS: Readonly<Record<string, readonly [string, string]>> = {
|
||||
write_task_document: ["PUT", "/api/issues/{id}/documents/{key}"],
|
||||
};
|
||||
|
||||
function taskBridgeApiRequest(tool: string, input: Record<string, any>): RunnerApiCall {
|
||||
export function taskBridgeApiRequest(tool: string, input: Record<string, any>): 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 } : {}) };
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> }).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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user