From 2d8c8abbfbbfcb6917a0190fd8fa9316e0af3418 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 20 Mar 2026 08:11:19 -0500 Subject: [PATCH] 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 --- server/src/__tests__/routines-service.test.ts | 46 ++++++++++++++++++- server/src/routes/issues.ts | 23 ++++------ .../src/services/issue-assignment-wakeup.ts | 43 +++++++++++++++++ server/src/services/routines.ts | 13 +++++- 4 files changed, 109 insertions(+), 16 deletions(-) create mode 100644 server/src/services/issue-assignment-wakeup.ts diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 323a0c96..9240dfbf 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -120,6 +120,18 @@ describe("routine service live-execution coalescing", () => { const agentId = randomUUID(); const projectId = randomUUID(); 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 | null; + requestedByActorType?: "user" | "agent" | "system"; + requestedByActorId?: string | null; + contextSnapshot?: Record; + }; + }> = []; await db.insert(companies).values({ id: companyId, @@ -147,7 +159,14 @@ describe("routine service live-execution coalescing", () => { 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 routine = await svc.create( 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 () => { @@ -216,6 +235,29 @@ describe("routine service live-execution coalescing", () => { 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 () => { const { agentId, companyId, issueSvc, routine, svc } = await seedFixture(); const previousRunId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index b5ee52cf..4173f1a6 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -35,6 +35,7 @@ import { forbidden, HttpError, unauthorized } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; +import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; const MAX_ISSUE_COMMENT_LIMIT = 500; @@ -781,19 +782,15 @@ export function issueRoutes(db: Db, storage: StorageService) { details: { title: issue.title, identifier: issue.identifier }, }); - if (issue.assigneeAgentId && issue.status !== "backlog") { - void heartbeat - .wakeup(issue.assigneeAgentId, { - source: "assignment", - triggerDetail: "system", - reason: "issue_assigned", - payload: { issueId: issue.id, mutation: "create" }, - requestedByActorType: actor.actorType, - 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")); - } + queueIssueAssignmentWakeup({ + heartbeat, + issue, + reason: "issue_assigned", + mutation: "create", + contextSource: "issue.create", + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + }); res.status(201).json(issue); }); diff --git a/server/src/services/issue-assignment-wakeup.ts b/server/src/services/issue-assignment-wakeup.ts new file mode 100644 index 00000000..aa39d0c5 --- /dev/null +++ b/server/src/services/issue-assignment-wakeup.ts @@ -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 | null; + requestedByActorType?: "user" | "agent" | "system"; + requestedByActorId?: string | null; + contextSnapshot?: Record; + }, + ) => Promise; +} + +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")); +} diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 92ab14d0..0fb8436a 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -29,6 +29,8 @@ import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../e import { issueService } from "./issues.js"; import { secretService } from "./secrets.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 LIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running"]; @@ -128,9 +130,10 @@ function nextResultText(status: string, issueId?: string | null) { return status; } -export function routineService(db: Db) { +export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeupDeps } = {}) { const issueSvc = issueService(db); const secretsSvc = secretService(db); + const heartbeat = deps.heartbeat ?? heartbeatService(db); async function getRoutineById(id: string) { return db @@ -616,6 +619,14 @@ export function routineService(db: Db) { status: "issue_created", linkedIssueId: createdIssue.id, }); + queueIssueAssignmentWakeup({ + heartbeat, + issue: createdIssue, + reason: "issue_assigned", + mutation: "create", + contextSource: "routine.dispatch", + requestedByActorType: input.source === "schedule" ? "system" : undefined, + }); await updateRoutineTouchedState({ routineId: input.routine.id, triggerId: input.trigger?.id ?? null,