mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
## 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>
344 lines
24 KiB
TypeScript
344 lines
24 KiB
TypeScript
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 };
|
||
}
|