Skip to content

fix(polls): a poll never reached the phone - #310

Open
WhichPaths wants to merge 1 commit into
yetone:mainfrom
WhichPaths:fix/a-poll-never-reaches-the-phone
Open

WhichPaths wants to merge 1 commit into
yetone:mainfrom
WhichPaths:fix/a-poll-never-reaches-the-phone

Conversation

@WhichPaths

Copy link
Copy Markdown
Collaborator

The bug

createPoll commits a kind='poll' row and enqueues the same message.new broadcast every other sender enqueues — and then stops.

dispatchMessagePush has four call sites:

caller file
POST /conversations/:id/messages server/src/api/router.ts:4306
cmdReply server/src/agents/cli.ts:2421
startPrivateChat server/src/agents/private_chat.ts:249
startPulledGroup server/src/agents/scanner_helper.ts:152

Both poll producers — POST /api/polls and cumora poll create — funnel into createPoll and are in neither list. It was the last human-visible message kind that never reached a phone.

This is an asymmetry, not a missing feature. NotificationToasts skips only kind === 'system':

if (m.kind === 'system') return

so a poll already toasts on desktop and in the Electron notification window, and Message.tsx renders the poll bubble on both shells. A phone-only member could see and vote on a poll nobody ever told them about. And polls.ts says what the shadow body is for, in its own comment:

// Body shadows the question so any code that lists messages without
// unpacking the poll payload (notifications, search index, plain-text
// logs) still surfaces something meaningful.
const body = `📊 ${question}`

A body built for a notification that was never sent.

How often: every poll posted while a recipient's phone is their only live surface. Polls are a first-class composer action and agents post them too — turn.ts has a whole wake path so a poll author can nudge laggards for missing votes, which makes it worse: the agent keeps poking a room whose members were never told there was a poll.

This is a gap my own #241 left

That commit claimed the set was complete:

Every other INSERT INTO messages in the server is correct as it stands: membership.ts, calendar.ts and inproc-client.ts write kind='system'…

polls.ts writes kind='poll', so it fell outside that check. I did look at it at the time and set it aside as a product question — whether a poll is worth a phone buzz. That was the wrong call: the desktop already answers yes, and the code documents the intent.

The fix

One fire-and-forget dispatch after COMMIT, matching how the other four do it:

void dispatchMessagePush({
  conversationId: input.conversationId,
  authorId: input.authorId,
  messageId,
  body,
  companyId: input.companyId,
})

Recipients need no new filtering: computeMessageRecipients joins users, so agents are never notified, and the mute / "currently looking at the app" filters apply unchanged.

Tests

Two added to agent-reply-push.test.ts, which already owns this contract and its seed helpers:

  • a poll reaches the phone — asserts the recipient is the offline human and that the notification body names the question
  • a poll obeys the same recipient filters — the guard rail: a new dispatch must not add a delivery path that ignores mute
against main:  not ok 8 - a poll reaches the phone
                 a poll produced no push — the phone is never told
               # pass 8  # fail 1

with the fix:  # pass 9  # fail 0

The second passes either way, on purpose.

Also green: tsc --noEmit, biome lint ., all three scripts/guard-*.mjs, the unit suite (1502 tests, 0 failures), and the polls integration suite (8/8).

createPoll commits a kind='poll' row and enqueues the same message.new
broadcast every other sender enqueues, and then stops. dispatchMessagePush has
four call sites -- the HTTP message route, cmdReply, startPrivateChat and
startPulledGroup -- and neither poll producer (POST /api/polls, `cumora poll
create`) is among them. It was the last human-visible message kind that never
reached a phone.

This is an asymmetry, not a missing feature. NotificationToasts skips only
`kind === 'system'`, so a poll already toasts on desktop and in the Electron
notification window, and Message.tsx renders the poll bubble on both shells --
so a phone-only member could see and vote on a poll nobody ever told them
about. polls.ts says what the shadow body is for in its own comment:
"notifications, search index, plain-text logs". A body built for a
notification that was never sent.

This is the gap my own yetone#241 left. Its commit message claimed the set was
complete -- "Every other INSERT INTO messages in the server is correct as it
stands: membership.ts, calendar.ts and inproc-client.ts write kind='system'"
-- and polls.ts writes kind='poll', so it fell outside that check. I looked at
it at the time and set it aside as a product question, whether a poll is worth
a phone buzz. That was the wrong call: the desktop already answers yes, and
the code documents the intent.

Recipients need no new filtering: computeMessageRecipients joins `users`, so
agents are never notified, and the mute and "currently looking at the app"
filters apply unchanged. A test pins the mute case.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant