From eb63af2348a6ca361acb3cad76f2e559ed4a7af1 Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 11 Sep 2026 12:39:36 +0200 Subject: [PATCH 1/2] fix(cli): require explicit heartbeat scope --- cli/src/__tests__/agent-lifecycle.test.ts | 27 +++++++++++- cli/src/commands/client/agent.ts | 53 ++++++++++++++++++++++- doc/CLI.md | 3 +- server/src/routes/openapi.ts | 20 ++++++++- 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/cli/src/__tests__/agent-lifecycle.test.ts b/cli/src/__tests__/agent-lifecycle.test.ts index 41817823db..bf1956864c 100644 --- a/cli/src/__tests__/agent-lifecycle.test.ts +++ b/cli/src/__tests__/agent-lifecycle.test.ts @@ -5,6 +5,7 @@ import { registerAgentCommands } from "../commands/client/agent.js"; const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; const AGENT_ID = "11111111-1111-4111-8111-111111111111"; const REVISION_ID = "33333333-3333-4333-8333-333333333333"; +const ISSUE_ID = "44444444-4444-4444-8444-444444444444"; function createProgram(): Command { const program = new Command(); @@ -52,7 +53,7 @@ describe("agent lifecycle commands", () => { await run(["agent", "resume", AGENT_ID]); await run(["agent", "approve", AGENT_ID]); await run(["agent", "terminate", AGENT_ID]); - await run(["agent", "heartbeat:invoke", AGENT_ID]); + await run(["agent", "heartbeat:invoke", AGENT_ID, "--allow-unscoped"]); await run(["agent", "claude-login", AGENT_ID]); await run(["agent", "delete", AGENT_ID, "--yes"]); @@ -70,6 +71,30 @@ describe("agent lifecycle commands", () => { ]); }); + it("binds a manual heartbeat invoke to the assigned issue", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({ + id: ISSUE_ID, + identifier: "PC-42", + assigneeAgentId: AGENT_ID, + }))); + vi.stubGlobal("fetch", fetchMock); + + await run(["agent", "heartbeat:invoke", AGENT_ID, "--issue-id", "PC-42"]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", "http://localhost:3100/api/issues/PC-42"], + ["POST", `http://localhost:3100/api/agents/${AGENT_ID}/heartbeat/invoke`], + ]); + expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({ + reason: "manual_issue_wake", + payload: { + issueId: ISSUE_ID, + taskId: ISSUE_ID, + taskKey: ISSUE_ID, + }, + }); + }); + it("wraps configuration, runtime, skills, and instructions endpoints", async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); vi.stubGlobal("fetch", fetchMock); diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 3f81da6c6b..084fc6ff36 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -56,6 +56,11 @@ interface AgentWakeOptions extends BaseClientOptions { forceFreshSession?: boolean; } +interface AgentHeartbeatInvokeOptions extends BaseClientOptions { + issueId?: string; + allowUnscoped?: boolean; +} + interface AgentJsonPayloadOptions extends BaseClientOptions { companyId?: string; payloadJson: string; @@ -420,7 +425,6 @@ export function registerAgentCommands(program: Command): void { ["resume", "resume", "Resume an agent"], ["approve", "approve", "Approve a pending agent"], ["terminate", "terminate", "Terminate an agent"], - ["heartbeat:invoke", "heartbeat/invoke", "Invoke an agent heartbeat"], ["claude-login", "claude-login", "Trigger Claude login for an agent"], ] as const) { addCommonClientOptions( @@ -440,6 +444,53 @@ export function registerAgentCommands(program: Command): void { ); } + addCommonClientOptions( + agent + .command("heartbeat:invoke") + .description("Invoke an issue-scoped agent heartbeat") + .argument("", "Agent ID") + .option("--issue-id ", "Issue UUID or identifier to bind to the run") + .option("--allow-unscoped", "Explicitly allow a run with no issue binding") + .action(async (agentId: string, opts: AgentHeartbeatInvokeOptions) => { + try { + if (opts.issueId && opts.allowUnscoped) { + throw new Error("Use either --issue-id or --allow-unscoped, not both"); + } + if (!opts.issueId && !opts.allowUnscoped) { + throw new Error("Specify --issue-id for task work or --allow-unscoped for a generic heartbeat"); + } + + const ctx = resolveCommandContext(opts); + let body: Record = {}; + if (opts.issueId) { + const issue = await ctx.api.get(`/api/issues/${encodeURIComponent(opts.issueId)}`); + if (!issue) { + throw new Error(`Issue not found: ${opts.issueId}`); + } + if (issue.assigneeAgentId !== agentId) { + throw new Error(`Issue ${issue.identifier} is not assigned to agent ${agentId}`); + } + body = { + reason: "manual_issue_wake", + payload: { + issueId: issue.id, + taskId: issue.id, + taskKey: issue.id, + }, + }; + } + + const result = await ctx.api.post( + `${apiPath`/api/agents/${agentId}`}/heartbeat/invoke`, + body, + ); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( agent .command("permissions:update") diff --git a/doc/CLI.md b/doc/CLI.md index 04264ddf11..b19868330a 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -541,7 +541,8 @@ npx paperclipai agent pause npx paperclipai agent resume npx paperclipai agent approve npx paperclipai agent terminate -npx paperclipai agent heartbeat:invoke +npx paperclipai agent heartbeat:invoke --issue-id +npx paperclipai agent heartbeat:invoke --allow-unscoped npx paperclipai agent claude-login npx paperclipai agent local-cli --company-id ``` diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 8ec5e2d49c..227b12c6a9 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -6638,8 +6638,24 @@ registry.registerPath({ path: "/api/agents/{id}/heartbeat/invoke", tags: ["agents"], summary: "Invoke agent heartbeat", - request: { params: z.object({ id: z.string() }) }, - responses: { 200: r.ok(), 401: r.unauthorized }, + description: + "Legacy manual invoke endpoint. Bind task work by supplying payload.issueId, payload.taskId, and payload.taskKey with the same issue UUID. An omitted body creates an intentionally unscoped run.", + request: { + params: z.object({ id: z.string() }), + body: { + content: { + "application/json": { schema: wakeAgentSchema.omit({ failedRunId: true }) }, + }, + required: false, + }, + }, + responses: { + 202: r.ok(), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, }); registry.registerPath({ From 6aa7cee733c4d2420401f87913bad88dc93da782 Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 11 Sep 2026 12:51:12 +0200 Subject: [PATCH 2/2] test(cli): cover heartbeat scope guards --- cli/src/__tests__/agent-lifecycle.test.ts | 46 +++++++++++++++++++++++ server/src/routes/openapi.ts | 1 + 2 files changed, 47 insertions(+) diff --git a/cli/src/__tests__/agent-lifecycle.test.ts b/cli/src/__tests__/agent-lifecycle.test.ts index bf1956864c..4e54a552da 100644 --- a/cli/src/__tests__/agent-lifecycle.test.ts +++ b/cli/src/__tests__/agent-lifecycle.test.ts @@ -26,6 +26,16 @@ async function run(args: string[]): Promise { ], { from: "user" }); } +async function expectCommandFailure(args: string[], message: string): Promise { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit:${code}`); + }); + + await expect(run(args)).rejects.toThrow("process.exit:1"); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(message)); +} + describe("agent lifecycle commands", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -95,6 +105,42 @@ describe("agent lifecycle commands", () => { }); }); + it.each([ + { + args: ["agent", "heartbeat:invoke", AGENT_ID], + message: "Specify --issue-id for task work or --allow-unscoped for a generic heartbeat", + }, + { + args: ["agent", "heartbeat:invoke", AGENT_ID, "--issue-id", "PC-42", "--allow-unscoped"], + message: "Use either --issue-id or --allow-unscoped, not both", + }, + ])("rejects an invalid heartbeat scope choice", async ({ args, message }) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expectCommandFailure(args, message); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects an issue assigned to another agent without posting a heartbeat", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse({ + id: ISSUE_ID, + identifier: "PC-42", + assigneeAgentId: "55555555-5555-4555-8555-555555555555", + }))); + vi.stubGlobal("fetch", fetchMock); + + await expectCommandFailure( + ["agent", "heartbeat:invoke", AGENT_ID, "--issue-id", "PC-42"], + `Issue PC-42 is not assigned to agent ${AGENT_ID}`, + ); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", "http://localhost:3100/api/issues/PC-42"], + ]); + }); + it("wraps configuration, runtime, skills, and instructions endpoints", async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); vi.stubGlobal("fetch", fetchMock); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 227b12c6a9..4fd6f3c356 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -6655,6 +6655,7 @@ registry.registerPath({ 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, + 409: r.conflict, }, });