diff --git a/doc/plans/2026-10-25-voice-reply-delivery-sleep-audit.md b/doc/plans/2026-10-25-voice-reply-delivery-sleep-audit.md index 4f8b8817474..4c169de2343 100644 --- a/doc/plans/2026-10-25-voice-reply-delivery-sleep-audit.md +++ b/doc/plans/2026-10-25-voice-reply-delivery-sleep-audit.md @@ -169,3 +169,28 @@ intent after call termination, nested producer rollback/commit, real question retrieval, authority restoration, and drain/resume without passive SQL. Final server typechecking also passed. The earlier retained-fixture failures remain recorded above; shared publication-path and broader acceptance work remains open. + +## Publication-path and broader verification + +Both real chat-service paths now have a commit-boundary regression: normal +publication and operator mark-delivered. The tests hold the real final transaction +before its voice notification, wait until the early enqueue wake is fully idle, +and then verify one send after publication. Disabling only the final notification +made both tests fail with zero sends; restoring it passed both. A 105-action real +PostgreSQL case also proves startup inventory paging and bounded action recovery. + +Workspace typechecking passed. Seven shared access/connection/settings suites +passed all 651 tests. A combined full voice/startup/parent-recovery run used one +external database: voice/startup passed 123 tests, but ten parent recovery tests +observed the voice suite's intentionally retained sessions and failed their global +quiet assertions. This is a test invocation isolation error, not accepted evidence +for parent recovery. The parent suite is being rerun against its separate database. +Do not combine these two suites under one external database URL. + +The 107 full voice cases now include real publication and operator producers, +105 abandoned sends, restart, rollback/outer commit, drain, authority restoration, +question barriers and no-resend outcomes. Full workspace build and current-head +PR CI/reviews remain required. The local all-suite command has the previously +recorded embedded-PostgreSQL initialization limitation; targeted database suites +run on real isolated PostgreSQL instead. Do not count skipped or uninitialized +database tests as passes. diff --git a/server/src/__tests__/voice-session-store.integration.test.ts b/server/src/__tests__/voice-session-store.integration.test.ts index 1ca23ae0aeb..7368d22c735 100644 --- a/server/src/__tests__/voice-session-store.integration.test.ts +++ b/server/src/__tests__/voice-session-store.integration.test.ts @@ -1,3 +1,4 @@ +import * as voiceReplyNotifications from "../services/voice/voice-reply-notifications.js"; import { voiceReplySchedule } from "../services/voice/voice-reply-schedule.js"; import { registerVoiceReplyWork } from "../services/voice/voice-reply-work.js"; import { pushVoiceReplies } from "../services/voice/voice-reply-push.js"; @@ -816,6 +817,54 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu await worker.quiet(); expect(worker.onError).not.toHaveBeenCalled(); } finally { await worker.coordinator.stop(); vi.useRealTimers(); } }); + it.each(["normal publication", "operator resolution"] as const)("wakes phone delivery from actual %s after its early enqueue has gone idle", async path => { + const f = await pushFixture(); + await db.update(chatPublications).set({ state: path === "normal publication" ? "pending" : "delivery_unknown" }) + .where(eq(chatPublications.id, f.publication.id)); + const chat = chatChannelService(db, { heartbeat: { wakeup: vi.fn() } }); + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true }); + const worker = await replyWorker(f); + let release!: () => void, reached!: () => void; + const barrier = new Promise(resolve => { release = resolve; }); + const entered = new Promise(resolve => { reached = resolve; }); + const notify = voiceReplyNotifications.notifyVoicePublication; + const notification = vi.spyOn(voiceReplyNotifications, "notifyVoicePublication").mockImplementationOnce(async (...args) => { + reached(); await barrier; return notify(...args); + }); + let publishing: Promise | undefined; + try { + await worker.settled(); await worker.quiet(2000); + publishing = path === "normal publication" + ? chat.processPendingPublications(25, { maintenance: false }) + : chat.resolvePublication(f.endpointId, f.publication.id, "mark_delivered", f.callerId); + await Promise.race([entered, publishing.then(() => { throw new Error("Publication ended before its final commit boundary"); })]); + // Hold the real final publication transaction before its voice intent. + // An earlier enqueue wake can fully drain while the row is not published. + await vi.advanceTimersByTimeAsync(2000); + expect(f.transport.sendCallMessage).not.toHaveBeenCalled(); + expect(worker.coordinator.nextWakeAt()).toBeNull(); + release(); await publishing; await worker.settled(); + expect((await db.select().from(chatPublications).where(eq(chatPublications.id, f.publication.id)))[0].state).toBe("published"); + expect(f.transport.sendCallMessage).toHaveBeenCalledOnce(); await worker.quiet(); expect(worker.onError).not.toHaveBeenCalled(); + } finally { + release(); await publishing; notification.mockRestore(); await worker.coordinator.stop(); await chat.shutdown(); vi.useRealTimers(); + } + }); + it("reconstructs and quarantines every abandoned phone dispatch across inventory and work batches", async () => { + const f = await pushFixture(); + await db.update(chatVoiceSessions).set({ state: "ended", endedAt: new Date() }).where(eq(chatVoiceSessions.id, f.session.id)); + await db.insert(chatActions).values(Array.from({ length: 105 }, () => ({ companyId: f.companyId, endpointId: f.endpointId, + conversationId: f.conversationId, kind: "speko_voice_reply_push", providerActionId: randomUUID(), status: "dispatching", + payload: { sessionId: f.session.id, replyId: randomUUID() }, updatedAt: new Date(Date.now() - 60_000) }))); + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true }); + const worker = await replyWorker(f); + try { + await worker.settled(); + const actions = await db.select().from(chatActions).where(eq(chatActions.endpointId, f.endpointId)); + expect(actions).toHaveLength(105); expect(actions.every(action => action.status === "unknown")).toBe(true); + expect(f.transport.sendCallMessage).not.toHaveBeenCalled(); await worker.quiet(); expect(worker.onError).not.toHaveBeenCalled(); + } finally { await worker.coordinator.stop(); vi.useRealTimers(); } + }); it("notifies phone questions once and waits for authorized retrieval before later answers", async () => { const f = await pushFixture(), interactionId = randomUUID(); await db.insert(issueThreadInteractions).values({id: interactionId, companyId: f.companyId, issueId: f.issueId, kind: "ask_user_questions", status: "pending", addresseeUserId: f.callerId, effectiveResolverPolicy: "addressee_only", payload: {version: 1, questions: [{id: "color", prompt: "Which color?", selectionMode: "single", required: true, options: [{id: "cobalt", label: "Cobalt"}]}]}});