Skip to content

hasIdenticalSend matches sends from previous fires, so recurring reminders with fixed text stop arriving #2997

Description

@glifocat

What happens

A recurring task whose reminder text does not change delivers on its first fire and then never again. The inbound row is marked completed. Host logs show nothing. The only trace is a Dropping turn-final echo line in the container log, which is gone once the container exits (--rm).

Why it happens

hasIdenticalSend (container/agent-runner/src/db/messages-out.ts:151 at 0c0f4c25, introduced in #2981) runs before a task fire's final-text block is written. The query has no seq or time bound, so it scans all of messages_out history:

SELECT 1 FROM messages_out
 WHERE platform_id = $platform_id AND channel_type = $channel_type
   AND (in_reply_to IS NULL OR in_reply_to = '')
   AND json_extract(content, '$.text') = $text
 LIMIT 1

Task-fire sends are written with in_reply_to null by design (see the comment in sendToDestination, poll-loop.ts). So yesterday's fire wrote exactly the row that today's lookup matches. On a hit, sendToDestination returns before writing. Zero delivery.

How to see it

This test fails at 0c0f4c25 (add to container/agent-runner/src/poll-loop.test.ts, next to the existing duplicate-send suppression tests):

it('delivers a recurring task reminder even when an identical row exists from a previous fire', async () => {
  getInboundDb()
    .prepare('INSERT INTO destinations (name, display_name, type, channel_type, platform_id) VALUES (?, ?, ?, ?, ?)')
    .run('discord-test', 'Discord', 'channel', 'discord', 'chan-1');

  // Yesterday's fire: identical text, in_reply_to null, written before the turn starts.
  writeMessageOut({
    id: 'previous-fire', kind: 'chat', platform_id: 'chan-1',
    channel_type: 'discord', thread_id: null,
    content: JSON.stringify({ text: 'Water the plants!' }),
  });

  const { query } = makeResultQuery({
    type: 'result',
    text: '<message to="discord-test">Water the plants!</message>',
  });
  await processQuery(query, { platformId: 'chan-1', channelType: 'discord', threadId: null, inReplyTo: 'm1', taskFire: true }, ['m1'], 'claude', undefined, 'prompt', undefined);

  expect(getUndeliveredMessages()).toHaveLength(2); // fails with 1: the reminder was dropped
});

I also checked a live install running 2.1.41. The session holding a daily 21:00 reminder task has 15 byte-identical in_reply_to-null rows for the same destination, one per past fire. Each of those rows now blocks the next fire.

Suggested fix

Bound the lookup to the turn in flight with a seq floor. The same file already has this primitive for the chat restatement dedup (getToolSentTextsSince(floorSeq), fed by toolSentFloorSeq in poll-loop.ts):

export function hasIdenticalSend(platformId: string, channelType: string, text: string, sinceSeq: number): boolean {
  // same query, plus: AND seq > $since
}

with toolSentFloorSeq threaded through dispatchResultText into sendToDestination. The case #2981 guards (the turn-final echo of a send_message made this turn) still matches, because those rows sit above the floor. I run this patch on my install with a regression test; the container suite passes. I can open a PR.

Related, but not blocking


Assisted by Claude; reviewed and verified by a human before submission.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions