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:
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user