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:
DottaandPaperclip authored and GitHub committed 2026-10-28 15:00:31 -05:00
1 parent 60b8089a44
commit ee67d2ece9
19 files changed
+1192 -39

No files matched your search

+2 -2
View File
@@ -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." },