## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Issue comment wake handoffs are part of the control-plane execution loop that decides when agents resume work after comments and issue updates. > - PR #7678 changed that wake handoff behavior in server issue routes, heartbeat context, and related tests. > - The change broke an important workflow after merge, so the safest immediate fix is to restore the pre-#7678 wake behavior. > - This pull request reverts the wake-handoff behavior from PR #7678 while keeping narrow review-requested safeguards that prevent known runtime/test regressions. > - The benefit is that Paperclip returns to the last known working wake behavior without reintroducing avoidable UUID skill lookup and annotation-resolution test gaps. ## Linked Issues or Issue Description Refs: #7678 Bug context: - What happened: PR #7678 was reported to have broken an important Paperclip workflow after it merged. - Expected behavior: Paperclip should preserve the prior issue comment wake handoff behavior until a corrected change is ready. - Steps to reproduce: Use the workflow affected by PR #7678's issue comment wake handoff changes. - Paperclip version/commit: `master` after merge commit `4da79a88c67e54084d40bd18cada5ee5c8be23da`. - Deployment mode: Paperclip control-plane server behavior. ## What Changed - Reverted merge commit `4da79a88c67e54084d40bd18cada5ee5c8be23da` from PR #7678 to restore pre-#7678 wake-handoff behavior. - Preserved the safe accepted-plan routing check so `parseObject(...)` is not used as a boolean. - Preserved UUID filtering for run-scoped skill mentions so legacy non-UUID skill IDs do not reach a Postgres UUID lookup. - Restored the annotation thread-resolution test guard that verifies resolving a thread does not wake the assignee. ## Verification - `pnpm run preflight:workspace-links && NODE_ENV=test PAPERCLIP_HOME=/tmp/... PAPERCLIP_INSTANCE_ID=pap10614-revert TMPDIR=/tmp/... pnpm exec vitest run --project @paperclipai/server --no-file-parallelism --maxWorkers=1 server/src/__tests__/document-annotation-routes.test.ts server/src/__tests__/heartbeat-project-env.test.ts server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts server/src/__tests__/heartbeat-context-summary.test.ts` - Result: 4 test files passed, 26 tests passed. - Earlier targeted revert verification also passed: 4 test files, 50 tests. ## Risks - This intentionally restores behavior from before PR #7678, so intended wake-handoff improvements from that PR are removed. - The PR is no longer a byte-for-byte revert because Greptile identified two narrow safeguards worth preserving. - Low migration risk: no schema or dependency changes are included. - Follow-up work may still be needed to reintroduce the desired wake handoff behavior without the regression. ## Model Used OpenAI Codex, GPT-5 coding agent in this Paperclip heartbeat, with shell/tool execution and repository write access. ## 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
This commit is contained in:
@@ -237,7 +237,7 @@ describe("document annotation routes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates annotation threads, syncs references, logs activity, and does not wake the assignee", async () => {
|
it("creates annotation threads, syncs references, logs activity, and wakes the assignee", async () => {
|
||||||
mockIssueService.getById.mockResolvedValue({
|
mockIssueService.getById.mockResolvedValue({
|
||||||
id: issueId,
|
id: issueId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -261,7 +261,15 @@ describe("document annotation routes", () => {
|
|||||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||||
action: "issue.document_annotation_thread_created",
|
action: "issue.document_annotation_thread_created",
|
||||||
}));
|
}));
|
||||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||||
|
"99999999-9999-4999-8999-999999999999",
|
||||||
|
expect.objectContaining({
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
annotationThreadId: annotationThread.id,
|
||||||
|
annotationCommentId: annotationComment.id,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects agent cross-company annotation reads", async () => {
|
it("rejects agent cross-company annotation reads", async () => {
|
||||||
|
|||||||
@@ -737,17 +737,6 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: 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({
|
await seedAcceptedPlanClaim({
|
||||||
companyId,
|
companyId,
|
||||||
issueId,
|
issueId,
|
||||||
@@ -814,14 +803,5 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
|
|||||||
expect(adapterInput.runtime.sessionId).toBe("accepted-plan-retry-session");
|
expect(adapterInput.runtime.sessionId).toBe("accepted-plan-retry-session");
|
||||||
expect(adapterInput.context.acceptedPlanWakeRouting).toBeUndefined();
|
expect(adapterInput.context.acceptedPlanWakeRouting).toBeUndefined();
|
||||||
expect(adapterInput.context.paperclipTaskMarkdown).toContain("Create child issues from the approved plan only");
|
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);
|
}, 20_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,34 +72,6 @@ describe("buildPaperclipTaskMarkdown", () => {
|
|||||||
expect(acceptedConfirmation).not.toContain("- Work mode: \"planning\"");
|
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", () => {
|
it("prefers ordinary comment planning guidance over stale accepted confirmation state", () => {
|
||||||
const commentWake = buildPaperclipTaskMarkdown({
|
const commentWake = buildPaperclipTaskMarkdown({
|
||||||
issue: {
|
issue: {
|
||||||
|
|||||||
@@ -175,69 +175,6 @@ describeEmbeddedPostgres("deleted issue comment redaction", () => {
|
|||||||
expect(JSON.stringify(wakePayload)).not.toContain("secret metadata");
|
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 () => {
|
it("excludes deleted comment bodies from company search", async () => {
|
||||||
const { companyId, issueId } = await seedIssue();
|
const { companyId, issueId } = await seedIssue();
|
||||||
await db.insert(issueComments).values({
|
await db.insert(issueComments).values({
|
||||||
|
|||||||
@@ -639,8 +639,20 @@ describe.sequential("issue comment reopen routes", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled());
|
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
"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",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not move scheduled-retry issues to todo when POST comment retry cancellation fails", async () => {
|
it("does not move scheduled-retry issues to todo when POST comment retry cancellation fails", async () => {
|
||||||
@@ -689,8 +701,12 @@ describe.sequential("issue comment reopen routes", () => {
|
|||||||
expect(mockIssueService.getCurrentScheduledRetry).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111");
|
expect(mockIssueService.getCurrentScheduledRetry).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111");
|
||||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||||
await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled());
|
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
"22222222-2222-4222-8222-222222222222",
|
||||||
|
expect.objectContaining({
|
||||||
|
reason: "issue_commented",
|
||||||
|
}),
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes validated comment presentation fields to trusted board comment writes", async () => {
|
it("passes validated comment presentation fields to trusted board comment writes", async () => {
|
||||||
@@ -792,8 +808,21 @@ describe.sequential("issue comment reopen routes", () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||||
await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled());
|
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
"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",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => {
|
it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => {
|
||||||
@@ -888,8 +917,16 @@ describe.sequential("issue comment reopen routes", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1");
|
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1");
|
||||||
await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled());
|
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
"22222222-2222-4222-8222-222222222222",
|
||||||
|
expect.objectContaining({
|
||||||
|
reason: "issue_commented",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
commentId: "comment-1",
|
||||||
|
mutation: "comment",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not move scheduled-retry issues to todo when PATCH comment retry cancellation fails", async () => {
|
it("does not move scheduled-retry issues to todo when PATCH comment retry cancellation fails", async () => {
|
||||||
@@ -991,8 +1028,16 @@ describe.sequential("issue comment reopen routes", () => {
|
|||||||
"11111111-1111-4111-8111-111111111111",
|
"11111111-1111-4111-8111-111111111111",
|
||||||
expect.objectContaining({ status: "todo" }),
|
expect.objectContaining({ status: "todo" }),
|
||||||
);
|
);
|
||||||
await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled());
|
await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
"22222222-2222-4222-8222-222222222222",
|
||||||
|
expect.objectContaining({
|
||||||
|
reason: "issue_commented",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
commentId: "comment-1",
|
||||||
|
mutation: "comment",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("wakes the assignee when an assigned blocked issue moves back to todo", async () => {
|
it("wakes the assignee when an assigned blocked issue moves back to todo", async () => {
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ describe("issue update comment wakeups", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not wake the assignee on comment-only issue updates", async () => {
|
it("wakes the assignee on comment-only issue updates", async () => {
|
||||||
const existing = makeIssue({
|
const existing = makeIssue({
|
||||||
assigneeAgentId: ASSIGNEE_AGENT_ID,
|
assigneeAgentId: ASSIGNEE_AGENT_ID,
|
||||||
assigneeUserId: null,
|
assigneeUserId: null,
|
||||||
@@ -292,10 +292,26 @@ describe("issue update comment wakeups", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
await vi.waitFor(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalledWith(
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
|
||||||
existing.companyId,
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||||
"please revise this",
|
ASSIGNEE_AGENT_ID,
|
||||||
));
|
expect.objectContaining({
|
||||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
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",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+123
-33
@@ -1431,6 +1431,46 @@ 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: {
|
async function canonicalizePaperclipArtifactMetadata(input: {
|
||||||
issue: { id: string; companyId: string };
|
issue: { id: string; companyId: string };
|
||||||
metadata: Record<string, unknown> | null | undefined;
|
metadata: Record<string, unknown> | null | undefined;
|
||||||
@@ -3095,6 +3135,16 @@ export function issueRoutes(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (firstComment) {
|
||||||
|
queueAnnotationCommentWakeup({
|
||||||
|
issue,
|
||||||
|
actor,
|
||||||
|
threadId: thread.id,
|
||||||
|
commentId: firstComment.id,
|
||||||
|
documentKey: thread.documentKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json(thread);
|
res.status(201).json(thread);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -3177,6 +3227,14 @@ export function issueRoutes(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
queueAnnotationCommentWakeup({
|
||||||
|
issue,
|
||||||
|
actor,
|
||||||
|
threadId: comment.threadId,
|
||||||
|
commentId: comment.id,
|
||||||
|
documentKey: keyParsed.data,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json(comment);
|
res.status(201).json(comment);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -5488,17 +5546,20 @@ export function issueRoutes(
|
|||||||
|
|
||||||
if (commentBody && comment) {
|
if (commentBody && comment) {
|
||||||
const assigneeId = issue.assigneeAgentId;
|
const assigneeId = issue.assigneeAgentId;
|
||||||
|
const actorIsAgent = actor.actorType === "agent";
|
||||||
|
const selfComment = actorIsAgent && actor.actorId === assigneeId;
|
||||||
|
const skipAssigneeCommentWake = selfComment || isClosed;
|
||||||
|
|
||||||
if (assigneeId && !assigneeChanged && reopened) {
|
if (assigneeId && !assigneeChanged && (reopened || !skipAssigneeCommentWake)) {
|
||||||
addWakeup(assigneeId, {
|
addWakeup(assigneeId, {
|
||||||
source: "automation",
|
source: "automation",
|
||||||
triggerDetail: "system",
|
triggerDetail: "system",
|
||||||
reason: "issue_reopened_via_comment",
|
reason: reopened ? "issue_reopened_via_comment" : "issue_commented",
|
||||||
payload: {
|
payload: {
|
||||||
issueId: id,
|
issueId: id,
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
mutation: "comment",
|
mutation: "comment",
|
||||||
reopenedFrom: reopenFromStatus,
|
...(reopened ? { reopenedFrom: reopenFromStatus } : {}),
|
||||||
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
||||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
},
|
},
|
||||||
@@ -5509,9 +5570,9 @@ export function issueRoutes(
|
|||||||
taskId: id,
|
taskId: id,
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
wakeCommentId: comment.id,
|
wakeCommentId: comment.id,
|
||||||
source: "issue.comment.reopen",
|
source: reopened ? "issue.comment.reopen" : "issue.comment",
|
||||||
wakeReason: "issue_reopened_via_comment",
|
wakeReason: reopened ? "issue_reopened_via_comment" : "issue_commented",
|
||||||
reopenedFrom: reopenFromStatus,
|
...(reopened ? { reopenedFrom: reopenFromStatus } : {}),
|
||||||
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
||||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
},
|
},
|
||||||
@@ -6653,33 +6714,62 @@ export function issueRoutes(
|
|||||||
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
|
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
|
||||||
const assigneeId = currentIssue.assigneeAgentId;
|
const assigneeId = currentIssue.assigneeAgentId;
|
||||||
const actorIsAgent = actor.actorType === "agent";
|
const actorIsAgent = actor.actorType === "agent";
|
||||||
if (assigneeId && reopened) {
|
const selfComment = actorIsAgent && actor.actorId === assigneeId;
|
||||||
wakeups.set(assigneeId, {
|
const skipWake = selfComment || isClosed;
|
||||||
source: "automation",
|
if (assigneeId && (reopened || !skipWake)) {
|
||||||
triggerDetail: "system",
|
if (reopened) {
|
||||||
reason: "issue_reopened_via_comment",
|
wakeups.set(assigneeId, {
|
||||||
payload: {
|
source: "automation",
|
||||||
issueId: currentIssue.id,
|
triggerDetail: "system",
|
||||||
commentId: comment.id,
|
reason: "issue_reopened_via_comment",
|
||||||
reopenedFrom: reopenFromStatus,
|
payload: {
|
||||||
mutation: "comment",
|
issueId: currentIssue.id,
|
||||||
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
commentId: comment.id,
|
||||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
reopenedFrom: reopenFromStatus,
|
||||||
},
|
mutation: "comment",
|
||||||
requestedByActorType: actor.actorType,
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
||||||
requestedByActorId: actor.actorId,
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
contextSnapshot: {
|
},
|
||||||
issueId: currentIssue.id,
|
requestedByActorType: actor.actorType,
|
||||||
taskId: currentIssue.id,
|
requestedByActorId: actor.actorId,
|
||||||
commentId: comment.id,
|
contextSnapshot: {
|
||||||
wakeCommentId: comment.id,
|
issueId: currentIssue.id,
|
||||||
source: "issue.comment.reopen",
|
taskId: currentIssue.id,
|
||||||
wakeReason: "issue_reopened_via_comment",
|
commentId: comment.id,
|
||||||
reopenedFrom: reopenFromStatus,
|
wakeCommentId: comment.id,
|
||||||
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
source: "issue.comment.reopen",
|
||||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
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 } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mentionedIds: string[] = [];
|
let mentionedIds: string[] = [];
|
||||||
|
|||||||
@@ -528,9 +528,8 @@ export function extractMentionedSkillIdsFromSources(
|
|||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
if (typeof source !== "string" || source.length === 0) continue;
|
if (typeof source !== "string" || source.length === 0) continue;
|
||||||
for (const skillId of extractSkillMentionIds(source)) {
|
for (const skillId of extractSkillMentionIds(source)) {
|
||||||
if (isUuidLike(skillId)) {
|
if (!isUuidLike(skillId)) continue;
|
||||||
mentionedIds.add(skillId);
|
mentionedIds.add(skillId);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...mentionedIds];
|
return [...mentionedIds];
|
||||||
@@ -2291,20 +2290,6 @@ function normalizeInteractionContinuationWakeContext(
|
|||||||
clearInteractionContinuationWakeContext(contextSnapshot);
|
clearInteractionContinuationWakeContext(contextSnapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAcceptedPlanContinuationWakeContext(
|
|
||||||
contextSnapshot: Record<string, unknown>,
|
|
||||||
issueWorkMode?: string | null,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
readNonEmptyString(contextSnapshot.workspaceRefreshReason) === "accepted_plan_confirmation" ||
|
|
||||||
(
|
|
||||||
issueWorkMode === "planning" &&
|
|
||||||
readNonEmptyString(contextSnapshot.interactionKind) === "request_confirmation" &&
|
|
||||||
readNonEmptyString(contextSnapshot.interactionStatus) === "accepted"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type AcceptedPlanWakeRoutingDecision = {
|
type AcceptedPlanWakeRoutingDecision = {
|
||||||
otherActiveClaimIssueId: string;
|
otherActiveClaimIssueId: string;
|
||||||
otherActiveClaimIdentifier: string | null;
|
otherActiveClaimIdentifier: string | null;
|
||||||
@@ -2411,7 +2396,7 @@ export async function buildPaperclipWakePayload(input: {
|
|||||||
exposeLowTrustRaw?: boolean;
|
exposeLowTrustRaw?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const executionStage = parseObject(input.contextSnapshot.executionStage);
|
const executionStage = parseObject(input.contextSnapshot.executionStage);
|
||||||
let commentIds = extractWakeCommentIds(input.contextSnapshot);
|
const commentIds = extractWakeCommentIds(input.contextSnapshot);
|
||||||
const annotationCommentId = readNonEmptyString(input.contextSnapshot.annotationCommentId);
|
const annotationCommentId = readNonEmptyString(input.contextSnapshot.annotationCommentId);
|
||||||
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
|
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
|
||||||
const continuationSummary = input.continuationSummary ?? null;
|
const continuationSummary = input.continuationSummary ?? null;
|
||||||
@@ -2431,28 +2416,6 @@ export async function buildPaperclipWakePayload(input: {
|
|||||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
|
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
|
||||||
.then((rows) => rows[0] ?? null)
|
.then((rows) => rows[0] ?? null)
|
||||||
: 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;
|
if (commentIds.length === 0 && Object.keys(executionStage).length === 0 && !issueSummary) return null;
|
||||||
|
|
||||||
const commentRows =
|
const commentRows =
|
||||||
@@ -2487,7 +2450,7 @@ export async function buildPaperclipWakePayload(input: {
|
|||||||
const commentsById = new Map(commentRows.map((comment) => [comment.id, comment]));
|
const commentsById = new Map(commentRows.map((comment) => [comment.id, comment]));
|
||||||
const comments: Array<Record<string, unknown>> = [];
|
const comments: Array<Record<string, unknown>> = [];
|
||||||
let remainingBodyChars = MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS;
|
let remainingBodyChars = MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS;
|
||||||
let truncated = acceptedPlanCommentWindowTruncated;
|
let truncated = false;
|
||||||
let missingCommentCount = 0;
|
let missingCommentCount = 0;
|
||||||
const safeContinuationSummary =
|
const safeContinuationSummary =
|
||||||
continuationSummary && !input.exposeLowTrustRaw
|
continuationSummary && !input.exposeLowTrustRaw
|
||||||
@@ -2625,9 +2588,6 @@ export async function buildPaperclipWakePayload(input: {
|
|||||||
: null,
|
: null,
|
||||||
interactionKind: readNonEmptyString(input.contextSnapshot.interactionKind),
|
interactionKind: readNonEmptyString(input.contextSnapshot.interactionKind),
|
||||||
interactionStatus: readNonEmptyString(input.contextSnapshot.interactionStatus),
|
interactionStatus: readNonEmptyString(input.contextSnapshot.interactionStatus),
|
||||||
commentContextSource: acceptedPlanContinuationWake && commentIds.length > 0
|
|
||||||
? "accepted_plan_confirmation"
|
|
||||||
: null,
|
|
||||||
checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true,
|
checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true,
|
||||||
dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true,
|
dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true,
|
||||||
treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true,
|
treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true,
|
||||||
@@ -2702,11 +2662,6 @@ export function buildPaperclipTaskMarkdown(input: {
|
|||||||
kind?: string | null;
|
kind?: string | null;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
acceptedPlanComments?: Array<{
|
|
||||||
id?: string | null;
|
|
||||||
authorType?: string | null;
|
|
||||||
body?: string | null;
|
|
||||||
}> | null;
|
|
||||||
acceptedPlanContinuation?: boolean;
|
acceptedPlanContinuation?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const quoteTaskScalar = (value: string) => JSON.stringify(value);
|
const quoteTaskScalar = (value: string) => JSON.stringify(value);
|
||||||
@@ -2767,28 +2722,6 @@ export function buildPaperclipTaskMarkdown(input: {
|
|||||||
if (wakeComment?.body.trim()) {
|
if (wakeComment?.body.trim()) {
|
||||||
lines.push("", "Latest wake comment:", fenceTaskText(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.");
|
lines.push("", "Use this task context as the current assignment.");
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
@@ -7815,7 +7748,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
issueId,
|
issueId,
|
||||||
acceptedPlanContinuationWake: isAcceptedPlanContinuationWakeContext(context, issueContext.workMode),
|
acceptedPlanContinuationWake:
|
||||||
|
readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation"
|
||||||
|
|| (
|
||||||
|
issueContext.workMode === "planning"
|
||||||
|
&& readNonEmptyString(context.interactionKind) === "request_confirmation"
|
||||||
|
&& readNonEmptyString(context.interactionStatus) === "accepted"
|
||||||
|
),
|
||||||
contextSnapshot: context,
|
contextSnapshot: context,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
@@ -7954,18 +7893,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
} else {
|
} else {
|
||||||
delete context[PAPERCLIP_WAKE_PAYLOAD_KEY];
|
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({
|
const taskMarkdown = buildPaperclipTaskMarkdown({
|
||||||
issue: issueRef
|
issue: issueRef
|
||||||
? {
|
? {
|
||||||
@@ -7981,8 +7908,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
kind: readNonEmptyString(context.interactionKind),
|
kind: readNonEmptyString(context.interactionKind),
|
||||||
status: readNonEmptyString(context.interactionStatus),
|
status: readNonEmptyString(context.interactionStatus),
|
||||||
},
|
},
|
||||||
acceptedPlanComments: acceptedPlanCommentsForTask,
|
acceptedPlanContinuation:
|
||||||
acceptedPlanContinuation: acceptedPlanContinuationForTask,
|
readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation"
|
||||||
|
&& Object.keys(parseObject(context.acceptedPlanWakeRouting)).length === 0,
|
||||||
});
|
});
|
||||||
if (issueRef) {
|
if (issueRef) {
|
||||||
context.paperclipIssue = {
|
context.paperclipIssue = {
|
||||||
|
|||||||
Reference in New Issue
Block a user