Skip to content

Commit c88cec1

Browse files
committed
Apply the terminal freshness window to Live Activity alerts too
The freshness gate added for terminal push notifications left Live Activity alerts unguarded: alertForNewlyTerminal could buzz on a restart replay whenever the device's last delivered aggregate still showed the thread as live, and the end-event fallback rang via alertForTerminalAggregate precisely when the companion push had been silenced for staleness. Both alert helpers now take nowMs and share the same isFreshTerminalRow window as notificationForAggregate, so a stale completion stays silent on every channel.
1 parent 387d068 commit c88cec1

2 files changed

Lines changed: 61 additions & 14 deletions

File tree

infra/relay/src/agentActivity/ApnsDeliveries.test.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1525,15 +1525,30 @@ describe("live activity alert decisions", () => {
15251525
activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, status: "Done" }],
15261526
};
15271527
expect(
1528-
ApnsDeliveries.alertForTerminalAggregate({ aggregate: terminalAggregate, preferences }),
1528+
ApnsDeliveries.alertForTerminalAggregate({
1529+
aggregate: terminalAggregate,
1530+
preferences,
1531+
nowMs: 0,
1532+
}),
15291533
).toEqual({ title: "Thread", body: "Done: Project" });
15301534
expect(
15311535
ApnsDeliveries.alertForTerminalAggregate({
15321536
aggregate: terminalAggregate,
15331537
preferences: { ...preferences, notifyOnCompletion: false },
1538+
nowMs: 0,
1539+
}),
1540+
).toBeNull();
1541+
expect(
1542+
ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences, nowMs: 0 }),
1543+
).toBeNull();
1544+
// A completion replayed long after the fact must not ring again.
1545+
expect(
1546+
ApnsDeliveries.alertForTerminalAggregate({
1547+
aggregate: terminalAggregate,
1548+
preferences,
1549+
nowMs: 10 * 60 * 1_000,
15341550
}),
15351551
).toBeNull();
1536-
expect(ApnsDeliveries.alertForTerminalAggregate({ aggregate: null, preferences })).toBeNull();
15371552
});
15381553

15391554
it("alerts when a previously active thread finishes mid-flight", () => {
@@ -1552,6 +1567,7 @@ describe("live activity alert decisions", () => {
15521567
previousAggregate: aggregate,
15531568
nextAggregate: next,
15541569
preferences,
1570+
nowMs: 0,
15551571
}),
15561572
).toEqual({ title: "Thread", body: "Done: Project" });
15571573
// The completion switch mutes it.
@@ -1560,6 +1576,7 @@ describe("live activity alert decisions", () => {
15601576
previousAggregate: aggregate,
15611577
nextAggregate: next,
15621578
preferences: { ...preferences, notifyOnCompletion: false },
1579+
nowMs: 0,
15631580
}),
15641581
).toBeNull();
15651582
// No baseline means no transition to ring on.
@@ -1568,6 +1585,7 @@ describe("live activity alert decisions", () => {
15681585
previousAggregate: null,
15691586
nextAggregate: next,
15701587
preferences,
1588+
nowMs: 0,
15711589
}),
15721590
).toBeNull();
15731591
// A Done row that was already terminal (or absent) before stays silent.
@@ -1576,13 +1594,25 @@ describe("live activity alert decisions", () => {
15761594
previousAggregate: next,
15771595
nextAggregate: next,
15781596
preferences,
1597+
nowMs: 0,
15791598
}),
15801599
).toBeNull();
15811600
expect(
15821601
ApnsDeliveries.alertForNewlyTerminal({
15831602
previousAggregate: { ...aggregate, activities: [attentionRow] },
15841603
nextAggregate: next,
15851604
preferences,
1605+
nowMs: 0,
1606+
}),
1607+
).toBeNull();
1608+
// A stale completion replayed after a restart (previous aggregate still
1609+
// shows the thread as running) must not ring the device again.
1610+
expect(
1611+
ApnsDeliveries.alertForNewlyTerminal({
1612+
previousAggregate: aggregate,
1613+
nextAggregate: next,
1614+
preferences,
1615+
nowMs: 10 * 60 * 1_000,
15861616
}),
15871617
).toBeNull();
15881618
});

infra/relay/src/agentActivity/ApnsDeliveries.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,20 @@ export function alertForAttentionTransition(input: {
210210
};
211211
}
212212

213+
// Completions replayed long after the fact (server restarts republish every
214+
// recently-finished thread) must not ring the device again — on any channel:
215+
// push notifications, Live Activity update alerts, and end alerts all honor
216+
// the same freshness window.
217+
const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;
218+
219+
function isFreshTerminalRow(row: { readonly updatedAt: string }, nowMs: number): boolean {
220+
const updatedAtMs = Option.match(DateTime.make(row.updatedAt), {
221+
onNone: () => null,
222+
onSome: (dt) => dt.epochMilliseconds,
223+
});
224+
return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS;
225+
}
226+
213227
// Alert copy for an update whose aggregate contains threads that finished
214228
// (Done/Failed) since the previously delivered aggregate — the mid-flight
215229
// completion buzz while other agents keep the activity alive. Requires the
@@ -219,6 +233,7 @@ export function alertForNewlyTerminal(input: {
219233
readonly previousAggregate: RelayAgentActivityAggregateState | null;
220234
readonly nextAggregate: RelayAgentActivityAggregateState;
221235
readonly preferences: RelayAgentAwarenessPreferences | null;
236+
readonly nowMs: number;
222237
}): ApnsLiveActivityAlert | null {
223238
if (input.previousAggregate === null) {
224239
return null;
@@ -230,6 +245,9 @@ export function alertForNewlyTerminal(input: {
230245
if (row.phase !== "completed" && row.phase !== "failed") {
231246
return false;
232247
}
248+
if (!isFreshTerminalRow(row, input.nowMs)) {
249+
return false;
250+
}
233251
const previousPhase = previousPhases.get(row.threadId);
234252
return (
235253
previousPhase !== undefined &&
@@ -255,11 +273,15 @@ export function alertForNewlyTerminal(input: {
255273
export function alertForTerminalAggregate(input: {
256274
readonly aggregate: RelayAgentActivityAggregateState | null;
257275
readonly preferences: RelayAgentAwarenessPreferences | null;
276+
readonly nowMs: number;
258277
}): ApnsLiveActivityAlert | null {
259278
const row = input.aggregate?.activities[0];
260279
if (!row || (row.phase !== "completed" && row.phase !== "failed")) {
261280
return null;
262281
}
282+
if (!isFreshTerminalRow(row, input.nowMs)) {
283+
return null;
284+
}
263285
if (!alertAllowedForPhase(input.preferences, row.phase)) {
264286
return null;
265287
}
@@ -298,10 +320,6 @@ function shouldUpdateLiveActivity(input: {
298320
);
299321
}
300322

301-
// Completions replayed long after the fact (server restarts republish every
302-
// recently-finished thread) must not ring the device again.
303-
const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;
304-
305323
function notificationForAggregate(input: {
306324
readonly target: LiveActivities.TargetRow;
307325
readonly aggregate: RelayAgentActivityAggregateState | null;
@@ -318,14 +336,11 @@ function notificationForAggregate(input: {
318336
if (!activity) {
319337
return null;
320338
}
321-
if (activity.phase === "completed" || activity.phase === "failed") {
322-
const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), {
323-
onNone: () => null,
324-
onSome: (dt) => dt.epochMilliseconds,
325-
});
326-
if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) {
327-
return null;
328-
}
339+
if (
340+
(activity.phase === "completed" || activity.phase === "failed") &&
341+
!isFreshTerminalRow(activity, input.nowMs)
342+
) {
343+
return null;
329344
}
330345
const enabled =
331346
(activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) ||
@@ -419,6 +434,7 @@ function chooseLiveActivityDelivery(input: {
419434
previousAggregate,
420435
nextAggregate,
421436
preferences,
437+
nowMs: input.nowMs,
422438
}),
423439
}
424440
: "suppressed";
@@ -1100,6 +1116,7 @@ export const make = Effect.gen(function* () {
11001116
: alertForTerminalAggregate({
11011117
aggregate: delivery.aggregate,
11021118
preferences: parsePreferences(input.target.preferences_json),
1119+
nowMs: input.nowMs,
11031120
})
11041121
: delivery.alert;
11051122
const result = yield* deliveryQueue.enqueueLiveActivity({

0 commit comments

Comments
 (0)