Files
PaperClipAI/server/src/services/chat-completion-delivery.ts
T
DottaandPaperclip 835a022936 perf(server): wake delivery queues from transaction-aware work signals (#15625)
## Thinking Path

> - Paperclip manages AI-agent work and its delivery to users and
agents.
> - Five delivery queues scan the database even when empty.
> - Each queue already has durable rows; empty polling wastes idle
hosting capacity.
> - Producers can register intent inside their existing database
transaction.
> - Central transaction tracking can wake consumers after the outer
commit without extra caller wrappers.
> - A lost response needs reconciliation against PostgreSQL before an
empty queue is safe to leave idle.
> - This change uses one scheduler for outstanding work and lets
settled, empty queues go quiet.

## Linked Issues or Issue Description

Supersedes the closed #15614. Related idle-safety work: #15522 and
#15599.

**What existing behavior does this improve?**

Delivery scheduling for feedback exports, chat completions, connection
continuations, question answers, and tool-action receipts.

**Current behavior**

Feedback scans every five seconds. Four delivery queues scan on the
heartbeat interval, normally 30 seconds. Startup and some event paths
also scan them.

**Proposed behavior**

Each producer awaits one intent registration before its queue write. The
existing `createDb` transaction boundary tracks nested savepoints and
wakes the consumer after outer settlement. Startup scans restore durable
work. Pending rows, failures, and unresolved transactions retain retry
deadlines. Empty, settled queues have no timer or recurring scan.

**Reason and benefit**

Normal delivery starts after commit. Central tracking removes
caller-specific post-commit plumbing. PostgreSQL transaction status
resolves lost responses without new tables or a permanent uncertainty
latch.

**Breaking changes**

No API or schema changes. The notification scope is one DB owner and its
dedicated child pools. Direct SQL, separate roots, and other processes
require an explicit wake/recovery integration. This path requires
PostgreSQL 14+ transaction-information functions; embedded PostgreSQL
uses 18.

## What Changed

- Add one named-deadline scheduler and app-owned coordinator for five
existing queues.
- Instrument `createDb().transaction()` centrally, including nested
savepoints. Dedicated child connections share their owner's signal
scope.
- Register work before the five queue insert paths. The first
registration obtains the outer XID; unrelated transactions issue no
extra SQL. Tool receipt insertion gains a small transaction around its
existing insert.
- Query `pg_xact_status` only for rejected, tracked transactions. Probe
before scanning the queue. Retain the idle hold while PostgreSQL still
reports an in-progress transaction or the probe fails. Release it after
settlement and reconciliation.
- Remove unconditional delivery recovery scans and caller-specific
completion post-commit actions. Retain the existing task-scoped
completion activity fast path and durable claims.
- Coalesce wakes without postponing an earlier deadline. Retry
outstanding rows and failures; suppress dispatch during idle drain while
allowing transaction and queue reconciliation; suppress both during warm
standby. Cancel deadlines and await active delivery sweeps on shutdown.
- Serialize feedback flushes. Vote routes return after saving. Uploads
have a 30-second deadline and shutdown cancellation; unfinished exports
remain recoverable.
- Document the writer contract and update the dated sleep inventory.

## Verification

- Final head: `a4609956841107ad60c4cb50fe2acb05636b1363`.
- Passed: all final-head hosted checks, with 54 successes and two
intentional skips. The full test matrix, typecheck, build, canary, and
aggregate verification passed in [run
37940948440](https://github.com/paperclipai/paperclip/actions/runs/37940948440).
- Passed: Greptile 5/5 on the same head, with a successful check run and
zero unresolved review threads. The branch is mergeable.
- Passed locally after the final rebase: 50 coordinator, scheduler, and
startup tests; server typecheck; server build.
- Passed locally before the final import-only rebase: repo-wide
typecheck and build; 237 PostgreSQL producer/delivery and database
signal tests; feedback, native-question, and idle-safety regression
tests.
- Real PostgreSQL tests hold transactions open after an injected client
response loss, then commit or abort. They verify that an empty scan
cannot clear an in-progress transaction. Another test terminates a real
backend and verifies recovery without replay.
- Coordinator tests cover an hour with no empty-queue timers,
pending/error retries, worker replacement, concurrent writes,
earliest-deadline preservation, rollback during idle drain, committed
work during drain, standby, and shutdown.
- Local `pnpm test:run` was started and stopped after embedded
PostgreSQL startup failures appeared. It did not finish and is not
reported as a pass. Later local database reruns had skipped suites;
those skips are not claimed as verification. The earlier PostgreSQL
tests did execute and pass, and the final hosted full matrix passed.

## Risks

- One XID query is added per transaction that registers delivery work.
Nested transactions share that ID. Registration must be awaited before
writing; new enqueue paths must follow this contract.
- Work signals are local to a DB owner and its dedicated child pools.
Independent roots, direct SQL, or other processes are not observed.
Startup scans recover already committed rows, but do not fence late
transactions from a previous process. Cross-process ownership and
database failover require separate work.
- A database outage or genuinely unresolved transaction keeps an idle
hold until status can be reconciled. No timer clears uncertainty by
assumption.
- These changes remove five empty polling loops. They do not implement
whole-instance sleep, database teardown, or an external waker. The
earliest deadline covers only the registered queues.
- Waiting tool reviews retain their existing retry cadence until their
receipts can be delivered.
- Feedback vote responses no longer wait for the remote upload. Sharing
consent and the saved vote response are unchanged.
- Real hosting-provider sleep and cost savings have not been measured.

## Model Used

OpenAI Codex, GPT-6. Used reasoning, repository inspection, code
editing, and command execution. The runtime did not expose the exact
model revision or context window.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-09 10:01:26 -05:00

256 lines
18 KiB
TypeScript

import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { and, asc, eq, inArray, lte, sql } from "drizzle-orm";
import { agents, agentWakeupRequests, chatCompletionDeliveries as deliveries, chatTaskHandoffs as handoffs,
heartbeatRuns, issueComments, issueDocuments, issues, type Db } from "@paperclipai/db";
import { instanceSettingsService } from "./instance-settings.js";
export const CHAT_COMPLETION_WAKE_REASON = "chat_task_completed";
const MAX_ATTEMPTS = 5;
const LEASE_MS = 60_000;
type Issue = typeof issues.$inferSelect;
type Run = typeof heartbeatRuns.$inferSelect;
type Tx = Parameters<Parameters<Db["transaction"]>[0]>[0];
type Connection = Db | Tx;
const pending = ["pending", "queued"] as const;
const activeRuns = ["queued", "scheduled_retry", "running"];
function ids(run: Run): string[] {
const value = run.contextSnapshot?.chatCompletionDeliveryIds;
return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
}
export async function recordChatHandoff(tx: Connection, task: Issue, actorRunId: string | null | undefined) {
if (!actorRunId || task.conversationAgentId || !task.createdByAgentId) return;
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, actorRunId),
eq(heartbeatRuns.companyId, task.companyId), eq(heartbeatRuns.agentId, task.createdByAgentId)));
const sourceId = run?.nativeIssueId ?? run?.contextSnapshot?.issueId;
if (typeof sourceId !== "string" || sourceId === task.id) return;
const [source] = await tx.select().from(issues).where(and(eq(issues.id, sourceId), eq(issues.companyId, task.companyId)));
if (!source?.conversationAgentId || source.conversationAgentId !== run.agentId ||
source.conversationSessionGeneration !== run.contextSnapshot?.conversationSessionGeneration) return;
await tx.insert(handoffs).values({ taskId: task.id, companyId: task.companyId,
conversationId: source.id, agentId: source.conversationAgentId, sessionGeneration: source.conversationSessionGeneration }).onConflictDoNothing();
}
/** Must run on the same transaction as status projection (including native arbitration). */
export async function recordChatCompletion(tx: Connection, before: Issue, after: Issue): Promise<boolean> {
if (before.status === after.status) return false;
await tx.update(deliveries).set({ status: "superseded" }).where(and(eq(deliveries.taskId, after.id),
eq(deliveries.companyId, after.companyId), inArray(deliveries.status, [...pending])));
if (after.status !== "done") return false;
const [handoff] = await tx.select().from(handoffs).where(and(eq(handoffs.taskId, after.id), eq(handoffs.companyId, after.companyId)));
if (!handoff) return false;
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatCompletion);
await tx.insert(deliveries).values({ companyId: after.companyId, taskId: after.id, statusVersion: after.statusVersion }).onConflictDoNothing();
return true;
}
async function loadAudience(tx: Connection, deliveryId: string) {
const [row] = await tx.select({ delivery: deliveries, handoff: handoffs, task: issues }).from(deliveries)
.innerJoin(handoffs, and(eq(handoffs.taskId, deliveries.taskId), eq(handoffs.companyId, deliveries.companyId)))
.innerJoin(issues, and(eq(issues.id, deliveries.taskId), eq(issues.companyId, deliveries.companyId)))
.where(eq(deliveries.id, deliveryId));
if (!row) return null;
const [source] = await tx.select().from(issues).where(and(eq(issues.id, row.handoff.conversationId), eq(issues.companyId, row.handoff.companyId)));
return { ...row, source };
}
function current(row: NonNullable<Awaited<ReturnType<typeof loadAudience>>>) {
return row.task.status === "done" && !["superseded", "exhausted"].includes(row.delivery.status) &&
row.source?.conversationAgentId === row.handoff.agentId &&
row.source.conversationSessionGeneration === row.handoff.sessionGeneration;
}
async function taskResult(tx: Connection, task: Issue) {
// Never copy worker-authored titles, comments or document bodies into the
// source agent's instructions. These links and lifecycle facts are generated
// by the server; the saved work remains on its normal access-controlled task.
const documents = await tx.select({ id: issueDocuments.documentId }).from(issueDocuments)
.where(and(eq(issueDocuments.companyId, task.companyId), eq(issueDocuments.issueId, task.id))).limit(1);
return { id: task.id, identifier: task.identifier, status: task.status,
completedAt: task.completedAt, url: `/issues/${task.identifier ?? task.id}`,
hasSavedDocuments: documents.length > 0 };
}
/** A completed onboarding parent still owes the result of its own child handoff.
* This permits a reporting turn without reopening Done or reviving cancellation. */
export async function isCompletedOnboardingHandoffWake(db: Connection, input: {
companyId: string; issueId: string; agentId: string; reason: string | null;
contextSnapshot: Record<string, unknown>;
}) {
if (input.reason !== "issue_children_completed" || typeof input.contextSnapshot.completedChildIssueId !== "string") return false;
const [source] = await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)));
if (source?.originKind !== "onboarding_first_task" || source.status !== "done" || source.assigneeAgentId !== input.agentId) return false;
const children = await db.select({ id: issues.id, status: issues.status }).from(issues)
.where(and(eq(issues.companyId, input.companyId), eq(issues.parentId, source.id)));
return children.some(child => child.id === input.contextSnapshot.completedChildIssueId && child.status === "done") &&
children.every(child => ["done", "cancelled"].includes(child.status));
}
/** Freeze the input at turn start. New completions cannot be consumed by an already-running turn. */
export async function prepareChatCompletionTurn(db: Db, run: Run): Promise<Run> {
const issueId = run.contextSnapshot?.issueId;
if (typeof issueId !== "string") return run;
return db.transaction(async tx => {
const [source] = await tx.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))).for("update");
if (!source) return run;
if (source.originKind === "onboarding_first_task" && run.contextSnapshot?.wakeReason === "issue_children_completed") {
const children = await tx.select().from(issues).where(and(eq(issues.companyId, run.companyId), eq(issues.parentId, source.id), eq(issues.status, "done"))).orderBy(issues.createdAt).limit(20);
const contextSnapshot = { ...run.contextSnapshot, onboardingCompletion: true,
chatCompletionUpdates: await Promise.all(children.map(child => taskResult(tx, child))) };
await tx.update(heartbeatRuns).set({ contextSnapshot }).where(eq(heartbeatRuns.id, run.id));
return { ...run, contextSnapshot };
}
const initialIds = ids(run);
// Only completion wakes absorb pending events; ordinary user turns retain their existing input.
if (initialIds.length === 0 && run.contextSnapshot?.wakeReason !== CHAT_COMPLETION_WAKE_REASON) return run;
if (run.contextSnapshot?.conversationSessionGeneration !== source.conversationSessionGeneration) throw new Error("chat_completion_superseded");
const rows = await tx.select({ delivery: deliveries }).from(deliveries).innerJoin(handoffs, eq(handoffs.taskId, deliveries.taskId))
.where(and(eq(deliveries.companyId, run.companyId), eq(handoffs.conversationId, issueId),
eq(handoffs.agentId, run.agentId), eq(handoffs.sessionGeneration, source.conversationSessionGeneration),
inArray(deliveries.status, [...pending]),
sql`(${deliveries.targetRunId} is null or ${deliveries.targetRunId} = ${run.id})`))
.orderBy(asc(deliveries.createdAt)).limit(20);
const updates: Awaited<ReturnType<typeof taskResult>>[] = [];
const accepted: string[] = [];
for (const { delivery } of rows) {
const row = await loadAudience(tx, delivery.id);
if (!row || !current(row)) {
await tx.update(deliveries).set({ status: "superseded" }).where(eq(deliveries.id, delivery.id));
continue;
}
accepted.push(delivery.id); updates.push(await taskResult(tx, row.task));
await tx.update(deliveries).set({ status: "queued", targetRunId: run.id }).where(eq(deliveries.id, delivery.id));
}
if (accepted.length === 0) throw new Error("chat_completion_superseded");
const contextSnapshot = { ...run.contextSnapshot, chatCompletionDeliveryIds: accepted, chatCompletionUpdates: updates };
await tx.update(heartbeatRuns).set({ contextSnapshot }).where(eq(heartbeatRuns.id, run.id));
return { ...run, contextSnapshot };
});
}
export function chatCompletionInstruction(context: Record<string, unknown>) {
if (!Array.isArray(context.chatCompletionUpdates) || !context.chatCompletionUpdates.length) return "";
return `\n\nDelegated work has completed. Tell the user in this conversation what finished and provide access using the supplied task links. Report completion only for the tasks listed in this update; other tasks receive their own completion updates. Use the recorded status and result locations; do not repeat a promise to do work that is already Done. Do not start more work or change these tasks. The following JSON contains server-recorded lifecycle facts and result locations:\n${JSON.stringify(context.chatCompletionUpdates)}`;
}
/** Called under the comment transaction, before insertion. An event can publish only once. */
export async function existingChatCompletionReply(tx: Connection, runId: string, issueId: string) {
const [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
if (!run || ids(run).length === 0) return null;
const rows = await tx.select().from(deliveries).where(and(eq(deliveries.companyId, run.companyId), inArray(deliveries.id, ids(run))));
for (const delivery of rows) {
// Match the status writer's task-first lock order, then reload the outbox
// row so a concurrent reopen cannot leave us with its pre-commit snapshot.
await tx.select({ id: issues.id }).from(issues).where(and(eq(issues.id, delivery.taskId), eq(issues.companyId, run.companyId))).for("update");
const row = await loadAudience(tx, delivery.id);
if (!row || row.source?.id !== issueId || row.handoff.agentId !== run.agentId || !current(row)) throw new Error("chat_completion_superseded");
if (delivery.responseCommentId) {
const [comment] = await tx.select().from(issueComments).where(and(eq(issueComments.id, delivery.responseCommentId), eq(issueComments.issueId, issueId)));
if (comment) return comment;
}
if (delivery.status !== "queued" || delivery.targetRunId !== runId) throw new Error("chat_completion_superseded");
}
if (rows.length !== ids(run).length) throw new Error("chat_completion_superseded");
return null;
}
export async function acknowledgeChatCompletionReply(tx: Connection, runId: string, commentId: string) {
await tx.update(deliveries).set({ status: "delivered", responseCommentId: commentId, error: null })
.where(and(eq(deliveries.targetRunId, runId), eq(deliveries.status, "queued")));
}
export function chatCompletionDeliveryService(db: Db, heartbeat: { wakeup(agentId: string, options: {
source: "automation"; triggerDetail: "system"; reason: string; idempotencyKey: string; allowRunCoalescing: boolean;
requestedByActorType: "system"; requestedByActorId: string; payload: Record<string, unknown>; contextSnapshot: Record<string, unknown>;
}): Promise<{ id: string } | null> }) {
async function deliver(id: string): Promise<void> {
// Lease outbox work before leaving the transaction; recovery reuses the same wake key.
const claimed = await db.update(deliveries).set({ nextAttemptAt: new Date(Date.now() + LEASE_MS) })
.where(and(eq(deliveries.id, id), inArray(deliveries.status, [...pending]), lte(deliveries.nextAttemptAt, new Date())))
.returning().then(rows => rows[0]);
if (!claimed) return;
try {
const row = await loadAudience(db, id);
if (!row || !current(row)) {
await db.update(deliveries).set({ status: "superseded" }).where(eq(deliveries.id, id)); return;
}
if (!(await instanceSettingsService(db).getExperimental()).enableAgentChat) return;
const [agent] = await db.select().from(agents).where(and(eq(agents.id, row.handoff.agentId), eq(agents.companyId, claimed.companyId)));
if (!agent || ["paused", "terminated"].includes(agent.status)) return;
const key = `chat-completion:${id}:${claimed.attempts}`;
const wakes = await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, claimed.companyId), eq(agentWakeupRequests.idempotencyKey, key)));
const runId = claimed.targetRunId ?? wakes.find(w => w.runId)?.runId;
if (runId) {
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
if (run && activeRuns.includes(run.status)) return;
// A published reply is acknowledged in its own transaction, independent of run outcome.
const [latest] = await db.select().from(deliveries).where(eq(deliveries.id, id));
if (!latest || !pending.includes(latest.status as typeof pending[number])) return;
await db.update(deliveries).set({ status: claimed.attempts + 1 >= MAX_ATTEMPTS ? "exhausted" : "pending",
attempts: claimed.attempts + 1, targetRunId: null, nextAttemptAt: new Date(),
error: run?.error ?? "Completion turn ended without a reply" })
.where(and(eq(deliveries.id, id), inArray(deliveries.status, [...pending])));
// The failed run is already terminal. Reclaim the advanced attempt now;
// a second lease interval would only delay restart recovery. The atomic
// claim and fresh wake key still serialize competing sweepers.
if (claimed.attempts + 1 < MAX_ATTEMPTS) await deliver(id);
return;
}
if (wakes.some(w => ["queued", "claimed", "deferred_issue_execution", "coalesced"].includes(w.status))) return;
if (wakes.length) {
// Terminal receipts must advance the key; reusing them collides forever.
const dailyCap = wakes.some(w => w.reason?.startsWith("heartbeat.daily_"));
const next = new Date(Date.now() + LEASE_MS);
if (dailyCap) next.setUTCHours(24, 0, 0, 0);
await db.update(deliveries).set({ attempts: claimed.attempts + 1, targetRunId: null,
status: !dailyCap && claimed.attempts + 1 >= MAX_ATTEMPTS ? "exhausted" : "pending",
nextAttemptAt: next, error: wakes[0].reason ?? "Completion wake did not start" })
.where(and(eq(deliveries.id, id), inArray(deliveries.status, [...pending])));
return;
}
// One undispatched head per conversation. Its turn absorbs the remaining
// events at admission; events arriving after admission need a later turn.
const siblings = await db.select({ delivery: deliveries, run: heartbeatRuns }).from(deliveries)
.innerJoin(handoffs, eq(handoffs.taskId, deliveries.taskId))
.leftJoin(heartbeatRuns, eq(heartbeatRuns.id, deliveries.targetRunId))
.where(and(eq(deliveries.companyId, claimed.companyId), eq(handoffs.conversationId, row.handoff.conversationId),
eq(handoffs.sessionGeneration, row.handoff.sessionGeneration), inArray(deliveries.status, [...pending])))
.orderBy(asc(deliveries.createdAt), asc(deliveries.id));
if (siblings.some(s => s.run && activeRuns.includes(s.run.status) &&
!Array.isArray(s.run.contextSnapshot?.chatCompletionUpdates))) return;
if (siblings.find(s => !s.delivery.targetRunId)?.delivery.id !== id) return;
const run = await heartbeat.wakeup(row.handoff.agentId, {
source: "automation", triggerDetail: "system", reason: CHAT_COMPLETION_WAKE_REASON, idempotencyKey: key, allowRunCoalescing: false,
requestedByActorType: "system", requestedByActorId: "chat_completion_delivery",
payload: { issueId: row.handoff.conversationId, chatCompletionDeliveryIds: [id] },
contextSnapshot: { issueId: row.handoff.conversationId, taskId: row.handoff.conversationId,
wakeReason: CHAT_COMPLETION_WAKE_REASON, chatCompletionDeliveryIds: [id], conversationSessionGeneration: row.handoff.sessionGeneration },
});
if (!run) {
const receipts = await db.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, claimed.companyId), eq(agentWakeupRequests.idempotencyKey, key)));
if (!receipts.length) throw new Error("Completion wake returned no run or durable receipt");
}
if (run) await db.update(deliveries).set({ targetRunId: run.id, status: "queued" })
.where(and(eq(deliveries.id, id), inArray(deliveries.status, [...pending]), sql`${deliveries.targetRunId} is null`));
} catch (error) {
const receipts = await db.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, claimed.companyId), eq(agentWakeupRequests.idempotencyKey, `chat-completion:${id}:${claimed.attempts}`)));
await db.update(deliveries).set({ error: error instanceof Error ? error.message : String(error),
...(receipts.length ? {} : { attempts: claimed.attempts + 1,
status: claimed.attempts + 1 >= MAX_ATTEMPTS ? "exhausted" as const : "pending" as const }) })
.where(and(eq(deliveries.id, id), inArray(deliveries.status, [...pending])));
}
}
async function sweepPending(scope?: { companyId: string; taskId: string }) {
const due = await db.select({ id: deliveries.id }).from(deliveries)
.where(and(inArray(deliveries.status, [...pending]), lte(deliveries.nextAttemptAt, new Date()),
...(scope ? [eq(deliveries.companyId, scope.companyId), eq(deliveries.taskId, scope.taskId)] : [])))
.orderBy(asc(deliveries.nextAttemptAt)).limit(100);
for (const row of due) await deliver(row.id);
}
async function hasPending() {
return (await db.select({ id: deliveries.id }).from(deliveries)
.where(inArray(deliveries.status, [...pending])).limit(1)).length > 0;
}
return { deliver, sweepPending, hasPending };
}