From e729c0b1fe714b776fe28a88d8d4d967eb27ff73 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 24 Apr 2026 11:32:16 -0500 Subject: [PATCH] Prevent mention wakes from taking issue execution lock --- .../heartbeat-comment-wake-batching.test.ts | 163 ++++++++++++++++++ server/src/services/heartbeat.ts | 6 +- 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 02cdf8c7738..de1b80056ef 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -1296,6 +1296,20 @@ describe("heartbeat comment wake batching", () => { wakeReason: "issue_comment_mentioned", }); + const issueAfterMention = await db + .select({ + assigneeAgentId: issues.assigneeAgentId, + executionRunId: issues.executionRunId, + executionAgentNameKey: issues.executionAgentNameKey, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + + expect(issueAfterMention?.assigneeAgentId).toBe(primaryAgentId); + expect(issueAfterMention?.executionRunId).not.toBe(mentionedRuns[0]?.id); + expect(issueAfterMention?.executionAgentNameKey).not.toBe("mentioned agent"); + const primaryRuns = await db .select() .from(heartbeatRuns) @@ -1322,6 +1336,155 @@ describe("heartbeat comment wake batching", () => { await gateway.close(); } }, 120_000); + + it("does not mark a direct mentioned-agent run as the issue execution owner", async () => { + const gateway = await createControlledGatewayServer(); + const companyId = randomUUID(); + const primaryAgentId = randomUUID(); + const mentionedAgentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values([ + { + id: primaryAgentId, + companyId, + name: "Primary Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }, + { + id: mentionedAgentId, + companyId, + name: "Mentioned Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Mention should not steal execution ownership", + status: "todo", + priority: "medium", + assigneeAgentId: primaryAgentId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + const mentionComment = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "@Mentioned Agent please inspect this.", + }) + .returning() + .then((rows) => rows[0]); + + const mentionRun = await heartbeat.wakeup(mentionedAgentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_comment_mentioned", + payload: { issueId, commentId: mentionComment.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: mentionComment.id, + wakeCommentId: mentionComment.id, + wakeReason: "issue_comment_mentioned", + source: "comment.mention", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(mentionRun).not.toBeNull(); + await waitFor(() => gateway.getAgentPayloads().length === 1); + + const issueDuringMention = await db + .select({ + assigneeAgentId: issues.assigneeAgentId, + executionRunId: issues.executionRunId, + executionAgentNameKey: issues.executionAgentNameKey, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + + expect(issueDuringMention).toMatchObject({ + assigneeAgentId: primaryAgentId, + executionRunId: null, + executionAgentNameKey: null, + }); + + gateway.releaseFirstWait(); + await waitFor(async () => { + const run = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, mentionRun!.id)) + .then((rows) => rows[0] ?? null); + return run?.status === "succeeded"; + }, 90_000); + + const issueAfterMention = await db + .select({ + assigneeAgentId: issues.assigneeAgentId, + executionRunId: issues.executionRunId, + executionAgentNameKey: issues.executionAgentNameKey, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + + expect(issueAfterMention).toMatchObject({ + assigneeAgentId: primaryAgentId, + executionRunId: null, + executionAgentNameKey: null, + }); + } finally { + gateway.releaseFirstWait(); + await gateway.close(); + } + }, 120_000); it("treats the automatic run summary as fallback-only when the run already posted a comment", async () => { const gateway = await createControlledGatewayServer(); const companyId = randomUUID(); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 53607401556..d05635974e6 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3752,6 +3752,9 @@ export function heartbeatService(db: Db) { and( eq(issues.id, claimedIssueId), eq(issues.companyId, claimed.companyId), + // Mention/context runs can touch an issue, but only the current assignee + // owns the issue execution lock shown as the active run. + eq(issues.assigneeAgentId, claimed.agentId), or(isNull(issues.executionRunId), eq(issues.executionRunId, claimed.id)), ), ); @@ -5918,7 +5921,8 @@ export function heartbeatService(db: Db) { executionLockedAt: now, updatedAt: now, }) - .where(eq(issues.id, issue.id)); + // Promoted mention wakes are issue-scoped, not issue ownership transfers. + .where(and(eq(issues.id, issue.id), eq(issues.assigneeAgentId, deferredAgent.id))); return { kind: "promoted" as const,