Files
PaperClipAI/server/src/services/decision-retention.ts
T
DottaandPaperclip 5785090ff1 fix(server): wake archive notification delivery from committed changes (#17475)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Auto-archive writes an existing notification outbox in its archive
transaction.
> - The maintenance sweep polls that outbox even when it is empty.
> - The committing producer can wake delivery. Saved rows and claim
timestamps can restore unfinished work.
> - This PR separates delivery from retention scanning and uses the
existing coordinator for retries and deadlines.
> - Settled delivery becomes quiet while archive and notification
behavior remain intact.

## Linked Issues or Issue Description

**What happened?**

The retention maintenance sweep calls archive notification delivery on
every tick. An empty outbox and retained delivered history still cause
database work.

**What did you expect?**

Committed auto-archive should wake delivery. Pending work and existing
claims should retain retry or expiry deadlines. Settled history should
cause no recurring delivery SQL.

**Steps to reproduce**

Start a configured instance with an empty or fully delivered archive
outbox. Leave it idle. The old maintenance tick still checks the outbox.

This is slice 08 of the sleep migration. It remains stacked on
projection repair #17468. Retention eligibility and scanning, decision
TTL expiry and decision continuation recovery have separate owners and
PRs.

## What Changed

- Notify archive delivery before the existing outbox insert in the
auto-archive transaction. Preserve outer transaction commit, rollback
and uncertainty handling.
- Register a delivery worker with the existing coordinator. Remove
notification delivery from retention sweeps.
- Keep claim recovery, selection, company/agent grouping and
timestamp-fenced settlement in the delivery service. Keep the existing
service API and payload.
- Count a notified group only when it settles rows. Use one timestamp
for failure reset.
- Add generated concurrent migration
`0387_boring_professor_monster.sql`. Its partial index matches pending
order: `last_attempt_at NULLS FIRST, created_at, id`. Add no table.
- Test real startup registration and prove a retention tick does not
send notifications. Preserve the disabled-scheduler no-notification
contract.

## Verification

Current candidate: `457c70fc6acbd681fcccd90d08b6391efb202bce`, normally
merged with pinned master `8161f2a813` after projection #17468 landed.
All 39 independent files from that master integration are preserved
exactly. Archive owner behavior and migration 0387 are unchanged.

- **58 archive/startup/scoped-deadline cases pass** after master
reconciliation. Server typecheck and build pass. The preceding
projection-parent integration passed all **239**
gateway/projection/archive/startup/deadline cases.
- All 385 parent journal entries remain an exact prefix, with own
migration 0387 appended; the five newest generated snapshots remain.
Generated index apply, replay, schema drift and real pending-order query
measurements passed in the prior owner verification.
- Failure accounting: the broader local command also attempted six
upstream heartbeat-session-preparation and 55 native control-plane
cases. The embedded PostgreSQL runtime was unavailable: the six
heartbeat cases skipped, and native setup failed before its 55 cases
could execute. This is an incomplete additional local check, not a
passing full run. The owning 58 cases passed with the existing external
fixture where applicable. CI must exercise its supported embedded
environment.
- The first independent-master comparison used the shared mutable remote
ref, which another worktree advanced during verification. Pinning the
actual merged master SHA confirms all 39 independent files match
exactly. No incoming source change was dropped.

The behavior audit in `doc/idle-decision-archive-delivery.md` records
earlier fixture failures, the stale-claim regression and the index
probe. Local tests do not prove deployed suspension or fleet savings.
Fresh current-head CI and both reviews remain required.

## Risks

- Delivery remains at least once. A wake accepted before an
acknowledgement is lost can be retried.
- Each pass keeps the existing 500-row limit. Failed or remaining
pending work retains the existing thirty-second retry. Saved delivery
claims retain their five-minute deadline.
- Auto-archive is the existing outbox producer. Manual archive/revive
and reviewed-manifest actions do not gain notifications.
- Signals use the existing single-active-writer database owner.
Independent SQL or another active server needs a wake contract.
- Concurrent index creation uses the existing top-level executor and
invalid-index retry path. Existing SQL and migration receipts are
unchanged.
- Cloud sleep guards and external-wake coverage do not change. Local
worker quiet is not proof of database or host suspension.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning and code execution. The exact
serving model identifier and context window were not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge


The final projection efficiency parent is integrated normally. All 239
affected gateway/projection, archive, startup and scoped-deadline cases
pass, with server typecheck/build. The archive owner behavior and index
remain unchanged. Fresh reviews and CI are required on this parent
integration.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-27 17:26:54 -05:00

405 lines
17 KiB
TypeScript

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, eq, inArray, isNotNull, isNull, lte, ne, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
approvals,
decisionArchiveNotificationOutbox,
decisionRetention,
decisions,
heartbeatRuns,
issueApprovals,
issueRecoveryActions,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import type {
AttentionArchiveManifestEntry,
AttentionItem,
AttentionSourceKind,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
import type { AuthorizationActor } from "./authorization.js";
import {
canReadDecisionSource,
type DecisionMutationActor,
} from "./decision-queues.js";
export const DEFAULT_DECISION_SHELF_DAYS = 30;
export const DEFAULT_DECISION_ARCHIVE_DAYS = 90;
const DAY_MS = 86_400_000;
export type DecisionRetentionState = typeof decisionRetention.$inferSelect;
export type { ArchiveNotificationBatch } from "./decision-archive-delivery.js";
function sourceKey(sourceKind: string, sourceId: string) {
return `${sourceKind}:${sourceId}`;
}
function canonicalManifest(manifest: readonly AttentionArchiveManifestEntry[]) {
return [...manifest].sort((left, right) => {
const leftKey = `${left.companyId}:${left.sourceKind}:${left.sourceId}`;
const rightKey = `${right.companyId}:${right.sourceKind}:${right.sourceId}`;
return leftKey.localeCompare(rightKey);
}).map((entry) => ({
companyId: entry.companyId,
sourceKind: entry.sourceKind,
sourceId: entry.sourceId,
expectedVersion: entry.expectedVersion,
activityAt: entry.activityAt,
reason: entry.reason,
}));
}
export function hashAttentionArchiveManifest(manifest: readonly AttentionArchiveManifestEntry[]) {
return createHash("sha256").update(JSON.stringify(canonicalManifest(manifest))).digest("hex");
}
function archiveActorColumns(actor: DecisionMutationActor) {
return {
archivedByType: actor.actorType,
archivedByAgentId: actor.agentId,
archivedByUserId: actor.userId,
archivedByRunId: actor.runId,
};
}
async function resolveOrigin(
db: Db,
companyId: string,
sourceKind: AttentionSourceKind,
sourceId: string,
) {
let originAgentId: string | null = null;
let originIssueId: string | null = null;
if (sourceKind === "approval") {
const row = await db.select({ agentId: approvals.requestedByAgentId, issueId: issueApprovals.issueId })
.from(approvals)
.leftJoin(issueApprovals, and(eq(issueApprovals.companyId, companyId), eq(issueApprovals.approvalId, approvals.id)))
.where(and(eq(approvals.companyId, companyId), eq(approvals.id, sourceId)))
.then((rows) => rows[0] ?? null);
originAgentId = row?.agentId ?? null;
originIssueId = row?.issueId ?? null;
} else if (sourceKind === "decision") {
const row = await db.select({ agentId: decisions.originAgentId, issueId: decisions.originIssueId })
.from(decisions).where(and(eq(decisions.companyId, companyId), eq(decisions.id, sourceId)))
.then((rows) => rows[0] ?? null);
originAgentId = row?.agentId ?? null;
originIssueId = row?.issueId ?? null;
} else if (sourceKind === "issue_thread_interaction") {
const row = await db.select({ agentId: issueThreadInteractions.createdByAgentId, issueId: issueThreadInteractions.issueId })
.from(issueThreadInteractions)
.where(and(eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.id, sourceId)))
.then((rows) => rows[0] ?? null);
originAgentId = row?.agentId ?? null;
originIssueId = row?.issueId ?? null;
} else if (sourceKind === "failed_run") {
const row = await db.select({ agentId: heartbeatRuns.agentId, context: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, sourceId)))
.then((rows) => rows[0] ?? null);
const context = row?.context && typeof row.context === "object" ? row.context as Record<string, unknown> : {};
originAgentId = row?.agentId ?? null;
originIssueId = typeof context.issueId === "string" ? context.issueId : typeof context.taskId === "string" ? context.taskId : null;
} else {
const issueId = sourceKind === "recovery_action"
? await db.select({ issueId: issueRecoveryActions.sourceIssueId }).from(issueRecoveryActions)
.where(and(eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.id, sourceId)))
.then((rows) => rows[0]?.issueId ?? null)
: (["productivity_review", "blocker_attention", "review"] as string[]).includes(sourceKind) ? sourceId : null;
if (issueId) {
const row = await db.select({ agentId: issues.createdByAgentId, issueId: issues.id }).from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
.then((rows) => rows[0] ?? null);
originAgentId = row?.agentId ?? null;
originIssueId = row?.issueId ?? null;
}
}
if (!originAgentId || !originIssueId) return null;
const activeAgent = await db.select({ id: agents.id }).from(agents)
.where(and(eq(agents.companyId, companyId), eq(agents.id, originAgentId), eq(agents.status, "active")))
.then((rows) => rows[0] ?? null);
return activeAgent ? { originAgentId, originIssueId } : null;
}
export function decisionRetentionService(
db: Db,
options: { notifyOriginAgent?: (batch: ArchiveNotificationBatch) => Promise<unknown> } = {},
) {
async function syncItems(companyId: string, items: readonly AttentionItem[]) {
if (items.length === 0) return new Map<string, DecisionRetentionState>();
const unique = [...new Map(items.map((item) => [itemSourceKey(item), item])).values()];
await db.insert(decisionRetention).values(unique.map((item) => ({
companyId,
sourceKind: item.sourceKind,
sourceId: item.subject.id,
sourceActivityAt: new Date(item.activityAt),
}))).onConflictDoNothing({
target: [decisionRetention.companyId, decisionRetention.sourceKind, decisionRetention.sourceId],
});
let rows = await db.select().from(decisionRetention).where(and(
eq(decisionRetention.companyId, companyId),
inArray(decisionRetention.sourceId, unique.map((item) => item.subject.id)),
));
const byKey = new Map(rows.map((row) => [sourceKey(row.sourceKind, row.sourceId), row]));
for (const item of unique) {
const key = itemSourceKey(item);
const row = byKey.get(key);
const activityAt = new Date(item.activityAt);
if (!row || row.sourceActivityAt.getTime() === activityAt.getTime()) continue;
const updated = await db.update(decisionRetention).set({
sourceActivityAt: activityAt,
version: sql`${decisionRetention.version} + 1`,
updatedAt: new Date(),
}).where(and(
eq(decisionRetention.id, row.id),
ne(decisionRetention.sourceActivityAt, activityAt),
)).returning().then((values) => values[0] ?? null);
if (updated) byKey.set(key, updated);
}
rows = [...byKey.values()];
return new Map(rows.map((row) => [sourceKey(row.sourceKind, row.sourceId), row]));
}
function itemSourceKey(item: AttentionItem) {
return sourceKey(item.sourceKind, item.subject.id);
}
async function getState(companyId: string, sourceKind: AttentionSourceKind, sourceId: string) {
return db.select().from(decisionRetention).where(and(
eq(decisionRetention.companyId, companyId),
eq(decisionRetention.sourceKind, sourceKind),
eq(decisionRetention.sourceId, sourceId),
)).then((rows) => rows[0] ?? null);
}
async function setKeep(input: {
companyId: string;
sourceKind: AttentionSourceKind;
sourceId: string;
keep: boolean;
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) {
if (!(await canReadDecisionSource(db, input.authActor, input.companyId, input.sourceKind, input.sourceId))) {
throw notFound("Attention source not found");
}
const updated = await db.update(decisionRetention).set({
keep: input.keep,
version: sql`${decisionRetention.version} + 1`,
updatedAt: new Date(),
}).where(and(
eq(decisionRetention.companyId, input.companyId),
eq(decisionRetention.sourceKind, input.sourceKind),
eq(decisionRetention.sourceId, input.sourceId),
)).returning().then((rows) => rows[0] ?? null);
if (!updated) throw notFound("Attention source not found");
await logActivity(db, {
companyId: input.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId,
runId: input.actor.runId,
agentApiKeyId: input.actor.agentApiKeyId,
responsibleUserIdOverride: input.actor.responsibleUserId,
action: "decision_retention.keep_updated",
entityType: "attention_source",
entityId: input.sourceId,
details: { sourceKind: input.sourceKind, keep: input.keep },
});
return updated;
}
async function changeArchived(input: {
companyId: string;
sourceKind: AttentionSourceKind;
sourceId: string;
archived: boolean;
authActor: AuthorizationActor;
actor: DecisionMutationActor;
}) {
if (!(await canReadDecisionSource(db, input.authActor, input.companyId, input.sourceKind, input.sourceId))) {
throw notFound("Attention source not found");
}
const now = new Date();
const updated = await db.update(decisionRetention).set(input.archived ? {
archivedAt: now,
archivedReason: "manual",
...archiveActorColumns(input.actor),
archiveVersion: sql`${decisionRetention.archiveVersion} + 1`,
version: sql`${decisionRetention.version} + 1`,
updatedAt: now,
} : {
archivedAt: null,
archivedReason: null,
archivedByType: null,
archivedByAgentId: null,
archivedByUserId: null,
archivedByRunId: null,
version: sql`${decisionRetention.version} + 1`,
updatedAt: now,
}).where(and(
eq(decisionRetention.companyId, input.companyId),
eq(decisionRetention.sourceKind, input.sourceKind),
eq(decisionRetention.sourceId, input.sourceId),
input.archived ? isNull(decisionRetention.archivedAt) : isNotNull(decisionRetention.archivedAt),
)).returning().then((rows) => rows[0] ?? null);
if (!updated) {
const current = await getState(input.companyId, input.sourceKind, input.sourceId);
if (!current) throw notFound("Attention source not found");
return current;
}
await logActivity(db, {
companyId: input.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId,
runId: input.actor.runId,
agentApiKeyId: input.actor.agentApiKeyId,
responsibleUserIdOverride: input.actor.responsibleUserId,
action: input.archived ? "decision_retention.archived" : "decision_retention.revived",
entityType: "attention_source",
entityId: input.sourceId,
details: { sourceKind: input.sourceKind },
});
return updated;
}
async function archiveReviewedManifest(input: {
companyId: string;
manifest: AttentionArchiveManifestEntry[];
originActor: AuthorizationActor;
decidingActor: AuthorizationActor;
decidedByUserId: string;
}) {
const keys = input.manifest.map((entry) => sourceKey(entry.sourceKind, entry.sourceId));
if (new Set(keys).size !== keys.length || input.manifest.some((entry) => entry.companyId !== input.companyId)) {
throw unprocessable("Archive manifest must contain one exact company-scoped set");
}
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
for (const entry of input.manifest) {
const kind = entry.sourceKind as AttentionSourceKind;
if (!(await canReadDecisionSource(txDb, input.originActor, input.companyId, kind, entry.sourceId))
|| !(await canReadDecisionSource(txDb, input.decidingActor, input.companyId, kind, entry.sourceId))) {
throw conflict("archive_proposal_authority_changed", { code: "archive_proposal_authority_changed" });
}
await txDb.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`decision-retention:${input.companyId}:${entry.sourceKind}:${entry.sourceId}`}, 0))`);
const state = await txDb.select().from(decisionRetention).where(and(
eq(decisionRetention.companyId, input.companyId),
eq(decisionRetention.sourceKind, entry.sourceKind),
eq(decisionRetention.sourceId, entry.sourceId),
)).then((rows) => rows[0] ?? null);
if (!state || state.version !== entry.expectedVersion || state.sourceActivityAt.toISOString() !== entry.activityAt || state.archivedAt) {
throw conflict("archive_proposal_stale", { code: "archive_proposal_stale" });
}
}
const now = new Date();
for (const entry of input.manifest) {
const archived = await txDb.update(decisionRetention).set({
archivedAt: now,
archivedReason: "accepted_proposal",
archivedByType: "user",
archivedByAgentId: null,
archivedByUserId: input.decidedByUserId,
archivedByRunId: null,
archiveVersion: sql`${decisionRetention.archiveVersion} + 1`,
version: sql`${decisionRetention.version} + 1`,
updatedAt: now,
}).where(and(
eq(decisionRetention.companyId, input.companyId),
eq(decisionRetention.sourceKind, entry.sourceKind),
eq(decisionRetention.sourceId, entry.sourceId),
eq(decisionRetention.version, entry.expectedVersion),
isNull(decisionRetention.archivedAt),
)).returning({ id: decisionRetention.id });
if (archived.length !== 1) {
throw conflict("archive_proposal_stale", { code: "archive_proposal_stale" });
}
await logActivity(txDb, {
companyId: input.companyId,
actorType: "system",
actorId: "decision-archive-proposal",
responsibleUserIdOverride: input.decidedByUserId,
action: "decision_retention.archived",
entityType: "attention_source",
entityId: entry.sourceId,
details: { sourceKind: entry.sourceKind, reason: "accepted_proposal" },
});
}
return { archived: input.manifest.length };
});
}
async function autoArchive(input: { companyId: string; items: AttentionItem[]; now?: Date }) {
const now = input.now ?? new Date();
const cutoff = new Date(now.getTime() - DEFAULT_DECISION_ARCHIVE_DAYS * DAY_MS);
const candidates = input.items.filter((item) => new Date(item.activityAt) <= cutoff && !item.keep && !item.archivedAt);
let archived = 0;
await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
for (const item of candidates) {
const updated = await txDb.update(decisionRetention).set({
archivedAt: now,
archivedReason: "idle_ttl",
archivedByType: "system",
archivedByAgentId: null,
archivedByUserId: null,
archivedByRunId: null,
archiveVersion: sql`${decisionRetention.archiveVersion} + 1`,
version: sql`${decisionRetention.version} + 1`,
updatedAt: now,
}).where(and(
eq(decisionRetention.companyId, input.companyId),
eq(decisionRetention.sourceKind, item.sourceKind),
eq(decisionRetention.sourceId, item.subject.id),
eq(decisionRetention.version, item.retentionVersion),
eq(decisionRetention.keep, false),
isNull(decisionRetention.archivedAt),
lte(decisionRetention.sourceActivityAt, cutoff),
)).returning().then((rows) => rows[0] ?? null);
if (!updated) continue;
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,
sourceId: item.subject.id,
archiveVersion: updated.archiveVersion,
originAgentId: origin.originAgentId,
originIssueId: origin.originIssueId,
}).onConflictDoNothing();
}
await logActivity(txDb, {
companyId: input.companyId,
actorType: "system",
actorId: "decision-retention-sweeper",
action: "decision_retention.auto_archived",
entityType: "attention_source",
entityId: item.subject.id,
details: { sourceKind: item.sourceKind, archiveVersion: updated.archiveVersion },
});
}
});
return archived;
}
const archiveDelivery = decisionArchiveDeliveryService(db, options);
return {
syncItems,
getState,
setKeep,
archive: (input: Omit<Parameters<typeof changeArchived>[0], "archived">) => changeArchived({ ...input, archived: true }),
revive: (input: Omit<Parameters<typeof changeArchived>[0], "archived">) => changeArchived({ ...input, archived: false }),
archiveReviewedManifest,
autoArchive,
deliverNotifications: archiveDelivery.deliver,
nextArchiveNotificationAt: archiveDelivery.next,
};
}