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.
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 aDropping turn-final echoline in the container log, which is gone once the container exits (--rm).Why it happens
hasIdenticalSend(container/agent-runner/src/db/messages-out.ts:151at0c0f4c25, 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 ofmessages_outhistory:Task-fire sends are written with
in_reply_tonull by design (see the comment insendToDestination,poll-loop.ts). So yesterday's fire wrote exactly the row that today's lookup matches. On a hit,sendToDestinationreturns before writing. Zero delivery.How to see it
This test fails at
0c0f4c25(add tocontainer/agent-runner/src/poll-loop.test.ts, next to the existing duplicate-send suppression tests):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 bytoolSentFloorSeqinpoll-loop.ts):with
toolSentFloorSeqthreaded throughdispatchResultTextintosendToDestination. The case #2981 guards (the turn-final echo of asend_messagemade 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
hasIdenticalSendentirely, which resolves this going forward. But 2.1.41 ships the unbounded version, and the seq floor is a two-line fix that can land on its own if Tasks: one-door delivery — send_message is the only path out of a task session #2988 takes a while.routingis computed once per query from the initial batch (extractRouting). A task that comes due while a chat query is open arrives through the follow-up push and inherits the chat batch'staskFire = false, so it skips the guard and is written within_reply_toset. In other words, the guard only runs when the session is idle at fire time. Might be worth folding into Tasks: one-door delivery — send_message is the only path out of a task session #2988: per-batch routing instead of per-query.Assisted by Claude; reviewed and verified by a human before submission.