Implement issue execution lock with deferred wake promotion

Add per-issue execution lock (executionRunId, executionAgentNameKey,
executionLockedAt) to prevent concurrent runs on the same issue.
Same-name wakes are coalesced into the active run; different-name
wakes are deferred and promoted when the lock holder finishes.

Includes checkout/release run ownership enforcement, agent run ID
propagation from JWT claims, wakeup deduplication across assignee
and mention wakes, and claimQueuedRun extraction for reuse. Adds
two DB migrations for checkoutRunId and execution lock columns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-20 15:48:22 -06:00
parent 4e18cfa818
commit 49e15f056d
13 changed files with 9050 additions and 180 deletions

View File

@@ -1063,23 +1063,25 @@ export function agentRoutes(db: Db) {
}
assertCompanyAccess(req, issue.companyId);
if (!issue.assigneeAgentId || issue.status !== "in_progress") {
res.json(null);
return;
let run = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null;
if (run && run.status !== "queued" && run.status !== "running") {
run = null;
}
const agent = await svc.getById(issue.assigneeAgentId);
if (!agent) {
res.json(null);
return;
if (!run && issue.assigneeAgentId && issue.status === "in_progress") {
run = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId);
}
const run = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId);
if (!run) {
res.json(null);
return;
}
const agent = await svc.getById(run.agentId);
if (!agent) {
res.json(null);
return;
}
res.json({
...run,
agentId: agent.id,

View File

@@ -78,6 +78,34 @@ export function issueRoutes(db: Db, storage: StorageService) {
return false;
}
function requireAgentRunId(req: Request, res: Response) {
if (req.actor.type !== "agent") return null;
const runId = req.actor.runId?.trim();
if (runId) return runId;
res.status(401).json({ error: "Agent run id required" });
return null;
}
async function assertAgentRunCheckoutOwnership(
req: Request,
res: Response,
issue: { id: string; status: string; assigneeAgentId: string | null },
) {
if (req.actor.type !== "agent") return true;
const actorAgentId = req.actor.agentId;
if (!actorAgentId) {
res.status(403).json({ error: "Agent authentication required" });
return false;
}
if (issue.status !== "in_progress" || issue.assigneeAgentId !== actorAgentId) {
return true;
}
const runId = requireAgentRunId(req, res);
if (!runId) return false;
await svc.assertCheckoutOwner(issue.id, actorAgentId, runId);
return true;
}
router.get("/companies/:companyId/issues", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -225,6 +253,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
assertCompanyAccess(req, existing.companyId);
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
const { comment: commentBody, hiddenAt: hiddenAtRaw, ...updateFields } = req.body;
if (hiddenAtRaw !== undefined) {
@@ -276,35 +305,17 @@ export function issueRoutes(db: Db, storage: StorageService) {
details: { commentId: comment.id },
});
// @-mention wakeups
svc.findMentionedAgents(issue.companyId, commentBody).then((ids) => {
for (const mentionedId of ids) {
heartbeat.wakeup(mentionedId, {
source: "automation",
triggerDetail: "system",
reason: "issue_comment_mentioned",
payload: { issueId: id, commentId: comment!.id },
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: id,
taskId: id,
commentId: comment!.id,
wakeCommentId: comment!.id,
wakeReason: "issue_comment_mentioned",
source: "comment.mention",
},
}).catch((err) => logger.warn({ err, agentId: mentionedId }, "failed to wake mentioned agent"));
}
}).catch((err) => logger.warn({ err, issueId: id }, "failed to resolve @-mentions"));
}
const assigneeChanged =
req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId;
if (assigneeChanged && issue.assigneeAgentId) {
void heartbeat
.wakeup(issue.assigneeAgentId, {
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
if (assigneeChanged && issue.assigneeAgentId) {
wakeups.set(issue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
@@ -312,9 +323,44 @@ export function issueRoutes(db: Db, storage: StorageService) {
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: { issueId: issue.id, source: "issue.update" },
})
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue update"));
}
});
}
if (commentBody && comment) {
let mentionedIds: string[] = [];
try {
mentionedIds = await svc.findMentionedAgents(issue.companyId, commentBody);
} catch (err) {
logger.warn({ err, issueId: id }, "failed to resolve @-mentions");
}
for (const mentionedId of mentionedIds) {
if (wakeups.has(mentionedId)) continue;
wakeups.set(mentionedId, {
source: "automation",
triggerDetail: "system",
reason: "issue_comment_mentioned",
payload: { issueId: id, commentId: comment.id },
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: id,
taskId: id,
commentId: comment.id,
wakeCommentId: comment.id,
wakeReason: "issue_comment_mentioned",
source: "comment.mention",
},
});
}
}
for (const [agentId, wakeup] of wakeups.entries()) {
heartbeat
.wakeup(agentId, wakeup)
.catch((err) => logger.warn({ err, issueId: issue.id, agentId }, "failed to wake agent on issue update"));
}
})();
res.json({ ...issue, comment });
});
@@ -372,7 +418,9 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses);
const checkoutRunId = requireAgentRunId(req, res);
if (req.actor.type === "agent" && !checkoutRunId) return;
const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId);
const actor = getActorInfo(req);
await logActivity(db, {
@@ -410,8 +458,14 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
assertCompanyAccess(req, existing.companyId);
const actorRunId = requireAgentRunId(req, res);
if (req.actor.type === "agent" && !actorRunId) return;
const released = await svc.release(id, req.actor.type === "agent" ? req.actor.agentId : undefined);
const released = await svc.release(
id,
req.actor.type === "agent" ? req.actor.agentId : undefined,
actorRunId,
);
if (!released) {
res.status(404).json({ error: "Issue not found" });
return;
@@ -452,6 +506,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return;
const actor = getActorInfo(req);
const reopenRequested = req.body.reopen === true;
@@ -505,10 +560,66 @@ export function issueRoutes(db: Db, storage: StorageService) {
details: { commentId: comment.id },
});
// @-mention wakeups
svc.findMentionedAgents(issue.companyId, req.body.body).then((ids) => {
for (const mentionedId of ids) {
heartbeat.wakeup(mentionedId, {
// Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs.
void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
const assigneeId = currentIssue.assigneeAgentId;
if (assigneeId) {
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",
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: currentIssue.id,
taskId: currentIssue.id,
commentId: comment.id,
source: "issue.comment.reopen",
wakeReason: "issue_reopened_via_comment",
reopenedFrom: reopenFromStatus,
},
});
} else {
wakeups.set(assigneeId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: {
issueId: currentIssue.id,
commentId: comment.id,
mutation: "comment",
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: currentIssue.id,
taskId: currentIssue.id,
commentId: comment.id,
source: "issue.comment",
wakeReason: "issue_commented",
},
});
}
}
let mentionedIds: string[] = [];
try {
mentionedIds = await svc.findMentionedAgents(issue.companyId, req.body.body);
} catch (err) {
logger.warn({ err, issueId: id }, "failed to resolve @-mentions");
}
for (const mentionedId of mentionedIds) {
if (wakeups.has(mentionedId)) continue;
wakeups.set(mentionedId, {
source: "automation",
triggerDetail: "system",
reason: "issue_comment_mentioned",
@@ -523,57 +634,15 @@ export function issueRoutes(db: Db, storage: StorageService) {
wakeReason: "issue_comment_mentioned",
source: "comment.mention",
},
}).catch((err) => logger.warn({ err, agentId: mentionedId }, "failed to wake mentioned agent"));
});
}
}).catch((err) => logger.warn({ err, issueId: id }, "failed to resolve @-mentions"));
if (reopened && currentIssue.assigneeAgentId) {
void heartbeat
.wakeup(currentIssue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_reopened_via_comment",
payload: {
issueId: currentIssue.id,
commentId: comment.id,
reopenedFrom: reopenFromStatus,
mutation: "comment",
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: currentIssue.id,
taskId: currentIssue.id,
commentId: comment.id,
source: "issue.comment.reopen",
wakeReason: "issue_reopened_via_comment",
reopenedFrom: reopenFromStatus,
},
})
.catch((err) => logger.warn({ err, issueId: currentIssue.id }, "failed to wake assignee on issue reopen comment"));
} else if (currentIssue.assigneeAgentId) {
void heartbeat
.wakeup(currentIssue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: {
issueId: currentIssue.id,
commentId: comment.id,
mutation: "comment",
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: currentIssue.id,
taskId: currentIssue.id,
commentId: comment.id,
source: "issue.comment",
wakeReason: "issue_commented",
},
})
.catch((err) => logger.warn({ err, issueId: currentIssue.id }, "failed to wake assignee on issue comment"));
}
for (const [agentId, wakeup] of wakeups.entries()) {
heartbeat
.wakeup(agentId, wakeup)
.catch((err) => logger.warn({ err, issueId: currentIssue.id, agentId }, "failed to wake agent on issue comment"));
}
})();
res.status(201).json(comment);
});