authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-08 16:39:48-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 16:39:48-07:00
log3dfc134e6c1e2359400c56ea415e74fecf9d5c12
tree714428671226282cdea453ae955518066d9f2119
parentfdf34c4018352df41aa22fbaa774c25c6c1ff7da
signature Signed by PGP key B5690EEEBB952194

fix(relay): recheck queued iOS alerts and retain fast completions (#10849)


3 files changed, 192 insertions(+), 34 deletions(-)

infra/relay/src/agentActivity/AgentActivityPublisher.ts+1
...@@ -130,6 +130,7 @@ export const make = Effect.gen(function* () {...@@ -130,6 +130,7 @@ export const make = Effect.gen(function* () {
130 target,130 target,
131 aggregate,131 aggregate,
132 nowMs: now.epochMilliseconds,132 nowMs: now.epochMilliseconds,
133 replay: true,
133 });134 });
134 }),135 }),
135 publish: Effect.fn("relay.agent_activity_publisher.publish")(function* (input) {136 publish: Effect.fn("relay.agent_activity_publisher.publish")(function* (input) {
infra/relay/src/agentActivity/ApnsDeliveries.test.ts+110
...@@ -1862,3 +1862,113 @@ describe("live activity alert decisions", () => {...@@ -1862,3 +1862,113 @@ describe("live activity alert decisions", () => {
1862 ).toBeNull();1862 ).toBeNull();
1863 });1863 });
1864});1864});
1865
1866describe("queued iOS alert policy", () => {
1867 for (const scenario of ["enabled", "muted", "late"] as const) {
1868 it.effect(`checks the current policy for a ${scenario} completion`, () => {
1869 let sent = 0;
1870 const completed = { ...state, phase: "completed" as const };
1871 const prefs = JSON.parse(enabledPreferences);
1872 if (scenario === "muted") prefs.notifyOnCompletion = false;
1873 const payload = makeApnsDeliveryJobPayload({
1874 kind: "push_notification",
1875 userId: target.user_id,
1876 deviceId: target.device_id,
1877 token: "push",
1878 aggregate: null,
1879 notification: {
1880 title: "Thread",
1881 body: "Done: Project",
1882 environmentId: "env",
1883 threadId: "thread",
1884 deepLink: "/",
1885 phase: "completed",
1886 updatedAt: completed.updatedAt,
1887 },
1888 createdAt: completed.updatedAt,
1889 expiresAt: "1970-01-01T00:10:00.000Z",
1890 jobId: `delivery-policy-${scenario}`,
1891 });
1892 const signed = signApnsDeliveryJob({ secret: config.apnsDeliveryJobSigningSecret, payload });
1893 return Effect.gen(function* () {
1894 if (scenario === "late") yield* TestClock.adjust("3 minutes");
1895 const d = yield* ApnsDeliveries.ApnsDeliveries;
1896 yield* d.processSignedJob(signed);
1897 expect(sent).toBe(scenario === "enabled" ? 1 : 0);
1898 }).pipe(
1899 Effect.provide(
1900 makeLayer({
1901 attempts: [],
1902 config: signingConfig,
1903 currentTargets: [
1904 { ...target, push_token: "push", preferences_json: JSON.stringify(prefs) },
1905 ],
1906 currentActivityStates: [completed],
1907 execute: (request) =>
1908 Effect.sync(() => {
1909 sent++;
1910 return HttpClientResponse.fromWeb(request, new Response("", { status: 200 }));
1911 }),
1912 }),
1913 ),
1914 );
1915 });
1916 }
1917});
1918
1919describe("fast completion delivery", () => {
1920 it.effect("keeps a completion alert when work finishes before running delivery", () => {
1921 const queuedJobs: SignedApnsDeliveryJob[] = [];
1922 const old = {
1923 ...aggregate,
1924 activeCount: 0,
1925 activities: [
1926 {
1927 ...aggregate.activities[0]!,
1928 threadId: "old" as RelayAgentActivityState["threadId"],
1929 phase: "completed" as const,
1930 },
1931 ],
1932 };
1933 const device = { ...target, last_aggregate_json: JSON.stringify(old) };
1934 const done = {
1935 ...aggregate,
1936 activeCount: 0,
1937 activities: [{ ...aggregate.activities[0]!, phase: "completed" as const }],
1938 };
1939 return Effect.gen(function* () {
1940 const d = yield* ApnsDeliveries.ApnsDeliveries;
1941 yield* d.sendForTarget({ target: device, aggregate, nowMs: 0 });
1942 yield* d.sendForTarget({ target: device, aggregate: done, nowMs: 0 });
1943 expect(
1944 queuedJobs.some((x) => x.payload.alert !== null && x.payload.alert !== undefined),
1945 ).toBe(true);
1946 }).pipe(Effect.provide(makeLayer({ attempts: [], queuedJobs, currentTargets: [device] })));
1947 });
1948 it.effect("replays a newly visible completion without alerting", () => {
1949 const queuedJobs: SignedApnsDeliveryJob[] = [];
1950 const done = {
1951 ...aggregate,
1952 activeCount: 0,
1953 activities: [{ ...aggregate.activities[0]!, phase: "completed" as const }],
1954 };
1955 const previous = {
1956 ...aggregate,
1957 activities: [
1958 { ...aggregate.activities[0]!, threadId: "other" as RelayAgentActivityState["threadId"] },
1959 ],
1960 };
1961 const device = { ...target, last_aggregate_json: JSON.stringify(previous) };
1962 return Effect.gen(function* () {
1963 const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
1964 yield* deliveries.sendForTarget({
1965 target: device,
1966 aggregate: done,
1967 nowMs: 0,
1968 replay: true,
1969 });
1970 expect(queuedJobs).toHaveLength(1);
1971 expect(queuedJobs[0]?.payload.alert).toBeUndefined();
1972 }).pipe(Effect.provide(makeLayer({ attempts: [], queuedJobs })));
1973 });
1974});
infra/relay/src/agentActivity/ApnsDeliveries.ts+81-34
...@@ -181,7 +181,7 @@ function shouldUpdateLiveActivity(input: {...@@ -181,7 +181,7 @@ function shouldUpdateLiveActivity(input: {
181 // A thread finishing must never be throttled away: when a completion and a181 // A thread finishing must never be throttled away: when a completion and a
182 // new start land in the same window, activeCount is unchanged and the Done182 // new start land in the same window, activeCount is unchanged and the Done
183 // transition (and its alert) would otherwise be suppressed.183 // transition (and its alert) would otherwise be suppressed.
184 if (newlyTerminalRows(input.previousAggregate, input.nextAggregate).length > 0) {184 if (newlyTerminalRows(input.previousAggregate, input.nextAggregate, true).length > 0) {
185 return true;185 return true;
186 }186 }
187 const lastDeliveryAtMs =187 const lastDeliveryAtMs =
...@@ -228,6 +228,7 @@ function chooseLiveActivityDelivery(input: {...@@ -228,6 +228,7 @@ function chooseLiveActivityDelivery(input: {
228 readonly target: LiveActivities.TargetRow;228 readonly target: LiveActivities.TargetRow;
229 readonly aggregate: RelayAgentActivityAggregateState | null;229 readonly aggregate: RelayAgentActivityAggregateState | null;
230 readonly nowMs: number;230 readonly nowMs: number;
231 readonly replay?: boolean;
231}): ChosenLiveActivityDelivery | "suppressed" | null {232}): ChosenLiveActivityDelivery | "suppressed" | null {
232 const preferences = parsePreferences(input.target.preferences_json);233 const preferences = parsePreferences(input.target.preferences_json);
233 if (preferences?.liveActivitiesEnabled === false) {234 if (preferences?.liveActivitiesEnabled === false) {
...@@ -286,18 +287,20 @@ function chooseLiveActivityDelivery(input: {...@@ -286,18 +287,20 @@ function chooseLiveActivityDelivery(input: {
286 kind: "live_activity_update",287 kind: "live_activity_update",
287 token: input.target.activity_push_token,288 token: input.target.activity_push_token,
288 aggregate: nextAggregate,289 aggregate: nextAggregate,
289 alert:290 alert: input.replay
290 alertForAttentionTransition({291 ? null
291 previousAggregate,292 : (alertForAttentionTransition({
292 nextAggregate,293 previousAggregate,
293 preferences,294 nextAggregate,
294 }) ??295 preferences,
295 alertForNewlyTerminal({296 }) ??
296 previousAggregate,297 alertForNewlyTerminal({
297 nextAggregate,298 previousAggregate,
298 preferences,299 nextAggregate,
299 nowMs: input.nowMs,300 preferences,
300 }),301 nowMs: input.nowMs,
302 includeUnobserved: true,
303 })),
301 }304 }
302 : "suppressed";305 : "suppressed";
303}306}
...@@ -306,6 +309,7 @@ function chooseDelivery(input: {...@@ -306,6 +309,7 @@ function chooseDelivery(input: {
306 readonly target: LiveActivities.TargetRow;309 readonly target: LiveActivities.TargetRow;
307 readonly aggregate: RelayAgentActivityAggregateState | null;310 readonly aggregate: RelayAgentActivityAggregateState | null;
308 readonly nowMs: number;311 readonly nowMs: number;
312 readonly replay?: boolean;
309}): ChosenDelivery | null {313}): ChosenDelivery | null {
310 const liveActivityDelivery = chooseLiveActivityDelivery(input);314 const liveActivityDelivery = chooseLiveActivityDelivery(input);
311 if (liveActivityDelivery === "suppressed") {315 if (liveActivityDelivery === "suppressed") {
...@@ -314,7 +318,7 @@ function chooseDelivery(input: {...@@ -314,7 +318,7 @@ function chooseDelivery(input: {
314 if (liveActivityDelivery) {318 if (liveActivityDelivery) {
315 return liveActivityDelivery;319 return liveActivityDelivery;
316 }320 }
317 const notification = notificationForAggregate(input);321 const notification = input.replay ? null : notificationForAggregate(input);
318 return notification && input.target.push_token322 return notification && input.target.push_token
319 ? {323 ? {
320 kind: "push_notification",324 kind: "push_notification",
...@@ -519,6 +523,7 @@ export class ApnsDeliveries extends Context.Service<...@@ -519,6 +523,7 @@ export class ApnsDeliveries extends Context.Service<
519 readonly target: LiveActivities.TargetRow;523 readonly target: LiveActivities.TargetRow;
520 readonly aggregate: RelayAgentActivityAggregateState | null;524 readonly aggregate: RelayAgentActivityAggregateState | null;
521 readonly nowMs: number;525 readonly nowMs: number;
526 readonly replay?: boolean;
522 }) => Effect.Effect<RelayDeliveryResult | null, ApnsDeliveryError>;527 }) => Effect.Effect<RelayDeliveryResult | null, ApnsDeliveryError>;
523 readonly sendPushNotificationForTarget: (input: {528 readonly sendPushNotificationForTarget: (input: {
524 readonly target: LiveActivities.TargetRow;529 readonly target: LiveActivities.TargetRow;
...@@ -652,7 +657,7 @@ export const make = Effect.gen(function* () {...@@ -652,7 +657,7 @@ export const make = Effect.gen(function* () {
652 });657 });
653 });658 });
654659
655 const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: {660 const currentSignedJobTarget = Effect.fnUntraced(function* (input: {
656 readonly target: LiveActivityDeliveryTarget;661 readonly target: LiveActivityDeliveryTarget;
657 readonly kind: RelayDeliveryKind;662 readonly kind: RelayDeliveryKind;
658 readonly token: string;663 readonly token: string;
...@@ -660,10 +665,10 @@ export const make = Effect.gen(function* () {...@@ -660,10 +665,10 @@ export const make = Effect.gen(function* () {
660 return yield* liveActivities.listTargets({ userId: input.target.user_id }).pipe(665 return yield* liveActivities.listTargets({ userId: input.target.user_id }).pipe(
661 Effect.map((targets) => {666 Effect.map((targets) => {
662 const currentTarget = targets.find((row) => row.device_id === input.target.device_id);667 const currentTarget = targets.find((row) => row.device_id === input.target.device_id);
663 return (668 return currentTarget &&
664 currentTarget !== undefined &&
665 expectedCurrentToken({ target: currentTarget, kind: input.kind }) === input.token669 expectedCurrentToken({ target: currentTarget, kind: input.kind }) === input.token
666 );670 ? currentTarget
671 : null;
667 }),672 }),
668 );673 );
669 });674 });
...@@ -679,11 +684,7 @@ export const make = Effect.gen(function* () {...@@ -679,11 +684,7 @@ export const make = Effect.gen(function* () {
679 const now = yield* DateTime.now;684 const now = yield* DateTime.now;
680 const aggregate =685 const aggregate =
681 input.aggregate === null ? null : sanitizeAgentActivityAggregateState(input.aggregate);686 input.aggregate === null ? null : sanitizeAgentActivityAggregateState(input.aggregate);
682 const { epochSeconds, iso, request } = makeLiveActivityDeliveryRequest(687 let alert = input.alert ?? null;
683 apns,
684 { ...input, aggregate } as SendLiveActivityDeliveryInput,
685 now,
686 );
687 const recoverTransportError = (cause: Apns.ApnsError) =>688 const recoverTransportError = (cause: Apns.ApnsError) =>
688 recoverApnsDeliveryTransportError(689 recoverApnsDeliveryTransportError(
689 {690 {
...@@ -709,18 +710,37 @@ export const make = Effect.gen(function* () {...@@ -709,18 +710,37 @@ export const make = Effect.gen(function* () {
709 if (claim === "in_flight") {710 if (claim === "in_flight") {
710 return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId });711 return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId });
711 }712 }
712 const tokenIsCurrent = yield* isCurrentSignedJobToken({713 const currentTarget = yield* currentSignedJobTarget({
713 target: input.target,714 target: input.target,
714 kind: input.kind,715 kind: input.kind,
715 token: input.token,716 token: input.token,
716 });717 });
717 if (!tokenIsCurrent) {718 if (!currentTarget) {
718 yield* attempts.completeSourceJob({719 yield* attempts.completeSourceJob({
719 sourceJobId: input.sourceJobId,720 sourceJobId: input.sourceJobId,
720 apnsReason: "Stale APNs delivery job skipped.",721 apnsReason: "Stale APNs delivery job skipped.",
721 });722 });
722 return staleJobResult({ deviceId: input.target.device_id, kind: input.kind });723 return staleJobResult({ deviceId: input.target.device_id, kind: input.kind });
723 }724 }
725 if (alert) {
726 const preferences = parsePreferences(currentTarget.preferences_json);
727 const previousAggregate = parseAggregate(currentTarget.last_aggregate_json);
728 alert =
729 !preferences?.notificationsEnabled || !aggregate
730 ? null
731 : (alertForAttentionTransition({
732 previousAggregate,
733 nextAggregate: aggregate,
734 preferences,
735 }) ??
736 alertForNewlyTerminal({
737 previousAggregate,
738 nextAggregate: aggregate,
739 preferences,
740 nowMs: now.epochMilliseconds,
741 includeUnobserved: true,
742 }));
743 }
724 if (744 if (
725 input.kind !== "live_activity_start" &&745 input.kind !== "live_activity_start" &&
726 aggregate !== null &&746 aggregate !== null &&
...@@ -752,6 +772,11 @@ export const make = Effect.gen(function* () {...@@ -752,6 +772,11 @@ export const make = Effect.gen(function* () {
752 }772 }
753 return staleJobResult({ deviceId: input.target.device_id, kind: input.kind });773 return staleJobResult({ deviceId: input.target.device_id, kind: input.kind });
754 }774 }
775 const { epochSeconds, iso, request } = makeLiveActivityDeliveryRequest(
776 apns,
777 { ...input, aggregate, alert } as SendLiveActivityDeliveryInput,
778 now,
779 );
755 const result = yield* apns780 const result = yield* apns
756 .sendLiveActivityRequest({781 .sendLiveActivityRequest({
757 credentials: credentialsForTarget(config.apns, input.target),782 credentials: credentialsForTarget(config.apns, input.target),
...@@ -859,12 +884,12 @@ export const make = Effect.gen(function* () {...@@ -859,12 +884,12 @@ export const make = Effect.gen(function* () {
859 if (claim === "in_flight") {884 if (claim === "in_flight") {
860 return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId });885 return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId });
861 }886 }
862 const tokenIsCurrent = yield* isCurrentSignedJobToken({887 const currentTarget = yield* currentSignedJobTarget({
863 target: input.target,888 target: input.target,
864 kind: "push_notification",889 kind: "push_notification",
865 token: input.token,890 token: input.token,
866 });891 });
867 if (!tokenIsCurrent) {892 if (!currentTarget) {
868 yield* attempts.completeSourceJob({893 yield* attempts.completeSourceJob({
869 sourceJobId: input.sourceJobId,894 sourceJobId: input.sourceJobId,
870 apnsReason: "Stale APNs delivery job skipped.",895 apnsReason: "Stale APNs delivery job skipped.",
...@@ -889,6 +914,24 @@ export const make = Effect.gen(function* () {...@@ -889,6 +914,24 @@ export const make = Effect.gen(function* () {
889 kind: "push_notification",914 kind: "push_notification",
890 });915 });
891 }916 }
917 const preferences = parsePreferences(currentTarget.preferences_json);
918 const alertAllowed =
919 notification.phase !== undefined && notification.updatedAt !== undefined
920 ? shouldAlertForActivity({
921 ...notification,
922 phase: notification.phase,
923 updatedAt: notification.updatedAt,
924 preferences,
925 nowMs: now.epochMilliseconds,
926 })
927 : preferences?.notificationsEnabled === true;
928 if (!alertAllowed) {
929 yield* attempts.completeSourceJob({
930 sourceJobId: input.sourceJobId,
931 apnsReason: "Notification is disabled or no longer fresh.",
932 });
933 return staleJobResult({ deviceId: input.target.device_id, kind: "push_notification" });
934 }
892 }935 }
893 const result = yield* apns936 const result = yield* apns
894 .sendPushNotificationRequest({937 .sendPushNotificationRequest({
...@@ -1057,6 +1100,7 @@ export const make = Effect.gen(function* () {...@@ -1057,6 +1100,7 @@ export const make = Effect.gen(function* () {
1057 target: input.target,1100 target: input.target,
1058 aggregate: input.aggregate,1101 aggregate: input.aggregate,
1059 nowMs: input.nowMs,1102 nowMs: input.nowMs,
1103 replay: input.replay ?? false,
1060 });1104 });
1061 if (!delivery) {1105 if (!delivery) {
1062 return null;1106 return null;
...@@ -1072,17 +1116,20 @@ export const make = Effect.gen(function* () {...@@ -1072,17 +1116,20 @@ export const make = Effect.gen(function* () {
1072 });1116 });
1073 return result;1117 return result;
1074 }1118 }
1075 const notification = notificationForAggregate({1119 const notification = input.replay
1076 target: input.target,1120 ? null
1077 aggregate: input.aggregate,1121 : notificationForAggregate({
1078 nowMs: input.nowMs,1122 target: input.target,
1079 });1123 aggregate: input.aggregate,
1124 nowMs: input.nowMs,
1125 });
1080 // The end event doubles as the "task finished" moment. When a companion1126 // The end event doubles as the "task finished" moment. When a companion
1081 // push notification is about to ring the device (below), the activity end1127 // push notification is about to ring the device (below), the activity end
1082 // stays silent; otherwise the end itself carries the alert so LA-only1128 // stays silent; otherwise the end itself carries the alert so LA-only
1083 // users still get the buzz.1129 // users still get the buzz.
1084 const alert =1130 const alert = input.replay
1085 delivery.kind === "live_activity_end"1131 ? null
1132 : delivery.kind === "live_activity_end"
1086 ? notification && input.target.push_token1133 ? notification && input.target.push_token
1087 ? null1134 ? null
1088 : alertForTerminalAggregate({1135 : alertForTerminalAggregate({