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.
//
+70
View File
@@ -0,0 +1,70 @@
import type { ServerWebSocket } from 'bun';
import * as sidecar from '@@/sidecar-registry';
import { getChatNotifyPreference, getUserById } from 'officerdb';
import { resolveHomeDir } from '@@/user-home';
import { liveSessionTitle } from './claude-sessions';
// The browser-facing half of the bell feature: a per-user, session-independent socket that the shell
// holds open once, and the fan-out that decides whether a sidecar's turn-end/input-needed ping is
// actually worth telling anyone about.
//
// Deliberately separate from `chatWebsocket` (websocket.ts) — that one is bound to exactly one
// `sessionId` per connection, and retrofitting a sessionless "tell me about ALL my chats" mode into its
// already-dense state machine risked entangling two very different lifecycles. This one carries nothing
// but small, best-effort pings; a missed one (a reconnect gap, a restart) is not replayed and does not
// need to be — the badge and the stored preference are the durable truth, this socket is just a bell.
type WSData = { userId: number };
const socketsByUser = new Map<number, Set<ServerWebSocket<WSData>>>();
export const chatNotifyWebsocket = {
open(ws: ServerWebSocket<WSData>) {
const { userId } = ws.data;
let set = socketsByUser.get(userId);
if (!set) {
set = new Set();
socketsByUser.set(userId, set);
}
set.add(ws);
},
message() {
// No inbound protocol — this socket only ever receives, it never sends a session id or a command.
},
close(ws: ServerWebSocket<WSData>) {
const { userId } = ws.data;
const set = socketsByUser.get(userId);
if (!set) return;
set.delete(ws);
if (set.size === 0) socketsByUser.delete(userId);
},
};
/** Best-effort — a session with no group/cwd on record (never taken a turn, or the transcript vanished
* mid-lookup) still gets a notification, just with a generic label rather than the chat's real name. */
async function resolveTitle(userId: number, sessionKey: string): Promise<string> {
try {
const user = await getUserById(userId);
const resolved = await resolveHomeDir(userId);
if (!user || !resolved.ok) return 'Chat';
const who = { email: user.email, home: resolved.home, osUser: resolved.osUser, isOwner: resolved.isOwner };
return liveSessionTitle(who, sessionKey)?.title ?? 'Chat';
} catch {
return 'Chat';
}
}
sidecar.onClaudeNotify(async (sessionKey, userId, kind) => {
const sockets = socketsByUser.get(userId);
if (!sockets?.size) return; // nobody listening — skip the DB round trip entirely
const preference = await getChatNotifyPreference(sessionKey).catch(() => null);
const matches =
(kind === 'turn-ended' && preference === 'every_turn') ||
(kind === 'input-needed' && preference === 'input_needed');
if (!matches) return;
const title = await resolveTitle(userId, sessionKey);
const payload = JSON.stringify({ type: 'notify', sessionId: sessionKey, kind, title });
for (const ws of sockets) ws.send(payload);
});