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 ec32eeba90f..7d0eb73524c 100644 --- a/doc/plans/2026-10-24-recurring-server-sleep-stack.md +++ b/doc/plans/2026-10-24-recurring-server-sleep-stack.md @@ -1632,3 +1632,12 @@ Three local cancellation diagnostic runs passed, including full projects coverag and a database-only delay, without proving the CI deadlock's competing owner. Those failures, parent security/deletion findings, all other original slices, fresh remote review and reconciliation with master 7ebf98eafd remain open. + + +Slice 28 ordinary ingress now wakes existing milestone debt before reactivating +conversation bindings and writing inbound links. A real Slack ingress regression +failed before repair and passes without depending on a new run being created. +All 12 selected ingress/revocation/Telegram migration cases and server types/build +pass. The milestone audit records evidence, isolated DB, and remaining authority/ +lineage producers. This is another producer checkpoint in draft #17606, not +production polling removal or completion of the full requested stack. 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 b431518c2f7..0b4b36f8229 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 @@ -1187,3 +1187,36 @@ E2E failures; no blind retry or cancellation-error masking was applied. Normal inbound conversation/link activation and remaining authority/lineage producer dispositions still gate production registration and polling removal. Fresh master is 7ebf98eafd; final base reconciliation remains outstanding. + + +## Ordinary inbound conversation activation + +The actual Slack ingress owner reproduces another missing wake: a prior failed +run has valid inbound lineage, but its unavailable conversation is restored by a +new message after the projector exhausted its startup work. The harness accepts +the new wake without creating a new run, so this does not rely on later queue +admission to hide the gap. Before repair the expected failed-run publication +never appears (/tmp/sleep28-inbound-before.log). + +persistTaskMutation now registers the existing issue scope before its mutations, +or the newly created issue scope before inserting its conversation. The same +transaction creates the inbound link and reactivates the binding. Existing +endpoint/resource/principal authorization runs before this owner; provider calls +remain outside it. This is a direct use of the shared transaction intent helper, +not a new wrapper or background scan. No business policy or public API changes. + +The regression passes (/tmp/sleep28-inbound-tests.log), and the combined selection +passes 12 cases: ordinary ingress reactivation, three existing Slack/Teams/Telegram +reach-revocation cases, and eight Telegram migration cases +(/tmp/sleep28-inbound-acceptance.log; 1,419 deselected). Server production +typecheck/build pass in /tmp/sleep28-inbound-{typecheck,build}.log. The isolated +DB is paperclip_sleep28_inbound_20261025_test. Existing resource and lifecycle +proofs remain recorded at their prior checkpoints; the whole integration file +was not run locally for this edit. + +Remaining audit: alternate monitor/wakeup receipt rewrites, board reply identity, +provider-specific configuration/authority, and any remaining direct lineage +writers. Endpoint update does not assign a new agent or change publication mode; +its unrelated health/configuration writes must not cause blanket milestone scans. +Production registration/poll removal, complete remote gates, prior parent findings, +and the full original stack remain unfinished. diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 00af2005997..ab36da8d749 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -16,6 +16,7 @@ import { chatSlackManagerGrants, chatSlackRegistrations, toolOauthStates } from import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../services/paperclip-cloud-connector.js"; import { buildSlackAppManifest, SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; import { registerChatActionWork, dueChatAction } from "../services/chat-action-work.js"; +import { chatRunMilestoneWork } from "../services/chat-run-milestone-work.js"; import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js"; import { registerChatDeliveryWork } from "../services/chat-delivery-work.js"; import { notifyChatPublicationWork, notifyChatActionWork, CHAT_ACTION_QUEUES } from "../services/chat-work-notifications.js"; @@ -34442,6 +34443,43 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { ]); }); + it("wakes an existing chat milestone when ordinary ingress reactivates its conversation", async () => { + const fixture = await seedCompany(); + const { callbacks, endpoint, wakeup } = await configuredSlackEndpoint(fixture); + const thread = makeThread({ channelId: "C-REOPEN-MILESTONE", id: "slack:C-REOPEN-MILESTONE:4100.1", name: "reopen-milestone" }); + const send = (id: string) => deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, + message: makeMessage({ id, text: "@maya continue this conversation", mentioned: true }), trigger: "mention" }); + await send("4100.1"); + const [conversation] = await readChatConversationsByEndpointId(endpoint.id); + const [link] = await db.select().from(chatMessageLinks).where(and( + eq(chatMessageLinks.conversationId, conversation.id), eq(chatMessageLinks.providerMessageId, "4100.1"))); + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: runId, companyId: fixture.companyId, agentId: fixture.assignedAgentId, + status: "failed", errorCode: "low_trust_isolation_unavailable", updatedAt: new Date(), + contextSnapshot: { issueId: conversation.issueId, source: "chat:slack", endpointId: endpoint.id, wakeCommentId: link.commentId }, + }); + await db.update(chatConversations).set({ state: "unavailable" }).where(eq(chatConversations.id, conversation.id)); + const onError = vi.fn(); + const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError }); + const notice = () => db.select().from(chatPublications).where(and(eq(chatPublications.issueId, conversation.issueId), + eq(chatPublications.idempotencyKey, `run:${runId}:failed:${endpoint.id}`))); + try { + await chatRunMilestoneWork(db).register(coordinator, () => true).ready; + await vi.waitFor(() => expect(coordinator.nextWakeAt()).toBeNull()); + expect(await notice()).toEqual([]); + const wakesBefore = wakeup.mock.calls.length; + await send("4100.2"); + expect(wakeup.mock.calls.length).toBe(wakesBefore + 1); + // The harness accepts the new wake without creating a run. Existing + // projection debt must wake from ingress, not a later run transition. + await expect.poll(notice, { timeout: 5000 }).toEqual([ + expect.objectContaining({ idempotencyKey: `run:${runId}:failed:${endpoint.id}` }), + ]); + await vi.waitFor(() => expect(coordinator.nextWakeAt()).toBeNull()); + expect(onError).not.toHaveBeenCalled(); + } finally { await coordinator.stop(); } + }); + it.each(["milliseconds", "microseconds"])("drains bounded Slack chat-origin milestones without admitting an internal issue run (%s)", async (precision) => { const fixture = await seedCompany(); const { callbacks, endpoint } = await configuredSlackEndpoint(fixture); diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 335dff4f6ab..58fb4bd8e49 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,6 +1,7 @@ import { leasedChatProvider } from "./chat-channels/runtime/receiver-policy.js"; import { chatReceiverSchedule } from "./chat-channels/runtime/receiver-schedule.js"; import { registerChatReceiverWork } from "./chat-channels/runtime/receiver-work.js"; +import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js"; import { notifyChatEndpointConfiguration } from "./chat-channels/configuration-work-notifications.js"; import { registerDiscordCommandWork } from "./chat-channels/discord/command-work.js"; import { discordCommandSchedule } from "./chat-channels/discord/command-schedule.js"; @@ -2125,6 +2126,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { taskUserId: string | null, ) => { let conversation = existingConversation; + if (conversation) { + await notifyChatRunMilestones(taskTx, { companyId: taskEndpoint.companyId, issueId: conversation.issueId }); + } if (!conversation) { const [taskResource] = await taskTx.select({ executionDefaults: chatEndpointResources.executionDefaults }) .from(chatEndpointResources).where(and(eq(chatEndpointResources.id, resource.id), eq(chatEndpointResources.endpointId, taskEndpoint.id))); @@ -2166,6 +2170,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }, taskTx, ); + await notifyChatRunMilestones(taskTx, { companyId: taskEndpoint.companyId, issueId: issue.id }); await taskTx .insert(chatConversations) .values({