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

+86
View File
@@ -0,0 +1,86 @@
# Decision-archive notification delivery
Auto-archive already writes `decision_archive_notification_outbox` in the same
transaction that archives its retention row. The old retention sweep called
notification delivery on every maintenance tick, including an empty outbox.
Delivery is now its own coordinator worker. Retention scanning remains a separate
migration; this PR does not change what is archived or when.
## Ownership and producer audit
`decision-retention.ts` owns eligibility, current-version checks, source authority,
origin resolution, and archive writes. Its sole outbox producer is `autoArchive`.
It now awaits the delivery notification immediately before inserting the outbox
row in the existing transaction. Caller-owned transactions and savepoints retain
outer commit/rollback ownership through the shared database signal mechanism.
Manual archive/revive and reviewed-manifest archive do not create these
notifications today; this change does not add notifications to those actions.
`decision-archive-delivery.ts` owns claim recovery, selection, grouping, delivery,
and settlement. `decisionRetentionService.deliverNotifications` remains a
compatible delegate. Its notification callback and batch type retain their
existing contract. The scheduler only sees the delivery operation and next
work deadline. Claim and acknowledgement writes belong to this worker; they do
not broadcast self-generated wakes. There is no additional table or migration.
The server registers the worker in enabled-heartbeat startup housekeeping and
removes notification delivery from `runRetentionSweep`. Registration scans saved
work before the retention pass can enqueue more. The disabled-heartbeat branch
continues not to dispatch agent notifications. A service without a callback
leaves outbox rows intact and does not attempt notification delivery.
## Work and deadlines
Pending outbox rows are due for delivery. Each pass claims up to 500 rows and
groups them by company and origin agent, preserving company scope and the
existing notification payload. Failed or remaining pending rows retain a
30-second retry. Never-attempted rows sort first, then the oldest attempts, so
one failing batch cannot repeatedly hide later rows.
A delivering row retains its existing five-minute claim deadline. Startup
reconstructs that deadline from `lastAttemptAt`; a legacy missing timestamp is
immediately recoverable. A worker waiting only for a future claim deadline does
not repeatedly query until that deadline. Settlement and failure reset require
the same claim timestamp, so a delayed old result cannot settle or release a
replacement claim. Delivered counts include only rows actually settled.
An empty outbox or retained delivered history has no worker deadline and no
recurring SQL. The coordinator coalesces writes and prevents overlapping passes.
The shared deadline worker preserves first enqueue during drain, changes during
delivery, and failed retries across drain/resume. Coordinator shutdown joins
in-flight work. These guarantees do not replace the durable rows.
## Recovery and limits
A failed enqueue rolls back both the archive mutation and its outbox insertion.
An uncertain transaction retains the shared intent until its outcome is known.
A notification failure returns the same rows to pending. A process stopping
while delivering leaves the claim for deadline recovery on restart.
Delivery remains at least once. A wake accepted before a lost acknowledgement
can be retried. This change does not promise exactly-once agent wakeups or add
new deduplication storage. The five-minute stale threshold is unchanged.
Supported producers share the process-local database signal owner. Independent
SQL or another active server needs a separate wake contract. The existing Cloud
sleep proof still blocks this outbox conservatively; no external-wake coverage
or sleep guard is relaxed. Worker quiet does not prove actual database or host
suspension. Retention scanning and decision continuations remain separate duties.
## Verification
The final selection passes 54 tests: 15 real-PostgreSQL retention/delivery cases
and 39 startup cases. Twelve new cases cover configured idle, retained delivered
history, real enqueue, restart, exact stale-claim timing, legacy missing timestamps,
first enqueue during drain, failed-delivery retry through drain, outer commit and
rollback, enqueue failure rollback, writes during delivery, more than 500 rows,
and stale-result fencing. Quiet assertions observe no reads or writes across an
hour of controlled time. Workspace typecheck and build pass.
The first 52-case selection passed. Adding fault/concurrency tests then exposed
two fixture errors: duplicate globally unique issue identifiers across two seeded
companies, and asserting quiet before rollback reconciliation had run. Seeded
identifiers now use their company prefix; quiet checks advance the retained retry
and await authoritative settlement. All 54 cases passed on rerun. No production
hold, recovery deadline, or timeout was weakened. Full repository CI and fresh
review are required before this PR is ready.
@@ -15,10 +15,10 @@ Do not mark an item done from a timer removal or a green check alone.
| 01 | Custom-image setup expiry | Session writes, existing expiry, startup/retry/drain | [#17443](https://github.com/paperclipai/paperclip/pull/17443), all checks passed, Greptile 5/5 at `36844a458b` |
| 02 | Remaining chat maintenance | Provider runtime discovery; voice reconciliation; milestones; wake notices; interaction continuations; terminal confirmations; GitHub recovery; Teams maintenance, separately by owner | Pending |
| 03 | Run and task recovery | Orphan runs and active leases; shutdown workspace settlement; scheduled retries; queued runs; stranded assignments; resolved dependencies; task watchdogs; silent-run monitoring; stale locks | Pending |
| 04 | Decision retention and notifications | Retention deadlines and notification delivery are separate reasons | Pending |
| 04 | Decision retention and notifications | Retention deadlines and notification delivery are separate reasons | Retention pending; notification delivery implemented in slice 08, PR pending |
| 05 | Decision expiry and interrupted effects | Expiry, effect recovery, continuation recovery | Pending |
| 06 | Tool connection health | Configuration/use activation and defined proactive health deadlines | Pending |
| 07 | Secret proposals, tool reviews, gateway tokens | Separate expiry/scrub/recovery owners | Secret proposals: [#17446](https://github.com/paperclipai/paperclip/pull/17446), checks passed at `d41590ac0e`; gateway credentials: [#17450](https://github.com/paperclipai/paperclip/pull/17450), all checks passed, Greptile 5/5 at `c5c53fc43f`; pending tool-review expiry: [#17453](https://github.com/paperclipai/paperclip/pull/17453), all checks passed, Greptile 5/5 at `ce6a260a83`; approved recovery: [#17454](https://github.com/paperclipai/paperclip/pull/17454), all checks passed, Greptile 5/5 at `7a1cf03e62`; interrupted claims: [#17458](https://github.com/paperclipai/paperclip/pull/17458), all checks passed, Greptile 5/5 at `85003a7ac4`; projection implemented, PR pending |
| 07 | Secret proposals, tool reviews, gateway tokens | Separate expiry/scrub/recovery owners | Secret proposals: [#17446](https://github.com/paperclipai/paperclip/pull/17446), checks passed at `d41590ac0e`; gateway credentials: [#17450](https://github.com/paperclipai/paperclip/pull/17450), all checks passed, Greptile 5/5 at `c5c53fc43f`; pending tool-review expiry: [#17453](https://github.com/paperclipai/paperclip/pull/17453), all checks passed, Greptile 5/5 at `ce6a260a83`; approved recovery: [#17454](https://github.com/paperclipai/paperclip/pull/17454), all checks passed, Greptile 5/5 at `7a1cf03e62`; interrupted claims: [#17458](https://github.com/paperclipai/paperclip/pull/17458), all checks passed, Greptile 5/5 at `85003a7ac4`; projection: [#17468](https://github.com/paperclipai/paperclip/pull/17468), Greptile 5/5 at `0956883a84`, CI running |
| 08 | GitHub connection events and continuity | Event discovery/replay and credential continuity separately | Pending |
| 09 | Merged-PR confirmations | Candidate writes, webhook activation where supported, outstanding-candidate fallback | Pending |
| 10 | External-object refresh | Configuration changes, refresh deadlines, source events | Pending |
@@ -284,3 +284,24 @@ and build pass. One initial interaction cleanup-hook timeout is recorded in
the behavior document; the complete isolated suite then passed. Both parent
PRs #17454 and #17458 now have every check passed and are mergeable, including
the additional Paperclip Review checks. Full CI/review for slice 07 is next.
## Slice 08: decision-archive notification delivery
Projection repair is #17468, head `0956883a84`, with Greptile 5/5 and no open
findings. Latest checks are running with no failure. Slice 08 is based on that
branch and separates archive notification delivery from retention scanning.
The existing auto-archive transaction is the sole outbox producer. A committed
enqueue wakes delivery; pending rows retain retries and delivering rows retain
their saved five-minute claim deadline. Delivered history is quiet.
The implementation preserves the delivery API and payload. It fences stale
completion by claim timestamp and orders unattempted/oldest-attempted pending
rows so failed batches do not starve later work. No table or migration is added.
See [archive delivery](../idle-decision-archive-delivery.md).
All 54 selected tests pass, including 12 new real-PostgreSQL lifecycle cases.
Workspace typecheck/build pass. Two added-fixture failures and their corrections
are recorded in the behavior document. Full CI/review remains required.
Retention eligibility/scanning, decision expiry, effect recovery, and decision
continuation delivery remain independent unfinished reasons in the full ledger.
@@ -1,5 +1,10 @@
import { sql } from "drizzle-orm";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { registerDecisionArchiveDeliveryWork } from "../services/decision-archive-delivery-work.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
import { idleWorkSnapshot } from "../services/task-admission.js";
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
@@ -13,19 +18,23 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } fro
import { attentionService } from "../services/attention.js";
import { decisionRetentionService } from "../services/decision-retention.js";
const support = await getEmbeddedPostgresTestSupport();
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_RETENTION_TEST_DATABASE_URL");
const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describePg = support.supported ? describe : describe.skip;
describePg("decision retention", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
let stopWorker: (() => Promise<void>) | undefined;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-decision-retention-");
db = createDb(tempDb.connectionString);
if (!externalUrl) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-decision-retention-");
db = createDb(externalUrl ?? tempDb!.connectionString, { maxConnections: 6, idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 });
}, 30_000);
afterEach(async () => {
await stopWorker?.(); stopWorker = undefined;
await db.delete(decisionArchiveNotificationOutbox);
await db.delete(decisionRetention);
await db.delete(activityLog);
@@ -34,7 +43,7 @@ describePg("decision retention", () => {
await db.delete(companies);
});
afterAll(async () => tempDb?.cleanup());
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await tempDb?.cleanup(); });
async function seedReviewItems(count: number) {
const companyId = randomUUID();
@@ -61,7 +70,7 @@ describePg("decision retention", () => {
await db.insert(issues).values(issueIds.map((id, index) => ({
id,
companyId,
identifier: `RET-${index + 1}`,
identifier: `R${companyId.slice(0, 6)}-${index + 1}`,
title: `Old review ${index + 1}`,
status: "in_review",
priority: "medium",
@@ -90,6 +99,180 @@ describePg("decision retention", () => {
responsibleUserId: "board-user",
};
describe("archive delivery worker", () => {
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
let fixture: Awaited<ReturnType<typeof seedReviewItems>>;
let service: ReturnType<typeof decisionRetentionService>;
let draining: boolean;
const errors: unknown[] = [];
const notify = vi.fn(async (_batch: unknown) => {});
beforeEach(async () => {
await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select pg_sleep(0.01)`)));
fixture = await seedReviewItems(1); draining = false; errors.length = 0; notify.mockReset();
service = decisionRetentionService(db, { notifyOriginAgent: notify });
stopWorker = async () => {
await coordinator?.stop(); coordinator = undefined;
vi.useRealTimers(); vi.restoreAllMocks();
await db.execute(sql`drop trigger if exists archive_delivery_fail on decision_archive_notification_outbox`);
await db.execute(sql`drop function if exists archive_delivery_fail()`);
expect(errors).toEqual([]);
};
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: true });
});
async function start() {
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining,
canReconcile: () => true, onError: error => errors.push(error) });
await registerDecisionArchiveDeliveryWork(coordinator, service, () => !draining).ready;
}
async function archive(source = db) {
const feed = await attentionService(source).list(fixture.companyId, { limit: 100 });
return decisionRetentionService(source).autoArchive({ companyId: fixture.companyId, items: feed.items });
}
async function settle(ms = 60_000) {
await vi.advanceTimersByTimeAsync(ms);
if (!draining) await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0), { timeout: 5000 });
}
async function quiet() {
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(30_000);
expect(idleWorkSnapshot().active).toBe(0);
expect(coordinator!.nextWakeAt()).toBeNull();
}, { timeout: 5000 });
const reads = vi.spyOn(db, "select"), executes = vi.spyOn(db, "execute"), writes = vi.spyOn(db, "update");
await settle(60 * 60_000);
expect(reads).not.toHaveBeenCalled(); expect(executes).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled();
reads.mockRestore(); executes.mockRestore(); writes.mockRestore();
}
async function delivered() {
const rows = await db.select().from(decisionArchiveNotificationOutbox);
expect(rows.length).toBeGreaterThan(0); expect(rows.every(row => row.status === "delivered")).toBe(true);
}
it("is quiet with configuration, wakes from real auto-archive, and leaves delivered history quiet", async () => {
await start(); await quiet();
expect(await archive()).toBe(1); await settle(); await delivered(); await quiet();
expect(notify).toHaveBeenCalledTimes(1);
expect(notify.mock.calls[0][0]).toMatchObject({ companyId: fixture.companyId, agentId: fixture.agentId,
items: [expect.objectContaining({ issueId: fixture.issueIds[0], archiveVersion: 1 })] });
});
it("reconstructs pending delivery after restart", async () => {
await archive(); await start(); await quiet(); await delivered(); expect(notify).toHaveBeenCalledTimes(1);
});
it("waits until an existing delivery claim expires", async () => {
await archive(); const attemptedAt = new Date();
await db.update(decisionArchiveNotificationOutbox).set({ status: "delivering", lastAttemptAt: attemptedAt, attemptCount: 1 });
await start(); expect(notify).not.toHaveBeenCalled();
expect(coordinator!.nextWakeAt()).toBe(attemptedAt.getTime() + 5 * 60_000);
const reads = vi.spyOn(db, "select"); await settle(4 * 60_000); expect(reads).not.toHaveBeenCalled(); reads.mockRestore();
await settle(2 * 60_000); await delivered(); await quiet(); expect(notify).toHaveBeenCalledTimes(1);
});
it("recovers a legacy delivering row with no attempt timestamp", async () => {
await archive(); await db.update(decisionArchiveNotificationOutbox).set({ status: "delivering" });
await start(); await delivered(); await quiet();
});
it("retains first enqueue during drain without passive SQL", async () => {
await start(); await quiet(); draining = true;
await archive(); await settle();
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "update");
await settle(60 * 60_000); expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled();
reads.mockRestore(); writes.mockRestore(); expect(notify).not.toHaveBeenCalled();
draining = false; await settle(); await delivered(); await quiet();
});
it("retries failed delivery through drain and then becomes quiet", async () => {
await archive(); notify.mockRejectedValueOnce(new Error("wake unavailable"));
await start(); expect(notify).toHaveBeenCalledTimes(1); expect(coordinator!.nextWakeAt()).not.toBeNull();
draining = true; await settle(60 * 60_000); expect(notify).toHaveBeenCalledTimes(1);
draining = false;
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(30_000); await delivered(); }, { timeout: 5000 });
await quiet(); expect(notify).toHaveBeenCalledTimes(2);
});
it.each([false, true])("waits for the archive's outer transaction (rollback %s)", async rollback => {
await start(); await quiet();
let entered!: () => void, release!: () => void;
const ready = new Promise<void>(resolve => { entered = resolve; });
const gate = new Promise<void>(resolve => { release = resolve; });
const aborted = new Error("fixture rollback");
const running = db.transaction(async tx => {
await archive(tx as unknown as typeof db); entered(); await gate; if (rollback) throw aborted;
}).catch(error => { if (error !== aborted) throw error; });
await ready;
try { await vi.advanceTimersByTimeAsync(60_000); expect(notify).not.toHaveBeenCalled(); }
finally { release(); await running; }
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(30_000); expect(idleWorkSnapshot().active).toBe(0);
if (rollback) expect(await db.select().from(decisionArchiveNotificationOutbox)).toEqual([]);
else await delivered();
}, { timeout: 5000 });
await quiet(); expect(notify).toHaveBeenCalledTimes(rollback ? 0 : 1);
});
it("rolls back archive state when enqueue fails and wakes on a later successful archive", async () => {
await start(); await quiet();
await db.execute(sql`create function archive_delivery_fail() returns trigger language plpgsql as $$
begin raise exception 'archive enqueue fixture failure'; end $$`);
await db.execute(sql`create trigger archive_delivery_fail before insert on decision_archive_notification_outbox
for each row execute function archive_delivery_fail()`);
await expect(archive()).rejects.toThrow();
await db.execute(sql`drop trigger archive_delivery_fail on decision_archive_notification_outbox`);
await quiet(); expect(notify).not.toHaveBeenCalled();
expect(await db.select().from(decisionArchiveNotificationOutbox)).toEqual([]);
expect((await db.select().from(decisionRetention))[0].archivedAt).toBeNull();
await archive(); await settle(); await delivered(); await quiet();
});
it("keeps an enqueue committed during delivery and drains it on the next pass", async () => {
await start(); await quiet();
let entered!: () => void, release!: () => void;
const ready = new Promise<void>(resolve => { entered = resolve; });
const gate = new Promise<void>(resolve => { release = resolve; });
notify.mockImplementationOnce(async () => { entered(); await gate; });
await archive();
await vi.advanceTimersByTimeAsync(30_000); await ready;
try {
fixture = await seedReviewItems(1); await archive();
expect(notify).toHaveBeenCalledTimes(1);
} finally { release(); }
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(30_000); await delivered(); }, { timeout: 5000 });
await quiet(); expect(notify).toHaveBeenCalledTimes(2);
});
it("does not let an older failed batch starve rows beyond the batch limit", async () => {
await archive(); const [row] = await db.select().from(decisionArchiveNotificationOutbox);
await db.insert(decisionArchiveNotificationOutbox).values(Array.from({ length: 501 }, (_, index) => ({
...row, id: randomUUID(), sourceId: `additional-${index}` })));
notify.mockRejectedValueOnce(new Error("first batch unavailable"));
await start(); expect(notify.mock.calls[0][0]).toMatchObject({ items: expect.any(Array) });
expect((notify.mock.calls[0][0] as { items: unknown[] }).items).toHaveLength(500);
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(30_000); await delivered(); }, { timeout: 5000 });
await quiet();
expect((notify.mock.calls[1][0] as { items: unknown[] }).items).toHaveLength(500);
expect((notify.mock.calls[2][0] as { items: unknown[] }).items).toHaveLength(2);
});
it("does not settle a replacement claim from an older delivery result", async () => {
await archive();
let entered!: () => void, release!: () => void;
const ready = new Promise<void>(resolve => { entered = resolve; });
const gate = new Promise<void>(resolve => { release = resolve; });
notify.mockImplementationOnce(async () => { entered(); await gate; });
const first = service.deliverNotifications(); await ready;
const replacementAt = new Date(Date.now() + 6 * 60_000);
try { await db.update(decisionArchiveNotificationOutbox).set({ lastAttemptAt: replacementAt, attemptCount: 2 }); }
finally { release(); }
expect((await first).delivered).toBe(0);
expect((await db.select().from(decisionArchiveNotificationOutbox))[0]).toMatchObject({ status: "delivering", lastAttemptAt: replacementAt });
await start(); expect(coordinator!.nextWakeAt()).toBe(replacementAt.getTime() + 5 * 60_000);
await settle(12 * 60_000); await delivered(); await quiet();
});
});
it("auto-archives, excludes by default, returns archived rows, and revives", async () => {
const { companyId, issueIds } = await seedReviewItems(1);
const now = new Date("2026-08-02T00:00:00.000Z");
@@ -344,6 +344,7 @@ vi.mock("../services/index.js", () => ({
decisionRetentionService: vi.fn(() => ({
autoArchive: vi.fn(async () => 0),
deliverNotifications: vi.fn(async () => ({ notifiedAgents: 0, delivered: 0 })),
nextArchiveNotificationAt: vi.fn(async () => null),
})),
feedbackService: feedbackServiceFactoryMock,
bootstrapExecutionPolicyFromEnv: vi.fn(async () => null),
+4 -2
View File
@@ -1,3 +1,4 @@
import { registerDecisionArchiveDeliveryWork } from "./services/decision-archive-delivery-work.js";
import { registerToolReviewProjectionWork } from "./services/tool-review-projection-work.js";
import { registerToolReviewInterruptionWork } from "./services/tool-review-interruption-work.js";
import { registerToolReviewRecoveryWork } from "./services/tool-review-recovery-work.js";
@@ -1775,8 +1776,7 @@ async function startServerWithDatabaseTeardown(
});
archived += await retentionExecutor.autoArchive({ companyId: company.id, items: page.items });
}
const notifications = await retentionExecutor.deliverNotifications();
return { archived, ...notifications };
return { archived };
};
// Startup housekeeping: provider and connection sweeps that every timer tick
// repeats and that neither serving requests nor run recovery depends on.
@@ -1809,6 +1809,8 @@ async function startServerWithDatabaseTeardown(
// Retry any orphan sandbox teardown left by a failed acquire before a
// server restart, so a leaked sandbox does not stay allocated.
await step("environment_lease_cleanup", startEnvironmentCleanup);
await step("archive_notification_worker", () => registerDecisionArchiveDeliveryWork(deliveryWork, retentionExecutor,
() => !isIdleTaskDrainActive() && !isWarmStandby() && !heartbeatSchedulerStopped).ready);
await step("retention_sweep", runRetentionSweep);
};
@@ -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",