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
+10 -1
View File
@@ -242,6 +242,11 @@ const sessionLog = createSessionLogStore((d) =>
connection.send({ type: 'claude:message', sessionKey: d.sessionId, msg: d.msg, seq: d.seq }),
);
// Notification delivery is a separate, non-durable ping — see `PersistentSession.notify` in
// claude-manager.ts for why it isn't folded into the message above.
const notifySignal = (sessionKey: string, userId: number, kind: 'turn-ended' | 'input-needed') =>
connection.send({ type: 'claude:notify', sessionKey, userId, kind });
// ── Command handlers ──
type ReplyFn = (msg: SidecarEvent) => void;
@@ -269,7 +274,11 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
const { sessionKey, durable = true } = cmd.params;
claudeManager
.spawnClaudeStreaming(cmd.params, (event) => sessionLog.push(sessionKey, event, durable))
.spawnClaudeStreaming(
cmd.params,
(event) => sessionLog.push(sessionKey, event, durable),
(kind) => notifySignal(sessionKey, cmd.params.userId, kind),
)
.catch((err) => {
// Through the log like any other output, so a failure to start is durable and replayable too.
const message = err instanceof Error ? err.message : String(err);