mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
fix(server): wake terminal confirmation recovery from committed changes (#17651)
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/202/changes/paperclip/202 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip persists confirmation clicks and interaction decisions before dispatch completes. > - A crash after a decision commits can leave a processing confirmation action. > - Periodic publication maintenance repeatedly scans those actions even when idle. > - Saved decisions and the supported board/admin writers already identify when settlement can change. > - This owner reconstructs durable actions, reconciles real changes and then becomes quiet. ## Linked Issues or Issue Description This existing terminal-confirmation slice follows merged #17649. It removes only terminal-confirmation recovery from periodic chat maintenance while retaining the explicit manual API and existing durable action/interaction/publication rows. **Problem:** An accepted response can commit before its dispatch settles. Removing periodic recovery without covering board decisions, expiry and ownership changes would strand processing actions. Pending human decisions must remain recoverable without polling indefinitely. **Expected behavior:** Committed supported changes wake the scoped owner. It reconciles terminal responses once, parks unresolved human decisions, preserves concurrent commits/restart/drain and becomes quiet when settled. ## What Changed - Register terminal-confirmation work with the existing delivery coordinator and scoped lifecycle. Reconstruct processing action IDs, bound selection pages, preserve concurrent wakes and keep current authorization in the existing interaction owner. - Add the existing transaction-aware confirmation notification topic at real resolution writes and callback settlement. Reuse milestone signals instead of adding per-caller recovery wrappers. - Cover ordinary and checkbox confirmations, suggestions and connection intents through their owning service. Cover board accept/reject, cancellation/withdrawal, opt-in comment supersession, expiry, reassignment and already-satisfied connection resolution. - Preserve task/actor authority, one-use callback tokens and immutable provider publication semantics. Explicitly fail malformed missing bindings instead of silently retaining unrecoverable processing actions. - Remove only this duty from recurring publication maintenance. Add no table or scheduler infrastructure. A concurrent partial index on existing action rows prevents recovery discovery from scanning completed history. ## Verification Published repair head: `a35e78d076c1b618bc643391b7520fa6952fc996`, targeting master after normal integration of `644937c93150c423f12674992c7ea1e8b5dbc98c`. [Detailed acceptance and preserved failures](https://github.com/paperclipai/paperclip/blob/a35e78d076c1b618bc643391b7520fa6952fc996/doc/plans/2026-10-25-terminal-confirmation-sleep-audit.md#bound-retained-history-reads--october-28-review-repair). - **New query-cost regression:** Capture the actual SQL emitted by startup and company-scoped confirmation discovery. Seed 20,000 completed/unrelated action rows and one pending human decision. Before the index, the regression fails because recovery examines 20,009 action rows. After the partial index, startup and scoped discovery must examine at most two action rows and fewer than 20 table/index buffers; the no-pending scoped path must also stay below 20 buffers. Planner settings are unchanged, and returned IDs remain correct. - Match startup keyset ordering to `(company_id, id)`, the partial index order. The index alone still let PostgreSQL choose the primary key and scan 20,206 rows for `ORDER BY id`; the final query fixes this without forcing a planner setting or adding a second index. Fixed kind/status predicates stay literal for prepared-plan index eligibility. - Add the generated `0398_yielding_random.sql` migration, using `CREATE INDEX CONCURRENTLY IF NOT EXISTS` and the existing top-level migration runner. Keep the five newest generated snapshots and preserve every prior SQL/journal entry. No work table is introduced. - **110 distinct cases pass:** 17 final confirmation lifecycle/query-cost cases (1,671 skipped), seven real Runner descriptor variants (295 skipped), and all 86 app/startup/bundled-plugin/plugin-database cases. Server/database typechecking and builds, migration safety/numbering, module boundaries and a fresh PostgreSQL migration through 0398 pass. The final test-only vacuum precondition is followed by another 17-case confirmation pass and server typecheck. Counts are not added across repeated runs. Normal integration of master `644937c93150c423f12674992c7ea1e8b5dbc98c` preserves the complete owned patch and all 18 unrelated master paths. - Previous landed-parent acceptance: 29 distinct cases (16 confirmation lifecycle/producer cases with 1,671 skipped, plus all 13 startup cases) passed on `32e64a7e9ea5b457e54ca7b268be90e1e155b451`; the earlier 80-case matrix remains scoped to `c1e8542b588a4fa59b62f9829541d3daea4f1ddc`. Counts are not added across repeated runs. - Actual producer cases prove parking/quiet, caller-owned outer transaction rollback, commit-driven automatic settlement, restart/drain and concurrent commits. Checkbox, suggestion, connection-intent and Telegram/Slack callback paths retain existing authority and immutable publication semantics. - **Fresh exact-head CI and Greptile 5/5 with no unresolved actionable findings are required.** The preceding head's Runner CI failed its timeout-attachment durable-suspension case (1 failed/1,955 passed/4 skipped); that failure is retained and not counted as a pass. The new head must pass this job. No full local repository, live provider or physical suspension claim is made. The owner audit retains the earlier missing-board-producer regressions and their corrections, including the invalid supersession fixture that assumed a new checkbox expires an older one. Existing behavior was preserved by using the supported opt-in human-comment path. Prior scheduler/backoff, route and plugin test failures remain at their original revisions; no old pass is substituted for current remote gates. ## Risks - Process-local notifications cover supported application writers and their shared pools, not independent SQL writers or other replicas. - Processing actions waiting on a human response are deliberately parked. Supported interaction resolution/expiry writers must use the notification boundary; a missing producer delays settlement until restart. - Invalid bindings have explicit failure evidence. Source, actor, task and publication authority remain checked by their existing owners. - Following continuation/GitHub duties remain separate. Cloud sleep gates and external alarms are unchanged; local worker quiet does not establish physical host/database suspension or fleet savings. ## Model Used OpenAI GPT-6 / Codex, with reasoning, repository inspection, code execution, and PostgreSQL tests. The exact runtime 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>
This commit is contained in:
1 parent
60b8089a44
commit
ee67d2ece9
19 files changed
+1192
-39
No files matched your search
@@ -3350,7 +3350,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
|
||||
async function processPublicationMaintenance(limit = 25) {
|
||||
scheduleGitHubMaintenance();
|
||||
await reconcileTerminalConfirmationActions(limit);
|
||||
await processPendingInteractionWakeups(limit);
|
||||
}
|
||||
|
||||
@@ -3479,7 +3478,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
const { processPendingInteractionWakeups } = createChatInteractionContinuations(db, {
|
||||
redactError, CONFIRMATION_WAKEUP_RETRY_BACKOFF_MS, get heartbeat() { return options.heartbeat; },
|
||||
});
|
||||
const { handleAction, handleModalSubmit, reconcileTerminalConfirmationActions } = createChatInteractionCallbacks(db, {
|
||||
const { handleAction, handleModalSubmit, reconcileTerminalConfirmationActions, registerTerminalConfirmationWork } = createChatInteractionCallbacks(db, {
|
||||
heartbeat: options.heartbeat,
|
||||
CAPABILITIES, MAX_ERROR_TEXT, actionThreadMatchesConversation, nonDirectDestinationAllowed, sameTeamsThreadIdentity,
|
||||
ensurePrincipal, endpointRecord, runtimeCallbackRecord, runtimeCallbackEndpoint,
|
||||
@@ -3503,6 +3502,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
|
||||
const { prepareTeamsBoardSend } = createTeamsBoardSendPreparation(db, { endpointRecord, runtimeFor });
|
||||
const { getPublicationBatchStatus, publishComment, publishBoardMessage } = createChatPublicationRequests(db, { endpointRecord, stageTeamsBoardFileIntent, processPendingSlackBoardMessages, processPendingPublications, prepareTeamsBoardSend });
|
||||
return {
|
||||
registerTerminalConfirmationWork,
|
||||
loadFailedRetryNoticeScopes,
|
||||
reconcileFailedRetryNotices,
|
||||
registerTeamsAttemptRecoveryWork,
|
||||
|
||||
@@ -145,7 +145,7 @@ type InteractionCallbacksOptions = {
|
||||
export function createChatInteractionCallbacks(db: Db, options: InteractionCallbacksOptions) {
|
||||
const { CAPABILITIES, MAX_ERROR_TEXT, actionThreadMatchesConversation, nonDirectDestinationAllowed, sameTeamsThreadIdentity, ensurePrincipal, endpointRecord, runtimeCallbackRecord, runtimeCallbackEndpoint, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, recordCurrentMicrosoftTeamsRoute, lockTelegramCallbackNoticeAuthority, stageProviderEffect, scheduleProviderEffect, invalidateRuntime, redactError, persistence, questionResponses, scheduleMessageProcessing, processPendingPublications } = options;
|
||||
const { handleDiscordFormCorrection, acceptDiscordQuestionForm } = createDiscordQuestionForms(db, { requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, persistence });
|
||||
const { reconcileTerminalConfirmationActions, handleChatConfirmation } = createChatConfirmationActions(db, {
|
||||
const { reconcileTerminalConfirmationActions, handleChatConfirmation, registerTerminalConfirmationWork } = createChatConfirmationActions(db, {
|
||||
heartbeat: options.heartbeat, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications,
|
||||
get confirmationResolutionPersistBarrier() { return options.confirmationResolutionPersistBarrier; },
|
||||
});
|
||||
@@ -2029,5 +2029,5 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb
|
||||
});
|
||||
return acceptedResponse();
|
||||
}
|
||||
return { handleAction, handleModalSubmit, reconcileTerminalConfirmationActions };
|
||||
return { handleAction, handleModalSubmit, reconcileTerminalConfirmationActions, registerTerminalConfirmationWork };
|
||||
}
|
||||
@@ -3,12 +3,20 @@ import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "../.
|
||||
import { chatInteractionPresentation } from "../../chat-interaction-presentation.js";
|
||||
import type { DbTransaction, RuntimeContext, LifecycleRuntimeFence } from "../runtime-types.js";
|
||||
|
||||
import type { createDeliveryWorkCoordinator } from "../../delivery-work-coordinator.js";
|
||||
import { registerScopedDeadlineWork } from "../../scoped-deadline-work.js";
|
||||
import { DELIVERY_QUEUES } from "../../delivery-work-notifications.js";
|
||||
import { notifyChatConfirmationWork, type ChatConfirmationScope } from "../../chat-confirmation-notifications.js";
|
||||
|
||||
import { notifyChatPublicationWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
eq,
|
||||
gt,
|
||||
isNull,
|
||||
or,
|
||||
inArray,
|
||||
ne,
|
||||
sql,
|
||||
@@ -176,6 +184,7 @@ export function createChatConfirmationActions(db: Db, options: ChatConfirmationO
|
||||
});
|
||||
},
|
||||
afterResolveInTransaction: async (tx: DbTransaction, resolved: import("@paperclipai/shared").IssueThreadInteraction) => {
|
||||
await notifyChatConfirmationWork(tx, { companyId: action.companyId, issueId: conversation.issueId });
|
||||
const [owned] = await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
@@ -286,8 +295,13 @@ export function createChatConfirmationActions(db: Db, options: ChatConfirmationO
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (action?.status === "processed") return true;
|
||||
if (!action || action.status !== "processing" || !action.conversationId)
|
||||
if (!action || action.status !== "processing") return false;
|
||||
if (!action.conversationId) {
|
||||
await db.update(chatActions).set({
|
||||
status: "failed", result: { code: "confirmation_action_binding_invalid" }, updatedAt: new Date(),
|
||||
}).where(and(eq(chatActions.id, action.id), eq(chatActions.status, "processing")));
|
||||
return false;
|
||||
}
|
||||
const token = action.payload;
|
||||
if (
|
||||
token.version !== 1 ||
|
||||
@@ -647,5 +661,44 @@ export function createChatConfirmationActions(db: Db, options: ChatConfirmationO
|
||||
}
|
||||
return settled;
|
||||
}
|
||||
return { settleTerminalConfirmationAction, reconcileTerminalConfirmationActions, handleChatConfirmation };
|
||||
function registerTerminalConfirmationWork(
|
||||
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
|
||||
canRun: () => boolean,
|
||||
) {
|
||||
type Entry = { id: string; companyId: string; issueId: string | null; dueAt: number };
|
||||
return registerScopedDeadlineWork(coordinator, {
|
||||
queue: DELIVERY_QUEUES.chatTerminalConfirmations,
|
||||
decodeScope: (hint: string): ChatConfirmationScope | undefined => hint === "*" ? undefined : JSON.parse(hint),
|
||||
matches: (entry: Entry, scope?: ChatConfirmationScope) => !scope ||
|
||||
(entry.companyId === scope.companyId && (!scope.issueId || !entry.issueId || entry.issueId === scope.issueId)),
|
||||
load: async (scope?: ChatConfirmationScope, signal?: AbortSignal): Promise<Entry[]> => {
|
||||
const entries: Entry[] = [];
|
||||
let cursor: { companyId: string; id: string } | undefined;
|
||||
while (!signal?.aborted) {
|
||||
const rows = await db.select({
|
||||
id: chatActions.id, companyId: chatActions.companyId, issueId: chatConversations.issueId,
|
||||
}).from(chatActions).leftJoin(chatConversations, and(
|
||||
eq(chatConversations.companyId, chatActions.companyId), eq(chatConversations.id, chatActions.conversationId),
|
||||
)).where(and(
|
||||
sql`${chatActions.kind} = 'confirmation_response' AND ${chatActions.status} = 'processing'`,
|
||||
scope ? eq(chatActions.companyId, scope.companyId) : undefined,
|
||||
scope?.issueId ? or(eq(chatConversations.issueId, scope.issueId), isNull(chatConversations.id)) : undefined,
|
||||
cursor ? or(
|
||||
gt(chatActions.companyId, cursor.companyId),
|
||||
and(eq(chatActions.companyId, cursor.companyId), gt(chatActions.id, cursor.id)),
|
||||
) : undefined,
|
||||
)).orderBy(asc(chatActions.companyId), asc(chatActions.id)).limit(100);
|
||||
entries.push(...rows.map(row => ({ ...row, dueAt: 0 })));
|
||||
if (rows.length < 100) return entries;
|
||||
cursor = rows[rows.length - 1];
|
||||
}
|
||||
throw signal?.reason ?? new Error("Confirmation recovery stopped");
|
||||
},
|
||||
check: async (entry: Entry) => {
|
||||
await settleTerminalConfirmationAction(entry.id);
|
||||
return null;
|
||||
},
|
||||
}, canRun);
|
||||
}
|
||||
return { settleTerminalConfirmationAction, reconcileTerminalConfirmationActions, handleChatConfirmation, registerTerminalConfirmationWork };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
export type ChatConfirmationScope = { companyId: string; issueId?: string };
|
||||
|
||||
export async function notifyChatConfirmationWork(tx: object, scope: ChatConfirmationScope) {
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatTerminalConfirmations,
|
||||
JSON.stringify({ companyId: scope.companyId, issueId: scope.issueId }));
|
||||
}
|
||||
|
||||
/** Interaction resolution can release a callback parked while its human input
|
||||
* was pending. Register on the owning transaction before its resolution write. */
|
||||
export async function notifyChatConfirmationResolution(tx: object, interaction: ChatConfirmationScope & { kind: string }) {
|
||||
if (["request_confirmation", "request_checkbox_confirmation", "suggest_tasks", "connection_intent"].includes(interaction.kind)) {
|
||||
await notifyChatConfirmationWork(tx, interaction);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyChatConfirmationWork } from "./chat-confirmation-notifications.js";
|
||||
import { notifyChatInboundWakeNotices } from "./chat-inbound-wake-notice-notifications.js";
|
||||
import { notifyChatNativeProgress } from "./chat-native-progress-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
@@ -13,4 +14,5 @@ export async function notifyChatRunMilestones(tx: object, scope: ChatRunMileston
|
||||
await notifyChatNativeProgress(tx, scope);
|
||||
// Admission and current chat authority also govern saved queue notices.
|
||||
await notifyChatInboundWakeNotices(tx, scope);
|
||||
await notifyChatConfirmationWork(tx, scope);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export const DELIVERY_QUEUES = {
|
||||
chatPublications: "chat-publications",
|
||||
chatNativeProgress: "chat-native-progress",
|
||||
chatInboundWakeNotices: "chat-inbound-wake-notices",
|
||||
chatTerminalConfirmations: "chat-terminal-confirmations",
|
||||
chatFailedRetryNotices: "chat-failed-retry-notices",
|
||||
chatRunMilestones: "chat-run-milestones",
|
||||
chatReceipts: "chat-slack-receipts",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyChatConfirmationResolution } from "./chat-confirmation-notifications.js";
|
||||
import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
|
||||
import { notifyToolReviewChange } from "./tool-review-work-notifications.js";
|
||||
import { normalizeEscapedLineBreaks } from "@paperclipai/shared/validators/text";
|
||||
@@ -1913,6 +1914,7 @@ async function expireStaleRequestConfirmationTarget(
|
||||
snapshot,
|
||||
});
|
||||
const expireInTransaction = async (tx: Db | any) => {
|
||||
await notifyChatConfirmationResolution(tx, args.row);
|
||||
if (args.row.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.row);
|
||||
const [updated] = await tx
|
||||
.update(issueThreadInteractions)
|
||||
@@ -2191,6 +2193,7 @@ export function issueThreadInteractionService(
|
||||
const postCommitActivityPublications: ActivityPublication[] = [];
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor);
|
||||
await notifyChatConfirmationResolution(tx, args.current);
|
||||
if (args.current.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.current);
|
||||
await args.mutationOptions?.beforeResolveInTransaction?.(tx);
|
||||
// Policy mutations and review transitions use the same issue-row lock,
|
||||
@@ -2475,6 +2478,7 @@ export function issueThreadInteractionService(
|
||||
const now = new Date();
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, args.issue, args.actor);
|
||||
await notifyChatConfirmationResolution(tx, args.current);
|
||||
if (args.current.kind === "request_confirmation") await notifyChatRunMilestones(tx, args.current);
|
||||
await args.mutationOptions?.beforeResolveInTransaction?.(tx);
|
||||
const issueContext = await tx
|
||||
@@ -2762,6 +2766,7 @@ export function issueThreadInteractionService(
|
||||
})
|
||||
.map((candidate) => candidate.id);
|
||||
if (supersededIds.length > 0) {
|
||||
await notifyChatConfirmationResolution(tx, { companyId: issue.companyId, issueId: issue.id, kind: "connection_intent" });
|
||||
await tx
|
||||
.delete(toolOauthStates)
|
||||
.where(inArray(toolOauthStates.interactionId, supersededIds));
|
||||
@@ -2860,6 +2865,7 @@ export function issueThreadInteractionService(
|
||||
: "expired";
|
||||
const resolvedAt = now();
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await notifyChatConfirmationResolution(tx, current);
|
||||
await mutationOptions.beforeResolveInTransaction?.(tx);
|
||||
const [row] = await tx
|
||||
.update(issueThreadInteractions)
|
||||
@@ -3294,6 +3300,7 @@ export function issueThreadInteractionService(
|
||||
const cancelled: IssueThreadInteraction[] = [];
|
||||
for (const row of rows) {
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await notifyChatConfirmationResolution(tx, row);
|
||||
if (["ask_user_questions", "request_confirmation"].includes(row.kind)) await notifyChatRunMilestones(tx, row);
|
||||
if (row.kind === "request_confirmation") await notifyToolProjectionForInteractions(tx, row.companyId, [row.id]);
|
||||
const [resolved] = await tx
|
||||
@@ -3380,6 +3387,7 @@ export function issueThreadInteractionService(
|
||||
const expired: IssueThreadInteraction[] = [];
|
||||
for (const { row, replacementInteractionId } of supersededRows) {
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await notifyChatConfirmationResolution(tx, row);
|
||||
if (["ask_user_questions", "request_confirmation"].includes(row.kind)) await notifyChatRunMilestones(tx, row);
|
||||
if (row.kind === "request_confirmation") await notifyToolProjectionForInteractions(tx, row.companyId, [row.id]);
|
||||
const [updatedRow] = await tx
|
||||
@@ -4071,6 +4079,7 @@ export function issueThreadInteractionService(
|
||||
const createdWakeTargets: IssueWakeTarget[] = [];
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await notifyChatConfirmationResolution(tx, current);
|
||||
await mutationOptions.beforeResolveInTransaction?.(tx);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
const resolvedAt = new Date();
|
||||
@@ -4377,6 +4386,7 @@ export function issueThreadInteractionService(
|
||||
}
|
||||
|
||||
const [updated] = await db.transaction(async (tx) => {
|
||||
await notifyChatConfirmationResolution(tx, current);
|
||||
await mutationOptions.beforeResolveInTransaction?.(tx);
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
const rows = await tx.update(issueThreadInteractions)
|
||||
@@ -4468,6 +4478,7 @@ export function issueThreadInteractionService(
|
||||
const expired: IssueThreadInteraction[] = [];
|
||||
for (const row of superseded) {
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await notifyChatConfirmationResolution(tx, row);
|
||||
if (["ask_user_questions", "request_confirmation"].includes(row.kind)) await notifyChatRunMilestones(tx, row);
|
||||
if (row.kind === "connection_intent") {
|
||||
await tx
|
||||
@@ -4644,6 +4655,7 @@ export function issueThreadInteractionService(
|
||||
);
|
||||
if (!sampleConfirmationRow) continue;
|
||||
const updatedInteractions = await db.transaction(async (tx) => {
|
||||
await notifyChatConfirmationResolution(tx, sampleConfirmationRow);
|
||||
if (commentRows.some(row => row.kind === "request_confirmation")) await notifyChatRunMilestones(tx, sampleConfirmationRow);
|
||||
await notifyToolProjectionForInteractions(tx, issue.companyId, confirmationRowIds);
|
||||
const updatedRows = await tx
|
||||
@@ -4705,6 +4717,7 @@ export function issueThreadInteractionService(
|
||||
|
||||
for (const row of connectionIntentRows) {
|
||||
const [updated] = await db.transaction(async (tx) => {
|
||||
await notifyChatConfirmationResolution(tx, row);
|
||||
await tx
|
||||
.delete(toolOauthStates)
|
||||
.where(eq(toolOauthStates.interactionId, row.id));
|
||||
@@ -4790,6 +4803,7 @@ export function issueThreadInteractionService(
|
||||
document,
|
||||
});
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await notifyChatConfirmationResolution(tx, row);
|
||||
if (row.kind === "request_confirmation") await notifyChatRunMilestones(tx, row);
|
||||
const [resolved] = await tx
|
||||
.update(issueThreadInteractions)
|
||||
@@ -4853,27 +4867,29 @@ export function issueThreadInteractionService(
|
||||
const subject = payload?.purpose === "ai"
|
||||
? "the AI account"
|
||||
: `the ${payload?.serviceName?.trim() || "requested"} connection`;
|
||||
const [updated] = await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "expired",
|
||||
reason: `The task was reassigned before the connection request was answered, so ${subject} is still not connected. Reconnect it or choose an available connection.`,
|
||||
},
|
||||
resolvedAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, row.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
if (updated) {
|
||||
await enqueueTerminalIssueInteractionChatPublications(db, hydrateInteraction(updated));
|
||||
expired.push(updated);
|
||||
}
|
||||
const updated = await db.transaction(async tx => {
|
||||
await notifyChatConfirmationResolution(tx, { companyId: issue.companyId, issueId: issue.id, kind: "connection_intent" });
|
||||
const [resolved] = await tx
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "expired",
|
||||
reason: `The task was reassigned before the connection request was answered, so ${subject} is still not connected. Reconnect it or choose an available connection.`,
|
||||
},
|
||||
resolvedAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, row.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
if (resolved) await enqueueTerminalIssueInteractionChatPublications(tx as unknown as Db, hydrateInteraction(resolved));
|
||||
return resolved;
|
||||
});
|
||||
if (updated) expired.push(updated);
|
||||
}
|
||||
if (expired.length) await db.delete(toolOauthStates).where(inArray(toolOauthStates.interactionId, expired.map((item) => item.id)));
|
||||
return expired;
|
||||
@@ -4911,6 +4927,7 @@ export function issueThreadInteractionService(
|
||||
// lifecycle reflection.
|
||||
const updated = await db
|
||||
.transaction(async (tx) => {
|
||||
await notifyChatConfirmationResolution(tx, row);
|
||||
if (["ask_user_questions", "request_confirmation"].includes(row.kind)) await notifyChatRunMilestones(tx, row);
|
||||
if (row.kind === "connection_intent") {
|
||||
await tx
|
||||
@@ -5002,6 +5019,7 @@ export function issueThreadInteractionService(
|
||||
// request must not outlive a withdrawn card.
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
await notifyChatConfirmationResolution(tx, current);
|
||||
if (["ask_user_questions", "request_confirmation"].includes(current.kind)) await notifyChatRunMilestones(tx, current);
|
||||
await resolveLinkedToolActionRequests(tx, current, {
|
||||
status: "cancelled",
|
||||
@@ -5263,6 +5281,7 @@ export function issueThreadInteractionService(
|
||||
const now = new Date();
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor);
|
||||
await notifyChatConfirmationResolution(tx, current);
|
||||
if (["ask_user_questions", "request_confirmation"].includes(current.kind)) await notifyChatRunMilestones(tx, current);
|
||||
await resolveLinkedToolActionRequests(tx, current, {
|
||||
status: "cancelled",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyChatConfirmationResolution } from "./chat-confirmation-notifications.js";
|
||||
import { enqueueTerminalIssueInteractionChatPublications } from "./chat-interaction-publications.js";
|
||||
import { connectionManagementService } from "./connection-management.js";
|
||||
import { chatCredentialEntryGenerations } from "./chat-channels/credentials.js";
|
||||
@@ -156,6 +157,7 @@ export function satisfiedConnectionIntentService(db: Db) {
|
||||
if (!connection) return false;
|
||||
const now = new Date();
|
||||
// Retire the obsolete request without inventing a human approval.
|
||||
await notifyChatConfirmationResolution(tx, intent);
|
||||
const [retired] = await tx.update(issueThreadInteractions).set({ status: "expired", resolvedAt: now, updatedAt: now,
|
||||
result: { version: 1, outcome: "expired", connectionId: connection.id,
|
||||
reason: "An already authorized connection is available to the requesting agent." },
|
||||
|
||||
Reference in new issue
Block a user