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.
- package/.github/workflows/codeql.yml +2 -2
- package/CHANGELOG.md +19 -0
- package/README.md +2 -2
- package/bin/adapter/entry-points/cli/fleetConfig.js +2 -0
- package/bin/adapter/entry-points/cli/fleetConfig.js.map +1 -1
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +1 -1
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +17 -2
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +36 -0
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/OauthTokenSelectUseCase.js +18 -10
- package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +23 -3
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/package.json +2 -2
- package/src/adapter/entry-points/cli/fleetConfig.test.ts +33 -1
- package/src/adapter/entry-points/cli/fleetConfig.ts +16 -0
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +78 -0
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +2 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +137 -17
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +30 -2
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +346 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +64 -0
- package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +99 -0
- package/src/domain/usecases/OauthTokenSelectUseCase.ts +30 -5
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +400 -1
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +44 -3
- package/types/adapter/entry-points/cli/fleetConfig.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +3 -0
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +1 -0
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +7 -1
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +1 -0
- 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
|
|
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('
|
|
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
|
-
'
|
|
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('
|
|
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 === '
|
|
169
|
+
(m) => m.name === 'nearlyUsedSevenDay',
|
|
170
170
|
);
|
|
171
|
-
expect(nearlyUsed?.
|
|
172
|
-
|
|
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('
|
|
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
|
-
'
|
|
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('
|
|
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 === '
|
|
197
|
+
(m) => m.name === 'aboutToResetNearlyUsedSevenDay',
|
|
200
198
|
);
|
|
201
|
-
expect(aboutToReset?.
|
|
202
|
-
|
|
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:
|
|
94
|
-
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,
|