Files
PaperClipAI/server/src/services/chat-interaction-publications.ts
T
DottaandPaperclip 19e9facc5b perf(chat): wake delivery queues from commits and deadlines (#15802)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Chat connectors store incoming messages and outgoing publications in
durable queues.
> - The app checks these queues every second, even when they are empty.
> - Empty scans keep the database active and hinder instance sleep.
> - This pull request wakes three queue workers after transactions
settle and at known retry or recovery deadlines.
> - Empty queues stop scheduling checks. Existing rows retain their
ordering, retry, and recovery rules.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Idle database activity from incoming chat deliveries, outgoing
publications, and Slack upload receipt recovery.

**Subsystem affected**

`server/` — chat delivery and background work scheduling.

**Current behavior**

The app's one-second chat timer scans the three queues together with
provider and action maintenance. Empty queues continue to query
PostgreSQL.

**Proposed behavior**

Register three independent workers with the existing delivery
coordinator. Producers announce work inside their database transactions.
Commit wakes the corresponding worker. Startup restores outstanding
work; persisted retries and stale claims supply the next deadline. An
empty queue has no recurring scan.

**Reason and benefit**

Remove avoidable queue reads without requiring a new table, an external
scheduler, or a persistent PostgreSQL listener.

**Breaking changes**

No schema or public API change. Work notifications are scoped to the
app's shared database owner. Direct SQL and independent database roots
do not send these notifications; startup still scans durable work.

**Additional context**

Refs #15625, #15750, and #15757 for the preceding sleep work. Related:
#15153 reduces chat query allocation while retaining polling. This PR
changes when the three queue workers run; it does not add query caching.

## What Changed

- Register independent incoming delivery, publication, and Slack receipt
workers on the shared scheduler.
- Add transaction work signals at queue writes, endpoint state changes,
and action transitions that unblock publications. Keep provider I/O
outside the added transactions.
- Restore deadlines from durable retry times, abandoned claims, and
publication lease expiry. Share publication and receipt eligibility
rules between dispatch and deadline queries. Respect incoming
conversation order and lifecycle dependencies.
- Exclude publications blocked by unresolved Slack tool sends. Wake them
when the tool receipt changes.
- Wake publication dispatch when an endpoint send finishes so free slots
refill. Preserve per-endpoint concurrency, conversation order, and
conservative handling of uncertain sends.
- Wake receipt recovery when a stale publication becomes
`delivery_unknown`, and wake queues when a provider connection recovers.
- Commit interaction publications with their callback tokens so dispatch
cannot see an incomplete card.
- Keep provider runtimes, other action outboxes, milestone and
wake-notice projection, Teams protocol maintenance, GitHub recovery, and
Slack session synchronization on their existing maintenance cadence.
This PR does not make the whole chat subsystem idle.
- Update the instance sleep inventory with this partial migration and
its remaining work.

## Verification

- All 1,225 PostgreSQL chat integration cases passed across the three
hosted chat shards. These cover retry and lease deadlines, blocked
followers, unresolved Slack sends, provider recovery, commit visibility,
and endpoint slot refill.
- 32 local app-wiring, queue-worker, and delivery-coordinator tests
passed after updating the wiring assertion for event-driven dispatch.
Fake timers cover an hour of idle time without queue scans.
- Related publication, interaction, receipt, and wake-notice tests
passed locally. The 13 interaction integration cases include atomic
rollback when callback-token storage fails.
- Local `pnpm typecheck`, `pnpm build`, and final server typecheck
passed. All hosted checks passed on the final commit, including build,
typecheck, test shards, release registry checks, and the canary dry run
(54 successful checks; two Storybook checks skipped for this backend
change).
- The last local PostgreSQL rerun was skipped because the embedded
database startup probe failed after five attempts. The hosted PostgreSQL
cases ran successfully; their skips only partition cases across shards.
- `git diff --check` passed. Greptile is 5/5 on the final commit with no
unresolved findings.

## Risks

- Every producer that makes queued work eligible must send a transaction
work signal. The tests exercise commit visibility, concurrent wakes,
worker failures, and restart recovery. Future direct SQL writers must
participate in this contract.
- The existing database work-signal layer reconciles uncertain
transaction outcomes. This PR adds no work-signal table or schema
migration.
- Provider and action maintenance still polls. Further changes are
required before an idle chat-enabled instance can sleep.
- Tests use real PostgreSQL with simulated provider responses; they do
not send messages through live provider accounts.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, tool use, and code
execution. The exact serving model ID 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
- [ ] 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-10 09:13:44 -05:00

809 lines
28 KiB
TypeScript

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,
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 !== "ask_user_questions") return 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}.` : "Answered.";
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,
): 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 > MAX_NATIVE_QUESTION_OPTIONS ||
question.options.some((option) => option.freeText === true)
) {
return null;
}
return question;
}
/**
* Telegram can safely 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 nativeTelegramConfirmation(
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;
}
function textForQuestionInteraction(
interaction: AskUserQuestionsInteraction,
taskUrl: string | null,
): string {
const lines = [
interaction.payload.title ?? interaction.title ?? "Input needed",
"",
];
for (const question of interaction.payload.questions) {
lines.push(question.prompt);
for (const option of question.options) lines.push(`- ${option.label}`);
lines.push("");
}
lines.push(
taskUrl
? `Open the task in Paperclip to respond: ${taskUrl}`
: "Open the task in Paperclip to respond.",
);
return lines.join("\n");
}
function genericInteractionText(taskUrl: string | null): string {
return [
"This task needs an authorized response in Paperclip.",
taskUrl
? `Open the task in Paperclip to respond: ${taskUrl}`
: "Open the task in Paperclip to respond.",
]
.filter((value): value is string => Boolean(value))
.join("\n\n");
}
/**
* 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 [];
// The first native-chat wave intentionally externalizes only questions and
// confirmations. Other governance interactions have richer partial and
// terminal semantics that are authoritative in Paperclip; projecting a
// generic link card without complete settlement/recovery would leave stale
// provider prompts after a board decision.
if (
interaction.kind !== "ask_user_questions" &&
interaction.kind !== "request_confirmation"
) {
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 question =
interaction.kind === "ask_user_questions"
? nativeChatQuestion(interaction)
: null;
const inserted: Array<typeof chatPublications.$inferSelect> = [];
for (const { conversation, endpoint } of bindings) {
if (endpoint.assignedAgentId !== interaction.createdByAgentId) continue;
const formDraft =
interaction.kind === "ask_user_questions" &&
(endpoint.provider === "slack" ||
endpoint.provider === "discord" ||
endpoint.provider === "microsoft-teams") &&
endpoint.capabilities.actions === true &&
endpoint.capabilities.modals === true
? createChatQuestionFormDraft(
interaction,
endpoint.provider === "discord"
? { nativeProvider: "discord" }
: {},
)
: null;
const supportsCallbacks =
endpoint.provider !== "imessage-photon" &&
formDraft === null &&
question !== null &&
endpoint.capabilities.actions === true;
const questionActionTokens = supportsCallbacks
? question.options.map((option) => ({
actionId: createChatQuestionOptionActionToken(),
option,
}))
: [];
const confirmation =
endpoint.provider === "telegram" && endpoint.capabilities.actions === true
? nativeTelegramConfirmation(interaction)
: null;
const confirmationActionTokens = confirmation
? (["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
? [
{
type: "callback" as const,
actionId: formDraft.openActionId,
label: "Respond",
style: "primary" as const,
},
]
: supportsCallbacks
? questionActionTokens.map(({ actionId, option }) => ({
type: "callback" as const,
actionId,
label: option.label,
}))
: confirmation
? confirmationActionTokens.map(({ actionId, decision }) => ({
type: "callback" as const,
actionId,
label:
decision === "accept"
? (confirmation.payload.acceptLabel ?? "Accept")
: (confirmation.payload.rejectLabel ?? "Reject"),
style:
decision === "accept"
? ("primary" as const)
: ("danger" as const),
}))
: taskUrl
? [
{
type: "link" as const,
label: "Open in Paperclip",
url: taskUrl,
},
]
: [];
const text =
interaction.kind === "ask_user_questions"
? textForQuestionInteraction(interaction, taskUrl)
: genericInteractionText(taskUrl);
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?.prompt ??
interaction.payload.title ??
interaction.title ??
"Input needed")
: interaction.kind === "request_confirmation"
? interaction.payload.prompt
: "Response needed in Paperclip",
body:
interaction.kind === "ask_user_questions"
? (question?.helpText ?? undefined)
: interaction.kind === "request_confirmation"
? (interaction.payload.detailsMarkdown ?? undefined)
: "Open the task in Paperclip to review and respond.",
actions,
},
},
});
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,
}),
);
} 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 &&
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,
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.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
.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();
});
}