github-issue-tower-defence-management 1.146.7 → 1.146.9
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/CHANGELOG.md +14 -0
- package/README.md +5 -4
- package/bin/adapter/entry-points/cli/fleetConfig.js +101 -0
- package/bin/adapter/entry-points/cli/fleetConfig.js.map +1 -0
- package/bin/adapter/entry-points/cli/index.js +21 -4
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +4 -5
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +24 -9
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/fleetConfig.test.ts +177 -0
- package/src/adapter/entry-points/cli/fleetConfig.ts +109 -0
- package/src/adapter/entry-points/cli/index.test.ts +82 -0
- package/src/adapter/entry-points/cli/index.ts +31 -3
- package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.test.ts +8 -0
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +8 -0
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +25 -10
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +5 -5
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +12 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +325 -156
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +50 -22
- package/types/adapter/entry-points/cli/fleetConfig.d.ts +6 -0
- package/types/adapter/entry-points/cli/fleetConfig.d.ts.map +1 -0
- package/types/adapter/entry-points/cli/index.d.ts +2 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +3 -4
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +10 -2
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -1
|
@@ -2,13 +2,13 @@ import { ClaudeLiveSessionRepository } from '../../../domain/usecases/adapter-in
|
|
|
2
2
|
import {
|
|
3
3
|
LiveSessionOauthTokenSelectResult,
|
|
4
4
|
LiveSessionOauthTokenSelectUseCase,
|
|
5
|
+
LiveSessionOauthTokenSelectionSettings,
|
|
5
6
|
} from '../../../domain/usecases/LiveSessionOauthTokenSelectUseCase';
|
|
6
7
|
import {
|
|
7
8
|
DEFAULT_SELECTION_WEIGHT,
|
|
8
9
|
FIVE_HOUR_MIN_FREE_RATIO,
|
|
9
10
|
OauthTokenCandidate,
|
|
10
11
|
SEVEN_DAY_MIN_FREE_RATIO,
|
|
11
|
-
SelectionRandom,
|
|
12
12
|
} from '../../../domain/usecases/OauthTokenSelectUseCase';
|
|
13
13
|
import { ProcClaudeLiveSessionRepository } from '../../repositories/ProcClaudeLiveSessionRepository';
|
|
14
14
|
import { FABLE_LIMIT_TYPE, readRateLimit } from '../../proxy/RateLimitCache';
|
|
@@ -22,6 +22,7 @@ export type LiveSessionOauthTokenSelectHandlerInput = {
|
|
|
22
22
|
tokenListJsonPath: string | null;
|
|
23
23
|
cacheDirectory: string | null;
|
|
24
24
|
nowEpochSeconds: number;
|
|
25
|
+
selectionSettings: LiveSessionOauthTokenSelectionSettings;
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
export type LiveSessionOauthTokenSelectHandlerOutput = {
|
|
@@ -34,7 +35,6 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
34
35
|
constructor(
|
|
35
36
|
private readonly useCase: LiveSessionOauthTokenSelectUseCase = new LiveSessionOauthTokenSelectUseCase(),
|
|
36
37
|
private readonly liveSessionRepository: ClaudeLiveSessionRepository = new ProcClaudeLiveSessionRepository(),
|
|
37
|
-
private readonly random: SelectionRandom = Math.random,
|
|
38
38
|
) {}
|
|
39
39
|
|
|
40
40
|
handle = (
|
|
@@ -98,7 +98,7 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
98
98
|
candidates,
|
|
99
99
|
liveSessions,
|
|
100
100
|
input.nowEpochSeconds,
|
|
101
|
-
|
|
101
|
+
input.selectionSettings,
|
|
102
102
|
);
|
|
103
103
|
|
|
104
104
|
return {
|
|
@@ -119,7 +119,7 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
119
119
|
const status = metric.eligible
|
|
120
120
|
? 'eligible'
|
|
121
121
|
: `excluded (${metric.exclusionReason})`;
|
|
122
|
-
return `${metric.name}: ${metric.liveSessionCount} live session(s), 5h ${Math.round(metric.fiveHourFreeRatio * 100)}% free, 7d ${Math.round(metric.sevenDayFreeRatio * 100)}% free, 7d-end in ${secondsUntilSevenDayEnd}s -> ${status}`;
|
|
122
|
+
return `${metric.name}: ${metric.liveSessionCount}/${metric.concurrentSessionLimit} live session(s), 5h ${Math.round(metric.fiveHourFreeRatio * 100)}% free, 7d ${Math.round(metric.sevenDayFreeRatio * 100)}% free, 7d-end in ${secondsUntilSevenDayEnd}s -> ${status}`;
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
if (result.selected === null) {
|
|
@@ -128,7 +128,7 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
128
128
|
);
|
|
129
129
|
} else {
|
|
130
130
|
lines.push(
|
|
131
|
-
`Selected ${result.selected.name} (
|
|
131
|
+
`Selected ${result.selected.name} (the soonest-resetting 7d window among tokens still under their concurrent session limit, which is set by the free share of the 5h window alone).`,
|
|
132
132
|
);
|
|
133
133
|
}
|
|
134
134
|
|
|
@@ -627,6 +627,18 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
627
627
|
expect(result).toBeNull();
|
|
628
628
|
expect(projectRepository.getProject).not.toHaveBeenCalled();
|
|
629
629
|
});
|
|
630
|
+
|
|
631
|
+
it('returns null when cached project data does not match the expected Project shape', async () => {
|
|
632
|
+
const { repository, localStorageCacheRepository } =
|
|
633
|
+
createApiV3CheerioRestIssueRepository();
|
|
634
|
+
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
635
|
+
project: { invalid: 'data' },
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const result = await repository.getCachedProject('some-project');
|
|
639
|
+
|
|
640
|
+
expect(result).toBeNull();
|
|
641
|
+
});
|
|
630
642
|
});
|
|
631
643
|
|
|
632
644
|
describe('updateNextActionDate', () => {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { ClaudeLiveSession } from './adapter-interfaces/ClaudeLiveSessionRepository';
|
|
2
|
-
import {
|
|
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('
|
|
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
|
-
[
|
|
48
|
-
|
|
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
|
-
|
|
78
|
+
SETTINGS,
|
|
51
79
|
);
|
|
52
80
|
|
|
53
|
-
expect(result.selected?.name).toBe('
|
|
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('
|
|
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(
|
|
63
|
-
|
|
104
|
+
candidate(
|
|
105
|
+
'distantResetIdle',
|
|
106
|
+
snapshot({ sevenDayReset: NOW + 6 * DAY }),
|
|
107
|
+
),
|
|
108
|
+
candidate('soonResetFull', snapshot({ sevenDayReset: NOW + 2 * HOUR })),
|
|
64
109
|
],
|
|
65
|
-
|
|
110
|
+
sessionsFor('soonResetFull', MAX_CONCURRENT_SESSION_COUNT),
|
|
66
111
|
NOW,
|
|
67
|
-
|
|
112
|
+
SETTINGS,
|
|
68
113
|
);
|
|
69
114
|
|
|
70
|
-
expect(result.selected?.name).toBe('
|
|
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('
|
|
121
|
+
it('lowers the concurrent session limit as the five hour window fills', () => {
|
|
74
122
|
const result = useCase.run(
|
|
75
123
|
[
|
|
76
|
-
candidate(
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
195
|
+
SETTINGS,
|
|
85
196
|
);
|
|
86
197
|
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
484
|
+
expect(
|
|
485
|
+
liveSessionConcurrentLimitOf(
|
|
486
|
+
SETTINGS.fullSpeedFiveHourFreeRatio,
|
|
487
|
+
1,
|
|
488
|
+
SETTINGS,
|
|
489
|
+
),
|
|
490
|
+
).toBe(MAX_CONCURRENT_SESSION_COUNT);
|
|
251
491
|
});
|
|
252
492
|
|
|
253
|
-
it('
|
|
254
|
-
|
|
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('
|
|
268
|
-
|
|
269
|
-
|
|
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('
|
|
299
|
-
|
|
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('
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
);
|
|
336
|
-
|
|
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
|
-
|
|
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
|
});
|