From 36cf01b4b5a3f5bf5222d6faef8ec3c9664af1f4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 25 Oct 2026 09:35:51 -0500 Subject: [PATCH] fix(server): wake chat milestones from recovered prompt receipts Co-Authored-By: Paperclip --- ...2026-10-24-recurring-server-sleep-stack.md | 8 +++ ...26-10-25-chat-run-milestone-sleep-audit.md | 50 +++++++++++++++++++ .../chat-channels.integration.test.ts | 13 ++++- .../chat-channels/interactions/callbacks.ts | 2 +- 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/doc/plans/2026-10-24-recurring-server-sleep-stack.md b/doc/plans/2026-10-24-recurring-server-sleep-stack.md index e19ae7bb42e..d05ba854a0a 100644 --- a/doc/plans/2026-10-24-recurring-server-sleep-stack.md +++ b/doc/plans/2026-10-24-recurring-server-sleep-stack.md @@ -1536,3 +1536,11 @@ native review acceptance cases. Server production types pass. The slice audit retains the invalid fixture and missed test-file selection. Polling removal, remaining producers, fresh CI/review, older findings and the full inventory remain unfinished; no stack merge or whole-goal completion is claimed. + + +Slice 28 also covers interactive callback recovery of an unknown prompt receipt. +Its existing Slack acceptance test failed before the answer-write barrier without +the receipt signal, then five Slack/Discord/Telegram cases and server production +types passed after the one-line integration. File transport and ordinary tool +receipt ordering were audited without adding unnecessary notifications. Remaining +configuration/recovery producers and production wiring still gate polling removal. diff --git a/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md b/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md index f2bf931904c..7e26643cecc 100644 --- a/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md +++ b/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md @@ -994,3 +994,53 @@ above or chat-run-publications since the preceding 3536b3649f inspection. Full stack reconciliation remains outstanding. The lifecycle projector remains unregistered and its periodic production scan remains until the rest of the publication, configuration, and recovery producer audit is complete. + + +## Interactive callback receipt recovery + +The shared interactive callback can recover a question/confirmation prompt from +`delivery_unknown` to `published` before attempting to resolve the interaction. +That receipt alone can authorize existing continuation lineage, even when the +later resolution does not write. It now supplies the issue scope to the existing +publication notification in that receipt transaction. A published row receiving +only its missing provider message ID keeps its existing delivery wake without +adding a milestone wake: prompt lineage reads publication state, not that ID. +No provider protocol, action authorization, or dispatch policy moved into wiring. + +The existing full Slack closed-choice test now observes the scoped settled signal +at its pre-resolution barrier. This prevents the later answer transaction from +masking a missing receipt wake. `/tmp/sleep28-callback-receipt-before.log` fails +there before the change; `/tmp/sleep28-callback-receipt-tests.log` passes all five +selected Slack/Discord/Telegram callback, modal, and continuation cases. The test +still verifies actual publication receipt, answer, continuation, deduplication, +unauthorized actions and acknowledged callback behavior. Provider transports are +controlled test ports, not live-provider qualification. Server production types +pass in `/tmp/sleep28-callback-receipt-typecheck.log`; diff whitespace checks pass. + +Additional producer dispositions inspected at this checkpoint: +- Discord and Telegram large-publication preparation explicitly reject cards and + interactionId. They preserve the root publication identity while splitting text + or attachments; they do not confirm an interactive prompt receipt. +- Slack file-receipt admission rejects cards/interactionId. Teams file-transfer + authority also rejects those plus progress/control/wake publications. Their + file delivery settlement is not an interactive prompt lineage producer. +- Authored-final suppression tests durable publication/comment identity, not the + transport state. File receipt outcomes do not remove that identity. +- Ordinary provider question/confirmation winner-action writes run in the same + resolution transaction as the already-covered interaction signal. Winner + actions restrict eligible destinations; they do not create a separate callback + polling obligation. Alternate recovery writers still require review. +- Tool-action continuation admission selects already-terminal requests joined to + existing delivery receipts before calling heartbeat.wakeup. Delivery's later + deliveredAt write is not read by milestone lineage. Normal admission therefore + observes the earlier durable request/receipt and supplies its existing wake. + Do not add an unconditional signal to every tool-request metadata write merely + because the lineage query joins that table. +- Gateway connection-authorization creation/reopening inserts a pending review + without superseding another or changing sourceRunId. It restricts eligibility; + its later ordinary resolution is covered by interaction-service signals. + +These dispositions do not finish the entire publication/configuration/recovery +audit. In particular, endpoint and conversation reactivation, policy changes and +alternate recovery receipts still need complete real-producer coverage before +production registration and removal of the lifecycle scan. diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 5c18ea0406c..00af2005997 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -41213,13 +41213,18 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await service.shutdown(); }); - it("publishes a closed-choice question, settles its Slack card, and delivers its exact continuation response", async () => { + it("publishes a closed-choice question, settles its Slack card, and delivers its exact continuation response", async ({ onTestFinished }) => { + let receiptWakeObserved = false; const fixture = await seedCompany(); const continuationRunId = randomUUID(); const resolveNativeQuestion = vi.fn(async () => "not_native" as const); const { callbacks, endpoint, runtime, service } = await configuredSlackEndpoint(fixture, { resolveNativeQuestion, + questionResolutionPersistBarrier: async () => { + // Receipt commit must wake projection before the separate answer write. + expect(receiptWakeObserved).toBe(true); + }, wakeup: async (agentId, options) => { if (options.contextSnapshot?.source !== "issue.interaction.respond") { return { accepted: true }; @@ -41542,6 +41547,12 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { .set({ state: "delivery_unknown", providerMessageId: null }) .where(eq(chatPublications.id, publication.id)); + const unsubscribeReceipt = subscribeDatabaseWork(db, "chat-run-milestones", (event, hints) => { + if (event === "settled" && hints?.has(JSON.stringify({ companyId: fixture.companyId, issueId: conversation.issueId }))) { + receiptWakeObserved = true; + } + }); + onTestFinished(unsubscribeReceipt); const providerAcknowledgement = callbacks.onAction(actionEvent()); await expect( Promise.race([ diff --git a/server/src/services/chat-channels/interactions/callbacks.ts b/server/src/services/chat-channels/interactions/callbacks.ts index 157c48a5406..69e29c0e049 100644 --- a/server/src/services/chat-channels/interactions/callbacks.ts +++ b/server/src/services/chat-channels/interactions/callbacks.ts @@ -781,7 +781,7 @@ export function createChatInteractionCallbacks(db: Db, options: InteractionCallb .then((rows) => rows[0] ?? null); if (occupied && occupied.publicationId !== current.id) return null; const reconciledAt = new Date(); - await notifyChatPublicationWork(tx); + await notifyChatPublicationWork(tx, current.state === "delivery_unknown" ? current : undefined); const [reconciled] = await tx .update(chatPublications) .set({