Skip to content

Commit ffb4a0c

Browse files
authored
fix(services): enqueue notify-deliver for the approval-queue staged-action and reminder badges (#10182)
A notification_deliveries row is only visible in the feed once notify-deliver promotes it out of pending, and every other insert in the codebase pairs the insert with an enqueue. The approval queue's two inserts did not: stageForApproval's staged-action badge and sweepStaleApprovalQueue's #9032 reminder badge were both created at pending with no notify-deliver job, so they sat invisible until the stranded-delivery sweep rescued them 10+ minutes later (a rescue for a FAILED enqueue, not the primary path) -- contradicting the staleness module's framing of the staging badge as the notification the maintainer gets. Enqueue one notify-deliver per freshly-created pending delivery at both sites, mirroring evaluateAndEnqueueNotificationDeliveries' created + pending guard: a dedup hit or a suppressed non-pending row enqueues nothing. Best-effort: a rejected send is caught, warns approval_notification_enqueue_failed, and does not abort staging (still returns true) or the sweep loop (still counts the reminder). Extend the notify-deliver requestedBy union with agent-approval. The dedup keys, the insert helper, deliverNotification, buildNotificationFeed, and the stranded sweep are unchanged. Closes #10025
1 parent db3fe36 commit ffb4a0c

5 files changed

Lines changed: 109 additions & 5 deletions

File tree

src/services/agent-action-executor.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1465,7 +1465,7 @@ async function stageForApproval(env: Env, ctx: AgentActionExecutionContext, acti
14651465
if (!created) return false;
14661466
/* v8 ignore next -- a repo full name always has an owner segment; the empty fallback is purely defensive. */
14671467
const recipientLogin = ctx.repoFullName.split("/")[0] ?? "";
1468-
await insertNotificationDeliveryIfAbsent(env, {
1468+
const { created: deliveryCreated, delivery } = await insertNotificationDeliveryIfAbsent(env, {
14691469
dedupKey: `agent.pending_action:${ctx.repoFullName}#${ctx.pullNumber}:${action.actionClass}`,
14701470
channel: "badge",
14711471
recipientLogin,
@@ -1477,5 +1477,20 @@ async function stageForApproval(env: Env, ctx: AgentActionExecutionContext, acti
14771477
deeplink: `https://github.com/${ctx.repoFullName}/pull/${ctx.pullNumber}`,
14781478
actorLogin: AGENT_ACTOR,
14791479
});
1480+
// #10025: enqueue the notify-deliver job that promotes this pending row to delivered — without it the badge
1481+
// sits invisible in notification_deliveries until the stranded-delivery sweep rescues it 10+ minutes later.
1482+
// Mirrors evaluateAndEnqueueNotificationDeliveries' `created && status === "pending"` guard: a dedup hit
1483+
// (already sent) or a rate-limit-suppressed non-pending row enqueues nothing. Best-effort: a failed send
1484+
// must not abort staging (still returns true), only log.
1485+
// The `status === "pending"` arm mirrors evaluateAndEnqueueNotificationDeliveries' guard and honours the
1486+
// "send nothing for a suppressed row" contract, but this insert never passes a status, so the delivery is
1487+
// always pending here -- the false arm is unreachable from THIS caller (rate-limit suppression lives in
1488+
// evaluateNotificationEvent, a different helper), hence the ignore. `created` false IS reachable (a dedup).
1489+
/* v8 ignore next -- `delivery.status === "pending"` is always true from this caller; the guard is the shared contract */
1490+
if (deliveryCreated && delivery.status === "pending") {
1491+
await env.JOBS.send({ type: "notify-deliver", requestedBy: "agent-approval", deliveryId: delivery.id }).catch((error: unknown) => {
1492+
console.warn(JSON.stringify({ event: "approval_notification_enqueue_failed", deliveryId: delivery.id, repoFullName: ctx.repoFullName, pullNumber: ctx.pullNumber, message: errorMessage(error).slice(0, 200) }));
1493+
});
1494+
}
14801495
return true;
14811496
}

src/services/agent-approval-queue.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { resolvePerRepoContributorCapMatch } from "../queue/processors";
2424
import { isBelowAccountAgeThreshold } from "../queue/account-age-throttle";
2525
import { isAutoCloseExempt } from "../settings/auto-close-exempt";
2626
import { isPerTenantAdmin } from "../auth/security";
27+
import { errorMessage } from "../utils/json";
2728
import type { AgentPendingActionParams, AgentPendingActionRecord } from "../types";
2829

2930
export type ApprovalDecision = "accept" | "reject";
@@ -614,7 +615,7 @@ export async function sweepStaleApprovalQueue(env: Env, nowMs: number = Date.now
614615
const recipientLogin = row.repoFullName.split("/")[0] ?? "";
615616
if (plan.kind === "remind") {
616617
const ageDays = plan.bucket;
617-
const { created } = await insertNotificationDeliveryIfAbsent(env, {
618+
const inserted = await insertNotificationDeliveryIfAbsent(env, {
618619
// The bucket index is what makes this fire again at all: the dedup key changes once per interval, so
619620
// the ~2-minute sweep cadence collapses to exactly one badge per interval with no extra persisted state.
620621
dedupKey: `agent.pending_action.reminder:${row.repoFullName}#${row.pullNumber}:${row.actionClass}:${plan.bucket}`,
@@ -627,8 +628,21 @@ export async function sweepStaleApprovalQueue(env: Env, nowMs: number = Date.now
627628
body: `${row.reason ?? "A staged action"} — accept to execute it, or reject to cancel. It expires after ${Math.round(APPROVAL_EXPIRY_MS / (24 * 60 * 60 * 1000))} days.`,
628629
deeplink: `https://github.com/${row.repoFullName}/pull/${row.pullNumber}`,
629630
actorLogin: "loopover",
630-
}).catch(() => ({ created: false }));
631-
if (created) reminded += 1;
631+
}).catch(() => null);
632+
if (inserted?.created) {
633+
reminded += 1;
634+
// #10025: enqueue the notify-deliver job so the reminder badge — the #9032 escape hatch for a
635+
// maintainer who missed the first badge — actually reaches the feed, instead of waiting 10+ minutes
636+
// for the stranded-delivery sweep. Best-effort: a failed send still counts the reminder and continues.
637+
// As in stageForApproval: this insert never passes a status, so the delivery is always pending here --
638+
// the false arm is unreachable from this caller. The check mirrors the shared enqueue contract.
639+
/* v8 ignore next -- `inserted.delivery.status === "pending"` is always true from this caller */
640+
if (inserted.delivery.status === "pending") {
641+
await env.JOBS.send({ type: "notify-deliver", requestedBy: "agent-approval", deliveryId: inserted.delivery.id }).catch((error: unknown) => {
642+
console.warn(JSON.stringify({ event: "approval_notification_enqueue_failed", deliveryId: inserted.delivery.id, repoFullName: row.repoFullName, pullNumber: row.pullNumber, message: errorMessage(error).slice(0, 200) }));
643+
});
644+
}
645+
}
632646
continue;
633647
}
634648
// Atomic pending→expired, so a maintainer accepting at the exact moment the sweep expires the row still

src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,9 @@ export type JobMessage =
222222
}
223223
| {
224224
type: "notify-deliver";
225-
requestedBy: "notify-evaluate" | "test";
225+
// #10025: the approval-queue's staged-action + reminder badges enqueue their own notify-deliver jobs so
226+
// they reach the feed without waiting on the stranded-delivery sweep.
227+
requestedBy: "notify-evaluate" | "test" | "agent-approval";
226228
deliveryId: string;
227229
}
228230
| {

test/unit/agent-approval-queue.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import {
8484
upsertRepositorySettings,
8585
} from "../../src/db/repositories";
8686
import { AGENT_LABEL_NEEDS_REVIEW, type PlannedAgentAction } from "../../src/settings/agent-actions";
87+
import { buildNotificationFeed, deliverNotification } from "../../src/notifications/service";
8788
import { createTestEnv } from "../helpers/d1";
8889

8990
function ctx(over: Partial<AgentActionExecutionContext> = {}): AgentActionExecutionContext {
@@ -152,6 +153,49 @@ describe("agent approval queue (#779)", () => {
152153
expect(deliveries).toHaveLength(1);
153154
});
154155

156+
// #10025: capture the notify-deliver jobs the staging path enqueues.
157+
const withJobsCapture = (env: Env): Array<{ type: string; deliveryId?: string }> => {
158+
const sent: Array<{ type: string; deliveryId?: string }> = [];
159+
(env as unknown as { JOBS: { send: (msg: unknown) => Promise<void> } }).JOBS = { send: async (msg) => void sent.push(msg as { type: string; deliveryId?: string }) };
160+
return sent;
161+
};
162+
163+
it("#10025: staging enqueues exactly one notify-deliver job for the created badge; a second staging enqueues none", async () => {
164+
const env = createTestEnv({});
165+
const sent = withJobsCapture(env);
166+
await executeAgentMaintenanceActions(env, ctx(), [mergeApproval]);
167+
const notifyJobs = sent.filter((m) => m.type === "notify-deliver");
168+
expect(notifyJobs).toHaveLength(1);
169+
const delivery = (await listNotificationDeliveriesForRecipient(env, "owner")).find((d) => d.eventType === "agent.pending_action" && d.pullNumber === 7);
170+
expect(notifyJobs[0]?.deliveryId).toBe(delivery?.id);
171+
172+
// A second staging hits the dedup (created:false) → enqueues nothing more.
173+
await executeAgentMaintenanceActions(env, ctx(), [mergeApproval]);
174+
expect(sent.filter((m) => m.type === "notify-deliver")).toHaveLength(1);
175+
});
176+
177+
it("#10025: a rejected notify-deliver send is caught, warns, and staging still returns queued", async () => {
178+
const env = createTestEnv({});
179+
(env as unknown as { JOBS: { send: (msg: unknown) => Promise<void> } }).JOBS = { send: async () => { throw new Error("queue down"); } };
180+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
181+
const outcomes = await executeAgentMaintenanceActions(env, ctx(), [mergeApproval]);
182+
expect(outcomes[0]?.outcome).toBe("queued"); // the send failure did not abort staging
183+
expect(warn.mock.calls.map((c) => String(c[0])).some((m) => m.includes("approval_notification_enqueue_failed"))).toBe(true);
184+
warn.mockRestore();
185+
});
186+
187+
it("#10025 REGRESSION: after staging + delivering the enqueued job, the recipient's feed contains the staged-action item", async () => {
188+
const env = createTestEnv({});
189+
const sent = withJobsCapture(env);
190+
await executeAgentMaintenanceActions(env, ctx(), [mergeApproval]);
191+
const deliveryId = sent.find((m) => m.type === "notify-deliver")?.deliveryId;
192+
expect(deliveryId).toBeDefined();
193+
// Run the job that promotes the pending row to delivered.
194+
await deliverNotification(env, deliveryId!);
195+
const feed = buildNotificationFeed("owner", await listNotificationDeliveriesForRecipient(env, "owner"));
196+
expect(feed.notifications.some((item) => item.eventType === "agent.pending_action" && item.pullNumber === 7)).toBe(true);
197+
});
198+
155199
it("createPendingAgentActionIfAbsent reports created vs already-staged", async () => {
156200
const env = createTestEnv({});
157201
const input = { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge" as const, autonomyLevel: "auto_with_approval" as const, params: { mergeMethod: "squash" as const }, reason: "x" };

test/unit/approval-queue-staleness.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,35 @@ describe("sweepStaleApprovalQueue (#9032)", () => {
8787
expect(deliveries.every((delivery) => delivery.recipientLogin === "alice")).toBe(true);
8888
});
8989

90+
it("#10025: a row aged past the reminder interval enqueues exactly one notify-deliver; a second sweep in the same bucket enqueues none", async () => {
91+
const env = createTestEnv();
92+
const sent: Array<{ type: string; deliveryId?: string }> = [];
93+
(env as unknown as { JOBS: { send: (msg: unknown) => Promise<void> } }).JOBS = { send: async (msg) => void sent.push(msg as { type: string; deliveryId?: string }) };
94+
const id = await stage(env, 3);
95+
const stagedAt = Date.parse((await getPendingAgentAction(env, id))!.createdAt);
96+
97+
await sweepStaleApprovalQueue(env, stagedAt + APPROVAL_REMINDER_INTERVAL_MS);
98+
const notifyJobs = sent.filter((m) => m.type === "notify-deliver");
99+
expect(notifyJobs).toHaveLength(1);
100+
const reminder = (await listNotificationDeliveriesForRecipient(env, "alice", { limit: 50 })).find((d) => d.title.includes("Still waiting"));
101+
expect(notifyJobs[0]?.deliveryId).toBe(reminder?.id);
102+
103+
// A second sweep inside the SAME reminder bucket hits the dedup (created:false) → no further enqueue.
104+
await sweepStaleApprovalQueue(env, stagedAt + APPROVAL_REMINDER_INTERVAL_MS + 60_000);
105+
expect(sent.filter((m) => m.type === "notify-deliver")).toHaveLength(1);
106+
});
107+
108+
it("#10025: a rejected notify-deliver send is caught, warns, and the sweep still counts the reminder", async () => {
109+
const env = createTestEnv();
110+
(env as unknown as { JOBS: { send: (msg: unknown) => Promise<void> } }).JOBS = { send: async () => { throw new Error("queue down"); } };
111+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
112+
const id = await stage(env, 4);
113+
const stagedAt = Date.parse((await getPendingAgentAction(env, id))!.createdAt);
114+
expect(await sweepStaleApprovalQueue(env, stagedAt + APPROVAL_REMINDER_INTERVAL_MS)).toEqual({ reminded: 1, expired: 0 });
115+
expect(warn.mock.calls.map((c) => String(c[0])).some((m) => m.includes("approval_notification_enqueue_failed"))).toBe(true);
116+
warn.mockRestore();
117+
});
118+
90119
it("still writes a readable reminder for a row staged without a reason", async () => {
91120
const env = createTestEnv();
92121
const { action } = await createPendingAgentActionIfAbsent(env, {

0 commit comments

Comments
 (0)