github-issue-tower-defence-management 1.146.7 → 1.146.8

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 (25) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +5 -4
  3. package/bin/adapter/entry-points/cli/fleetConfig.js +101 -0
  4. package/bin/adapter/entry-points/cli/fleetConfig.js.map +1 -0
  5. package/bin/adapter/entry-points/cli/index.js +5 -2
  6. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +4 -5
  8. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
  9. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +24 -9
  10. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/adapter/entry-points/cli/fleetConfig.test.ts +177 -0
  13. package/src/adapter/entry-points/cli/fleetConfig.ts +109 -0
  14. package/src/adapter/entry-points/cli/index.ts +14 -2
  15. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +25 -10
  16. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +5 -5
  17. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +325 -156
  18. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +50 -22
  19. package/types/adapter/entry-points/cli/fleetConfig.d.ts +6 -0
  20. package/types/adapter/entry-points/cli/fleetConfig.d.ts.map +1 -0
  21. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  22. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +3 -4
  23. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
  24. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +10 -2
  25. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -1
@@ -1,5 +1,10 @@
1
1
  import { ClaudeLiveSession } from './adapter-interfaces/ClaudeLiveSessionRepository';
2
- import { LiveSessionOauthTokenSelectUseCase } from './LiveSessionOauthTokenSelectUseCase';
2
+ import {
3
+ DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS,
4
+ LiveSessionOauthTokenSelectUseCase,
5
+ LiveSessionOauthTokenSelectionSettings,
6
+ liveSessionConcurrentLimitOf,
7
+ } from './LiveSessionOauthTokenSelectUseCase';
3
8
  import {
4
9
  OauthTokenCandidate,
5
10
  OauthTokenWindowSnapshot,
@@ -9,6 +14,13 @@ const NOW = 1_000_000;
9
14
  const HOUR = 3600;
10
15
  const DAY = 86400;
11
16
 
17
+ const SETTINGS = DEFAULT_LIVE_SESSION_OAUTH_TOKEN_SELECTION_SETTINGS;
18
+ const MAX_CONCURRENT_SESSION_COUNT = SETTINGS.maxConcurrentSessionCount;
19
+
20
+ const settingsWith = (
21
+ overrides: Partial<LiveSessionOauthTokenSelectionSettings>,
22
+ ): LiveSessionOauthTokenSelectionSettings => ({ ...SETTINGS, ...overrides });
23
+
12
24
  const snapshot = (
13
25
  overrides: Partial<OauthTokenWindowSnapshot>,
14
26
  ): OauthTokenWindowSnapshot => ({
@@ -39,52 +51,298 @@ const session = (name: string, sessionKey: string): ClaudeLiveSession => ({
39
51
  sessionKey,
40
52
  });
41
53
 
54
+ const sessionsFor = (name: string, count: number): ClaudeLiveSession[] =>
55
+ Array.from({ length: count }, (_unused, index) =>
56
+ session(name, `${name}-session-${index}`),
57
+ );
58
+
59
+ const withSelectionWeight = (
60
+ base: OauthTokenCandidate,
61
+ selectionWeight: number,
62
+ ): OauthTokenCandidate => ({ ...base, selectionWeight });
63
+
42
64
  describe('LiveSessionOauthTokenSelectUseCase', () => {
43
65
  const useCase = new LiveSessionOauthTokenSelectUseCase();
44
66
 
45
- it('prefers the eligible token with zero live sessions', () => {
67
+ it('selects the token whose seven day window resets soonest even when an idle token has a distant reset', () => {
46
68
  const result = useCase.run(
47
- [candidate('busy', snapshot({})), candidate('idle', snapshot({}))],
48
- [session('busy', 'session-a')],
69
+ [
70
+ candidate(
71
+ 'distantResetIdle',
72
+ snapshot({ sevenDayReset: NOW + 6 * DAY }),
73
+ ),
74
+ candidate('soonResetBusy', snapshot({ sevenDayReset: NOW + 2 * HOUR })),
75
+ ],
76
+ sessionsFor('soonResetBusy', 5),
49
77
  NOW,
50
- () => 0.5,
78
+ SETTINGS,
51
79
  );
52
80
 
53
- expect(result.selected?.name).toBe('idle');
54
- expect(result.selected?.token).toBe('fake-token-idle');
55
- const idle = result.metrics.find((m) => m.name === 'idle');
56
- expect(idle?.liveSessionCount).toBe(0);
81
+ expect(result.selected?.name).toBe('soonResetBusy');
57
82
  });
58
83
 
59
- it('breaks an occupancy tie by the soonest 7d reset', () => {
84
+ it('keeps filling the soonest resetting token until it reaches its concurrent session limit', () => {
85
+ const belowLimit = useCase.run(
86
+ [
87
+ candidate(
88
+ 'distantResetIdle',
89
+ snapshot({ sevenDayReset: NOW + 6 * DAY }),
90
+ ),
91
+ candidate('soonReset', snapshot({ sevenDayReset: NOW + 2 * HOUR })),
92
+ ],
93
+ sessionsFor('soonReset', MAX_CONCURRENT_SESSION_COUNT - 1),
94
+ NOW,
95
+ SETTINGS,
96
+ );
97
+
98
+ expect(belowLimit.selected?.name).toBe('soonReset');
99
+ });
100
+
101
+ it('moves to the next soonest resetting token once the soonest one is at its concurrent session limit', () => {
60
102
  const result = useCase.run(
61
103
  [
62
- candidate('far', snapshot({ sevenDayReset: NOW + 6 * DAY })),
63
- candidate('soon', snapshot({ sevenDayReset: NOW + 2 * DAY })),
104
+ candidate(
105
+ 'distantResetIdle',
106
+ snapshot({ sevenDayReset: NOW + 6 * DAY }),
107
+ ),
108
+ candidate('soonResetFull', snapshot({ sevenDayReset: NOW + 2 * HOUR })),
64
109
  ],
65
- [session('far', 'session-a'), session('soon', 'session-b')],
110
+ sessionsFor('soonResetFull', MAX_CONCURRENT_SESSION_COUNT),
66
111
  NOW,
67
- () => 0.5,
112
+ SETTINGS,
68
113
  );
69
114
 
70
- expect(result.selected?.name).toBe('soon');
115
+ expect(result.selected?.name).toBe('distantResetIdle');
116
+ const full = result.metrics.find((m) => m.name === 'soonResetFull');
117
+ expect(full?.hasConcurrencyHeadroom).toBe(false);
118
+ expect(full?.concurrentSessionLimit).toBe(MAX_CONCURRENT_SESSION_COUNT);
71
119
  });
72
120
 
73
- it('falls back to fewer live sessions when occupancy exactly cancels the urgency gap', () => {
121
+ it('lowers the concurrent session limit as the five hour window fills', () => {
74
122
  const result = useCase.run(
75
123
  [
76
- candidate('soonButBusy', snapshot({ sevenDayReset: NOW + 2 * DAY })),
77
- candidate('farButIdle', snapshot({ sevenDayReset: NOW + 6 * DAY })),
124
+ candidate(
125
+ 'soonResetNarrowFiveHour',
126
+ snapshot({
127
+ sevenDayReset: NOW + 2 * HOUR,
128
+ fiveHourUtilization: 0.7,
129
+ }),
130
+ ),
131
+ candidate(
132
+ 'distantResetIdle',
133
+ snapshot({ sevenDayReset: NOW + 6 * DAY }),
134
+ ),
78
135
  ],
136
+ sessionsFor('soonResetNarrowFiveHour', 6),
137
+ NOW,
138
+ SETTINGS,
139
+ );
140
+
141
+ const narrow = result.metrics.find(
142
+ (m) => m.name === 'soonResetNarrowFiveHour',
143
+ );
144
+ expect(narrow?.concurrentSessionLimit).toBe(6);
145
+ expect(result.selected?.name).toBe('distantResetIdle');
146
+ });
147
+
148
+ it('keeps the full concurrent session limit on a nearly used seven day window so the expiring allowance is drained', () => {
149
+ const result = useCase.run(
150
+ [
151
+ candidate(
152
+ 'soonResetNearlyUsedSevenDay',
153
+ snapshot({
154
+ sevenDayReset: NOW + 3 * DAY,
155
+ sevenDayUtilization: 0.9,
156
+ }),
157
+ ),
158
+ candidate(
159
+ 'distantResetIdle',
160
+ snapshot({ sevenDayReset: NOW + 6 * DAY }),
161
+ ),
162
+ ],
163
+ sessionsFor('soonResetNearlyUsedSevenDay', 5),
164
+ NOW,
165
+ SETTINGS,
166
+ );
167
+
168
+ const nearlyUsed = result.metrics.find(
169
+ (m) => m.name === 'soonResetNearlyUsedSevenDay',
170
+ );
171
+ expect(nearlyUsed?.concurrentSessionLimit).toBe(
172
+ MAX_CONCURRENT_SESSION_COUNT,
173
+ );
174
+ expect(result.selected?.name).toBe('soonResetNearlyUsedSevenDay');
175
+ });
176
+
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', () => {
178
+ const result = useCase.run(
79
179
  [
80
- session('soonButBusy', 'session-a'),
81
- session('soonButBusy', 'session-b'),
180
+ candidate(
181
+ 'aboutToResetHalfFreeFiveHour',
182
+ snapshot({
183
+ sevenDayReset: NOW + HOUR,
184
+ sevenDayUtilization: 0.9,
185
+ fiveHourUtilization: 0.5,
186
+ }),
187
+ ),
188
+ candidate(
189
+ 'distantResetIdle',
190
+ snapshot({ sevenDayReset: NOW + 6 * DAY }),
191
+ ),
82
192
  ],
193
+ sessionsFor('aboutToResetHalfFreeFiveHour', 5),
83
194
  NOW,
84
- throwingRandom,
195
+ SETTINGS,
85
196
  );
86
197
 
87
- expect(result.selected?.name).toBe('farButIdle');
198
+ const aboutToReset = result.metrics.find(
199
+ (m) => m.name === 'aboutToResetHalfFreeFiveHour',
200
+ );
201
+ expect(aboutToReset?.concurrentSessionLimit).toBe(
202
+ MAX_CONCURRENT_SESSION_COUNT,
203
+ );
204
+ expect(result.selected?.name).toBe('aboutToResetHalfFreeFiveHour');
205
+ });
206
+
207
+ it('still throttles a seven day window that resets within the hour once its five hour window falls below half free', () => {
208
+ const result = useCase.run(
209
+ [
210
+ candidate(
211
+ 'aboutToResetNarrowFiveHour',
212
+ snapshot({
213
+ sevenDayReset: NOW + HOUR,
214
+ fiveHourUtilization: 0.7,
215
+ }),
216
+ ),
217
+ candidate(
218
+ 'distantResetIdle',
219
+ snapshot({ sevenDayReset: NOW + 6 * DAY }),
220
+ ),
221
+ ],
222
+ sessionsFor('aboutToResetNarrowFiveHour', 6),
223
+ NOW,
224
+ SETTINGS,
225
+ );
226
+
227
+ const aboutToReset = result.metrics.find(
228
+ (m) => m.name === 'aboutToResetNarrowFiveHour',
229
+ );
230
+ expect(aboutToReset?.concurrentSessionLimit).toBe(6);
231
+ expect(result.selected?.name).toBe('distantResetIdle');
232
+ });
233
+
234
+ it('scales the concurrent session limit down by the configured selection weight', () => {
235
+ const result = useCase.run(
236
+ [
237
+ withSelectionWeight(
238
+ candidate(
239
+ 'downWeighted',
240
+ snapshot({ sevenDayReset: NOW + 2 * HOUR }),
241
+ ),
242
+ 0.5,
243
+ ),
244
+ candidate(
245
+ 'distantResetIdle',
246
+ snapshot({ sevenDayReset: NOW + 6 * DAY }),
247
+ ),
248
+ ],
249
+ sessionsFor('downWeighted', 5),
250
+ NOW,
251
+ SETTINGS,
252
+ );
253
+
254
+ const downWeighted = result.metrics.find((m) => m.name === 'downWeighted');
255
+ expect(downWeighted?.concurrentSessionLimit).toBe(5);
256
+ expect(result.selected?.name).toBe('distantResetIdle');
257
+ });
258
+
259
+ it('honours a fleet supplied maximum concurrent session count', () => {
260
+ const result = useCase.run(
261
+ [candidate('onlyToken', snapshot({}))],
262
+ [],
263
+ NOW,
264
+ settingsWith({ maxConcurrentSessionCount: 24 }),
265
+ );
266
+
267
+ const onlyToken = result.metrics.find((m) => m.name === 'onlyToken');
268
+ expect(onlyToken?.concurrentSessionLimit).toBe(24);
269
+ });
270
+
271
+ it('honours a fleet supplied five hour free ratio for the full concurrent session limit', () => {
272
+ const result = useCase.run(
273
+ [candidate('narrowFiveHour', snapshot({ fiveHourUtilization: 0.6 }))],
274
+ [],
275
+ NOW,
276
+ settingsWith({ fullSpeedFiveHourFreeRatio: 0.8 }),
277
+ );
278
+
279
+ const narrowFiveHour = result.metrics.find(
280
+ (m) => m.name === 'narrowFiveHour',
281
+ );
282
+ expect(narrowFiveHour?.concurrentSessionLimit).toBe(5);
283
+ });
284
+
285
+ it('never starves a sole eligible token whose selection weight rounds its limit below one', () => {
286
+ const result = useCase.run(
287
+ [
288
+ withSelectionWeight(candidate('tinyWeight', snapshot({})), 0.01),
289
+ candidate('blocked', snapshot({ fiveHourUtilization: 0.9 })),
290
+ ],
291
+ [],
292
+ NOW,
293
+ SETTINGS,
294
+ );
295
+
296
+ expect(result.selected?.name).toBe('tinyWeight');
297
+ const tiny = result.metrics.find((m) => m.name === 'tinyWeight');
298
+ expect(tiny?.concurrentSessionLimit).toBe(1);
299
+ });
300
+
301
+ it('still selects the soonest resetting token when every eligible token is at its limit', () => {
302
+ const result = useCase.run(
303
+ [
304
+ candidate(
305
+ 'distantResetFull',
306
+ snapshot({ sevenDayReset: NOW + 6 * DAY }),
307
+ ),
308
+ candidate('soonResetFull', snapshot({ sevenDayReset: NOW + 2 * HOUR })),
309
+ ],
310
+ [
311
+ ...sessionsFor('distantResetFull', MAX_CONCURRENT_SESSION_COUNT),
312
+ ...sessionsFor('soonResetFull', MAX_CONCURRENT_SESSION_COUNT),
313
+ ],
314
+ NOW,
315
+ SETTINGS,
316
+ );
317
+
318
+ expect(result.selected?.name).toBe('soonResetFull');
319
+ });
320
+
321
+ it('breaks a seven day reset tie by the fewer live sessions', () => {
322
+ const result = useCase.run(
323
+ [
324
+ candidate('sameResetBusy', snapshot({ sevenDayReset: NOW + 2 * HOUR })),
325
+ candidate('sameResetIdle', snapshot({ sevenDayReset: NOW + 2 * HOUR })),
326
+ ],
327
+ sessionsFor('sameResetBusy', 1),
328
+ NOW,
329
+ SETTINGS,
330
+ );
331
+
332
+ expect(result.selected?.name).toBe('sameResetIdle');
333
+ });
334
+
335
+ it('returns the same token on repeated calls with the same inputs', () => {
336
+ const candidates = [
337
+ candidate('firstOfEqualPair', snapshot({})),
338
+ candidate('secondOfEqualPair', snapshot({})),
339
+ ];
340
+
341
+ const first = useCase.run(candidates, [], NOW, SETTINGS);
342
+ const second = useCase.run(candidates, [], NOW, SETTINGS);
343
+
344
+ expect(first.selected?.name).toBe(second.selected?.name);
345
+ expect(first.selected?.name).toBe('firstOfEqualPair');
88
346
  });
89
347
 
90
348
  it('excludes a rate-limit-ineligible token even when it has no live sessions', () => {
@@ -95,6 +353,7 @@ describe('LiveSessionOauthTokenSelectUseCase', () => {
95
353
  ],
96
354
  [session('busyButFree', 'session-a')],
97
355
  NOW,
356
+ SETTINGS,
98
357
  );
99
358
 
100
359
  expect(result.selected?.name).toBe('busyButFree');
@@ -117,7 +376,7 @@ describe('LiveSessionOauthTokenSelectUseCase', () => {
117
376
  session('twoSessions', 'session-c'),
118
377
  ],
119
378
  NOW,
120
- () => 0.5,
379
+ SETTINGS,
121
380
  );
122
381
 
123
382
  expect(result.selected?.name).toBe('oneSession');
@@ -140,14 +399,14 @@ describe('LiveSessionOauthTokenSelectUseCase', () => {
140
399
  session('fresh', 'session-fresh'),
141
400
  ],
142
401
  NOW,
143
- () => 0.99,
402
+ SETTINGS,
144
403
  );
145
404
 
146
- expect(result.selected?.name).toBe('fresh');
147
405
  const resumedHeavy = result.metrics.find((m) => m.name === 'resumedHeavy');
148
406
  const fresh = result.metrics.find((m) => m.name === 'fresh');
149
407
  expect(resumedHeavy?.liveSessionCount).toBe(2);
150
408
  expect(fresh?.liveSessionCount).toBe(1);
409
+ expect(result.selected?.name).toBe('resumedHeavy');
151
410
  });
152
411
 
153
412
  it('returns null selection when no token passes the rate-limit filter', () => {
@@ -155,13 +414,14 @@ describe('LiveSessionOauthTokenSelectUseCase', () => {
155
414
  [candidate('blocked', snapshot({ fiveHourUtilization: 0.9 }))],
156
415
  [],
157
416
  NOW,
417
+ SETTINGS,
158
418
  );
159
419
 
160
420
  expect(result.selected).toBeNull();
161
421
  });
162
422
 
163
423
  it('returns null selection for an empty candidate list', () => {
164
- const result = useCase.run([], [], NOW);
424
+ const result = useCase.run([], [], NOW, SETTINGS);
165
425
 
166
426
  expect(result.selected).toBeNull();
167
427
  expect(result.metrics).toEqual([]);
@@ -172,6 +432,7 @@ describe('LiveSessionOauthTokenSelectUseCase', () => {
172
432
  [candidate('lonely', snapshot({}))],
173
433
  [session('other', 'session-x')],
174
434
  NOW,
435
+ SETTINGS,
175
436
  );
176
437
 
177
438
  const lonely = result.metrics.find((m) => m.name === 'lonely');
@@ -186,12 +447,12 @@ describe('LiveSessionOauthTokenSelectUseCase', () => {
186
447
  ],
187
448
  [session('active', 'session-a')],
188
449
  NOW,
450
+ SETTINGS,
189
451
  );
190
452
 
191
453
  expect(result.selected?.name).toBe('active');
192
454
  const disabled = result.metrics.find((m) => m.name === 'disabled');
193
455
  expect(disabled?.eligible).toBe(false);
194
- expect(disabled?.liveSessionCount).toBe(0);
195
456
  expect(disabled?.exclusionReason).toContain(
196
457
  'organization has disabled Claude subscription access for Claude Code',
197
458
  );
@@ -205,153 +466,61 @@ describe('LiveSessionOauthTokenSelectUseCase', () => {
205
466
  ],
206
467
  [session('active', 'session-a')],
207
468
  NOW,
469
+ SETTINGS,
208
470
  );
209
471
 
210
472
  expect(result.selected?.name).toBe('active');
211
473
  const rejected = result.metrics.find((m) => m.name === 'rejected');
212
474
  expect(rejected?.eligible).toBe(false);
213
- expect(rejected?.liveSessionCount).toBe(0);
214
475
  expect(rejected?.exclusionReason).toContain('rejected');
215
476
  });
216
477
  });
217
478
 
218
- const sweepingRandom = (count: number): (() => number) => {
219
- let index = 0;
220
- return () => {
221
- const value = (index + 0.5) / count;
222
- index += 1;
223
- return value;
224
- };
225
- };
226
-
227
- const throwingRandom = (): number => {
228
- throw new Error('random source must not be consulted');
229
- };
230
-
231
- const withSelectionWeight = (
232
- base: OauthTokenCandidate,
233
- selectionWeight: number,
234
- ): OauthTokenCandidate => ({ ...base, selectionWeight });
235
-
236
- describe('LiveSessionOauthTokenSelectUseCase selectionWeight', () => {
237
- const useCase = new LiveSessionOauthTokenSelectUseCase();
238
-
239
- it('keeps the deterministic selection and never consults random when the urgency weights are identical', () => {
240
- const result = useCase.run(
241
- [
242
- candidate('firstOfEqualPair', snapshot({})),
243
- candidate('secondOfEqualPair', snapshot({})),
244
- ],
245
- [],
246
- NOW,
247
- throwingRandom,
479
+ describe('liveSessionConcurrentLimitOf', () => {
480
+ it('gives the full limit while the five hour window is at or above the full speed free ratio', () => {
481
+ expect(liveSessionConcurrentLimitOf(1, 1, SETTINGS)).toBe(
482
+ MAX_CONCURRENT_SESSION_COUNT,
248
483
  );
249
-
250
- expect(result.selected?.name).toBe('firstOfEqualPair');
484
+ expect(
485
+ liveSessionConcurrentLimitOf(
486
+ SETTINGS.fullSpeedFiveHourFreeRatio,
487
+ 1,
488
+ SETTINGS,
489
+ ),
490
+ ).toBe(MAX_CONCURRENT_SESSION_COUNT);
251
491
  });
252
492
 
253
- it('selects a sole eligible low-weight token without consulting random (no starvation)', () => {
254
- const result = useCase.run(
255
- [
256
- withSelectionWeight(candidate('lowWeightOnly', snapshot({})), 0.01),
257
- candidate('blocked', snapshot({ fiveHourUtilization: 0.9 })),
258
- ],
259
- [],
260
- NOW,
261
- throwingRandom,
262
- );
263
-
264
- expect(result.selected?.name).toBe('lowWeightOnly');
493
+ it('tapers the limit in proportion to the five hour free ratio below the full speed free ratio', () => {
494
+ expect(liveSessionConcurrentLimitOf(0.25, 1, SETTINGS)).toBe(5);
495
+ expect(liveSessionConcurrentLimitOf(0.3, 1, SETTINGS)).toBe(6);
265
496
  });
266
497
 
267
- it('chooses a lower-weight token proportionally less often among equally-idle eligible candidates', () => {
268
- const count = 1000;
269
- const random = sweepingRandom(count);
270
- const selectionCounts = new Map<string, number>();
271
-
272
- for (let i = 0; i < count; i += 1) {
273
- const result = useCase.run(
274
- [
275
- withSelectionWeight(candidate('heavy', snapshot({})), 1),
276
- withSelectionWeight(candidate('light', snapshot({})), 0.5),
277
- ],
278
- [],
279
- NOW,
280
- random,
281
- );
282
- const name = result.selected?.name ?? 'none';
283
- selectionCounts.set(name, (selectionCounts.get(name) ?? 0) + 1);
284
- }
285
-
286
- const heavy = selectionCounts.get('heavy') ?? 0;
287
- const light = selectionCounts.get('light') ?? 0;
288
- expect(heavy + light).toBe(count);
289
- expect(light).toBeGreaterThan(0);
290
- expect(light).toBeLessThan(heavy);
291
- expect(Math.abs(light / count - 1 / 3)).toBeLessThan(0.02);
498
+ it('never returns less than one even when the five hour window is fully used', () => {
499
+ expect(liveSessionConcurrentLimitOf(0, 1, SETTINGS)).toBe(1);
500
+ expect(liveSessionConcurrentLimitOf(1, 0, SETTINGS)).toBe(1);
292
501
  });
293
- });
294
-
295
- describe('LiveSessionOauthTokenSelectUseCase seven day urgency weighting', () => {
296
- const useCase = new LiveSessionOauthTokenSelectUseCase();
297
502
 
298
- it('prefers the token whose seven day window resets soonest with capacity left over an idle token with a distant reset', () => {
299
- const soonResetting = candidate(
300
- 'soon-resetting',
301
- snapshot({
302
- sevenDayUtilization: 0.5,
303
- sevenDayReset: NOW + 8 * HOUR,
304
- }),
305
- );
306
- const distantReset = candidate(
307
- 'distant-reset',
308
- snapshot({
309
- sevenDayUtilization: 0,
310
- sevenDayReset: NOW + 160 * HOUR,
311
- }),
312
- );
313
-
314
- const result = useCase.run(
315
- [soonResetting, distantReset],
316
- [
317
- session('soon-resetting', 'session-a'),
318
- session('soon-resetting', 'session-b'),
319
- session('soon-resetting', 'session-c'),
320
- ],
321
- NOW,
322
- () => 0.5,
323
- );
324
-
325
- expect(result.selected?.name).toBe('soon-resetting');
503
+ it('raises the limit for a weight above one', () => {
504
+ expect(liveSessionConcurrentLimitOf(1, 1.5, SETTINGS)).toBe(15);
326
505
  });
327
506
 
328
- it('leaves the token with a distant reset selectable when the random draw falls in its share', () => {
329
- const soonResetting = candidate(
330
- 'soon-resetting',
331
- snapshot({
332
- sevenDayUtilization: 0.5,
333
- sevenDayReset: NOW + 8 * HOUR,
334
- }),
335
- );
336
- const distantReset = candidate(
337
- 'distant-reset',
338
- snapshot({
339
- sevenDayUtilization: 0,
340
- sevenDayReset: NOW + 160 * HOUR,
341
- }),
342
- );
343
-
344
- const result = useCase.run(
345
- [soonResetting, distantReset],
346
- [
347
- session('soon-resetting', 'session-a'),
348
- session('soon-resetting', 'session-b'),
349
- session('soon-resetting', 'session-c'),
350
- ],
351
- NOW,
352
- () => 0.9,
353
- );
507
+ it('uses the fleet supplied maximum concurrent session count', () => {
508
+ expect(
509
+ liveSessionConcurrentLimitOf(
510
+ 1,
511
+ 1,
512
+ settingsWith({ maxConcurrentSessionCount: 24 }),
513
+ ),
514
+ ).toBe(24);
515
+ });
354
516
 
355
- expect(result.selected?.name).toBe('distant-reset');
517
+ it('uses the fleet supplied five hour free ratio for the full limit', () => {
518
+ expect(
519
+ liveSessionConcurrentLimitOf(
520
+ 0.4,
521
+ 1,
522
+ settingsWith({ fullSpeedFiveHourFreeRatio: 0.8 }),
523
+ ),
524
+ ).toBe(5);
356
525
  });
357
526
  });