notify: only "the app is gone" deletes a device row

reported from the field on the first real push: it failed with BadDeviceToken
and the server deleted the registration. the build was production-signed and
the row said sandbox — a configuration mismatch, with the device and the app
both perfectly fine.

pruning there destroys the evidence. the phone re-registers on next launch,
the row comes back looking healthy, and the next send fails identically; you
can lose a day to that loop without ever seeing what is actually wrong.

so the one set becomes three. Unregistered/UNREGISTERED/NOT_FOUND prune —
apple and google are saying the app is uninstalled. BadDeviceToken,
DeviceTokenNotForTopic, TopicDisallowed, INVALID_ARGUMENT and
SENDER_ID_MISMATCH are OURS: the row is kept and the log says what to check.
everything else is transient and still counts toward three strikes.

INVALID_ARGUMENT was the sharpest one. it is how fcm reports a malformed
MESSAGE — including a non-string in `data`, which is the mistake this codebase
is most likely to make and already has a test for. deleting on it meant one
bug in the payload builder would wipe every android device from the registry
on a single send, presenting as "android push stopped working" with nothing
left to explain why.

misconfigured is also not counted as a strike. not deleting is not enough on
its own — a persistent mismatch would reach three failures and delete the row
anyway, which is the same bug with a slower fuse.

the apns log now names the mismatch rather than the reason code, since the
code alone sends people to look at the device: it prints the row's environment
and topic and says to check the SHIPPED artefact rather than the intermediate
build, which is exactly where this one hid.
This commit is contained in:
2026-08-19 21:25:20 +00:00
parent c6ef0b34db
commit 5cba1b65a6
19 changed files with 391 additions and 34 deletions
+40 -2
View File
@@ -4,7 +4,16 @@ import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import * as sidecar from '@@/sidecar-registry';
import { getUserSettings, getChatSessionModel, getChatSessionModels, listCommandHqSessionIds } from 'officerdb';
import {
getUserSettings,
getChatSessionModel,
getChatSessionModels,
listCommandHqSessionIds,
getChatNotifyPreference,
getChatNotifyPreferences,
setChatNotifyPreference,
} from 'officerdb';
import type { NotifyPreference } from 'officerdb/types';
import {
listClaudePwds,
listClaudeSessions,
@@ -156,13 +165,24 @@ chatRouter.get('/sessions', async (ctx) => {
() => new Map<string, { model: string; planMode: boolean }>(),
);
// Same batched-not-per-row shape as `models` above, for the same reason — the list renders a bell
// badge per row, and per-row queries do not scale to a page of a hundred sessions.
const notifyPrefs = await getChatNotifyPreferences(sessions.map((s) => s.id)).catch(
() => new Map<string, NotifyPreference>(),
);
return ctx.json({
sessions: sessions.map((s) => {
const stored = models.get(s.id);
// Spelled out field by field rather than spread: this row goes onto the wire untyped (`ctx.json`),
// so widening the stored shape from a string to a record would have silently shipped an OBJECT as
// `model` and broken every consumer that calls `.split('/')` on it — with nothing to catch it.
return { ...s, model: stored?.model ?? null, planMode: stored?.planMode ?? false };
return {
...s,
model: stored?.model ?? null,
planMode: stored?.planMode ?? false,
notifyPreference: notifyPrefs.get(s.id) ?? 'none',
};
}),
});
});
@@ -217,6 +237,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
*/
const stored = await getChatSessionModel(id).catch(() => null);
const officerModel = stored?.model ?? officerModelForApiModel(detail.model) ?? null;
const notifyPreference = (await getChatNotifyPreference(id).catch(() => null)) ?? 'none';
return ctx.json({
...detail,
@@ -226,6 +247,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
// Whether this conversation is planning, so the composer's toggle comes back in the state the user left
// it. Only a stored row can answer — plan mode leaves no trace in the transcript at all.
planMode: stored?.planMode ?? false,
notifyPreference,
messages,
total,
offset: windowed ? start : 0,
@@ -335,6 +357,22 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => {
return ctx.json({ ok: true });
});
const NOTIFY_PREFERENCES: NotifyPreference[] = ['every_turn', 'input_needed', 'none'];
// PATCH /chat/sessions/:id/notify-pref — set this session's bell preference. No ownership check beyond
// `chatIdentity()` above: the row is keyed only on sessionId, same trust boundary the title route uses,
// and a preference on a session the caller cannot otherwise reach is inert (nothing will ever fire it).
chatRouter.patch('/sessions/:id/notify-pref', async (ctx) => {
await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id');
const { preference } = await ctx.req.json<{ preference?: string }>();
if (!preference || !NOTIFY_PREFERENCES.includes(preference as NotifyPreference)) {
return ctx.text('preference must be one of: ' + NOTIFY_PREFERENCES.join(', '), 400);
}
await setChatNotifyPreference(id, preference as NotifyPreference);
return ctx.json({ preference });
});
// GET /chat/tasks/:id — what a background task is doing right now: a subagent's own trace, or the tail of
// a backgrounded shell's log. Polled by the tray above the chat input while the task is running.
//