@webex/plugin-meetings 3.12.0-next.64 → 3.12.0-next.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -136,6 +136,7 @@ import {
136
136
  STAGE_MANAGER_TYPE,
137
137
  LOCUSEVENT,
138
138
  LOCUS_LLM_EVENT,
139
+ LLM_DEFAULT_SESSION,
139
140
  LLM_PRACTICE_SESSION,
140
141
  } from '../constants';
141
142
  import BEHAVIORAL_METRICS from '../metrics/constants';
@@ -3507,8 +3508,6 @@ export default class Meeting extends StatelessWebexPlugin {
3507
3508
  this.recordingController.setLocusUrl(this.locusUrl);
3508
3509
  this.controlsOptionsManager.setLocusUrl(this.locusUrl, !!isMainLocus);
3509
3510
  this.webinar.locusUrlUpdate(url);
3510
- // @ts-ignore
3511
- this.webex.internal.llm.setRefreshHandler(() => this.refreshDataChannelToken());
3512
3511
 
3513
3512
  Trigger.trigger(
3514
3513
  this,
@@ -3777,7 +3776,7 @@ export default class Meeting extends StatelessWebexPlugin {
3777
3776
  });
3778
3777
  this.updateLLMConnection();
3779
3778
  });
3780
- this.locusInfo.on(LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, (payload) => {
3779
+ this.locusInfo.on(LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, async (payload) => {
3781
3780
  this.stopKeepAlive();
3782
3781
 
3783
3782
  if (payload) {
@@ -3804,13 +3803,15 @@ export default class Meeting extends StatelessWebexPlugin {
3804
3803
  }
3805
3804
  this.rtcMetrics?.sendNextMetrics();
3806
3805
 
3807
- this.ensureDefaultDatachannelTokenAfterAdmit().catch((error) => {
3806
+ try {
3807
+ await this.ensureDefaultDatachannelTokenAfterAdmit();
3808
+ } catch (error) {
3808
3809
  LoggerProxy.logger.warn(
3809
3810
  `Meeting:index#setUpLocusInfoSelfListener --> failed post-admit token prefetch flow: ${
3810
3811
  error?.message || String(error)
3811
3812
  }`
3812
3813
  );
3813
- });
3814
+ }
3814
3815
 
3815
3816
  this.updateLLMConnection();
3816
3817
  });
@@ -6499,16 +6500,23 @@ export default class Meeting extends StatelessWebexPlugin {
6499
6500
  throwOnError?: boolean;
6500
6501
  } = {}): Promise<void> => {
6501
6502
  // @ts-ignore - Fix type
6502
- const currentOwner = this.webex.internal.llm.getOwnerMeetingId();
6503
- const isOwner = !currentOwner || currentOwner === this.id;
6503
+ // @ts-ignore - Fix type
6504
+ const {currentOwner, isOwner} = this.webex.internal.llm.resolveSessionOwnership(
6505
+ this.id,
6506
+ LLM_DEFAULT_SESSION
6507
+ );
6504
6508
 
6505
6509
  try {
6506
6510
  if (isOwner) {
6507
6511
  // @ts-ignore - Fix type
6508
- await this.webex.internal.llm.disconnectLLM({
6509
- code: 3050,
6510
- reason: 'done (permanent)',
6511
- });
6512
+ await this.webex.internal.llm.disconnectLLM(
6513
+ {
6514
+ code: 3050,
6515
+ reason: 'done (permanent)',
6516
+ },
6517
+ LLM_DEFAULT_SESSION,
6518
+ this.id
6519
+ );
6512
6520
  } else {
6513
6521
  LoggerProxy.logger.info(
6514
6522
  `Meeting:index#cleanupLLMConneciton --> skipping disconnect; LLM owned by meeting ${currentOwner}, not ${this.id}`
@@ -6530,27 +6538,31 @@ export default class Meeting extends StatelessWebexPlugin {
6530
6538
  }
6531
6539
  this.stopListeningForLLMEvents();
6532
6540
 
6533
- // If this meeting owned (or could have owned) the default LLM session,
6534
- // always release the owner tag here regardless of whether disconnectLLM
6535
- // resolved. `disconnectLLM` only clears the owner on its success path,
6536
- // so a failed disconnect would otherwise leave a stale owner pointing
6537
- // at a torn-down meeting and permanently block other meetings'
6538
- // `updateLLMConnection` calls via the ownership guard.
6541
+ // Re-check ownership after awaiting disconnectLLM. If ownership changed
6542
+ // while cleanup was in flight, do not clear another meeting's owner tag.
6539
6543
  if (isOwner) {
6540
- // @ts-ignore - Fix type
6541
- this.webex.internal.llm.setOwnerMeetingId?.(undefined);
6544
+ const {currentOwner: currentOwnerAfterCleanup} =
6545
+ // @ts-ignore - Fix type
6546
+ this.webex.internal.llm.resolveSessionOwnership(this.id, LLM_DEFAULT_SESSION);
6547
+
6548
+ if (currentOwnerAfterCleanup === this.id) {
6549
+ // @ts-ignore - Fix type
6550
+ this.webex.internal.llm.setOwnerMeetingId?.(undefined);
6551
+ }
6542
6552
  }
6543
6553
  }
6544
6554
  };
6545
6555
 
6546
6556
  /**
6547
- * Clears all data channel tokens stored in LLM.
6548
- * Called during meeting cleanup to ensure stale tokens are not reused.
6557
+ * Clears data channel tokens associated with this meeting ownership.
6558
+ * Ownership checks are enforced in internal-plugin-llm.
6549
6559
  * @returns {void}
6550
6560
  */
6551
6561
  clearDataChannelToken(): void {
6552
6562
  // @ts-ignore
6553
- this.webex.internal.llm.resetDatachannelTokens();
6563
+ this.webex.internal.llm.clearDatachannelToken(LLM_DEFAULT_SESSION, this.id);
6564
+ // @ts-ignore
6565
+ this.webex.internal.llm.clearDatachannelToken(LLM_PRACTICE_SESSION, this.id);
6554
6566
  }
6555
6567
 
6556
6568
  /**
@@ -6565,14 +6577,15 @@ export default class Meeting extends StatelessWebexPlugin {
6565
6577
 
6566
6578
  if (datachannelToken) {
6567
6579
  // @ts-ignore
6568
- this.webex.internal.llm.setDatachannelToken(datachannelToken, DataChannelTokenType.Default);
6580
+ this.webex.internal.llm.setDatachannelToken(datachannelToken, LLM_DEFAULT_SESSION, this.id);
6569
6581
  }
6570
6582
 
6571
6583
  if (practiceSessionDatachannelToken) {
6572
6584
  // @ts-ignore
6573
6585
  this.webex.internal.llm.setDatachannelToken(
6574
6586
  practiceSessionDatachannelToken,
6575
- DataChannelTokenType.PracticeSession
6587
+ LLM_PRACTICE_SESSION,
6588
+ this.id
6576
6589
  );
6577
6590
  }
6578
6591
  }
@@ -6585,7 +6598,10 @@ export default class Meeting extends StatelessWebexPlugin {
6585
6598
  private async ensureDefaultDatachannelTokenAfterAdmit(): Promise<boolean> {
6586
6599
  try {
6587
6600
  // @ts-ignore
6588
- const datachannelToken = this.webex.internal.llm.getDatachannelToken();
6601
+ const datachannelToken = this.webex.internal.llm.getDatachannelToken(
6602
+ LLM_DEFAULT_SESSION,
6603
+ this.id
6604
+ );
6589
6605
  // @ts-ignore
6590
6606
  const isDataChannelTokenEnabled = await this.webex.internal.llm.isDataChannelTokenEnabled();
6591
6607
 
@@ -6607,7 +6623,8 @@ export default class Meeting extends StatelessWebexPlugin {
6607
6623
  // @ts-ignore
6608
6624
  this.webex.internal.llm.setDatachannelToken(
6609
6625
  fetchedDatachannelToken,
6610
- DataChannelTokenType.Default
6626
+ LLM_DEFAULT_SESSION,
6627
+ this.id
6611
6628
  );
6612
6629
 
6613
6630
  return true;
@@ -6634,11 +6651,6 @@ export default class Meeting extends StatelessWebexPlugin {
6634
6651
 
6635
6652
  const isJoined = this.isJoined();
6636
6653
 
6637
- // @ts-ignore
6638
- const datachannelToken = this.webex.internal.llm.getDatachannelToken(
6639
- DataChannelTokenType.Default
6640
- );
6641
-
6642
6654
  const dataChannelUrl = datachannelUrl;
6643
6655
 
6644
6656
  // Ownership guard: when the default LLM session is already connected and
@@ -6648,10 +6660,33 @@ export default class Meeting extends StatelessWebexPlugin {
6648
6660
  // connection when this meeting is the current owner, or when no owner is
6649
6661
  // set yet (first claim).
6650
6662
  // @ts-ignore - Fix type
6651
- const currentOwner = this.webex.internal.llm.getOwnerMeetingId();
6663
+ const {currentOwner} = this.webex.internal.llm.resolveSessionOwnership(
6664
+ this.id,
6665
+ LLM_DEFAULT_SESSION
6666
+ );
6652
6667
 
6668
+ // Capture connectivity before any reconnect attempt. If LLM was already
6669
+ // connected, we must respect current ownership. If it was disconnected,
6670
+ // this flow may reclaim stale owner tags after a fresh connect.
6653
6671
  // @ts-ignore - Fix type
6654
- if (this.webex.internal.llm.isConnected()) {
6672
+ const wasConnected = this.webex.internal.llm.isConnected();
6673
+
6674
+ // Prefer ownership-scoped token read. For disconnected stale-owner reclaim
6675
+ // flows, fallback to ownerless read so initial register can still carry a
6676
+ // token and recover from stale ownership without 401/403 dead-end.
6677
+ // @ts-ignore - Fix type
6678
+ let datachannelToken = this.webex.internal.llm.getDatachannelToken(
6679
+ LLM_DEFAULT_SESSION,
6680
+ this.id
6681
+ );
6682
+
6683
+ if (!datachannelToken && !wasConnected && currentOwner && currentOwner !== this.id) {
6684
+ // @ts-ignore - Fix type
6685
+ datachannelToken = this.webex.internal.llm.getDatachannelToken(LLM_DEFAULT_SESSION);
6686
+ }
6687
+
6688
+ // @ts-ignore - Fix type
6689
+ if (wasConnected) {
6655
6690
  if (currentOwner && currentOwner !== this.id) {
6656
6691
  // Another meeting owns the live LLM socket. We must not disconnect
6657
6692
  // or reconfigure it -- doing so would tear down a session the
@@ -6684,6 +6719,20 @@ export default class Meeting extends StatelessWebexPlugin {
6684
6719
  return undefined;
6685
6720
  }
6686
6721
 
6722
+ // Bind refresh handler before registration so interceptor-triggered token
6723
+ // refresh during register POST can resolve a valid handler.
6724
+ // Prefer this meeting as owner, but allow owner-less fallback when a stale
6725
+ // foreign owner tag is present on a disconnected session.
6726
+ const refreshHandlerOwnerMeetingId =
6727
+ currentOwner && currentOwner !== this.id ? undefined : this.id;
6728
+ const shouldAlignRefreshHandlerAfterOwnershipClaim = refreshHandlerOwnerMeetingId !== this.id;
6729
+ // @ts-ignore - Fix type
6730
+ this.webex.internal.llm.setRefreshHandler(
6731
+ () => this.refreshDataChannelToken(),
6732
+ LLM_DEFAULT_SESSION,
6733
+ refreshHandlerOwnerMeetingId
6734
+ );
6735
+
6687
6736
  // @ts-ignore - Fix type
6688
6737
  return this.webex.internal.llm
6689
6738
  .registerAndConnect(url, dataChannelUrl, datachannelToken)
@@ -6693,7 +6742,33 @@ export default class Meeting extends StatelessWebexPlugin {
6693
6742
  // subsequent cross-meeting `updateLLMConnection` / `cleanupLLMConneciton`
6694
6743
  // calls can detect and skip work that doesn't belong to them.
6695
6744
  // @ts-ignore - Fix type
6696
- this.webex.internal.llm.setOwnerMeetingId?.(this.id);
6745
+ const {isOwner} = this.webex.internal.llm.resolveSessionOwnership(
6746
+ this.id,
6747
+ LLM_DEFAULT_SESSION
6748
+ );
6749
+ const canReclaimAfterDisconnectedStart = !wasConnected;
6750
+
6751
+ // Refresh handler is pre-bound before registerAndConnect so token
6752
+ // refresh can work even during the registration request itself.
6753
+ if (isOwner || canReclaimAfterDisconnectedStart) {
6754
+ // Record ownership of the default LLM session for this meeting so
6755
+ // subsequent cross-meeting `updateLLMConnection` / `cleanupLLMConneciton`
6756
+ // calls can detect and skip work that doesn't belong to them.
6757
+ // @ts-ignore - Fix type
6758
+ this.webex.internal.llm.setOwnerMeetingId?.(this.id);
6759
+
6760
+ // If we pre-bound refresh ownerlessly (stale-owner reclaim path),
6761
+ // align the handler with the newly claimed owner immediately after
6762
+ // ownership is updated.
6763
+ if (shouldAlignRefreshHandlerAfterOwnershipClaim) {
6764
+ // @ts-ignore - Fix type
6765
+ this.webex.internal.llm.setRefreshHandler(
6766
+ () => this.refreshDataChannelToken(),
6767
+ LLM_DEFAULT_SESSION,
6768
+ this.id
6769
+ );
6770
+ }
6771
+ }
6697
6772
  // @ts-ignore - Fix type
6698
6773
  this.webex.internal.llm.off('event:relay.event', this.processRelayEvent);
6699
6774
  // @ts-ignore - Fix type
@@ -9991,21 +10066,8 @@ export default class Meeting extends StatelessWebexPlugin {
9991
10066
  // again would double-emit MEETING_STOPPED_RECEIVING_TRANSCRIPTION
9992
10067
  // because stopTranscription() always fires its trigger.
9993
10068
  //
9994
- // Ownership-aware token clear: only clear the shared LLM data channel
9995
- // tokens when this meeting owns (or no meeting owns) the default LLM
9996
- // session. Otherwise we would wipe tokens still in use by another
9997
- // meeting's active LLM connection.
9998
- // @ts-ignore - Fix type
9999
- const currentOwner = this.webex.internal.llm.getOwnerMeetingId();
10000
- const isOwner = !currentOwner || currentOwner === this.id;
10001
-
10002
- if (isOwner) {
10003
- this.clearDataChannelToken();
10004
- } else {
10005
- LoggerProxy.logger.info(
10006
- `Meeting:index#clearMeetingData --> skipping clearDataChannelToken; LLM owned by meeting ${currentOwner}, not ${this.id}`
10007
- );
10008
- }
10069
+ // Ownership-aware token clear is encapsulated inside clearDataChannelToken().
10070
+ this.clearDataChannelToken();
10009
10071
 
10010
10072
  await this.cleanupLLMConneciton({throwOnError: false});
10011
10073
  };
@@ -4,7 +4,6 @@
4
4
  import {WebexPlugin, config} from '@webex/webex-core';
5
5
  import uuid from 'uuid';
6
6
  import {get} from 'lodash';
7
- import {DataChannelTokenType} from '@webex/internal-plugin-llm';
8
7
  import {
9
8
  _ID_,
10
9
  HEADERS,
@@ -14,6 +13,7 @@ import {
14
13
  SHARE_STATUS,
15
14
  DEFAULT_LARGE_SCALE_WEBINAR_ATTENDEE_SEARCH_LIMIT,
16
15
  LLM_PRACTICE_SESSION,
16
+ LOCUS_LLM_EVENT,
17
17
  } from '../constants';
18
18
 
19
19
  import WebinarCollection from './collection';
@@ -21,6 +21,19 @@ import LoggerProxy from '../common/logs/logger-proxy';
21
21
  import MeetingUtil from '../meeting/util';
22
22
  import {sanitizeParams} from './utils';
23
23
 
24
+ const PS_LLM_EVENTS = [
25
+ {
26
+ event: `event:relay.event:${LLM_PRACTICE_SESSION}`,
27
+ listenerKey: 'relay',
28
+ handlerKey: 'processRelayEvent',
29
+ },
30
+ {
31
+ event: `${LOCUS_LLM_EVENT}:${LLM_PRACTICE_SESSION}`,
32
+ listenerKey: 'locusLLM',
33
+ handlerKey: 'processLocusLLMEvent',
34
+ },
35
+ ];
36
+
24
37
  /**
25
38
  * @class Webinar
26
39
  */
@@ -171,28 +184,60 @@ const Webinar = WebexPlugin.extend({
171
184
  * @returns {Promise<void>}
172
185
  */
173
186
  async cleanupPSDataChannel() {
187
+ const {isOwner} = this.webex.internal.llm.resolveSessionOwnership(
188
+ this.meetingId,
189
+ LLM_PRACTICE_SESSION
190
+ );
191
+ this.llmListeners = this.llmListeners || {};
192
+
174
193
  if (this._pendingOnlineListener) {
175
194
  // @ts-ignore - Fix type
176
195
  this.webex.internal.llm.off('online', this._pendingOnlineListener);
177
196
  this._pendingOnlineListener = null;
178
197
  }
179
198
 
180
- // @ts-ignore - Fix type
181
- await this.webex.internal.llm.disconnectLLM(
182
- {
183
- code: 3050,
184
- reason: 'done (permanent)',
185
- },
186
- LLM_PRACTICE_SESSION
187
- );
188
-
189
- if (this._practiceSessionRelayListener) {
199
+ try {
190
200
  // @ts-ignore - Fix type
191
- this.webex.internal.llm.off(
192
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
193
- this._practiceSessionRelayListener
201
+ const disconnected = await this.webex.internal.llm.disconnectLLM(
202
+ {
203
+ code: 3050,
204
+ reason: 'done (permanent)',
205
+ },
206
+ LLM_PRACTICE_SESSION,
207
+ this.meetingId
194
208
  );
209
+
210
+ if (!disconnected) {
211
+ LoggerProxy.logger.info(
212
+ `Webinar:index#cleanupPSDataChannel --> skipping disconnect; practice-session LLM is not owned by meeting ${this.meetingId}`
213
+ );
214
+ }
215
+ } catch (error) {
216
+ // disconnectLLM clears ownership only on success; release a stale owner
217
+ // tag here so other meeting instances can reclaim practice-session LLM.
218
+ if (isOwner) {
219
+ // @ts-ignore - Fix type
220
+ this.webex.internal.llm.setOwnerMeetingId?.(undefined, LLM_PRACTICE_SESSION);
221
+ }
222
+
223
+ throw error;
224
+ } finally {
225
+ if (this._practiceSessionRelayListener) {
226
+ // @ts-ignore - Fix type
227
+ this.webex.internal.llm.off(
228
+ `event:relay.event:${LLM_PRACTICE_SESSION}`,
229
+ this._practiceSessionRelayListener
230
+ );
231
+ }
195
232
  this._practiceSessionRelayListener = null;
233
+
234
+ for (const {event, listenerKey} of PS_LLM_EVENTS) {
235
+ if (this.llmListeners[listenerKey]) {
236
+ // @ts-ignore - Fix type
237
+ this.webex.internal.llm.off(event, this.llmListeners[listenerKey]);
238
+ this.llmListeners[listenerKey] = null;
239
+ }
240
+ }
196
241
  }
197
242
  },
198
243
 
@@ -213,7 +258,8 @@ const Webinar = WebexPlugin.extend({
213
258
 
214
259
  // @ts-ignore
215
260
  const cachedToken = this.webex.internal.llm.getDatachannelToken(
216
- DataChannelTokenType.PracticeSession
261
+ LLM_PRACTICE_SESSION,
262
+ this.meetingId
217
263
  );
218
264
 
219
265
  if (cachedToken) {
@@ -231,7 +277,8 @@ const Webinar = WebexPlugin.extend({
231
277
  // @ts-ignore
232
278
  this.webex.internal.llm.setDatachannelToken(
233
279
  datachannelToken,
234
- dataChannelTokenType || DataChannelTokenType.PracticeSession
280
+ dataChannelTokenType || LLM_PRACTICE_SESSION,
281
+ this.meetingId
235
282
  );
236
283
 
237
284
  return datachannelToken;
@@ -252,11 +299,17 @@ const Webinar = WebexPlugin.extend({
252
299
  * @returns {Promise}
253
300
  */
254
301
  async updatePSDataChannel() {
302
+ this.llmListeners = this.llmListeners || {};
303
+
255
304
  this._updatePSDataChannelSequence = (this._updatePSDataChannelSequence || 0) + 1;
256
305
  const invocationSequence = this._updatePSDataChannelSequence;
257
306
 
258
307
  const meeting = this.getValidatedWebinarMeeting();
259
308
  const isPracticeSession = meeting?.isJoined() && this.isJoinPracticeSessionDataChannel();
309
+ const {currentOwner, isOwner} = this.webex.internal.llm.resolveSessionOwnership(
310
+ this.meetingId,
311
+ LLM_PRACTICE_SESSION
312
+ );
260
313
 
261
314
  if (!isPracticeSession) {
262
315
  await this.cleanupPSDataChannel();
@@ -264,13 +317,22 @@ const Webinar = WebexPlugin.extend({
264
317
  return undefined;
265
318
  }
266
319
 
320
+ if (!isOwner) {
321
+ LoggerProxy.logger.info(
322
+ `Webinar:index#updatePSDataChannel --> skipping; practice-session LLM owned by meeting ${currentOwner}, not ${this.meetingId}`
323
+ );
324
+
325
+ return undefined;
326
+ }
327
+
267
328
  // @ts-ignore - Fix type
268
329
  const {url = undefined, info: {practiceSessionDatachannelUrl = undefined} = {}} =
269
330
  meeting?.locusInfo || {};
270
331
 
271
332
  // @ts-ignore
272
333
  let practiceSessionDatachannelToken = this.webex.internal.llm.getDatachannelToken(
273
- DataChannelTokenType.PracticeSession
334
+ LLM_PRACTICE_SESSION,
335
+ this.meetingId
274
336
  );
275
337
 
276
338
  const isCaptionBoxOn = this.webex.internal.voicea.getIsCaptionBoxOn();
@@ -347,6 +409,30 @@ const Webinar = WebexPlugin.extend({
347
409
  practiceSessionDatachannelToken = refreshedPracticeSessionToken;
348
410
  }
349
411
 
412
+ const {currentOwner: currentOwnerBeforeConnect, isOwner: isOwnerBeforeConnect} =
413
+ this.webex.internal.llm.resolveSessionOwnership(this.meetingId, LLM_PRACTICE_SESSION);
414
+
415
+ if (!isOwnerBeforeConnect) {
416
+ LoggerProxy.logger.info(
417
+ `Webinar:index#updatePSDataChannel --> skipping pre-connect owner write; practice-session LLM owned by meeting ${currentOwnerBeforeConnect}, not ${this.meetingId}`
418
+ );
419
+
420
+ return undefined;
421
+ }
422
+
423
+ // Ensure refresh for practice datachannel requests is routed to this
424
+ // meeting only when we are actually about to connect the practice session.
425
+ // This avoids claiming ownership in flows that return early (e.g. missing
426
+ // practiceSessionDatachannelUrl or waiting for default session online).
427
+ // @ts-ignore - Fix type
428
+ this.webex.internal.llm.setRefreshHandler(
429
+ () => meeting.refreshDataChannelToken(),
430
+ LLM_PRACTICE_SESSION,
431
+ this.meetingId
432
+ );
433
+ // @ts-ignore - Fix type
434
+ this.webex.internal.llm.setOwnerMeetingId?.(this.meetingId, LLM_PRACTICE_SESSION);
435
+
350
436
  // @ts-ignore - Fix type
351
437
  return this.webex.internal.llm
352
438
  .registerAndConnect(
@@ -356,22 +442,30 @@ const Webinar = WebexPlugin.extend({
356
442
  LLM_PRACTICE_SESSION
357
443
  )
358
444
  .then((registerAndConnectResult) => {
359
- // Track the exact listener reference so cleanupPSDataChannel can
445
+ const {currentOwner: currentOwnerAfterConnect, isOwner: isOwnerAfterConnect} =
446
+ this.webex.internal.llm.resolveSessionOwnership(this.meetingId, LLM_PRACTICE_SESSION);
447
+
448
+ if (this.meetingId && isOwnerAfterConnect) {
449
+ // @ts-ignore - Fix type
450
+ this.webex.internal.llm.setOwnerMeetingId?.(this.meetingId, LLM_PRACTICE_SESSION);
451
+ } else {
452
+ LoggerProxy.logger.info(
453
+ `Webinar:index#updatePSDataChannel --> skipping post-connect owner write; practice-session LLM owned by meeting ${currentOwnerAfterConnect}, not ${this.meetingId}`
454
+ );
455
+ }
456
+
457
+ // Track the exact listener references so cleanupPSDataChannel can
360
458
  // unsubscribe deterministically, even if the meeting can no longer
361
459
  // be resolved at cleanup time.
362
- if (this._practiceSessionRelayListener) {
460
+ for (const {event, listenerKey, handlerKey} of PS_LLM_EVENTS) {
461
+ if (this.llmListeners[listenerKey]) {
462
+ // @ts-ignore - Fix type
463
+ this.webex.internal.llm.off(event, this.llmListeners[listenerKey]);
464
+ }
465
+ this.llmListeners[listenerKey] = meeting?.[handlerKey];
363
466
  // @ts-ignore - Fix type
364
- this.webex.internal.llm.off(
365
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
366
- this._practiceSessionRelayListener
367
- );
467
+ this.webex.internal.llm.on(event, this.llmListeners[listenerKey]);
368
468
  }
369
- this._practiceSessionRelayListener = meeting?.processRelayEvent;
370
- // @ts-ignore - Fix type
371
- this.webex.internal.llm.on(
372
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
373
- this._practiceSessionRelayListener
374
- );
375
469
  // @ts-ignore - Fix type
376
470
  this.webex.internal.voicea?.announce?.();
377
471
  if (isCaptionBoxOn) {
@@ -382,6 +476,23 @@ const Webinar = WebexPlugin.extend({
382
476
  );
383
477
 
384
478
  return Promise.resolve(registerAndConnectResult);
479
+ })
480
+ .catch((error) => {
481
+ const {
482
+ currentOwner: currentOwnerAfterRegisterFailure,
483
+ isOwner: isOwnerAfterRegisterFailure,
484
+ } = this.webex.internal.llm.resolveSessionOwnership(this.meetingId, LLM_PRACTICE_SESSION);
485
+
486
+ if (isOwnerAfterRegisterFailure) {
487
+ // @ts-ignore - Fix type
488
+ this.webex.internal.llm.setOwnerMeetingId?.(undefined, LLM_PRACTICE_SESSION);
489
+ } else {
490
+ LoggerProxy.logger.info(
491
+ `Webinar:index#updatePSDataChannel --> skipping failure owner release; practice-session LLM owned by meeting ${currentOwnerAfterRegisterFailure}, not ${this.meetingId}`
492
+ );
493
+ }
494
+
495
+ throw error;
385
496
  });
386
497
  },
387
498