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
+21 -2
View File
@@ -227,6 +227,14 @@ type PersistentSession = {
closeInput: () => void;
abort: AbortController;
emit: (event: ChatEvent) => void;
/**
* A lightweight, non-durable ping for notification delivery — deliberately separate from `emit`.
* `ChatEvent`/`TurnMessage` is the durable, replayed transcript; this is not part of it, so it fires
* even for events (`plan:proposed` parking, an `AskUserQuestion` gate) that have no event of their own
* to piggyback on. Officer decides whether to actually notify (preference lookup + fan-out) — this
* only says "something notify-worthy just happened for this session."
*/
notify: (kind: 'turn-ended' | 'input-needed') => void;
isGenerating: boolean;
pendingTasks: Set<string>; // background tasks started but not yet notified; suppress idle-GC while non-empty
/**
@@ -390,6 +398,7 @@ function dropDeadSession(session: PersistentSession, detail: string): void {
if (!wasGenerating) return;
session.emit({ type: 'error', message: diagnosis.message });
session.notify('turn-ended');
}
/** Wrap a spawn so the session self-heals when its child goes away. */
@@ -502,7 +511,11 @@ function armIdle(session: PersistentSession): void {
}, IDLE_TIMEOUT_MS);
}
function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: ChatEvent) => void): PersistentSession {
function createSession(
params: ClaudeSpawnStreamingParams,
onEvent: (event: ChatEvent) => void,
onNotify: (kind: 'turn-ended' | 'input-needed') => void,
): PersistentSession {
const { sessionKey } = params;
const input = makeInputQueue();
const abort = new AbortController();
@@ -515,6 +528,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
closeInput: () => input.close(),
abort,
emit: onEvent,
notify: onNotify,
isGenerating: false,
pendingTasks: new Set<string>(),
planMode: params.planMode === true,
@@ -767,6 +781,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
// Whatever ended the turn, the interrupt is spent — a later genuine error must not wear it.
session.interrupted = false;
if (session.pendingTasks.size === 0) armIdle(session);
session.notify('turn-ended');
}
// Any event at all is a sign of life, so push the stall deadline back. Placed after the branch
// above so a terminal event disarms rather than re-arms it.
@@ -804,6 +819,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
export async function spawnClaudeStreaming(
params: ClaudeSpawnStreamingParams,
onEvent: (event: ChatEvent) => void,
onNotify: (kind: 'turn-ended' | 'input-needed') => void,
): Promise<void> {
let session = sessions.get(params.sessionKey);
if (session && session.userId !== params.userId) {
@@ -817,10 +833,11 @@ export async function spawnClaudeStreaming(
}
if (session) {
session.emit = onEvent; // adopt the latest emitter (equivalent across turns; keeps events flowing)
session.notify = onNotify;
await applyModelChange(session, params.model);
await applyPlanModeChange(session, params.planMode);
} else {
session = createSession(params, onEvent);
session = createSession(params, onEvent, onNotify);
}
session.pushTurn(params.prompt, params.images);
}
@@ -903,6 +920,7 @@ async function planGate(
session.pendingPlans.set(requestId, { resolve, timer });
session.emit({ type: 'plan:proposed', requestId, plan });
session.notify('input-needed');
});
session.emit({ type: 'plan:decided', requestId, approved: decision.approve, feedback: decision.feedback });
@@ -958,6 +976,7 @@ async function questionGate(session: PersistentSession, toolCallId: string): Pro
}, QUESTION_ANSWER_TIMEOUT_MS);
session.pendingQuestions.set(toolCallId, { resolve, timer });
session.notify('input-needed');
});
}