Fix routine assignment wakeups
Share issue-assignment wakeup logic between direct issue creation and routine-created execution issues, and add regression coverage for the routine path. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -120,6 +120,18 @@ describe("routine service live-execution coalescing", () => {
|
|||||||
const agentId = randomUUID();
|
const agentId = randomUUID();
|
||||||
const projectId = randomUUID();
|
const projectId = randomUUID();
|
||||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||||
|
const wakeups: Array<{
|
||||||
|
agentId: string;
|
||||||
|
opts: {
|
||||||
|
source?: string;
|
||||||
|
triggerDetail?: string;
|
||||||
|
reason?: string | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
requestedByActorType?: "user" | "agent" | "system";
|
||||||
|
requestedByActorId?: string | null;
|
||||||
|
contextSnapshot?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}> = [];
|
||||||
|
|
||||||
await db.insert(companies).values({
|
await db.insert(companies).values({
|
||||||
id: companyId,
|
id: companyId,
|
||||||
@@ -147,7 +159,14 @@ describe("routine service live-execution coalescing", () => {
|
|||||||
status: "in_progress",
|
status: "in_progress",
|
||||||
});
|
});
|
||||||
|
|
||||||
const svc = routineService(db);
|
const svc = routineService(db, {
|
||||||
|
heartbeat: {
|
||||||
|
wakeup: async (agentId, opts) => {
|
||||||
|
wakeups.push({ agentId, opts });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
const issueSvc = issueService(db);
|
const issueSvc = issueService(db);
|
||||||
const routine = await svc.create(
|
const routine = await svc.create(
|
||||||
companyId,
|
companyId,
|
||||||
@@ -166,7 +185,7 @@ describe("routine service live-execution coalescing", () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
return { companyId, agentId, issueSvc, projectId, routine, svc };
|
return { companyId, agentId, issueSvc, projectId, routine, svc, wakeups };
|
||||||
}
|
}
|
||||||
|
|
||||||
it("creates a fresh execution issue when the previous routine issue is open but idle", async () => {
|
it("creates a fresh execution issue when the previous routine issue is open but idle", async () => {
|
||||||
@@ -216,6 +235,29 @@ describe("routine service live-execution coalescing", () => {
|
|||||||
expect(routineIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
|
expect(routineIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("wakes the assignee when a routine creates a fresh execution issue", async () => {
|
||||||
|
const { agentId, routine, svc, wakeups } = await seedFixture();
|
||||||
|
|
||||||
|
const run = await svc.runRoutine(routine.id, { source: "manual" });
|
||||||
|
|
||||||
|
expect(run.status).toBe("issue_created");
|
||||||
|
expect(run.linkedIssueId).toBeTruthy();
|
||||||
|
expect(wakeups).toEqual([
|
||||||
|
{
|
||||||
|
agentId,
|
||||||
|
opts: {
|
||||||
|
source: "assignment",
|
||||||
|
triggerDetail: "system",
|
||||||
|
reason: "issue_assigned",
|
||||||
|
payload: { issueId: run.linkedIssueId, mutation: "create" },
|
||||||
|
requestedByActorType: undefined,
|
||||||
|
requestedByActorId: null,
|
||||||
|
contextSnapshot: { issueId: run.linkedIssueId, source: "routine.dispatch" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("coalesces only when the existing routine issue has a live execution run", async () => {
|
it("coalesces only when the existing routine issue has a live execution run", async () => {
|
||||||
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
|
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
|
||||||
const previousRunId = randomUUID();
|
const previousRunId = randomUUID();
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { forbidden, HttpError, unauthorized } from "../errors.js";
|
|||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||||
|
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
||||||
|
|
||||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||||
|
|
||||||
@@ -781,19 +782,15 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
details: { title: issue.title, identifier: issue.identifier },
|
details: { title: issue.title, identifier: issue.identifier },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (issue.assigneeAgentId && issue.status !== "backlog") {
|
queueIssueAssignmentWakeup({
|
||||||
void heartbeat
|
heartbeat,
|
||||||
.wakeup(issue.assigneeAgentId, {
|
issue,
|
||||||
source: "assignment",
|
reason: "issue_assigned",
|
||||||
triggerDetail: "system",
|
mutation: "create",
|
||||||
reason: "issue_assigned",
|
contextSource: "issue.create",
|
||||||
payload: { issueId: issue.id, mutation: "create" },
|
requestedByActorType: actor.actorType,
|
||||||
requestedByActorType: actor.actorType,
|
requestedByActorId: actor.actorId,
|
||||||
requestedByActorId: actor.actorId,
|
});
|
||||||
contextSnapshot: { issueId: issue.id, source: "issue.create" },
|
|
||||||
})
|
|
||||||
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue create"));
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(201).json(issue);
|
res.status(201).json(issue);
|
||||||
});
|
});
|
||||||
|
|||||||
43
server/src/services/issue-assignment-wakeup.ts
Normal file
43
server/src/services/issue-assignment-wakeup.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { logger } from "../middleware/logger.js";
|
||||||
|
|
||||||
|
type WakeupTriggerDetail = "manual" | "ping" | "callback" | "system";
|
||||||
|
type WakeupSource = "timer" | "assignment" | "on_demand" | "automation";
|
||||||
|
|
||||||
|
export interface IssueAssignmentWakeupDeps {
|
||||||
|
wakeup: (
|
||||||
|
agentId: string,
|
||||||
|
opts: {
|
||||||
|
source?: WakeupSource;
|
||||||
|
triggerDetail?: WakeupTriggerDetail;
|
||||||
|
reason?: string | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
requestedByActorType?: "user" | "agent" | "system";
|
||||||
|
requestedByActorId?: string | null;
|
||||||
|
contextSnapshot?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueIssueAssignmentWakeup(input: {
|
||||||
|
heartbeat: IssueAssignmentWakeupDeps;
|
||||||
|
issue: { id: string; assigneeAgentId: string | null; status: string };
|
||||||
|
reason: string;
|
||||||
|
mutation: string;
|
||||||
|
contextSource: string;
|
||||||
|
requestedByActorType?: "user" | "agent" | "system";
|
||||||
|
requestedByActorId?: string | null;
|
||||||
|
}) {
|
||||||
|
if (!input.issue.assigneeAgentId || input.issue.status === "backlog") return;
|
||||||
|
|
||||||
|
void input.heartbeat
|
||||||
|
.wakeup(input.issue.assigneeAgentId, {
|
||||||
|
source: "assignment",
|
||||||
|
triggerDetail: "system",
|
||||||
|
reason: input.reason,
|
||||||
|
payload: { issueId: input.issue.id, mutation: input.mutation },
|
||||||
|
requestedByActorType: input.requestedByActorType,
|
||||||
|
requestedByActorId: input.requestedByActorId ?? null,
|
||||||
|
contextSnapshot: { issueId: input.issue.id, source: input.contextSource },
|
||||||
|
})
|
||||||
|
.catch((err) => logger.warn({ err, issueId: input.issue.id }, "failed to wake assignee on issue assignment"));
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../e
|
|||||||
import { issueService } from "./issues.js";
|
import { issueService } from "./issues.js";
|
||||||
import { secretService } from "./secrets.js";
|
import { secretService } from "./secrets.js";
|
||||||
import { parseCron, validateCron } from "./cron.js";
|
import { parseCron, validateCron } from "./cron.js";
|
||||||
|
import { heartbeatService } from "./heartbeat.js";
|
||||||
|
import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "./issue-assignment-wakeup.js";
|
||||||
|
|
||||||
const OPEN_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"];
|
const OPEN_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"];
|
||||||
const LIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running"];
|
const LIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running"];
|
||||||
@@ -128,9 +130,10 @@ function nextResultText(status: string, issueId?: string | null) {
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function routineService(db: Db) {
|
export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeupDeps } = {}) {
|
||||||
const issueSvc = issueService(db);
|
const issueSvc = issueService(db);
|
||||||
const secretsSvc = secretService(db);
|
const secretsSvc = secretService(db);
|
||||||
|
const heartbeat = deps.heartbeat ?? heartbeatService(db);
|
||||||
|
|
||||||
async function getRoutineById(id: string) {
|
async function getRoutineById(id: string) {
|
||||||
return db
|
return db
|
||||||
@@ -616,6 +619,14 @@ export function routineService(db: Db) {
|
|||||||
status: "issue_created",
|
status: "issue_created",
|
||||||
linkedIssueId: createdIssue.id,
|
linkedIssueId: createdIssue.id,
|
||||||
});
|
});
|
||||||
|
queueIssueAssignmentWakeup({
|
||||||
|
heartbeat,
|
||||||
|
issue: createdIssue,
|
||||||
|
reason: "issue_assigned",
|
||||||
|
mutation: "create",
|
||||||
|
contextSource: "routine.dispatch",
|
||||||
|
requestedByActorType: input.source === "schedule" ? "system" : undefined,
|
||||||
|
});
|
||||||
await updateRoutineTouchedState({
|
await updateRoutineTouchedState({
|
||||||
routineId: input.routine.id,
|
routineId: input.routine.id,
|
||||||
triggerId: input.trigger?.id ?? null,
|
triggerId: input.trigger?.id ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user