Add blocker relations and dependency wakeups

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-04 13:56:04 -05:00
parent 2f73346a64
commit dde4cc070e
18 changed files with 13924 additions and 69 deletions

View file

@ -442,11 +442,12 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload] = await Promise.all([
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations] = await Promise.all([
resolveIssueProjectAndGoal(issue),
svc.getAncestors(issue.id),
svc.findMentionedProjectIds(issue.id),
documentsSvc.getIssueDocumentPayload(issue),
svc.getRelationSummaries(issue.id),
]);
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
@ -459,6 +460,8 @@ export function issueRoutes(
...issue,
goalId: goal?.id ?? issue.goalId,
ancestors,
blockedBy: relations.blockedBy,
blocks: relations.blocks,
...documentPayload,
project: project ?? null,
goal: goal ?? null,
@ -482,11 +485,13 @@ export function issueRoutes(
? req.query.wakeCommentId.trim()
: null;
const [{ project, goal }, ancestors, commentCursor, wakeComment, attachments] = await Promise.all([
const [{ project, goal }, ancestors, commentCursor, wakeComment, relations, attachments] =
await Promise.all([
resolveIssueProjectAndGoal(issue),
svc.getAncestors(issue.id),
svc.getCommentCursor(issue.id),
wakeCommentId ? svc.getComment(wakeCommentId) : null,
svc.getRelationSummaries(issue.id),
svc.listAttachments(issue.id),
]);
@ -501,6 +506,8 @@ export function issueRoutes(
projectId: issue.projectId,
goalId: goal?.id ?? issue.goalId,
parentId: issue.parentId,
blockedBy: relations.blockedBy,
blocks: relations.blocks,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
updatedAt: issue.updatedAt,
@ -1058,7 +1065,11 @@ export function issueRoutes(
action: "issue.created",
entityType: "issue",
entityId: issue.id,
details: { title: issue.title, identifier: issue.identifier },
details: {
title: issue.title,
identifier: issue.identifier,
...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}),
},
});
void queueIssueAssignmentWakeup({
@ -1104,6 +1115,10 @@ export function issueRoutes(
const actor = getActorInfo(req);
const isClosed = existing.status === "done" || existing.status === "cancelled";
const existingRelations =
Array.isArray(req.body.blockedByIssueIds)
? await svc.getRelationSummaries(existing.id)
: null;
const {
comment: commentBody,
reopen: reopenRequested,
@ -1158,7 +1173,11 @@ export function issueRoutes(
}
let issue;
try {
issue = await svc.update(id, updateFields);
issue = await svc.update(id, {
...updateFields,
actorAgentId: actor.agentId ?? null,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
});
} catch (err) {
if (err instanceof HttpError && err.status === 422) {
logger.warn(
@ -1187,6 +1206,15 @@ export function issueRoutes(
res.status(404).json({ error: "Issue not found" });
return;
}
let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown } = issue;
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
const updatedRelations = await svc.getRelationSummaries(issue.id);
issueResponse = {
...issue,
blockedBy: updatedRelations.blockedBy,
blocks: updatedRelations.blocks,
};
}
await routinesSvc.syncRunStatusForIssue(issue.id);
if (actor.runId) {
@ -1201,6 +1229,9 @@ export function issueRoutes(
previous[key] = (existing as Record<string, unknown>)[key];
}
}
if (Array.isArray(req.body.blockedByIssueIds)) {
previous.blockedByIssueIds = existingRelations?.blockedBy.map((relation) => relation.id) ?? [];
}
const hasFieldChanges = Object.keys(previous).length > 0;
const reopened =
@ -1229,6 +1260,31 @@ export function issueRoutes(
},
});
if (Array.isArray(req.body.blockedByIssueIds)) {
const previousBlockedByIds = new Set((existingRelations?.blockedBy ?? []).map((relation) => relation.id));
const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]);
const addedBlockedByIssueIds = [...nextBlockedByIds].filter((candidate) => !previousBlockedByIds.has(candidate));
const removedBlockedByIssueIds = [...previousBlockedByIds].filter((candidate) => !nextBlockedByIds.has(candidate));
if (addedBlockedByIssueIds.length > 0 || removedBlockedByIssueIds.length > 0) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.blockers_updated",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
blockedByIssueIds: req.body.blockedByIssueIds,
addedBlockedByIssueIds,
removedBlockedByIssueIds,
},
});
}
}
if (issue.status === "done" && existing.status !== "done") {
const tc = getTelemetryClient();
if (tc && actor.agentId) {
@ -1277,10 +1333,18 @@ export function issueRoutes(
// 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]>();
type WakeupRequest = NonNullable<Parameters<typeof heartbeat.wakeup>[1]>;
const wakeups = new Map<string, { agentId: string; wakeup: WakeupRequest }>();
const addWakeup = (agentId: string, wakeup: WakeupRequest) => {
const wakeIssueId =
wakeup.payload && typeof wakeup.payload === "object" && typeof wakeup.payload.issueId === "string"
? wakeup.payload.issueId
: issue.id;
wakeups.set(`${agentId}:${wakeIssueId}`, { agentId, wakeup });
};
if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
wakeups.set(issue.assigneeAgentId, {
addWakeup(issue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_assigned",
@ -1300,7 +1364,7 @@ export function issueRoutes(
}
if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) {
wakeups.set(issue.assigneeAgentId, {
addWakeup(issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_status_changed",
@ -1328,9 +1392,8 @@ export function issueRoutes(
}
for (const mentionedId of mentionedIds) {
if (wakeups.has(mentionedId)) continue;
if (actor.actorType === "agent" && actor.actorId === mentionedId) continue;
wakeups.set(mentionedId, {
addWakeup(mentionedId, {
source: "automation",
triggerDetail: "system",
reason: "issue_comment_mentioned",
@ -1349,14 +1412,69 @@ export function issueRoutes(
}
}
for (const [agentId, wakeup] of wakeups.entries()) {
const becameDone = existing.status !== "done" && issue.status === "done";
if (becameDone) {
const dependents = await svc.listWakeableBlockedDependents(issue.id);
for (const dependent of dependents) {
addWakeup(dependent.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_blockers_resolved",
payload: {
issueId: dependent.id,
resolvedBlockerIssueId: issue.id,
blockerIssueIds: dependent.blockerIssueIds,
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: dependent.id,
taskId: dependent.id,
wakeReason: "issue_blockers_resolved",
source: "issue.blockers_resolved",
resolvedBlockerIssueId: issue.id,
blockerIssueIds: dependent.blockerIssueIds,
},
});
}
}
const becameTerminal =
!["done", "cancelled"].includes(existing.status) && ["done", "cancelled"].includes(issue.status);
if (becameTerminal && issue.parentId) {
const parent = await svc.getWakeableParentAfterChildCompletion(issue.parentId);
if (parent) {
addWakeup(parent.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_children_completed",
payload: {
issueId: parent.id,
completedChildIssueId: issue.id,
childIssueIds: parent.childIssueIds,
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: parent.id,
taskId: parent.id,
wakeReason: "issue_children_completed",
source: "issue.children_completed",
completedChildIssueId: issue.id,
childIssueIds: parent.childIssueIds,
},
});
}
}
for (const { agentId, wakeup } of wakeups.values()) {
heartbeat
.wakeup(agentId, wakeup)
.catch((err) => logger.warn({ err, issueId: issue.id, agentId }, "failed to wake agent on issue update"));
}
})();
res.json({ ...issue, comment });
res.json({ ...issueResponse, comment });
});
router.delete("/issues/:id", async (req, res) => {