From 4da79a88c67e54084d40bd18cada5ee5c8be23da Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 7 Jun 2026 06:24:48 -0500 Subject: [PATCH] [codex] Refine issue comment wake handoffs (#7678) 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. > - The heartbeat and issue-comment routes decide when an assigned agent wakes up and what context it receives. > - Passive comments and annotation notes can currently wake assignees even when no actionable state changed. > - Accepted planning confirmations also need to preserve recent plan comments so child-issue creation does not lose board/user constraints. > - Runtime skill mentions should only send UUID ids into database lookups, because legacy slug-like ids are not valid runtime skill ids. > - This pull request tightens those wake and handoff rules in one server-side branch. > - The benefit is fewer noisy agent wakeups and better accepted-plan continuation context without changing the task model. ## Linked Issues or Issue Description Internal Paperclip task: [PAP-10535](/PAP/issues/PAP-10535). Problem or motivation: Passive comments and annotation notes could wake the current assignee even when no actionable state changed, and accepted plan continuations needed recent plan comments preserved in the wake handoff. Runtime skill mentions also needed to ignore non-UUID ids before database lookup. Proposed solution: Tighten server-side wake routing so passive comments do not wake assignees unless they reopen the issue, preserve mention-targeted wakeups, include recent non-deleted plan comments in accepted confirmation wake payloads, and guard runtime skill mention lookup to UUID-like ids. Alternatives considered: Leaving passive assignee wakeups in place was rejected because it keeps generating noisy non-actionable heartbeats. Treating every skill mention-like token as a runtime skill id was rejected because legacy slug-like ids are not valid runtime skill ids. Roadmap alignment: This aligns with the V1 control-plane heartbeat contract by making wakeups more intentional and preserving handoff context for approved plans. This PR was split from the local `master` branch on June 7, 2026. It covers server-side heartbeat and comment-wakeup behavior only. I searched GitHub for duplicate/related PRs; the results were broader heartbeat/run PRs, not this exact passive-comment and accepted-plan handoff change. ## What Changed - Filter runtime skill mention extraction so only UUID-like skill ids are looked up. - Stop ordinary issue comments and document annotation comments from waking the current assignee unless the comment reopens the issue. - Keep mention-targeted wakeups intact while removing passive assignee wakeups. - Include recent non-deleted issue comments in accepted-plan confirmation wake payloads and task markdown. - Updated focused server tests for the new wakeup and accepted-plan behavior. ## Verification - `git diff --check origin/master..HEAD` - `NODE_ENV=test pnpm exec vitest run server/src/__tests__/heartbeat-project-env.test.ts server/src/__tests__/document-annotation-routes.test.ts server/src/__tests__/issue-comment-reopen-routes.test.ts server/src/__tests__/issue-update-comment-wakeup-routes.test.ts server/src/__tests__/heartbeat-context-summary.test.ts server/src/__tests__/issue-comment-redaction.test.ts` - `NODE_ENV=test pnpm exec vitest run server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts server/src/__tests__/issue-comment-redaction.test.ts server/src/__tests__/heartbeat-context-summary.test.ts` - `pnpm --filter /server typecheck` - PR checks green on head `a379a0264d384510ff8ac4a47fb1e44d7b556f68` - Greptile rerun green on head `a379a0264d384510ff8ac4a47fb1e44d7b556f68`: 9 files reviewed, 0 comments added, 0 unresolved review threads ## Risks - Medium behavioral risk: agents will no longer wake for passive comments unless mentioned or unless the comment reopens/resumes the issue. That is intentional, but any workflow relying on passive assignee comment wakeups should use explicit mentions or structured resume paths. - Low migration risk: no schema or migration changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex coding agent based on GPT-5, with shell, git, GitHub CLI, and local test execution. Exact hosted model variant and context-window size were not exposed by the runtime. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../document-annotation-routes.test.ts | 13 +- ...at-accepted-plan-workspace-refresh.test.ts | 20 +++ .../heartbeat-context-summary.test.ts | 28 ++++ .../__tests__/heartbeat-project-env.test.ts | 22 ++- .../__tests__/issue-comment-redaction.test.ts | 63 +++++++ .../issue-comment-reopen-routes.test.ts | 65 ++------ ...issue-update-comment-wakeup-routes.test.ts | 28 +--- server/src/routes/issues.ts | 156 ++++-------------- server/src/services/heartbeat.ts | 99 +++++++++-- 9 files changed, 267 insertions(+), 227 deletions(-) diff --git a/server/src/__tests__/document-annotation-routes.test.ts b/server/src/__tests__/document-annotation-routes.test.ts index 0a2aa0e6..f7f4d199 100644 --- a/server/src/__tests__/document-annotation-routes.test.ts +++ b/server/src/__tests__/document-annotation-routes.test.ts @@ -237,7 +237,7 @@ describe("document annotation routes", () => { }); }); - it("creates annotation threads, syncs references, logs activity, and wakes the assignee", async () => { + it("creates annotation threads, syncs references, logs activity, and does not wake the assignee", async () => { mockIssueService.getById.mockResolvedValue({ id: issueId, companyId, @@ -261,15 +261,7 @@ describe("document annotation routes", () => { expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "issue.document_annotation_thread_created", })); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - "99999999-9999-4999-8999-999999999999", - expect.objectContaining({ - payload: expect.objectContaining({ - annotationThreadId: annotationThread.id, - annotationCommentId: annotationComment.id, - }), - }), - ); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("rejects agent cross-company annotation reads", async () => { @@ -293,5 +285,6 @@ describe("document annotation routes", () => { expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "issue.document_annotation_thread_resolved", })); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); }); diff --git a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts index 11f91785..831a6c95 100644 --- a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts +++ b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts @@ -737,6 +737,17 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => { createdAt: new Date(), updatedAt: new Date(), }); + const planCommentId = randomUUID(); + await db.insert(issueComments).values({ + id: planCommentId, + companyId, + issueId, + authorUserId: "board-user-1", + authorType: "user", + body: "Keep the accepted plan rollout split into separate child tasks.", + createdAt: new Date("2026-06-07T00:00:00.000Z"), + updatedAt: new Date("2026-06-07T00:00:00.000Z"), + }); await seedAcceptedPlanClaim({ companyId, issueId, @@ -803,5 +814,14 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => { expect(adapterInput.runtime.sessionId).toBe("accepted-plan-retry-session"); expect(adapterInput.context.acceptedPlanWakeRouting).toBeUndefined(); expect(adapterInput.context.paperclipTaskMarkdown).toContain("Create child issues from the approved plan only"); + expect(adapterInput.context.paperclipTaskMarkdown).toContain("Comments included with the confirmed plan:"); + expect(adapterInput.context.paperclipTaskMarkdown).toContain(planCommentId); + expect(adapterInput.context.paperclipTaskMarkdown).toContain( + "Keep the accepted plan rollout split into separate child tasks.", + ); + expect(adapterInput.context.paperclipWake).toEqual(expect.objectContaining({ + commentIds: [planCommentId], + commentContextSource: "accepted_plan_confirmation", + })); }, 20_000); }); diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts index 1fa9b800..84d7b0ce 100644 --- a/server/src/__tests__/heartbeat-context-summary.test.ts +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -72,6 +72,34 @@ describe("buildPaperclipTaskMarkdown", () => { expect(acceptedConfirmation).not.toContain("- Work mode: \"planning\""); }); + it("includes confirmed-plan comments in accepted-plan continuation task context", () => { + const acceptedConfirmation = buildPaperclipTaskMarkdown({ + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan first", + workMode: "planning", + description: null, + }, + interaction: { + kind: "request_confirmation", + status: "accepted", + }, + acceptedPlanComments: [ + { + id: "comment-1", + authorType: "user", + body: "Please keep the migration backwards compatible.", + }, + ], + }); + + expect(acceptedConfirmation).toContain("Create child issues from the approved plan only"); + expect(acceptedConfirmation).toContain("Comments included with the confirmed plan:"); + expect(acceptedConfirmation).toContain("Comment 1 - user - comment-1:"); + expect(acceptedConfirmation).toContain("Please keep the migration backwards compatible."); + }); + it("prefers ordinary comment planning guidance over stale accepted confirmation state", () => { const commentWake = buildPaperclipTaskMarkdown({ issue: { diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 410e0f11..1f62aa76 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -274,9 +274,11 @@ describe("resolveExecutionRunAdapterConfig", () => { }); describe("extractMentionedSkillIdsFromSources", () => { - it("collects explicit skill mention ids across issue sources", () => { - const releaseHref = buildSkillMentionHref("skill-1", "release-changelog"); - const browserHref = buildSkillMentionHref("skill-2", "agent-browser"); + it("collects UUID skill mention ids across issue sources", () => { + const releaseSkillId = "11111111-1111-4111-8111-111111111111"; + const browserSkillId = "22222222-2222-4222-8222-222222222222"; + const releaseHref = buildSkillMentionHref(releaseSkillId, "release-changelog"); + const browserHref = buildSkillMentionHref(browserSkillId, "agent-browser"); expect( extractMentionedSkillIdsFromSources([ @@ -284,7 +286,19 @@ describe("extractMentionedSkillIdsFromSources", () => { `And also [/agent-browser](${browserHref})`, `Duplicate mention [/release-changelog](${releaseHref})`, ]), - ).toEqual(["skill-1", "skill-2"]); + ).toEqual([releaseSkillId, browserSkillId]); + }); + + it("ignores legacy non-UUID skill mention ids before runtime database lookup", () => { + const validSkillId = "33333333-3333-4333-8333-333333333333"; + const validHref = buildSkillMentionHref(validSkillId, "greploop"); + const legacyHref = buildSkillMentionHref("skill-greploop", "greploop"); + + expect( + extractMentionedSkillIdsFromSources([ + `Use [/greploop](${legacyHref}) and [/prcheckloop](${validHref})`, + ]), + ).toEqual([validSkillId]); }); }); diff --git a/server/src/__tests__/issue-comment-redaction.test.ts b/server/src/__tests__/issue-comment-redaction.test.ts index 45e08d18..2a439e85 100644 --- a/server/src/__tests__/issue-comment-redaction.test.ts +++ b/server/src/__tests__/issue-comment-redaction.test.ts @@ -175,6 +175,69 @@ describeEmbeddedPostgres("deleted issue comment redaction", () => { expect(JSON.stringify(wakePayload)).not.toContain("secret metadata"); }); + it("adds recent issue comments to accepted-plan continuation wake payloads", async () => { + const { companyId, issueId } = await seedIssue(); + const firstCommentId = randomUUID(); + const secondCommentId = randomUUID(); + const deletedCommentId = randomUUID(); + await db.insert(issueComments).values([ + { + id: firstCommentId, + companyId, + issueId, + authorUserId: "board-user-1", + body: "Keep the migration backwards compatible.", + createdAt: new Date("2026-06-03T12:00:00.000Z"), + }, + { + id: deletedCommentId, + companyId, + issueId, + authorUserId: "board-user-1", + body: "Do not pass this deleted text downstream.", + deletedAt: new Date("2026-06-03T12:01:00.000Z"), + deletedByType: "user", + deletedByUserId: "board-user-1", + createdAt: new Date("2026-06-03T12:01:00.000Z"), + }, + { + id: secondCommentId, + companyId, + issueId, + authorUserId: "board-user-1", + body: "Split the rollout into a verification child task.", + createdAt: new Date("2026-06-03T12:02:00.000Z"), + }, + ]); + + const wakePayload = await buildPaperclipWakePayload({ + db, + companyId, + contextSnapshot: { + issueId, + interactionKind: "request_confirmation", + interactionStatus: "accepted", + workspaceRefreshReason: "accepted_plan_confirmation", + }, + issueSummary: { + id: issueId, + identifier: "RED-1", + title: "Deleted comment redaction", + status: "in_progress", + priority: "medium", + workMode: "planning", + }, + }); + + expect(wakePayload?.commentContextSource).toBe("accepted_plan_confirmation"); + expect(wakePayload?.commentIds).toEqual([firstCommentId, secondCommentId]); + expect(wakePayload?.comments.map((comment) => comment.body)).toEqual([ + "Keep the migration backwards compatible.", + "Split the rollout into a verification child task.", + ]); + expect(JSON.stringify(wakePayload)).not.toContain("Do not pass this deleted text downstream."); + }); + it("excludes deleted comment bodies from company search", async () => { const { companyId, issueId } = await seedIssue(); await db.insert(issueComments).values({ diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 4043d86a..d56164ca 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -639,20 +639,8 @@ describe.sequential("issue comment reopen routes", () => { }), }), ); - await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - "22222222-2222-4222-8222-222222222222", - expect.objectContaining({ - reason: "issue_commented", - payload: expect.objectContaining({ - commentId: "comment-1", - mutation: "comment", - }), - contextSnapshot: expect.objectContaining({ - wakeReason: "issue_commented", - source: "issue.comment", - }), - }), - )); + await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled()); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("does not move scheduled-retry issues to todo when POST comment retry cancellation fails", async () => { @@ -701,12 +689,8 @@ describe.sequential("issue comment reopen routes", () => { expect(mockIssueService.getCurrentScheduledRetry).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111"); expect(mockIssueService.update).not.toHaveBeenCalled(); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); - await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - "22222222-2222-4222-8222-222222222222", - expect.objectContaining({ - reason: "issue_commented", - }), - )); + await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled()); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("passes validated comment presentation fields to trusted board comment writes", async () => { @@ -808,21 +792,8 @@ describe.sequential("issue comment reopen routes", () => { expect(res.status).toBe(201); expect(mockIssueService.update).not.toHaveBeenCalled(); - await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - "22222222-2222-4222-8222-222222222222", - expect.objectContaining({ - reason: "issue_commented", - payload: expect.objectContaining({ - commentId: "comment-1", - mutation: "comment", - }), - contextSnapshot: expect.objectContaining({ - issueId: "11111111-1111-4111-8111-111111111111", - wakeCommentId: "comment-1", - wakeReason: "issue_commented", - }), - }), - )); + await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled()); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => { @@ -917,16 +888,8 @@ describe.sequential("issue comment reopen routes", () => { }), ); expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1"); - await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - "22222222-2222-4222-8222-222222222222", - expect.objectContaining({ - reason: "issue_commented", - payload: expect.objectContaining({ - commentId: "comment-1", - mutation: "comment", - }), - }), - )); + await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled()); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("does not move scheduled-retry issues to todo when PATCH comment retry cancellation fails", async () => { @@ -1028,16 +991,8 @@ describe.sequential("issue comment reopen routes", () => { "11111111-1111-4111-8111-111111111111", expect.objectContaining({ status: "todo" }), ); - await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - "22222222-2222-4222-8222-222222222222", - expect.objectContaining({ - reason: "issue_commented", - payload: expect.objectContaining({ - commentId: "comment-1", - mutation: "comment", - }), - }), - )); + await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled()); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("wakes the assignee when an assigned blocked issue moves back to todo", async () => { diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 89b1b257..dceb8e4e 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -269,7 +269,7 @@ describe("issue update comment wakeups", () => { ); }); - it("wakes the assignee on comment-only issue updates", async () => { + it("does not wake the assignee on comment-only issue updates", async () => { const existing = makeIssue({ assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null, @@ -292,26 +292,10 @@ describe("issue update comment wakeups", () => { }); expect(res.status).toBe(200); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - ASSIGNEE_AGENT_ID, - expect.objectContaining({ - source: "automation", - reason: "issue_commented", - payload: expect.objectContaining({ - issueId: existing.id, - commentId: "comment-2", - mutation: "comment", - }), - contextSnapshot: expect.objectContaining({ - issueId: existing.id, - taskId: existing.id, - commentId: "comment-2", - wakeCommentId: "comment-2", - wakeReason: "issue_commented", - source: "issue.comment", - }), - }), - ); + await vi.waitFor(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalledWith( + existing.companyId, + "please revise this", + )); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 40768a74..47d84ed3 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1431,46 +1431,6 @@ export function issueRoutes( }; } - function queueAnnotationCommentWakeup(input: { - issue: { id: string; assigneeAgentId: string | null; status: string }; - actor: { actorType: "user" | "agent"; actorId: string }; - threadId: string; - commentId: string; - documentKey: string; - }) { - const assigneeId = input.issue.assigneeAgentId; - const selfComment = input.actor.actorType === "agent" && input.actor.actorId === assigneeId; - if (!assigneeId || selfComment || isClosedIssueStatus(input.issue.status)) return; - void heartbeat.wakeup(assigneeId, { - source: "automation", - triggerDetail: "system", - reason: "issue_commented", - payload: { - issueId: input.issue.id, - annotationThreadId: input.threadId, - annotationCommentId: input.commentId, - documentKey: input.documentKey, - mutation: "document_annotation_comment", - }, - requestedByActorType: input.actor.actorType, - requestedByActorId: input.actor.actorId, - contextSnapshot: { - issueId: input.issue.id, - taskId: input.issue.id, - annotationThreadId: input.threadId, - annotationCommentId: input.commentId, - documentKey: input.documentKey, - source: "issue.document.annotation", - wakeReason: "issue_commented", - }, - }).catch((err) => logger.warn({ - err, - issueId: input.issue.id, - annotationThreadId: input.threadId, - annotationCommentId: input.commentId, - }, "failed to wake assignee on document annotation comment")); - } - async function canonicalizePaperclipArtifactMetadata(input: { issue: { id: string; companyId: string }; metadata: Record | null | undefined; @@ -3135,16 +3095,6 @@ export function issueRoutes( }, }); - if (firstComment) { - queueAnnotationCommentWakeup({ - issue, - actor, - threadId: thread.id, - commentId: firstComment.id, - documentKey: thread.documentKey, - }); - } - res.status(201).json(thread); }, ); @@ -3227,14 +3177,6 @@ export function issueRoutes( }, }); - queueAnnotationCommentWakeup({ - issue, - actor, - threadId: comment.threadId, - commentId: comment.id, - documentKey: keyParsed.data, - }); - res.status(201).json(comment); }, ); @@ -5546,20 +5488,17 @@ export function issueRoutes( if (commentBody && comment) { const assigneeId = issue.assigneeAgentId; - const actorIsAgent = actor.actorType === "agent"; - const selfComment = actorIsAgent && actor.actorId === assigneeId; - const skipAssigneeCommentWake = selfComment || isClosed; - if (assigneeId && !assigneeChanged && (reopened || !skipAssigneeCommentWake)) { + if (assigneeId && !assigneeChanged && reopened) { addWakeup(assigneeId, { source: "automation", triggerDetail: "system", - reason: reopened ? "issue_reopened_via_comment" : "issue_commented", + reason: "issue_reopened_via_comment", payload: { issueId: id, commentId: comment.id, mutation: "comment", - ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + reopenedFrom: reopenFromStatus, ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, @@ -5570,9 +5509,9 @@ export function issueRoutes( taskId: id, commentId: comment.id, wakeCommentId: comment.id, - source: reopened ? "issue.comment.reopen" : "issue.comment", - wakeReason: reopened ? "issue_reopened_via_comment" : "issue_commented", - ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + source: "issue.comment.reopen", + wakeReason: "issue_reopened_via_comment", + reopenedFrom: reopenFromStatus, ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, @@ -6714,62 +6653,33 @@ export function issueRoutes( const wakeups = new Map[1]>(); const assigneeId = currentIssue.assigneeAgentId; const actorIsAgent = actor.actorType === "agent"; - const selfComment = actorIsAgent && actor.actorId === assigneeId; - const skipWake = selfComment || isClosed; - if (assigneeId && (reopened || !skipWake)) { - if (reopened) { - wakeups.set(assigneeId, { - source: "automation", - triggerDetail: "system", - reason: "issue_reopened_via_comment", - payload: { - issueId: currentIssue.id, - commentId: comment.id, - reopenedFrom: reopenFromStatus, - mutation: "comment", - ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), - ...(interruptedRunId ? { interruptedRunId } : {}), - }, - requestedByActorType: actor.actorType, - requestedByActorId: actor.actorId, - contextSnapshot: { - issueId: currentIssue.id, - taskId: currentIssue.id, - commentId: comment.id, - wakeCommentId: comment.id, - source: "issue.comment.reopen", - wakeReason: "issue_reopened_via_comment", - reopenedFrom: reopenFromStatus, - ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), - ...(interruptedRunId ? { interruptedRunId } : {}), - }, - }); - } else { - wakeups.set(assigneeId, { - source: "automation", - triggerDetail: "system", - reason: "issue_commented", - payload: { - issueId: currentIssue.id, - commentId: comment.id, - mutation: "comment", - ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), - ...(interruptedRunId ? { interruptedRunId } : {}), - }, - requestedByActorType: actor.actorType, - requestedByActorId: actor.actorId, - contextSnapshot: { - issueId: currentIssue.id, - taskId: currentIssue.id, - commentId: comment.id, - wakeCommentId: comment.id, - source: "issue.comment", - wakeReason: "issue_commented", - ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), - ...(interruptedRunId ? { interruptedRunId } : {}), - }, - }); - } + if (assigneeId && reopened) { + wakeups.set(assigneeId, { + source: "automation", + triggerDetail: "system", + reason: "issue_reopened_via_comment", + payload: { + issueId: currentIssue.id, + commentId: comment.id, + reopenedFrom: reopenFromStatus, + mutation: "comment", + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: currentIssue.id, + taskId: currentIssue.id, + commentId: comment.id, + wakeCommentId: comment.id, + source: "issue.comment.reopen", + wakeReason: "issue_reopened_via_comment", + reopenedFrom: reopenFromStatus, + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + }); } let mentionedIds: string[] = []; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 6a7ec69e..b54cb59c 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -528,7 +528,9 @@ export function extractMentionedSkillIdsFromSources( for (const source of sources) { if (typeof source !== "string" || source.length === 0) continue; for (const skillId of extractSkillMentionIds(source)) { - mentionedIds.add(skillId); + if (isUuidLike(skillId)) { + mentionedIds.add(skillId); + } } } return [...mentionedIds]; @@ -2289,6 +2291,20 @@ function normalizeInteractionContinuationWakeContext( clearInteractionContinuationWakeContext(contextSnapshot); } +function isAcceptedPlanContinuationWakeContext( + contextSnapshot: Record, + issueWorkMode?: string | null, +) { + return ( + readNonEmptyString(contextSnapshot.workspaceRefreshReason) === "accepted_plan_confirmation" || + ( + issueWorkMode === "planning" && + readNonEmptyString(contextSnapshot.interactionKind) === "request_confirmation" && + readNonEmptyString(contextSnapshot.interactionStatus) === "accepted" + ) + ); +} + type AcceptedPlanWakeRoutingDecision = { otherActiveClaimIssueId: string; otherActiveClaimIdentifier: string | null; @@ -2395,7 +2411,7 @@ export async function buildPaperclipWakePayload(input: { exposeLowTrustRaw?: boolean; }) { const executionStage = parseObject(input.contextSnapshot.executionStage); - const commentIds = extractWakeCommentIds(input.contextSnapshot); + let commentIds = extractWakeCommentIds(input.contextSnapshot); const annotationCommentId = readNonEmptyString(input.contextSnapshot.annotationCommentId); const issueId = readNonEmptyString(input.contextSnapshot.issueId); const continuationSummary = input.continuationSummary ?? null; @@ -2415,6 +2431,28 @@ export async function buildPaperclipWakePayload(input: { .where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId))) .then((rows) => rows[0] ?? null) : null); + let acceptedPlanCommentWindowTruncated = false; + const acceptedPlanContinuationWake = isAcceptedPlanContinuationWakeContext( + input.contextSnapshot, + issueSummary?.workMode, + ); + if (commentIds.length === 0 && acceptedPlanContinuationWake && issueSummary?.id) { + const recentPlanCommentRows = await input.db + .select({ id: issueComments.id }) + .from(issueComments) + .where(and( + eq(issueComments.companyId, input.companyId), + eq(issueComments.issueId, issueSummary.id), + isNull(issueComments.deletedAt), + )) + .orderBy(desc(issueComments.createdAt)) + .limit(MAX_INLINE_WAKE_COMMENTS + 1); + acceptedPlanCommentWindowTruncated = recentPlanCommentRows.length > MAX_INLINE_WAKE_COMMENTS; + commentIds = recentPlanCommentRows + .slice(0, MAX_INLINE_WAKE_COMMENTS) + .reverse() + .map((comment) => comment.id); + } if (commentIds.length === 0 && Object.keys(executionStage).length === 0 && !issueSummary) return null; const commentRows = @@ -2449,7 +2487,7 @@ export async function buildPaperclipWakePayload(input: { const commentsById = new Map(commentRows.map((comment) => [comment.id, comment])); const comments: Array> = []; let remainingBodyChars = MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS; - let truncated = false; + let truncated = acceptedPlanCommentWindowTruncated; let missingCommentCount = 0; const safeContinuationSummary = continuationSummary && !input.exposeLowTrustRaw @@ -2587,6 +2625,9 @@ export async function buildPaperclipWakePayload(input: { : null, interactionKind: readNonEmptyString(input.contextSnapshot.interactionKind), interactionStatus: readNonEmptyString(input.contextSnapshot.interactionStatus), + commentContextSource: acceptedPlanContinuationWake && commentIds.length > 0 + ? "accepted_plan_confirmation" + : null, checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true, dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true, treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true, @@ -2661,6 +2702,11 @@ export function buildPaperclipTaskMarkdown(input: { kind?: string | null; status?: string | null; } | null; + acceptedPlanComments?: Array<{ + id?: string | null; + authorType?: string | null; + body?: string | null; + }> | null; acceptedPlanContinuation?: boolean; }) { const quoteTaskScalar = (value: string) => JSON.stringify(value); @@ -2721,6 +2767,28 @@ export function buildPaperclipTaskMarkdown(input: { if (wakeComment?.body.trim()) { lines.push("", "Latest wake comment:", fenceTaskText(wakeComment.body.trim())); } + if (acceptedPlanContinuation) { + const acceptedPlanComments = (input.acceptedPlanComments ?? []) + .map((comment) => ({ + ...comment, + body: comment.body?.trim() ?? "", + })) + .filter((comment) => comment.body.length > 0) + .slice(0, MAX_INLINE_WAKE_COMMENTS); + if (acceptedPlanComments.length > 0) { + lines.push("", "Comments included with the confirmed plan:"); + for (const [index, comment] of acceptedPlanComments.entries()) { + const authorType = comment.authorType?.trim(); + const commentId = comment.id?.trim(); + const labelParts = [ + `Comment ${index + 1}`, + ...(authorType ? [authorType] : []), + ...(commentId ? [commentId] : []), + ]; + lines.push("", `${labelParts.join(" - ")}:`, fenceTaskText(comment.body)); + } + } + } lines.push("", "Use this task context as the current assignment."); return lines.join("\n"); } @@ -7747,13 +7815,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) companyId: agent.companyId, agentId: agent.id, issueId, - acceptedPlanContinuationWake: - readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation" - || ( - issueContext.workMode === "planning" - && readNonEmptyString(context.interactionKind) === "request_confirmation" - && readNonEmptyString(context.interactionStatus) === "accepted" - ), + acceptedPlanContinuationWake: isAcceptedPlanContinuationWakeContext(context, issueContext.workMode), contextSnapshot: context, }) : null; @@ -7892,6 +7954,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } else { delete context[PAPERCLIP_WAKE_PAYLOAD_KEY]; } + const acceptedPlanWakeRouting = parseObject(context.acceptedPlanWakeRouting); + const acceptedPlanContinuationForTask = + isAcceptedPlanContinuationWakeContext(context, issueRef?.workMode) && + Object.keys(acceptedPlanWakeRouting).length === 0; + const acceptedPlanCommentsForTask = + acceptedPlanContinuationForTask && Array.isArray(paperclipWakePayload?.comments) + ? paperclipWakePayload.comments.map((comment) => ({ + id: readNonEmptyString(comment.id), + authorType: readNonEmptyString(comment.authorType), + body: readNonEmptyString(comment.body), + })) + : null; const taskMarkdown = buildPaperclipTaskMarkdown({ issue: issueRef ? { @@ -7907,9 +7981,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) kind: readNonEmptyString(context.interactionKind), status: readNonEmptyString(context.interactionStatus), }, - acceptedPlanContinuation: - readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation" - && !parseObject(context.acceptedPlanWakeRouting), + acceptedPlanComments: acceptedPlanCommentsForTask, + acceptedPlanContinuation: acceptedPlanContinuationForTask, }); if (issueRef) { context.paperclipIssue = {