github-issue-tower-defence-management 1.194.2 → 1.196.0

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.
Files changed (38) hide show
  1. package/.github/workflows/codeql.yml +2 -2
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +2 -2
  4. package/bin/adapter/entry-points/cli/fleetConfig.js +2 -0
  5. package/bin/adapter/entry-points/cli/fleetConfig.js.map +1 -1
  6. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +1 -1
  7. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
  8. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +17 -2
  9. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
  10. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +36 -0
  11. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  12. package/bin/domain/usecases/OauthTokenSelectUseCase.js +18 -10
  13. package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
  14. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +23 -3
  15. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  16. package/package.json +2 -2
  17. package/src/adapter/entry-points/cli/fleetConfig.test.ts +33 -1
  18. package/src/adapter/entry-points/cli/fleetConfig.ts +16 -0
  19. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +78 -0
  20. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +2 -0
  21. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +137 -17
  22. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +30 -2
  23. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +346 -0
  24. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +64 -0
  25. package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +99 -0
  26. package/src/domain/usecases/OauthTokenSelectUseCase.ts +30 -5
  27. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +400 -1
  28. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +44 -3
  29. package/types/adapter/entry-points/cli/fleetConfig.d.ts.map +1 -1
  30. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
  31. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +3 -0
  32. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -1
  33. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +1 -0
  34. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  35. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +7 -1
  36. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
  37. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +1 -0
  38. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
@@ -67,13 +67,15 @@ describe('loadLiveSessionOauthTokenSelectionSettings', () => {
67
67
  );
68
68
  });
69
69
 
70
- it('reads both tuning numbers from the fleet config file', () => {
70
+ it('reads all tuning numbers from the fleet config file', () => {
71
71
  const fleetConfigFilePath = writeFleetConfig(
72
72
  [
73
73
  'inTmuxLauncherCommand: cl',
74
74
  'liveSessionOauthTokenSelection:',
75
75
  ' maxConcurrentSessionCount: 16',
76
76
  ' fullSpeedFiveHourFreeRatio: 0.4',
77
+ ' minFiveHourFreeRatio: 0.7',
78
+ ' minSevenDayFreeRatio: 0.2',
77
79
  ].join('\n'),
78
80
  );
79
81
 
@@ -82,6 +84,8 @@ describe('loadLiveSessionOauthTokenSelectionSettings', () => {
82
84
  ).toEqual({
83
85
  maxConcurrentSessionCount: 16,
84
86
  fullSpeedFiveHourFreeRatio: 0.4,
87
+ minFiveHourFreeRatio: 0.7,
88
+ minSevenDayFreeRatio: 0.2,
85
89
  });
86
90
  });
87
91
 
@@ -99,6 +103,10 @@ describe('loadLiveSessionOauthTokenSelectionSettings', () => {
99
103
  maxConcurrentSessionCount: 16,
100
104
  fullSpeedFiveHourFreeRatio:
101
105
  DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS.fullSpeedFiveHourFreeRatio,
106
+ minFiveHourFreeRatio:
107
+ DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS.minFiveHourFreeRatio,
108
+ minSevenDayFreeRatio:
109
+ DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS.minSevenDayFreeRatio,
102
110
  });
103
111
  });
104
112
 
@@ -152,6 +160,30 @@ describe('loadLiveSessionOauthTokenSelectionSettings', () => {
152
160
  ).toThrow('fullSpeedFiveHourFreeRatio');
153
161
  });
154
162
 
163
+ it('throws when the minimum five hour free ratio is above one', () => {
164
+ const fleetConfigFilePath = writeFleetConfig(
165
+ ['liveSessionOauthTokenSelection:', ' minFiveHourFreeRatio: 1.5'].join(
166
+ '\n',
167
+ ),
168
+ );
169
+
170
+ expect(() =>
171
+ loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
172
+ ).toThrow('minFiveHourFreeRatio');
173
+ });
174
+
175
+ it('throws when the minimum seven day free ratio is above one', () => {
176
+ const fleetConfigFilePath = writeFleetConfig(
177
+ ['liveSessionOauthTokenSelection:', ' minSevenDayFreeRatio: 1.5'].join(
178
+ '\n',
179
+ ),
180
+ );
181
+
182
+ expect(() =>
183
+ loadLiveSessionOauthTokenSelectionSettings(fleetConfigFilePath),
184
+ ).toThrow('minSevenDayFreeRatio');
185
+ });
186
+
155
187
  it('throws when a tuning number is written as a string', () => {
156
188
  const fleetConfigFilePath = writeFleetConfig(
157
189
  [
@@ -105,5 +105,21 @@ export const loadLiveSessionOauthTokenSelectionSettings = (
105
105
  (value) => value > 0 && value <= 1,
106
106
  'above 0 and at most 1',
107
107
  ),
108
+ minFiveHourFreeRatio: readBoundedNumber(
109
+ section,
110
+ 'minFiveHourFreeRatio',
111
+ fleetConfigFilePath,
112
+ DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS.minFiveHourFreeRatio,
113
+ (value) => value > 0 && value <= 1,
114
+ 'above 0 and at most 1',
115
+ ),
116
+ minSevenDayFreeRatio: readBoundedNumber(
117
+ section,
118
+ 'minSevenDayFreeRatio',
119
+ fleetConfigFilePath,
120
+ DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS.minSevenDayFreeRatio,
121
+ (value) => value > 0 && value <= 1,
122
+ 'above 0 and at most 1',
123
+ ),
108
124
  };
109
125
  };
@@ -394,6 +394,84 @@ describe('OauthTokenSelectHandler', () => {
394
394
  expect(Number(drawWeightMatch?.[1])).toBeGreaterThan(0);
395
395
  });
396
396
 
397
+ it('excludes a token whose 5h window has only 59% free (CL script threshold is 60%)', () => {
398
+ writeTokenList([{ name: 'low5h', token: 'fake-low5h' }]);
399
+ writeCache('fake-low5h', {
400
+ fiveHourUtilization: 0.41,
401
+ fiveHourReset: NOW + HOUR,
402
+ sevenDayUtilization: 0.1,
403
+ sevenDayReset: NOW + DAY,
404
+ });
405
+
406
+ const handler = new OauthTokenSelectHandler();
407
+ const output = handler.handle({
408
+ tokenListJsonPath: tokenListPath,
409
+ cacheDirectory,
410
+ nowEpochSeconds: NOW,
411
+ });
412
+
413
+ expect(output.selectedToken).toBeNull();
414
+ expect(output.diagnostics.join('\n')).toContain('5h window');
415
+ });
416
+
417
+ it('treats exactly 60% 5h free as eligible in the handler (CL script boundary)', () => {
418
+ writeTokenList([{ name: 'boundary5h', token: 'fake-boundary5h' }]);
419
+ writeCache('fake-boundary5h', {
420
+ fiveHourUtilization: 0.4,
421
+ fiveHourReset: NOW + HOUR,
422
+ sevenDayUtilization: 0.1,
423
+ sevenDayReset: NOW + DAY,
424
+ });
425
+
426
+ const handler = new OauthTokenSelectHandler();
427
+ const output = handler.handle({
428
+ tokenListJsonPath: tokenListPath,
429
+ cacheDirectory,
430
+ nowEpochSeconds: NOW,
431
+ });
432
+
433
+ expect(output.selectedName).toBe('boundary5h');
434
+ });
435
+
436
+ it('excludes a token whose 7d window has only 13% free (CL script threshold is 14%)', () => {
437
+ writeTokenList([{ name: 'low7d', token: 'fake-low7d' }]);
438
+ writeCache('fake-low7d', {
439
+ fiveHourUtilization: 0.1,
440
+ fiveHourReset: NOW + HOUR,
441
+ sevenDayUtilization: 0.87,
442
+ sevenDayReset: NOW + DAY,
443
+ });
444
+
445
+ const handler = new OauthTokenSelectHandler();
446
+ const output = handler.handle({
447
+ tokenListJsonPath: tokenListPath,
448
+ cacheDirectory,
449
+ nowEpochSeconds: NOW,
450
+ });
451
+
452
+ expect(output.selectedToken).toBeNull();
453
+ expect(output.diagnostics.join('\n')).toContain('7d window');
454
+ });
455
+
456
+ it('treats exactly 14% 7d free as eligible in the handler (CL script boundary)', () => {
457
+ writeTokenList([{ name: 'boundary7d', token: 'fake-boundary7d' }]);
458
+ writeCache('fake-boundary7d', {
459
+ fiveHourUtilization: 0.1,
460
+ fiveHourReset: NOW + HOUR,
461
+ sevenDayUtilization: 0.86,
462
+ sevenDayReset: NOW + DAY,
463
+ });
464
+
465
+ const handler = new OauthTokenSelectHandler();
466
+ const output = handler.handle({
467
+ tokenListJsonPath: tokenListPath,
468
+ cacheDirectory,
469
+ nowEpochSeconds: NOW,
470
+ });
471
+
472
+ expect(output.selectedName).toBe('boundary7d');
473
+ });
474
+
397
475
  describe('resolveTokenListJsonPath', () => {
398
476
  it('prefers the explicit path over the environment variable', () => {
399
477
  process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH = '/from/env.json';
@@ -1,4 +1,5 @@
1
1
  import {
2
+ CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
2
3
  DEFAULT_SELECTION_WEIGHT,
3
4
  type OauthTokenCandidate,
4
5
  type OauthTokenSelectResult,
@@ -118,6 +119,7 @@ export class OauthTokenSelectHandler {
118
119
  candidates,
119
120
  input.nowEpochSeconds,
120
121
  this.random,
122
+ CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
121
123
  );
122
124
 
123
125
  return {
@@ -145,11 +145,11 @@ describe('LiveSessionOauthTokenSelectUseCase', () => {
145
145
  expect(result.selected?.name).toBe('distantResetIdle');
146
146
  });
147
147
 
148
- it('keeps the full concurrent session limit on a nearly used seven day window so the expiring allowance is drained', () => {
148
+ it('excludes a seven day window that has fallen below the minimum free ratio even when sessions are below the concurrent limit', () => {
149
149
  const result = useCase.run(
150
150
  [
151
151
  candidate(
152
- 'soonResetNearlyUsedSevenDay',
152
+ 'nearlyUsedSevenDay',
153
153
  snapshot({
154
154
  sevenDayReset: NOW + 3 * DAY,
155
155
  sevenDayUtilization: 0.9,
@@ -160,29 +160,27 @@ describe('LiveSessionOauthTokenSelectUseCase', () => {
160
160
  snapshot({ sevenDayReset: NOW + 6 * DAY }),
161
161
  ),
162
162
  ],
163
- sessionsFor('soonResetNearlyUsedSevenDay', 5),
163
+ sessionsFor('nearlyUsedSevenDay', 5),
164
164
  NOW,
165
165
  SETTINGS,
166
166
  );
167
167
 
168
168
  const nearlyUsed = result.metrics.find(
169
- (m) => m.name === 'soonResetNearlyUsedSevenDay',
169
+ (m) => m.name === 'nearlyUsedSevenDay',
170
170
  );
171
- expect(nearlyUsed?.concurrentSessionLimit).toBe(
172
- MAX_CONCURRENT_SESSION_COUNT,
173
- );
174
- expect(result.selected?.name).toBe('soonResetNearlyUsedSevenDay');
171
+ expect(nearlyUsed?.eligible).toBe(false);
172
+ expect(nearlyUsed?.exclusionReason).toContain('7d window');
173
+ expect(result.selected?.name).toBe('distantResetIdle');
175
174
  });
176
175
 
177
- it('keeps the full concurrent session limit on a seven day window that resets within the hour while its five hour window is still half free', () => {
176
+ it('excludes a token whose seven day window is below the minimum even when it resets within the hour', () => {
178
177
  const result = useCase.run(
179
178
  [
180
179
  candidate(
181
- 'aboutToResetHalfFreeFiveHour',
180
+ 'aboutToResetNearlyUsedSevenDay',
182
181
  snapshot({
183
182
  sevenDayReset: NOW + HOUR,
184
183
  sevenDayUtilization: 0.9,
185
- fiveHourUtilization: 0.5,
186
184
  }),
187
185
  ),
188
186
  candidate(
@@ -190,18 +188,16 @@ describe('LiveSessionOauthTokenSelectUseCase', () => {
190
188
  snapshot({ sevenDayReset: NOW + 6 * DAY }),
191
189
  ),
192
190
  ],
193
- sessionsFor('aboutToResetHalfFreeFiveHour', 5),
191
+ sessionsFor('aboutToResetNearlyUsedSevenDay', 5),
194
192
  NOW,
195
193
  SETTINGS,
196
194
  );
197
195
 
198
196
  const aboutToReset = result.metrics.find(
199
- (m) => m.name === 'aboutToResetHalfFreeFiveHour',
197
+ (m) => m.name === 'aboutToResetNearlyUsedSevenDay',
200
198
  );
201
- expect(aboutToReset?.concurrentSessionLimit).toBe(
202
- MAX_CONCURRENT_SESSION_COUNT,
203
- );
204
- expect(result.selected?.name).toBe('aboutToResetHalfFreeFiveHour');
199
+ expect(aboutToReset?.eligible).toBe(false);
200
+ expect(result.selected?.name).toBe('distantResetIdle');
205
201
  });
206
202
 
207
203
  it('still throttles a seven day window that resets within the hour once its five hour window falls below half free', () => {
@@ -509,6 +505,130 @@ describe('LiveSessionOauthTokenCandidateMetrics selectionWeight', () => {
509
505
  });
510
506
  });
511
507
 
508
+ describe('LiveSessionOauthTokenSelectUseCase minimum free ratio thresholds', () => {
509
+ const useCase = new LiveSessionOauthTokenSelectUseCase();
510
+
511
+ it('excludes a token whose five hour window has less than the minimum free ratio', () => {
512
+ const result = useCase.run(
513
+ [
514
+ candidate('narrowFiveHourMin', snapshot({ fiveHourUtilization: 0.5 })),
515
+ candidate('freeFiveHourMin', snapshot({})),
516
+ ],
517
+ [],
518
+ NOW,
519
+ SETTINGS,
520
+ );
521
+
522
+ const narrow = result.metrics.find((m) => m.name === 'narrowFiveHourMin');
523
+ expect(narrow?.eligible).toBe(false);
524
+ expect(narrow?.exclusionReason).toContain('5h window');
525
+ expect(result.selected?.name).toBe('freeFiveHourMin');
526
+ });
527
+
528
+ it('excludes a token whose seven day window has less than the minimum free ratio', () => {
529
+ const result = useCase.run(
530
+ [
531
+ candidate(
532
+ 'nearlyUsedSevenDayMin',
533
+ snapshot({ sevenDayUtilization: 0.9 }),
534
+ ),
535
+ candidate('freeSevenDayMin', snapshot({})),
536
+ ],
537
+ [],
538
+ NOW,
539
+ SETTINGS,
540
+ );
541
+
542
+ const nearlyUsed = result.metrics.find(
543
+ (m) => m.name === 'nearlyUsedSevenDayMin',
544
+ );
545
+ expect(nearlyUsed?.eligible).toBe(false);
546
+ expect(nearlyUsed?.exclusionReason).toContain('7d window');
547
+ expect(result.selected?.name).toBe('freeSevenDayMin');
548
+ });
549
+
550
+ it('selects a token whose ratios are at exactly the minimum free ratio thresholds', () => {
551
+ const result = useCase.run(
552
+ [
553
+ candidate(
554
+ 'atThreshold',
555
+ snapshot({ fiveHourUtilization: 0.4, sevenDayUtilization: 0.86 }),
556
+ ),
557
+ ],
558
+ [],
559
+ NOW,
560
+ SETTINGS,
561
+ );
562
+
563
+ const atThreshold = result.metrics.find((m) => m.name === 'atThreshold');
564
+ expect(atThreshold?.eligible).toBe(true);
565
+ expect(result.selected?.name).toBe('atThreshold');
566
+ });
567
+
568
+ it('honours a fleet supplied minimum five hour free ratio', () => {
569
+ const result = useCase.run(
570
+ [
571
+ candidate(
572
+ 'narrowForFleetFiveHour',
573
+ snapshot({ fiveHourUtilization: 0.15 }),
574
+ ),
575
+ candidate('freeForFleetFiveHour', snapshot({})),
576
+ ],
577
+ [],
578
+ NOW,
579
+ settingsWith({ minFiveHourFreeRatio: 0.9 }),
580
+ );
581
+
582
+ const narrow = result.metrics.find(
583
+ (m) => m.name === 'narrowForFleetFiveHour',
584
+ );
585
+ expect(narrow?.eligible).toBe(false);
586
+ expect(narrow?.exclusionReason).toContain('5h window');
587
+ expect(result.selected?.name).toBe('freeForFleetFiveHour');
588
+ });
589
+
590
+ it('honours a fleet supplied minimum seven day free ratio', () => {
591
+ const result = useCase.run(
592
+ [
593
+ candidate(
594
+ 'narrowForFleetSevenDay',
595
+ snapshot({ sevenDayUtilization: 0.5 }),
596
+ ),
597
+ candidate('freeForFleetSevenDay', snapshot({})),
598
+ ],
599
+ [],
600
+ NOW,
601
+ settingsWith({ minSevenDayFreeRatio: 0.6 }),
602
+ );
603
+
604
+ const narrow = result.metrics.find(
605
+ (m) => m.name === 'narrowForFleetSevenDay',
606
+ );
607
+ expect(narrow?.eligible).toBe(false);
608
+ expect(narrow?.exclusionReason).toContain('7d window');
609
+ expect(result.selected?.name).toBe('freeForFleetSevenDay');
610
+ });
611
+
612
+ it('reports the five hour window exclusion reason when both windows are below their minimums', () => {
613
+ const result = useCase.run(
614
+ [
615
+ candidate(
616
+ 'bothNarrow',
617
+ snapshot({ fiveHourUtilization: 0.5, sevenDayUtilization: 0.9 }),
618
+ ),
619
+ ],
620
+ [],
621
+ NOW,
622
+ SETTINGS,
623
+ );
624
+
625
+ const bothNarrow = result.metrics.find((m) => m.name === 'bothNarrow');
626
+ expect(bothNarrow?.eligible).toBe(false);
627
+ expect(bothNarrow?.exclusionReason).toContain('5h window');
628
+ expect(bothNarrow?.exclusionReason).not.toContain('7d window');
629
+ });
630
+ });
631
+
512
632
  describe('liveSessionConcurrentLimitOf', () => {
513
633
  it('gives the full limit while the five hour window is at or above the full speed free ratio', () => {
514
634
  expect(liveSessionConcurrentLimitOf(1, 1, SETTINGS)).toBe(
@@ -8,12 +8,16 @@ import {
8
8
  export type LiveSessionOauthTokenSelectionSettings = {
9
9
  maxConcurrentSessionCount: number;
10
10
  fullSpeedFiveHourFreeRatio: number;
11
+ minFiveHourFreeRatio: number;
12
+ minSevenDayFreeRatio: number;
11
13
  };
12
14
 
13
15
  export const DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS: LiveSessionOauthTokenSelectionSettings =
14
16
  {
15
17
  maxConcurrentSessionCount: 10,
16
18
  fullSpeedFiveHourFreeRatio: 0.5,
19
+ minFiveHourFreeRatio: 0.6,
20
+ minSevenDayFreeRatio: 0.14,
17
21
  };
18
22
 
19
23
  export const liveSessionConcurrentLimitOf = (
@@ -80,6 +84,12 @@ export class LiveSessionOauthTokenSelectUseCase {
80
84
  selectionWeightOf(candidate),
81
85
  settings,
82
86
  );
87
+ const exclusionReason = this.liveSessionExclusionReason(
88
+ rateLimitMetric.exclusionReason,
89
+ rateLimitMetric.fiveHourFreeRatio,
90
+ rateLimitMetric.sevenDayFreeRatio,
91
+ settings,
92
+ );
83
93
  return {
84
94
  candidate,
85
95
  metric: {
@@ -90,8 +100,8 @@ export class LiveSessionOauthTokenSelectUseCase {
90
100
  liveSessionCount,
91
101
  concurrentSessionLimit,
92
102
  hasConcurrencyHeadroom: liveSessionCount < concurrentSessionLimit,
93
- eligible: rateLimitMetric.eligible,
94
- exclusionReason: rateLimitMetric.exclusionReason,
103
+ eligible: exclusionReason === null,
104
+ exclusionReason,
95
105
  selectionWeight: selectionWeightOf(candidate),
96
106
  },
97
107
  };
@@ -113,6 +123,24 @@ export class LiveSessionOauthTokenSelectUseCase {
113
123
  return { selected: selected.candidate, metrics };
114
124
  };
115
125
 
126
+ private liveSessionExclusionReason = (
127
+ rateLimitExclusionReason: string | null,
128
+ fiveHourFreeRatio: number,
129
+ sevenDayFreeRatio: number,
130
+ settings: LiveSessionOauthTokenSelectionSettings,
131
+ ): string | null => {
132
+ if (rateLimitExclusionReason !== null) {
133
+ return rateLimitExclusionReason;
134
+ }
135
+ if (fiveHourFreeRatio < settings.minFiveHourFreeRatio) {
136
+ return `5h window only ${Math.round(fiveHourFreeRatio * 100)}% free (requires >= ${Math.round(settings.minFiveHourFreeRatio * 100)}% for live session selection)`;
137
+ }
138
+ if (sevenDayFreeRatio < settings.minSevenDayFreeRatio) {
139
+ return `7d window only ${Math.round(sevenDayFreeRatio * 100)}% free (requires >= ${Math.round(settings.minSevenDayFreeRatio * 100)}% for live session selection)`;
140
+ }
141
+ return null;
142
+ };
143
+
116
144
  private preferred = (
117
145
  candidateMetric: LiveSessionOauthTokenCandidateMetrics,
118
146
  incumbentMetric: LiveSessionOauthTokenCandidateMetrics,