fix(server): wake archive notification delivery from committed changes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-24 12:22:58 -07:00
1 parent 0956883a84
commit 692e3ee02c
9 files changed
+443 -96

No files matched your search

@@ -0,0 +1,17 @@
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { registerDeadlineWork } from "./deadline-work.js";
import { DECISION_ARCHIVE_RETRY_MS } from "./decision-archive-delivery.js";
import type { decisionRetentionService } from "./decision-retention.js";
export function registerDecisionArchiveDeliveryWork(
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
service: Pick<ReturnType<typeof decisionRetentionService>, "deliverNotifications" | "nextArchiveNotificationAt">,
canRun: () => boolean,
) {
return registerDeadlineWork(coordinator, DELIVERY_QUEUES.decisionArchiveDelivery, {
retryMs: DECISION_ARCHIVE_RETRY_MS, canRun,
run: () => service.deliverNotifications(),
next: service.nextArchiveNotificationAt,
});
}
@@ -0,0 +1,114 @@
import { and, asc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm";
import { decisionArchiveNotificationOutbox, type Db } from "@paperclipai/db";
import type { AttentionSourceKind } from "@paperclipai/shared";
const CLAIM_MS = 5 * 60_000;
export const DECISION_ARCHIVE_RETRY_MS = 30_000;
export type ArchiveNotificationBatch = {
companyId: string;
agentId: string;
items: Array<{
sourceKind: AttentionSourceKind;
sourceId: string;
archiveVersion: number;
issueId: string;
}>;
};
export function decisionArchiveDeliveryService(
db: Db,
options: { notifyOriginAgent?: (batch: ArchiveNotificationBatch) => Promise<unknown> },
) {
async function deliverNotifications(limit = 500) {
if (!options.notifyOriginAgent) return { notifiedAgents: 0, delivered: 0 };
const claimAt = new Date();
const staleClaimAt = new Date(claimAt.getTime() - CLAIM_MS);
await db.update(decisionArchiveNotificationOutbox).set({ status: "pending", updatedAt: claimAt })
.where(and(
eq(decisionArchiveNotificationOutbox.status, "delivering"),
or(isNull(decisionArchiveNotificationOutbox.lastAttemptAt), lte(decisionArchiveNotificationOutbox.lastAttemptAt, staleClaimAt)),
));
const rows = await db.transaction(async (tx) => {
const pendingIds = await tx.select({ id: decisionArchiveNotificationOutbox.id })
.from(decisionArchiveNotificationOutbox)
.where(eq(decisionArchiveNotificationOutbox.status, "pending"))
.orderBy(sql`${decisionArchiveNotificationOutbox.lastAttemptAt} asc nulls first`,
asc(decisionArchiveNotificationOutbox.createdAt), asc(decisionArchiveNotificationOutbox.id))
.limit(limit)
.then((values) => values.map((value) => value.id));
if (pendingIds.length === 0) return [];
return tx.update(decisionArchiveNotificationOutbox).set({
status: "delivering",
lastAttemptAt: claimAt,
attemptCount: sql`${decisionArchiveNotificationOutbox.attemptCount} + 1`,
updatedAt: claimAt,
}).where(and(
inArray(decisionArchiveNotificationOutbox.id, pendingIds),
eq(decisionArchiveNotificationOutbox.status, "pending"),
)).returning();
});
const groups = new Map<string, typeof rows>();
for (const row of rows) {
const key = `${row.companyId}:${row.originAgentId}`;
const grouped = groups.get(key) ?? [];
grouped.push(row);
groups.set(key, grouped);
}
let notifiedAgents = 0;
let delivered = 0;
for (const grouped of groups.values()) {
const first = grouped[0]!;
try {
await options.notifyOriginAgent({
companyId: first.companyId,
agentId: first.originAgentId,
items: grouped.map((row) => ({
sourceKind: row.sourceKind as AttentionSourceKind,
sourceId: row.sourceId,
archiveVersion: row.archiveVersion,
issueId: row.originIssueId,
})),
});
const deliveredAt = new Date();
const settled = await db.update(decisionArchiveNotificationOutbox).set({
status: "delivered",
deliveredAt,
lastAttemptAt: deliveredAt,
updatedAt: deliveredAt,
}).where(and(
inArray(decisionArchiveNotificationOutbox.id, grouped.map((row) => row.id)),
eq(decisionArchiveNotificationOutbox.status, "delivering"),
eq(decisionArchiveNotificationOutbox.lastAttemptAt, claimAt),
)).returning({ id: decisionArchiveNotificationOutbox.id });
notifiedAgents += 1;
delivered += settled.length;
} catch {
await db.update(decisionArchiveNotificationOutbox).set({
status: "pending",
lastAttemptAt: new Date(),
updatedAt: new Date(),
}).where(and(
inArray(decisionArchiveNotificationOutbox.id, grouped.map((row) => row.id)),
eq(decisionArchiveNotificationOutbox.status, "delivering"),
eq(decisionArchiveNotificationOutbox.lastAttemptAt, claimAt),
));
}
}
return { notifiedAgents, delivered };
}
async function next() {
if (!options.notifyOriginAgent) return null;
const [pending] = await db.select({ id: decisionArchiveNotificationOutbox.id })
.from(decisionArchiveNotificationOutbox).where(eq(decisionArchiveNotificationOutbox.status, "pending")).limit(1);
const [claimed] = await db.select({ attemptedAt: decisionArchiveNotificationOutbox.lastAttemptAt })
.from(decisionArchiveNotificationOutbox).where(eq(decisionArchiveNotificationOutbox.status, "delivering"))
.orderBy(sql`${decisionArchiveNotificationOutbox.lastAttemptAt} asc nulls first`).limit(1);
const pendingAt = pending ? Date.now() + DECISION_ARCHIVE_RETRY_MS : null;
const claimAt = claimed ? (claimed.attemptedAt?.getTime() ?? Date.now() - CLAIM_MS) + CLAIM_MS : null;
return pendingAt === null ? claimAt : claimAt === null ? pendingAt : Math.min(pendingAt, claimAt);
}
return { deliver: deliverNotifications, next };
}
+8 -86
View File
@@ -1,5 +1,7 @@
import { decisionArchiveDeliveryService, type ArchiveNotificationBatch } from "./decision-archive-delivery.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { createHash } from "node:crypto";
import { and, asc, eq, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
import { and, eq, inArray, isNotNull, isNull, lte, ne, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
@@ -31,16 +33,7 @@ export const DEFAULT_DECISION_ARCHIVE_DAYS = 90;
const DAY_MS = 86_400_000;
export type DecisionRetentionState = typeof decisionRetention.$inferSelect;
export type ArchiveNotificationBatch = {
companyId: string;
agentId: string;
items: Array<{
sourceKind: AttentionSourceKind;
sourceId: string;
archiveVersion: number;
issueId: string;
}>;
};
export type { ArchiveNotificationBatch } from "./decision-archive-delivery.js";
function sourceKey(sourceKind: string, sourceId: string) {
return `${sourceKind}:${sourceId}`;
@@ -371,6 +364,7 @@ export function decisionRetentionService(
archived += 1;
const origin = await resolveOrigin(txDb, input.companyId, item.sourceKind, item.subject.id);
if (origin) {
await notifyDeliveryWork(txDb, DELIVERY_QUEUES.decisionArchiveDelivery);
await txDb.insert(decisionArchiveNotificationOutbox).values({
companyId: input.companyId,
sourceKind: item.sourceKind,
@@ -394,80 +388,7 @@ export function decisionRetentionService(
return archived;
}
async function deliverNotifications(limit = 500) {
if (!options.notifyOriginAgent) return { notifiedAgents: 0, delivered: 0 };
const claimAt = new Date();
const staleClaimAt = new Date(claimAt.getTime() - 5 * 60_000);
await db.update(decisionArchiveNotificationOutbox).set({ status: "pending", updatedAt: claimAt })
.where(and(
eq(decisionArchiveNotificationOutbox.status, "delivering"),
or(isNull(decisionArchiveNotificationOutbox.lastAttemptAt), lte(decisionArchiveNotificationOutbox.lastAttemptAt, staleClaimAt)),
));
const rows = await db.transaction(async (tx) => {
const pendingIds = await tx.select({ id: decisionArchiveNotificationOutbox.id })
.from(decisionArchiveNotificationOutbox)
.where(eq(decisionArchiveNotificationOutbox.status, "pending"))
.orderBy(asc(decisionArchiveNotificationOutbox.createdAt), asc(decisionArchiveNotificationOutbox.id))
.limit(limit)
.then((values) => values.map((value) => value.id));
if (pendingIds.length === 0) return [];
return tx.update(decisionArchiveNotificationOutbox).set({
status: "delivering",
lastAttemptAt: claimAt,
attemptCount: sql`${decisionArchiveNotificationOutbox.attemptCount} + 1`,
updatedAt: claimAt,
}).where(and(
inArray(decisionArchiveNotificationOutbox.id, pendingIds),
eq(decisionArchiveNotificationOutbox.status, "pending"),
)).returning();
});
const groups = new Map<string, typeof rows>();
for (const row of rows) {
const key = `${row.companyId}:${row.originAgentId}`;
const grouped = groups.get(key) ?? [];
grouped.push(row);
groups.set(key, grouped);
}
let notifiedAgents = 0;
let delivered = 0;
for (const grouped of groups.values()) {
const first = grouped[0]!;
try {
await options.notifyOriginAgent({
companyId: first.companyId,
agentId: first.originAgentId,
items: grouped.map((row) => ({
sourceKind: row.sourceKind as AttentionSourceKind,
sourceId: row.sourceId,
archiveVersion: row.archiveVersion,
issueId: row.originIssueId,
})),
});
const deliveredAt = new Date();
await db.update(decisionArchiveNotificationOutbox).set({
status: "delivered",
deliveredAt,
lastAttemptAt: deliveredAt,
updatedAt: deliveredAt,
}).where(and(
inArray(decisionArchiveNotificationOutbox.id, grouped.map((row) => row.id)),
eq(decisionArchiveNotificationOutbox.status, "delivering"),
));
notifiedAgents += 1;
delivered += grouped.length;
} catch {
await db.update(decisionArchiveNotificationOutbox).set({
status: "pending",
lastAttemptAt: new Date(),
updatedAt: new Date(),
}).where(and(
inArray(decisionArchiveNotificationOutbox.id, grouped.map((row) => row.id)),
eq(decisionArchiveNotificationOutbox.status, "delivering"),
));
}
}
return { notifiedAgents, delivered };
}
const archiveDelivery = decisionArchiveDeliveryService(db, options);
return {
syncItems,
@@ -477,6 +398,7 @@ export function decisionRetentionService(
revive: (input: Omit<Parameters<typeof changeArchived>[0], "archived">) => changeArchived({ ...input, archived: false }),
archiveReviewedManifest,
autoArchive,
deliverNotifications,
deliverNotifications: archiveDelivery.deliver,
nextArchiveNotificationAt: archiveDelivery.next,
};
}
@@ -8,6 +8,7 @@ export const DELIVERY_QUEUES = {
executionStatus: "execution-status",
executionDisposition: "execution-disposition",
environmentCleanup: "environment-cleanup",
decisionArchiveDelivery: "decision-archive-delivery",
toolReviewProjection: "tool-review-projection",
toolReviewInterruption: "tool-review-interruption",
toolReviewRecovery: "tool-review-recovery",