Files
PaperClipAI/server/src/services/chat-completion-delivery.ts
T
DottaandPaperclip b889ac39b7 fix: serialize native completion replies and bounded retries (#18502)
## Thinking Path

> - Paperclip manages agents and their work.
> - A delegated task reports its result to the source conversation
through a durable completion outbox.
> - Native execution can succeed before the presentation worker saves
the accepted final reply.
> - The outbox treated that short interval as a missing answer and
cleared the target run.
> - The presentation worker then rejected the valid answer because its
delivery no longer belonged to that run.
> - This change retains the existing target for one fixed presentation
grace while accepted presentation is pending.
> - The existing presentation owner can save the reply without another
model run or more budget.

## Linked Issues or Issue Description

**What happened?**

A completion-delivery retry can run immediately after a native callback
succeeds. It clears the callback's delivery target before the
presentation worker saves the accepted answer. The reply then fails with
`chat_completion_superseded`. A daily run limit correctly blocks the
unnecessary replacement run, leaving the conversation active without its
saved answer.

**Expected behavior**

The outbox must retain its current target and attempt while that exact
native run has accepted presentation work. The existing presentation
worker must save the answer once, acknowledge the delivery, and settle
the conversation. Existing actor, session, result, digest, budget and
retry checks must remain enforced.

**Steps to reproduce**

1. Accept a native DONE result and current-turn final message for a
completion callback.
2. Run its due completion-outbox handler after terminal projection but
before reply presentation.
3. Run the existing presentation worker. Before this fix it rejects the
reply because the outbox has cleared the target.

**Paperclip version or commit**

Reproduced on `b7f1c3769c00a107ffe4128b48b0ef8cc452cf3f` through the
real PostgreSQL outbox and presentation services. The reported failure
remains preserved. A new QA run is required after deployment; local
tests do not qualify the deployed fix.

Related: #17696 schedules accepted-response recovery. #18118 preserves
canonical completion receipts. #18364 handles provider output and
response-stage contracts. Those changes do not protect the
completion-outbox target during this interval.

## What Changed

- Defer a new execution attempt when the existing selector finds pending
presentation for the same company, conversation, run and agent.
- Retain the queued delivery target and attempt with an atomic
compare-and-set. Await the existing scoped worker notification before
that write in the same transaction.
- Serialize retry with reply insertion under the existing
child-task-first lock. Recheck locked task Done state and the delivery's
stable completion-event version, status, target, attempt and exact
lease. A delivered, retargeted or newer-claim winner prevents another
wake. Admit the next attempt only after the winning transaction commits
and releases its locks.
- Limit presentation grace to five existing one-minute delivery leases,
measured from the original run `finishedAt`. Clamp both the delivery
lease and the native retry deadline to that immutable limit. Repeated
sweeps cannot extend it. After expiry, resume the existing retry,
exhaustion and daily-limit checks.
- Keep publication authority in the existing materializer. The candidate
query only defers a retry. Read the freshly reloaded delivery status,
target and saved receipt after acquiring its child-task lock.
- Fence both hold and retry writes with the exact claimed event version,
attempt and lease. A stalled old hold cannot overwrite a newer lease.
- Keep completion-event identity separate from mutable task ownership
versions. Ordinary Done reassignment preserves its existing completion
event; a real reopen retains normal supersession.
- Add real PostgreSQL coverage for the observed ordering, outer commit
and rollback, one saved reply, conversation settlement, idle SQL, retry
boundaries and superseded deliveries.
- Document the completion-outbox and presentation contract.

## Verification

- Before the fix, the real PostgreSQL regression fails with
`chat_completion_superseded` in `existingChatCompletionReply` when the
outbox handles the terminal run first. After the fix, the same test
passes without a new model wake.
- Focused command: `vitest run --project @paperclipai/server
server/src/services/native-runtime/agent-chat-response-recovery.test.ts
server/src/__tests__/chat-completion-delivery.test.ts` — **95 pass** on
`45f53a8c32fcca0bbdacf717286d37e30c1ba308`, using a disposable database
on an owned local PostgreSQL 17 container.
- Controls preserve retries for failed, cancelled, missing-final,
missing-accepted-result, old-generation, foreign-company and
foreign-agent targets. Reset or superseded task versions retain
supersession. Digest and external-context declines never authorize a
reply, never extend the fixed window, and leave it at exact expiry. An
external native deadline cannot extend the limit. A valid reply at the
last millisecond saves once without another model run.
- The first reviewed head `b6f9c484b9567bc60af6d684c04e00058c1048ed`
received Greptile 5/5 but an independent 3/5 review correctly found an
unbounded hold. Three new real PostgreSQL controls failed on that
source. This head adds the fixed bound and makes them pass. The earlier
review, controls and CI remain retained; its review score does not
qualify the new head.
- The bounded predecessor `7300d2a978a5c4b3c13d85899bed3e98ae3c49fa`
received an independent 4/5 review that correctly found an
expiry-versus-in-flight-reply race. A real PostgreSQL advisory gate
reproduced it after reply authority checked the delivery but before
insertion. The retry lock and exact claim CAS now make that control
pass. Both orderings, outer rollback, delivered/retargeted/superseded
winners and competing lease claims pass. The next-wake test can acquire
both parent and child locks, proving dispatch occurs after release. The
valid 4/5 review and failing control remain retained.
- At `39dd86e8f51d937cf382b2757736e0477201d21d`, both reviews found
valid remaining defects: the reply helper still used its pre-lock
delivery snapshot, and ordinary Done reassignment could stall a delivery
by changing the task version. Four real PostgreSQL controls fail on that
source and pass here: concurrent retry-first, a newly saved receipt
after waiting, and reassignment within grace or at expiry. A real reopen
through the issue service still supersedes the old event.
- A later coordinator review found the hold CAS still lacked the attempt
and lease fences already present on retry. A real PostgreSQL producer
barrier reproduced an old hold shortening a newer claim from 120 seconds
to 60 seconds. The same exact claim fences now prevent that overwrite.
This failed control is retained; no old run was blindly retried.
- The real worker saves one exact run-owned reply and acknowledges the
delivery only after outer commit. An outer rollback publishes no reply.
After settlement, advancing the scheduler clock by 60 seconds causes no
further SQL.
- Module boundaries, map structure, `git diff --check` and the canonical
server build (including TypeScript and Runner vendor preparation) pass.
The first local build stopped in the host package-manager setup; the
installed pinned pnpm completed the build. Full repository typecheck,
test and build gates pass in current-head CI; they have not all been run
locally.
- Final local checks also caught a test cleanup type error (TS2677) and
one 94/95 readiness failure: the worker saves its comment before
post-commit conversation settlement finishes. The test-only follow-up
removes an invalid cleanup type predicate and waits on the existing
bounded worker-quiet check before reading final state. All assertions
and the same five-second bound remain. Source
`45f53a8c32fcca0bbdacf717286d37e30c1ba308` passes all 95 cases and the
canonical server build. The earlier failures are retained.
- The final policy cleanup removes only 15 newly added narrative comment
lines (seven service, eight test), as required by CONTRIBUTING.md. Both
TypeScript AST signatures and `removeComments=true` emitted JavaScript
are identical to tested source `45f53a8c32`. This was checked with the
TypeScript 6.0.3 API and the installed native TypeScript 7.0.2 compiler.
Assertions, timeouts and behavior are unchanged; the 95 tests were not
repeated for identical compiled code.
- The superseded `45f53a8c32` CI run recorded two 15-second timeouts in
attention-retention cases. That failure remains retained. The test and
its ten inspected owner or support files are identical on the tested
master revisions. The canonical base run passes all 53 attention cases,
and the comparison PR passes the same server lane, including those two
cases in 9.3 and 9.8 seconds. This does not establish the timeout cause.
Current-head CI is a new run after the required comment cleanup, not a
retry of the failed source.
- The optional Paperclip review bot ended neutral without a completed
assessment because its execution paused. That result is not an approval.
A separate [independent read-only
audit](https://github.com/paperclipai/paperclip/pull/18502#issuecomment-6550216227)
reports 5/5 confidence on exact head
`9cfd3f74c69cc7a2da7b4ee5d1fca80f15d4af0c`, with no findings. Its manual
assessment does not replace or relabel the incomplete bot result.
- Current head `9cfd3f74c69cc7a2da7b4ee5d1fca80f15d4af0c` has a fresh
Greptile 5/5 review with no open threads. [CI run
41249007430](https://github.com/paperclipai/paperclip/actions/runs/41249007430)
passes all primary jobs and both required gates (`ci / verify` and `ci /
e2e`). All 59 PR contexts are terminal, with no failed contexts. The
actual CI checkout is `18db9e2750d6a799f26b5b8ba31bcccbea254227`, which
combines master `681f7b77262a9df6c1b4aad55291bf960061e491` with this
exact head. Fresh QA verification on the deployed application remains
required before claiming the live failure is fixed.

## Risks

A candidate can retain a delivery whose stricter presentation checks
still prevent publication, but only within the fixed five-minute window.
The existing worker keeps publication authority. If a valid reply
remains unavailable at expiry, the normal callback retry may require
another admitted model run; the original result and completed child work
remain preserved. Existing retry, exhaustion, model-run and spending
limits still apply. Failed or cancelled execution and genuinely missing
accepted output retain the existing retry path.

## Model Used

OpenAI GPT-6 through Codex, with repository inspection, tool use and
code execution. The runtime does not expose the exact hosted revision or
context-window size.

## 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-31 18:07:41 -05:00

344 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { notifyNativeResponsePresentation } from "./native-response-presentation-notifications.js";
import { nativeResponsePresentationCandidates } from "./native-runtime/native-response-presentation-query.js";
import { and, asc, desc, eq, inArray, isNull, lte, sql } from "drizzle-orm";
import { agents, agentWakeupRequests, chatCompletionDeliveries as deliveries, chatTaskHandoffs as handoffs,
heartbeatRuns, issueComments, issueDocuments, issueThreadInteractions, issues, type Db } from "@paperclipai/db";
import { instanceSettingsService } from "./instance-settings.js";
import { conflict, forbidden, notFound } from "../errors.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") : [];
}
/** Human-confirmed suggestions use the same durable audience as native create_task.
* The source must be read/authorized by the caller; ownership and generation are
* rechecked under the creation transaction so reset chats cannot receive new work. */
export async function lockAcceptedChatHandoffSource(tx: Connection, input: {
companyId: string; conversationId: string; sessionGeneration: number;
userId?: string | null; agentId?: string | null;
}) {
const [source] = await tx.select().from(issues).where(and(eq(issues.id, input.conversationId),
eq(issues.companyId, input.companyId))).for("update");
if (!source?.conversationAgentId) throw notFound("Originating conversation not found");
if (source.conversationSessionGeneration !== input.sessionGeneration) {
throw conflict("The originating conversation was reset; request a new task suggestion");
}
if (input.userId ? source.conversationUserId !== input.userId : source.conversationAgentId !== input.agentId) {
throw forbidden("Task handoff requires the originating conversation's participant");
}
return source;
}
export async function recordAcceptedChatHandoff(tx: Connection, task: Issue, source: Issue) {
if (!source.conversationAgentId || source.companyId !== task.companyId || task.conversationAgentId) {
throw forbidden("Task handoff requires an execution task in the originating conversation's company");
}
await tx.insert(handoffs).values({ taskId: task.id, companyId: task.companyId,
conversationId: source.id, agentId: source.conversationAgentId,
sessionGeneration: source.conversationSessionGeneration }).onConflictDoNothing();
}
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 recordAcceptedChatHandoff(tx, task, source);
}
/** 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);
// A bounded receipt projection gives the coordinator actual verdicts to link,
// rather than asking it to infer whether the worker's prose needs another review.
// These are historical decisions on their named versions, not publication authority.
const reviews = await tx.select().from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.companyId, task.companyId), eq(issueThreadInteractions.issueId, task.id),
inArray(issueThreadInteractions.kind, ["request_confirmation", "request_item_verdicts"]),
inArray(issueThreadInteractions.status, ["accepted", "answered"]),
)).orderBy(desc(issueThreadInteractions.resolvedAt), desc(issueThreadInteractions.id)).limit(20);
const recordedOutputDecisions = reviews.flatMap(review => {
const payload = review.payload as Record<string, any>, result = (review.result ?? {}) as Record<string, any>;
const candidates = review.kind === "request_confirmation" ? [{ target: payload.target, outcome: "approve", human: Boolean(review.resolvedByUserId) && !review.resolvedByAgentId }]
: (Array.isArray(result.items) ? result.items : []).map((item: any) => ({
target: (Array.isArray(payload.items) ? payload.items : []).find((entry: any) => entry.id === item.id)?.target ?? payload.target,
outcome: item.verdict, human: Boolean(item.resolvedByUserId) && !item.resolvedByAgentId,
}));
return candidates.filter(candidate => candidate.human && ["approve", "reject", "defer"].includes(candidate.outcome) &&
candidate.target && ["issue_document", "issue_attachment"].includes(candidate.target.type) &&
(!candidate.target.issueId || candidate.target.issueId === task.id) && candidate.target.key !== "plan")
.map(candidate => ({ interactionId: review.id, outcome: candidate.outcome,
target: candidate.target.type === "issue_document"
? { type: "issue_document", key: candidate.target.key, revisionId: candidate.target.revisionId }
: { type: "issue_attachment", attachmentId: candidate.target.attachmentId, assetId: candidate.target.assetId, sha256: candidate.target.sha256 },
url: `/issues/${task.identifier ?? task.id}#interaction-${review.id}` }));
}).slice(0, 30);
return { id: task.id, identifier: task.identifier, status: task.status,
completedAt: task.completedAt, url: `/issues/${task.identifier ?? task.id}`,
hasSavedDocuments: documents.length > 0, recordedOutputDecisions };
}
/** 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, change these tasks, or create duplicate review requests. Reuse and link the task’s existing canonical review/decision; completion updates are notifications, not fresh quality-review authority. recordedOutputDecisions are human verdicts on the exact versions shown. Link them instead of re-requesting review of those versions; do not treat approval as permission to publish. A separate explicit user request may authorize a new review. Keep opaque IDs and hashes out of your visible reply. 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 (row.delivery.responseCommentId) {
const [comment] = await tx.select().from(issueComments).where(and(eq(issueComments.id, row.delivery.responseCommentId), eq(issueComments.issueId, issueId)));
if (comment) return comment;
}
if (row.delivery.status !== "queued" || row.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;
const presentationDeadline = run?.runtimeMode === "native" && run.status === "succeeded" && run.finishedAt
? run.finishedAt.getTime() + MAX_ATTEMPTS * LEASE_MS : null;
if (run && presentationDeadline !== null && Date.now() < presentationDeadline) {
const scope = { companyId: claimed.companyId, issueId: row.handoff.conversationId,
runId: run.id, agentId: row.handoff.agentId };
const deferred = await db.transaction(async tx => {
const candidates = nativeResponsePresentationCandidates(scope).as("pending_native_completion_reply");
const [response] = await tx.select().from(candidates);
if (!response || Date.now() >= presentationDeadline) return false;
await notifyNativeResponsePresentation(tx, scope);
const updated = await tx.update(deliveries).set({ nextAttemptAt: new Date(Math.min(presentationDeadline, Math.max(
claimed.nextAttemptAt.getTime(), response.nextAttemptAt?.getTime() ?? 0,
))) }).where(and(eq(deliveries.id, id), eq(deliveries.companyId, claimed.companyId),
eq(deliveries.status, "queued"), eq(deliveries.targetRunId, run.id),
eq(deliveries.statusVersion, claimed.statusVersion), eq(deliveries.attempts, claimed.attempts),
eq(deliveries.nextAttemptAt, claimed.nextAttemptAt))).returning({ id: deliveries.id });
return updated.length > 0;
});
if (deferred) return;
}
const advanced = await db.transaction(async tx => {
const [task] = await tx.select({ status: issues.status }).from(issues)
.where(and(eq(issues.id, claimed.taskId), eq(issues.companyId, claimed.companyId))).for("update");
if (task?.status !== "done") return false;
const sameClaim = and(eq(deliveries.id, id), eq(deliveries.companyId, claimed.companyId),
inArray(deliveries.status, [...pending]), eq(deliveries.statusVersion, claimed.statusVersion),
eq(deliveries.attempts, claimed.attempts),
eq(deliveries.nextAttemptAt, claimed.nextAttemptAt),
claimed.targetRunId ? eq(deliveries.targetRunId, claimed.targetRunId) : isNull(deliveries.targetRunId));
const [latest] = await tx.select({ id: deliveries.id }).from(deliveries).where(sameClaim).for("update");
if (!latest) return false;
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatCompletion);
const updated = await tx.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(sameClaim).returning({ id: deliveries.id });
return updated.length > 0;
});
if (advanced && 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 };
}