Prevent mention wakes from taking issue execution lock

This commit is contained in:
Dotta committed 2026-04-24 13:32:07 -05:00
1 parent 98f6136a3e
commit e729c0b1fe
2 files changed
+168 -1

No files matched your search

@@ -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();
+5 -1
View File
@@ -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,