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:
@@ -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.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user