Files
PaperClipAI/server/src/services/chat-interaction-publications.ts
T
DottaandPaperclip 063cfd4381 fix(server): wake interaction continuations from committed responses (#17653)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/204/changes/paperclip/204
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip coordinates agent work and human approvals.
> - Human decisions already persist the continuation action that resumes
an agent.
> - A response can commit before its callback or publication finishes.
> - Periodic maintenance repeatedly discovers those rows, including when
idle.
> - Committed writes should wake the existing owner; saved retries and
claims supply real deadlines.
> - Startup reconstructs durable work, and settled history becomes
quiet.

## Linked Issues or Issue Description

Refs #17651, which is merged. This existing continuation slice targets
master and removes its duty from periodic chat maintenance.

**Problem:** A saved `interaction_wakeup` action can survive a committed
human response whose callback crashes. Without producer coverage,
removing maintenance strands the continuation. Repeated empty
maintenance also keeps the database active.

**Expected behavior:** The outer transaction's settlement wakes the
continuation owner. It handles ready conversation publications,
dispatches under existing authority, preserves interrupted/retry work,
and becomes quiet after settlement.

## What Changed

- Register continuation recovery with the shared scoped deadline
coordinator. Reconstruct existing issued/processing actions and retain
the existing retry and stale-claim deadlines.
- Notify from both enqueue producers and owner-local claim/settlement
writes. Preserve outer commit/rollback and avoid moving provider or
heartbeat calls into database transactions.
- Give the existing publication queue an optional
company/endpoint/conversation scope. The continuation owner gives ready
cards in that conversation a bounded delivery opportunity before
claiming the action; FIFO, capacity, ambiguity and provider authority
remain with the queue.
- Carry admission/cancellation through publication preparation and check
before every new send. Join in-flight requests and retain pending
actions/publications through drain, standby, shutdown and resume.
- Remove only recurring continuation recovery; keep the manual API. Add
no table. A concurrent partial index on existing pending continuation
actions and matching company/id keyset paging prevent retained history
from being scanned. Parent #17651's indexed confirmation discovery and
current master are retained.

## Verification

Published head: `a1d16f84b3fae361d5d648670a5964486370c3a3`, on actual
landed terminal-confirmation parent
`ee67d2ece9bda157dfad9bdf25175b1d004ed341`.

- **75 distinct current cases pass:** 32 selected real PostgreSQL chat
cases (14 continuation lifecycle/producer/stop cases, one new
continuation query-cost case, and 17 parent confirmation/query-cost
cases; 1,671 skipped), and all 43 cases in complete
interaction-publication and app/startup suites. Database build, server
typecheck/build, module boundaries and fresh migrations through 0399
pass. The original landed-parent matrix passed 74 cases before the
additional query-cost repair; those are not added again.
- The new real-SQL regression seeds 20,000 completed/unrelated actions
and one pending processing claim. Before repair it examines 20,345 rows
and hits 20,421 buffers. The final startup and company-scoped queries
must examine at most two action rows and fewer than 20 action buffers;
empty scoped recovery must also stay below 20 buffers. Planner settings
are unchanged. Migration `0399_ambitious_onslaught.sql` is
concurrent/idempotent and uses the existing top-level migration runner;
prior SQL/journal entries and the five newest snapshots are preserved.
- The matrix covers callback recovery, concurrent responses, malformed
prefixes, more than one page, saved retries/stale claims, outer
rollback/commit, deferred/future/paused publication states, restart,
drain and stop before each new provider request. Quiet assertions
inspect real selects/transactions across virtual idle time.
- Normal integration `db5a84c0f8b73a0ce863f4d2fbaee0a115cf044f`
preserves the complete 12-path owned binary patch and all 12 unrelated
current-master paths, including managed Cloud sandbox changes. A
verified semantic base resolves squash-ancestry conflicts; both normal
parents and the exact tree are checked. The subsequent index/keyset
repair is a deliberate additional change, tested separately; the final
patch is not claimed identical to the original 12-file implementation.
No force push or rebase occurs.
- The same 74 pre-index cases passed on the earlier prepared and actual
landed parents. The new query-cost regression failed before its repair,
then passes on the repaired source. Earlier 57-case and broad acceptance
retain their original revision scopes; repeated cases are not added to
the current count. The audit-only follow-up leaves all owned server
source/test hashes unchanged.
- [The owner
audit](https://github.com/paperclipai/paperclip/blob/a1d16f84b3fae361d5d648670a5964486370c3a3/doc/plans/2026-10-25-interaction-continuation-sleep-audit.md#acceptance-on-the-actual-landed-terminal-confirmation-parent)
retains the original missing-signal regression, the skipped standalone
publication suite, and the review's stop-before-send failures with their
corrections. Skips and earlier failing revisions are not relabelled as
current passes.
- **Fresh exact-head CI and Greptile 5/5 without unresolved actionable
findings remain required.** The former remote head's reviews are stale.
No full local repository suite, live provider or deployed sleep result
is claimed.

## Risks

- Process-local transaction hints cover supported application writers
and shared pools. Independent SQL writers and other replicas do not gain
a new wake guarantee.
- The publication queue's existing FIFO, capacity, retry/ambiguity and
authorization contracts still govern effects. In-flight provider
requests can settle after Stop; the fence prevents a subsequent request
from starting.
- Missing a supported producer can defer recovery until another change
or startup. Producer and rollback tests cover this slice's supported
paths.
- Worker quiet is separate from host/database suspension. External alarm
coverage and Cloud sleep gates are unchanged.

## Model Used

OpenAI Codex, using repository inspection, code execution and real
PostgreSQL tests. The exact model identifier and context-window size are
not exposed in this session.

## 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
- [ ] All Paperclip CI gates are green
- [ ] 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-28 15:45:18 -05:00

861 lines
36 KiB
TypeScript

import { notifyChatContinuationWork } from "./chat-continuation-notifications.js";
import { chatInteractionForm } from "./chat-interaction-forms.js";
import { slackInteractionRequiresBoard } from "./chat-channels/slack/cards.js";
import { answeredChatQuestionBody, chatInteractionPresentation } from "./chat-interaction-presentation.js";
import { notifyChatPublicationWork } from "./chat-work-notifications.js";
import { nativePhotonInteraction } from "./photon/interactions.js";
import { randomBytes } from "node:crypto";
import { and, eq, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
chatActions,
chatConversations,
chatEndpoints,
chatPublications,
heartbeatRuns,
issues,
} from "@paperclipai/db";
import type {
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
IssueThreadInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
SuggestTasksInteraction,
SafeExternalChatCardAction,
} from "@paperclipai/shared";
import { projectSafeChatPublication } from "./chat-publication-projection.js";
import { publicChatTaskUrl } from "./chat-task-url.js";
import {
chatQuestionFormActionRecords,
createChatQuestionFormDraft,
} from "./chat-question-forms.js";
const MAX_NATIVE_QUESTION_OPTIONS = 12;
const QUESTION_ACTION_PREFIX = "pcq:";
const QUESTION_ACTION_TOKEN_BYTES = 16;
export const CHAT_QUESTION_ACTION_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
export const TELEGRAM_CALLBACK_DATA_LIMIT_BYTES = 64;
type ChatPublicationDb = Pick<Db, "select" | "insert" | "update" | "transaction">;
function terminalNativeInteractionCopy(
interaction: IssueThreadInteraction,
): { body: string; text: string } | null {
if (interaction.kind === "request_confirmation") {
if (interaction.status === "accepted")
return { body: "Accepted", text: "Accepted." };
if (interaction.status === "rejected")
return { body: "Rejected", text: "Rejected." };
if (interaction.status === "cancelled") {
const outcome = interaction.result?.outcome;
const body =
outcome === "skipped"
? "Skipped in Paperclip"
: outcome === "withdrawn"
? "Withdrawn in Paperclip"
: outcome === "addressee_deleted"
? "Cancelled: addressed agent was removed"
: "Cancelled in Paperclip";
return { body, text: `${body}.` };
}
if (interaction.status === "expired") {
const body =
interaction.result?.outcome === "superseded_by_comment"
? "Expired: superseded by a newer reply"
: interaction.result?.outcome === "superseded_by_newer_request"
? "Expired: replaced by a newer request"
: interaction.result?.outcome === "stale_target"
? "Expired: target is no longer current"
: interaction.result?.outcome === "issue_closed"
? "Expired: task is closed"
: "Expired in Paperclip";
return { body, text: `${body}.` };
}
return null;
}
if (interaction.kind === "request_checkbox_confirmation" && interaction.status === "accepted") {
const labels = interaction.payload.options.filter(option => interaction.result?.selectedOptionIds?.includes(option.id)).map(option => option.label);
const body = labels.length ? `Saved checks: ${labels.join("; ")}` : "Saved checks: none selected.";
return { body, text: body };
}
if (interaction.kind === "request_item_verdicts" && interaction.status === "answered") {
const body = interaction.payload.items.map(item => {
const result = interaction.result?.items.find(result => result.id === item.id);
return `${item.label}: ${result?.verdict ?? "pending"}${result?.reason ? ` — ${result.reason}` : ""}`;
}).join("\n\n");
return { body, text: body };
}
if (interaction.kind === "suggest_tasks" && (interaction.status === "accepted" || interaction.status === "rejected")) {
const body = interaction.status === "rejected" ? "Dismissed — no tasks created." :
(interaction.result?.createdTasks ?? []).map(task => {
const url = publicChatTaskUrl(task.issueId);
return `Created: ${task.identifier ?? "Task"} — ${task.title}${url ? `\n${url}` : ""}`;
}).join("\n\n");
return { body, text: body };
}
if (interaction.kind === "connection_intent" && (interaction.status === "accepted" || interaction.status === "rejected")) {
const body = interaction.status === "accepted" ? `Connected: ${interaction.payload.serviceName}` : `Declined: ${interaction.payload.serviceName}. No access was granted.`;
return { body, text: body };
}
if (interaction.kind !== "ask_user_questions") {
const body = ({ accepted: "Accepted", rejected: "Rejected", answered: "Answered", cancelled: "Cancelled in Paperclip", expired: "Expired in Paperclip" } as Record<string, string>)[interaction.status];
return body ? { body, text: `${body}.` } : null;
}
if (interaction.status === "answered") {
const question = nativeChatQuestion(interaction);
const answer = question
? interaction.result?.answers.find(
(candidate) => candidate.questionId === question.id,
)
: null;
const option =
answer?.optionIds.length === 1 && !answer.otherText
? question?.options.find(
(candidate) => candidate.id === answer.optionIds[0],
)
: null;
const body = option ? `Answered: ${option.label}.` : answeredChatQuestionBody(interaction);
return { body, text: body };
}
if (interaction.status === "cancelled") {
const outcome = interaction.result?.outcome;
const body =
outcome === "skipped"
? "Skipped in Paperclip."
: outcome === "withdrawn"
? "Withdrawn in Paperclip."
: "Cancelled in Paperclip.";
return { body, text: body };
}
if (interaction.status === "expired") {
const body =
interaction.result?.expirationReason === "superseded_by_comment"
? "Expired: superseded by a newer reply"
: interaction.result?.expirationReason ===
"superseded_by_newer_interaction"
? "Expired: replaced by a newer request"
: interaction.result?.outcome === "issue_closed"
? "Expired: task is closed"
: "Expired in Paperclip";
return { body, text: `${body}.` };
}
return null;
}
export const publicChatInteractionTaskUrl = publicChatTaskUrl;
/**
* Provider callbacks carry this compact, cryptographically random action id.
* Its durable chat_actions row is the only mapping back to an interaction,
* question, and option. Keeping the token opaque avoids exposing canonical ids
* and fits Telegram's strict 64-byte callback_data envelope.
*/
export function createChatQuestionOptionActionToken(): string {
return `${QUESTION_ACTION_PREFIX}${randomBytes(QUESTION_ACTION_TOKEN_BYTES).toString("base64url")}`;
}
export function createChatConfirmationActionToken(): string {
return createChatQuestionOptionActionToken();
}
/** Mirrors the pinned Chat SDK Telegram adapter's exact wire envelope. */
export function telegramChatSdkCallbackData(
actionId: string,
value?: string,
): string {
return `chat:${JSON.stringify({
a: actionId,
...(typeof value === "string" ? { v: value } : {}),
})}`;
}
export function telegramCallbackDataByteLength(
actionId: string,
value?: string,
): number {
return Buffer.byteLength(
telegramChatSdkCallbackData(actionId, value),
"utf8",
);
}
export function nativeChatQuestion(
interaction: AskUserQuestionsInteraction,
maxOptions = MAX_NATIVE_QUESTION_OPTIONS,
): AskUserQuestionsQuestion | null {
if (interaction.payload.questions.length !== 1) return null;
const question = interaction.payload.questions[0];
if (
question.selectionMode !== "single" ||
question.allowOther === true ||
question.options.length > maxOptions ||
question.options.some((option) => option.freeText === true)
) {
return null;
}
return question;
}
export function nativeChatCustomQuestion(interaction: AskUserQuestionsInteraction): AskUserQuestionsQuestion | null {
const question = interaction.payload.questions[0];
if (interaction.payload.questions.length !== 1 || !question || question.selectionMode !== "single" || !question.allowOther ||
!interaction.payload.questionSet?.questions[0]?.customAnswer) return null;
const options = question.options.filter(option => !option.freeText);
return options.length > 0 && options.length <= 100 ? { ...question, options } : null;
}
export function nativeChatMultiQuestion(interaction: AskUserQuestionsInteraction): AskUserQuestionsQuestion | null {
const question = interaction.payload.questions[0];
if (interaction.payload.questions.length !== 1 || !question || question.selectionMode !== "multi" || question.allowOther || question.options.length === 0 || question.options.length > 100 || question.options.some(option => option.freeText)) return null;
return question;
}
export function nativeChatSuggestions(interaction: IssueThreadInteraction): SuggestTasksInteraction | null {
return interaction.kind === "suggest_tasks" && interaction.payload.tasks.filter(task => !task.hiddenInPreview).length > 0 && interaction.payload.tasks.length <= 100 ? interaction : null;
}
export function nativeChatCheckbox(interaction: IssueThreadInteraction): RequestCheckboxConfirmationInteraction | null {
return interaction.kind === "request_checkbox_confirmation" && !interaction.payload.rejectRequiresReason &&
interaction.payload.target?.type !== "issue_document" && interaction.payload.options.length <= 100 ? interaction : null;
}
/**
* Slack and Telegram can render ordinary binary confirmations as inline buttons.
* Confirmations that collect a rejection reason or authorize a credential,
* connection, or tool side effect stay in Paperclip, where the complete
* governed review UI and permission checks are available.
*/
export function nativeChatConfirmation(
interaction: IssueThreadInteraction,
): RequestConfirmationInteraction | null {
if (interaction.kind !== "request_confirmation") return null;
if (
interaction.payload.rejectRequiresReason === true ||
interaction.payload.toolAction !== undefined ||
interaction.payload.secretProposal !== undefined ||
interaction.payload.connectionAuthorization !== undefined ||
interaction.payload.target?.type === "issue_document"
) {
return null;
}
return interaction;
}
/**
* Enqueues one immutable outbox item per live external task binding. A lone
* closed single-select question receives compact executable buttons. Slack and
* Teams can also receive an opaque modal opener for safe text and closed
* single-select forms. Unsupported question shapes stay link-only.
*/
export async function enqueueIssueInteractionChatPublications(
db: ChatPublicationDb,
interaction: IssueThreadInteraction,
) {
if (interaction.status !== "pending") return [];
// An endpoint is one immutable provider bot identity. Never externalize a
// user/system-authored interaction, or let one agent speak through another
// agent's endpoint. If the interaction names a source run, verify that run's
// company and agent instead of trusting the denormalized creator alone.
if (!interaction.createdByAgentId) return [];
if (interaction.sourceRunId) {
const sourceRun = await db
.select({ agentId: heartbeatRuns.agentId })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, interaction.sourceRunId),
eq(heartbeatRuns.companyId, interaction.companyId),
eq(heartbeatRuns.agentId, interaction.createdByAgentId),
),
)
.then((rows) => rows[0] ?? null);
if (!sourceRun) return [];
}
const bindings = await db
.select({
conversation: chatConversations,
endpoint: chatEndpoints,
})
.from(chatConversations)
.innerJoin(
chatEndpoints,
and(
eq(chatEndpoints.companyId, chatConversations.companyId),
eq(chatEndpoints.id, chatConversations.endpointId),
eq(chatEndpoints.publicationMode, "automatic"),
),
)
.where(
and(
eq(chatConversations.companyId, interaction.companyId),
eq(chatConversations.issueId, interaction.issueId),
inArray(chatConversations.state, ["active", "waiting"]),
inArray(chatEndpoints.status, ["active", "verifying"]),
),
);
if (bindings.length === 0) return [];
const taskUrl = publicChatInteractionTaskUrl(interaction.issueId);
const inserted: Array<typeof chatPublications.$inferSelect> = [];
for (const { conversation, endpoint } of bindings) {
if (endpoint.assignedAgentId !== interaction.createdByAgentId) continue;
if (endpoint.provider !== "slack" && interaction.kind !== "ask_user_questions" && interaction.kind !== "request_confirmation") continue;
let connectionDecline = endpoint.provider === "slack" && endpoint.capabilities.actions && interaction.kind === "connection_intent" ? interaction : null;
let suggestions = endpoint.provider === "slack" && endpoint.capabilities.actions ? nativeChatSuggestions(interaction) : null;
const suggestedOptions = suggestions?.payload.tasks.filter(task => !task.hiddenInPreview).map(task => ({ actionId: createChatQuestionOptionActionToken(), option: { id: task.clientKey, label: task.title, description: task.description } })) ?? [];
let checklist = endpoint.provider === "slack" && endpoint.capabilities.actions ? nativeChatCheckbox(interaction) : null;
const checklistOptions = checklist?.payload.options.map(option => ({ actionId: createChatQuestionOptionActionToken(), option })) ?? [];
const customQuestion = endpoint.provider === "slack" && endpoint.capabilities.modals === true && interaction.kind === "ask_user_questions" ? nativeChatCustomQuestion(interaction) : null;
const multipleQuestion = endpoint.provider === "slack" && interaction.kind === "ask_user_questions" ? nativeChatMultiQuestion(interaction) : null;
const question = interaction.kind === "ask_user_questions"
? multipleQuestion ?? customQuestion ?? nativeChatQuestion(interaction, endpoint.provider === "slack" ? 100 : MAX_NATIVE_QUESTION_OPTIONS) : null;
const inlineSelection = endpoint.provider === "slack" && question && (multipleQuestion !== null || question.options.length > 5);
const multipleActionId = multipleQuestion ? createChatQuestionOptionActionToken() : null;
const presentation = chatInteractionPresentation(interaction, { compactChoices: Boolean(inlineSelection || checklist || suggestedOptions.length > 1), nativeCustomAnswer: Boolean(customQuestion) });
const requiresBoard = endpoint.provider === "slack" && slackInteractionRequiresBoard(presentation.body);
if (requiresBoard) {
connectionDecline = null;
suggestions = null;
checklist = null;
}
const formInteraction = chatInteractionForm(interaction, endpoint.provider);
const formDraft =
!requiresBoard && (!inlineSelection || customQuestion) && formInteraction &&
(endpoint.provider === "slack" ||
endpoint.provider === "discord" ||
endpoint.provider === "microsoft-teams") &&
endpoint.capabilities.actions === true &&
endpoint.capabilities.modals === true
? createChatQuestionFormDraft(
formInteraction,
(endpoint.provider === "discord" || endpoint.provider === "slack")
? { nativeProvider: endpoint.provider }
: {},
)
: null;
const supportsCallbacks =
!requiresBoard && endpoint.provider !== "imessage-photon" &&
(formDraft === null || customQuestion !== null) &&
question !== null &&
endpoint.capabilities.actions === true;
const questionActionTokens = supportsCallbacks
? question.options.map((option) => ({
actionId: createChatQuestionOptionActionToken(),
option,
}))
: [];
const confirmation =
!requiresBoard && (endpoint.provider === "telegram" || endpoint.provider === "slack") && endpoint.capabilities.actions === true
? nativeChatConfirmation(interaction) ?? checklist
: null;
const confirmationActionTokens = confirmation || suggestions || connectionDecline
? (connectionDecline ? ["reject"] as const : ["accept", "reject"] as const).map((decision) => ({
actionId: createChatConfirmationActionToken(),
decision,
}))
: [];
if (
endpoint.provider === "telegram" &&
[...questionActionTokens, ...confirmationActionTokens].some(
({ actionId }) =>
telegramCallbackDataByteLength(actionId) >
TELEGRAM_CALLBACK_DATA_LIMIT_BYTES,
)
) {
throw new Error("Generated Telegram question action exceeds 64 bytes");
}
const actions: SafeExternalChatCardAction[] = formDraft
? [
...(customQuestion && !inlineSelection ? questionActionTokens.map(({ actionId, option }) => ({ type: "callback" as const, actionId, label: option.label })) : []),
{
type: "callback" as const,
actionId: formDraft.openActionId,
label: customQuestion ? "Other…" : interaction.kind === "request_item_verdicts" ? "Review decisions" : "Respond",
style: customQuestion ? "default" as const : "primary" as const,
},
]
: supportsCallbacks
? (inlineSelection ? [] : questionActionTokens).map(({ actionId, option }) => ({
type: "callback" as const,
actionId,
label: option.label,
}))
: confirmation || suggestions || connectionDecline
? confirmationActionTokens.filter(item => !(checklist || suggestedOptions.length > 1) || item.decision !== "accept").map(({ actionId, decision }) => ({
type: "callback" as const,
actionId,
label:
decision === "accept"
? (suggestions ? "Create task" : confirmation!.payload.acceptLabel ?? "Accept")
: (connectionDecline ? "Not now" : suggestions ? "Dismiss" : confirmation!.payload.rejectLabel ?? "Reject"),
style:
decision === "accept"
? ("primary" as const)
: (connectionDecline || suggestions ? "default" as const : "danger" as const),
}))
: taskUrl
? [
{
type: "link" as const,
label: "Open in Paperclip",
url: taskUrl,
},
]
: [];
if (connectionDecline && taskUrl) actions.unshift({ type: "link", label: "Review connection", url: taskUrl });
if (suggestions && taskUrl) actions.push({ type: "link", label: "Revise in Paperclip", url: taskUrl });
const text = [presentation.title, presentation.body, taskUrl ? `Open the task in Paperclip to respond: ${taskUrl}` : "Open the task in Paperclip to respond."].join("\n\n");
const payload = projectSafeChatPublication({
classification: "external",
source: "issue_interaction",
text,
progressState: "waiting_for_input",
interaction: {
id: interaction.id,
card: {
kind:
interaction.kind === "ask_user_questions"
? "question"
: interaction.kind === "request_confirmation"
? "confirmation"
: "status",
title: interaction.kind === "ask_user_questions" && question ? question.prompt : presentation.title,
body: presentation.body,
actions,
...(suggestions && suggestedOptions.length > 1 ? { selection: {
actionId: confirmationActionTokens.find(item => item.decision === "accept")!.actionId,
label: "Select tasks to create", multiple: true, submitLabel: "Create selected",
options: suggestedOptions.map(({ actionId, option }) => ({ actionId, label: option.label, ...(option.description ? { description: option.description } : {}) })),
} } : {}),
...(checklist ? { selection: {
actionId: confirmationActionTokens.find(item => item.decision === "accept")!.actionId,
label: "Select checks", multiple: true, submitLabel: checklist.payload.acceptLabel ?? "Save checks",
options: checklistOptions.map(({ actionId, option }) => ({ actionId, label: option.label,
...(option.description ? { description: option.description } : {}),
selected: checklist.payload.defaultSelectedOptionIds?.includes(option.id) ?? false })),
} } : {}),
...(inlineSelection && supportsCallbacks ? { selection: {
actionId: multipleActionId ?? `pcs:${interaction.id}`,
label: multipleQuestion ? "Choose one or more" : "Choose an answer",
...(multipleQuestion ? { multiple: true, submitLabel: "Submit answer" } : {}),
options: questionActionTokens.map(({ actionId, option }) => ({ actionId,
label: option.label + (interaction.kind === "ask_user_questions" && interaction.payload.questionSet?.questions.find(item => item.id === question.id)?.options?.find(item => item.id === option.id)?.recommended ? " (recommended)" : ""),
...(option.description ? { description: option.description } : {}) })),
} } : {}),
},
},
});
const rows = await db.transaction(async (tx) => {
await notifyChatPublicationWork(tx);
const rows = await tx
.insert(chatPublications)
.values({
companyId: interaction.companyId,
endpointId: endpoint.id,
conversationId: conversation.id,
issueId: interaction.issueId,
idempotencyKey: `interaction:${interaction.id}:${endpoint.id}`,
payload,
state: "pending",
})
.onConflictDoNothing()
.returning();
const publication = rows[0];
if (publication && endpoint.provider === "imessage-photon" && nativePhotonInteraction(interaction)) {
const reference = randomBytes(9).toString("base64url");
await tx.insert(chatActions).values({ companyId: interaction.companyId, endpointId: endpoint.id, conversationId: conversation.id,
kind: "photon_interaction", providerActionId: `photon:${reference}`,
payload: { version: 1, reference, interactionId: interaction.id, publicationId: publication.id, sessionGeneration: conversation.sessionGeneration,
expiresAt: new Date(publication.createdAt.getTime() + CHAT_QUESTION_ACTION_TOKEN_TTL_MS).toISOString() }, status: "issued" });
} else if (publication && formDraft) {
await tx.insert(chatActions).values(
chatQuestionFormActionRecords(formDraft, {
companyId: interaction.companyId,
endpointId: endpoint.id,
conversationId: conversation.id,
publicationId: publication.id,
}),
);
}
if (publication && supportsCallbacks && multipleQuestion && multipleActionId) {
await tx.insert(chatActions).values({
companyId: interaction.companyId, endpointId: endpoint.id, conversationId: conversation.id,
kind: "question_answer", providerActionId: multipleActionId, status: "issued",
payload: { version: 1, publicationId: publication.id, interactionId: interaction.id, questionId: multipleQuestion.id,
selectionOptions: questionActionTokens.map(({ actionId, option }) => ({ value: actionId, optionId: option.id })),
expiresAt: new Date(publication.createdAt.getTime() + CHAT_QUESTION_ACTION_TOKEN_TTL_MS).toISOString() },
});
} else if (publication && question && questionActionTokens.length > 0) {
const expiresAt = new Date(
publication.createdAt.getTime() + CHAT_QUESTION_ACTION_TOKEN_TTL_MS,
).toISOString();
await tx.insert(chatActions).values(
questionActionTokens.map(({ actionId, option }) => ({
companyId: interaction.companyId,
endpointId: endpoint.id,
conversationId: conversation.id,
kind: "question_answer",
providerActionId: actionId,
payload: {
version: 1,
publicationId: publication.id,
interactionId: interaction.id,
questionId: question.id,
optionId: option.id,
expiresAt,
},
status: "issued",
})),
);
} else if (
publication &&
(confirmation || suggestions || connectionDecline) &&
confirmationActionTokens.length > 0
) {
const expiresAt = new Date(
publication.createdAt.getTime() + CHAT_QUESTION_ACTION_TOKEN_TTL_MS,
).toISOString();
await tx.insert(chatActions).values(
confirmationActionTokens.map(({ actionId, decision }) => ({
companyId: interaction.companyId,
endpointId: endpoint.id,
conversationId: conversation.id,
kind: "confirmation_response",
providerActionId: actionId,
payload: {
version: 1,
publicationId: publication.id,
interactionId: interaction.id,
decision,
...(suggestions ? { selectionOptions: suggestedOptions.map(({ actionId, option }) => ({ value: actionId, optionId: option.id })) } : {}),
...(checklist ? { selectionOptions: checklistOptions.map(({ actionId, option }) => ({ value: actionId, optionId: option.id })) } : {}),
expiresAt,
},
status: "issued",
})),
);
}
return rows;
});
inserted.push(...rows);
}
return inserted;
}
/**
* Settles every delivered question or confirmation card. Providers without a
* native callback receive the same actionless terminal edit/follow-up as
* providers with buttons, so an "Open in Paperclip" prompt never remains
* visibly pending after the authoritative board decision.
*
* The terminal publication shares the provider callback idempotency key. If a
* provider click wins the race, its handler converges on the same row; if the
* Paperclip UI wins, all still-issued callback tokens expire in this same
* authoritative resolution transaction.
*/
export async function enqueueTerminalIssueInteractionChatPublications(
db: ChatPublicationDb,
interaction: IssueThreadInteraction,
) {
if (interaction.status === "pending") return [];
const originals = (
await db
.select()
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, interaction.companyId),
eq(chatPublications.issueId, interaction.issueId),
eq(
sql<string>`${chatPublications.payload}->>'interactionId'`,
interaction.id,
),
),
)
).filter(
(publication) =>
publication.idempotencyKey ===
`interaction:${interaction.id}:${publication.endpointId}`,
);
if (originals.length === 0) return [];
await db
.update(chatActions)
.set({
status: "expired",
result: { code: "interaction_resolved_elsewhere" },
updatedAt: new Date(),
})
.where(
and(
eq(chatActions.companyId, interaction.companyId),
inArray(chatActions.kind, [
"photon_interaction",
"question_answer",
"question_form_open",
"question_form_submit",
"confirmation_response",
"photon_interaction",
]),
eq(chatActions.status, "issued"),
eq(
sql<string>`${chatActions.payload}->>'interactionId'`,
interaction.id,
),
),
);
const unsentIds = originals
.filter(
(publication) =>
publication.state === "pending" || publication.state === "retry",
)
.map((publication) => publication.id);
if (unsentIds.length > 0) {
await db.transaction(async (tx) => {
await notifyChatPublicationWork(tx);
return tx
.update(chatPublications)
.set({
state: "cancelled",
nextAttemptAt: null,
redactedError: "Interaction was resolved before provider publication",
updatedAt: new Date(),
})
.where(
and(
inArray(chatPublications.id, unsentIds),
inArray(chatPublications.state, ["pending", "retry"]),
),
);
});
}
const originalIds = originals.map((publication) => publication.id);
// The dispatcher may claim pending -> streaming after the first read but
// before the cancellation CAS. Re-read every executable original after that
// CAS so a loser is treated as potentially provider-visible and receives a
// terminal replacement instead of being decided from the stale snapshot.
// A published row without a provider id is also potentially visible: that
// is the durable result of an operator choosing `mark_delivered` after an
// ambiguous send, so resolution must post a terminal follow-up rather than
// leaving the external card looking actionable forever.
const currentOriginals =
originalIds.length > 0
? await db
.select()
.from(chatPublications)
.where(inArray(chatPublications.id, originalIds))
: [];
const providerVisibleOriginals = currentOriginals.filter(
(original) =>
original.state === "streaming" ||
original.state === "delivery_unknown" ||
original.state === "published",
);
const planTarget =
interaction.kind === "request_confirmation" &&
interaction.payload.target?.type === "issue_document" &&
interaction.payload.target.issueId === interaction.issueId &&
interaction.payload.target.key === "plan"
? interaction.payload.target
: null;
const rejectedPlanNeedsRevision =
interaction.status === "rejected" && planTarget !== null;
const resolutionOutcome = (interaction.result as { outcome?: unknown } | null)
?.outcome;
const continuationWakeRequired =
(interaction.kind === "ask_user_questions" || interaction.kind === "request_confirmation" || interaction.kind === "request_item_verdicts") &&
interaction.status !== "expired" &&
resolutionOutcome !== "skipped" &&
!(
interaction.kind === "ask_user_questions" &&
interaction.status === "answered"
) &&
(interaction.continuationPolicy === "wake_assignee" ||
(interaction.continuationPolicy === "wake_assignee_on_accept" &&
(interaction.status === "accepted" ||
interaction.status === "answered")) ||
rejectedPlanNeedsRevision);
if (continuationWakeRequired && currentOriginals.length > 0) {
const issue = await db
.select({
assigneeAgentId: issues.assigneeAgentId,
status: issues.status,
})
.from(issues)
.where(
and(
eq(issues.companyId, interaction.companyId),
eq(issues.id, interaction.issueId),
),
)
.then((rows) => rows[0] ?? null);
const requestedByActorType = interaction.resolvedByUserId
? "user"
: interaction.resolvedByAgentId
? "agent"
: "system";
const requestedByActorId =
interaction.resolvedByUserId ??
interaction.resolvedByAgentId ??
"system:interaction-resolution";
const wakeBinding = currentOriginals[0]!;
if (
issue?.assigneeAgentId &&
issue.status !== "done" &&
issue.status !== "cancelled" &&
!(
resolutionOutcome === "withdrawn" &&
interaction.resolvedByAgentId === issue.assigneeAgentId
)
) {
await db.transaction(async tx => {
await notifyChatContinuationWork(tx, { companyId: interaction.companyId, issueId: interaction.issueId });
await tx
.insert(chatActions)
.values({
companyId: interaction.companyId,
endpointId: wakeBinding.endpointId,
conversationId: wakeBinding.conversationId,
kind: "interaction_wakeup",
providerActionId: `interaction_wakeup:${interaction.id}`,
payload: {
version: 1,
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
issueId: interaction.issueId,
agentId: issue.assigneeAgentId,
sourceCommentId: interaction.sourceCommentId ?? null,
sourceRunId: interaction.sourceRunId ?? null,
requestedByActorType,
requestedByActorId,
...(planTarget
? {
planReviewInteraction: {
id: interaction.id,
kind: interaction.kind,
status: interaction.status,
target: planTarget,
acceptedTargetRevision:
interaction.status === "accepted" ? planTarget : null,
result: interaction.result,
},
}
: {}),
...(interaction.status === "accepted" && planTarget
? {
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
}
: {}),
...(interaction.resolvedByUserId
? { requestedByUserId: interaction.resolvedByUserId }
: {}),
},
status: "issued",
})
.onConflictDoNothing();
});
}
}
const copy = terminalNativeInteractionCopy(interaction);
if (!copy) return [];
const inserted: Array<typeof chatPublications.$inferSelect> = [];
for (const original of providerVisibleOriginals) {
const card = original.payload.card;
if (!card) continue;
const rows = await db.transaction(async (tx) => {
await notifyChatPublicationWork(tx);
return tx
.insert(chatPublications)
.values({
companyId: interaction.companyId,
endpointId: original.endpointId,
conversationId: original.conversationId,
issueId: interaction.issueId,
idempotencyKey: `interaction-resolution:${interaction.id}:${original.endpointId}`,
payload: projectSafeChatPublication({
classification: "external",
source: "issue_interaction",
text: copy.text,
interaction: {
id: interaction.id,
card: {
kind: card.kind,
title: card.title,
body: copy.body,
actions: [],
},
},
}),
state: "pending",
})
.onConflictDoNothing()
.returning();
});
inserted.push(...rows);
}
return inserted;
}
/** Prevents an unstarted stale question card from being sent after replacement. */
export async function cancelPendingIssueInteractionChatPublications(
db: ChatPublicationDb,
input: {
companyId: string;
issueId: string;
interactionIds: readonly string[];
},
) {
if (input.interactionIds.length === 0) return [];
await db
.update(chatActions)
.set({
status: "expired",
result: { code: "interaction_superseded_before_publication" },
updatedAt: new Date(),
})
.where(
and(
eq(chatActions.companyId, input.companyId),
inArray(chatActions.kind, [
"question_answer",
"question_form_open",
"question_form_submit",
"confirmation_response",
"photon_interaction",
]),
eq(chatActions.status, "issued"),
inArray(sql<string>`${chatActions.payload}->>'interactionId'`, [
...input.interactionIds,
]),
),
);
return db.transaction(async (tx) => {
await notifyChatPublicationWork(tx);
return tx
.update(chatPublications)
.set({
state: "cancelled",
nextAttemptAt: null,
redactedError: "Interaction was superseded before publication",
updatedAt: new Date(),
})
.where(
and(
eq(chatPublications.companyId, input.companyId),
eq(chatPublications.issueId, input.issueId),
inArray(chatPublications.state, ["pending", "retry"]),
inArray(sql<string>`${chatPublications.payload}->>'interactionId'`, [
...input.interactionIds,
]),
),
)
.returning();
});
}