Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions backend/__tests__/unit/services/digestEmailService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,23 @@ describe('digestEmailService', () => {
.resolves.toEqual(expect.objectContaining({ disabled: false, sent: 1 }));
});

// sprint-review on #2275: the switch is for an incident, and an incident is
// often a run that is already sending.
it('a switch flipped mid-run stops the recipients after the flip', async () => {
const first = await createUser();
const second = await createUser();
const digests = [await createDigest(first), await createDigest(second)];
mockSendEmail.mockImplementationOnce(async () => {
await SystemSetting.create({ key: digestEmailService.DIGEST_SENDING_KEY, value: { enabled: false } });
return { data: { data: { succeeded: 1 } } };
});

const result = await digestEmailService.sendDigestEmails(digests.map(runEntry));

expect(result).toEqual(expect.objectContaining({ sent: 1, disabled: true }));
expect(mockSendEmail).toHaveBeenCalledTimes(1);
});

it('sends nothing when the switch cannot be read', async () => {
const user = await createUser();
const digest = await createDigest(user);
Expand Down
3 changes: 3 additions & 0 deletions backend/services/attentionItemService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ const peopleThisAgentIsAnswering = async (
instanceId: String(author.botMetadata?.instanceId || 'default'),
replyToMessageId: message?.reply_to_message_id ?? message?.replyToMessageId,
// Carried from the post, never stored on the row (agentAnswerService).
// Only the PG write carries it: the Mongo fallback's post-save hook
// (models/Message.ts) passes the document, so with PG down an unclaimed
// addressed answer still opens a row. That fails toward showing an ask.
answeringMessageId: message?.answeringMessageId,
});
} catch (error) {
Expand Down
12 changes: 11 additions & 1 deletion backend/services/digestEmailService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ const SystemSetting = require('../models/SystemSetting');
/**
* The switch that stops digest SENDING in seconds, with no deploy and no
* restart: a system_settings row { key: 'digest.sending', value: { enabled:
* false } }. No row means on. Generation still runs; only the send stops.
* false } }. No row means on. Generation still runs; only the send stops. It
* is read before the run and again before each recipient, so flipping it
* also stops a run that is already sending.
* docs/runbooks/daily-digest.md has the one-line flip. Earned 2026-10-16,
* when the only fallback for a bad run was "land a fix before 06:00Z".
*/
Expand Down Expand Up @@ -156,6 +158,14 @@ export class DigestEmailService {
result.eligible = summaries.length;

for (const summary of summaries) {
// Read again per recipient (sprint-review on #2275): the switch is for an
// incident, and an incident is often a run that is already sending.
// eslint-disable-next-line no-await-in-loop
if (!await digestSendingEnabled()) {
console.warn(`Digest email delivery stopped mid-run: switched off (${DIGEST_SENDING_KEY}).`);
result.disabled = true;
break;
}
const userId = summary.metadata?.userId;
try {
if (!userId) {
Expand Down
11 changes: 8 additions & 3 deletions docs/runbooks/daily-digest.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ digest off.

Sending is controlled by one `system_settings` row. A missing row means
sending is **on**. Flipping it needs no deploy and no restart, and survives
later deploys. Generation still runs; only the send stops.
later deploys. Generation still runs; only the send stops. The row is read
before the run and again before each recipient, so flipping it also stops a
run that is already sending: whoever was already emailed stays emailed, and
nobody after the flip is.

```bash
# OFF
Expand All @@ -27,8 +30,10 @@ m.connect(process.env.MONGO_URI).then(async () => {
```

To turn it back on, run the same command with `enabled: true`, or delete the
row. If the row can't be read, nothing is sent: the switch fails closed. The
run logs `Digest email delivery skipped: switched off (digest.sending).`
row. If the row can't be read, nothing is sent: the switch fails closed. A run
logs `Digest email delivery skipped: switched off (digest.sending).` when it
starts switched off, and `Digest email delivery stopped mid-run: ...` when the
switch is flipped while it's sending.

## Before a change to digest content ships

Expand Down
Loading