github-issue-tower-defence-management 1.195.0 → 1.196.1
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 +3 -8
- 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 +2 -3
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/awaitingOwnerApprovalMessage.js +1 -1
- package/bin/domain/usecases/awaitingOwnerApprovalMessage.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 +11 -18
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +3 -15
- package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +99 -0
- package/src/domain/usecases/OauthTokenSelectUseCase.ts +30 -5
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +2 -2
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +2 -7
- package/src/domain/usecases/awaitingOwnerApprovalMessage.ts +1 -1
- 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.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.map +1 -1
- package/types/domain/usecases/awaitingOwnerApprovalMessage.d.ts +1 -1
- package/types/domain/usecases/awaitingOwnerApprovalMessage.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,
|
|
@@ -4675,7 +4675,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
4675
4675
|
describe('waitingForOwnerApproval', () => {
|
|
4676
4676
|
const issueUrl = 'https://github.com/user/repo/issues/1';
|
|
4677
4677
|
|
|
4678
|
-
it('moves to
|
|
4678
|
+
it('moves to Awaiting Quality Check and posts AWAITING_OWNER_APPROVAL message when last report declares waitingForOwnerApproval', async () => {
|
|
4679
4679
|
const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
|
|
4680
4680
|
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
4681
4681
|
mockIssueRepository.get.mockResolvedValue(issue);
|
|
@@ -4696,13 +4696,13 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
4696
4696
|
});
|
|
4697
4697
|
|
|
4698
4698
|
expect(mockIssueRepository.update).toHaveBeenCalledWith(
|
|
4699
|
-
expect.objectContaining({ status: '
|
|
4699
|
+
expect.objectContaining({ status: 'Awaiting Quality Check' }),
|
|
4700
4700
|
mockProject,
|
|
4701
4701
|
);
|
|
4702
4702
|
expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
|
|
4703
4703
|
mockProject,
|
|
4704
|
-
expect.objectContaining({ status: '
|
|
4705
|
-
'
|
|
4704
|
+
expect.objectContaining({ status: 'Awaiting Quality Check' }),
|
|
4705
|
+
'awaiting-quality-check-id',
|
|
4706
4706
|
);
|
|
4707
4707
|
expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
|
|
4708
4708
|
expect.objectContaining({ url: issueUrl }),
|
|
@@ -4742,18 +4742,20 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
4742
4742
|
);
|
|
4743
4743
|
});
|
|
4744
4744
|
|
|
4745
|
-
it('
|
|
4746
|
-
const
|
|
4745
|
+
it('writes no status at all when the project has no Awaiting Quality Check option', async () => {
|
|
4746
|
+
const projectWithoutQualityCheck = {
|
|
4747
4747
|
...mockProject,
|
|
4748
4748
|
status: {
|
|
4749
4749
|
...mockProject.status,
|
|
4750
4750
|
statuses: mockProject.status.statuses.filter(
|
|
4751
|
-
(status) => status.name !== '
|
|
4751
|
+
(status) => status.name !== 'Awaiting Quality Check',
|
|
4752
4752
|
),
|
|
4753
4753
|
},
|
|
4754
4754
|
};
|
|
4755
4755
|
const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
|
|
4756
|
-
mockProjectRepository.getByUrl.mockResolvedValue(
|
|
4756
|
+
mockProjectRepository.getByUrl.mockResolvedValue(
|
|
4757
|
+
projectWithoutQualityCheck,
|
|
4758
|
+
);
|
|
4757
4759
|
mockIssueRepository.get.mockResolvedValue(issue);
|
|
4758
4760
|
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
4759
4761
|
createMockComment({
|
|
@@ -4771,16 +4773,7 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
4771
4773
|
allowedIssueAuthors: null,
|
|
4772
4774
|
});
|
|
4773
4775
|
|
|
4774
|
-
expect(mockIssueRepository.updateStatus).
|
|
4775
|
-
projectWithoutTodo,
|
|
4776
|
-
expect.objectContaining({ status: 'Failed Preparation' }),
|
|
4777
|
-
'failed-preparation-id',
|
|
4778
|
-
);
|
|
4779
|
-
expect(mockIssueRepository.updateStatus).not.toHaveBeenCalledWith(
|
|
4780
|
-
projectWithoutTodo,
|
|
4781
|
-
expect.anything(),
|
|
4782
|
-
'awaiting-workspace-id',
|
|
4783
|
-
);
|
|
4776
|
+
expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
|
|
4784
4777
|
});
|
|
4785
4778
|
|
|
4786
4779
|
it('escalates to Failed Preparation after ownerApprovalTimeoutCycles AWAITING_OWNER_APPROVAL messages', async () => {
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
AWAITING_WORKSPACE_STATUS_NAME,
|
|
9
9
|
FAILED_PREPARATION_STATUS_NAME,
|
|
10
10
|
PREPARATION_STATUS_NAME,
|
|
11
|
-
TODO_STATUS_NAME,
|
|
12
11
|
} from '../entities/WorkflowStatus';
|
|
13
12
|
import {
|
|
14
13
|
IssueRejectionEvaluator,
|
|
@@ -158,9 +157,6 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
158
157
|
);
|
|
159
158
|
return;
|
|
160
159
|
}
|
|
161
|
-
const todoStatusOption = project.status.statuses.find(
|
|
162
|
-
(s) => s.name === TODO_STATUS_NAME,
|
|
163
|
-
);
|
|
164
160
|
|
|
165
161
|
const issue = await this.issueRepository.get(params.issueUrl, project);
|
|
166
162
|
|
|
@@ -330,21 +326,13 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
330
326
|
isTrustedAuthor(comment.author) &&
|
|
331
327
|
comment.content.startsWith(AWAITING_OWNER_APPROVAL_MESSAGE_HEAD),
|
|
332
328
|
).length;
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
`Todo status option '${TODO_STATUS_NAME}' not found in project; escalating to '${FAILED_PREPARATION_STATUS_NAME}' instead of returning the task to the dispatch pool.`,
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
if (
|
|
339
|
-
awaitingOwnerApprovalCount < ownerApprovalTimeoutCycles &&
|
|
340
|
-
todoStatusOption
|
|
341
|
-
) {
|
|
342
|
-
issue.status = TODO_STATUS_NAME;
|
|
329
|
+
if (awaitingOwnerApprovalCount < ownerApprovalTimeoutCycles) {
|
|
330
|
+
issue.status = AWAITING_QUALITY_CHECK_STATUS_NAME;
|
|
343
331
|
await this.issueRepository.update(issue, project);
|
|
344
332
|
await this.issueRepository.updateStatus(
|
|
345
333
|
project,
|
|
346
334
|
issue,
|
|
347
|
-
|
|
335
|
+
awaitingQualityCheckStatusOption.id,
|
|
348
336
|
);
|
|
349
337
|
await this.patchConsoleTab(issue);
|
|
350
338
|
await this.issueCommentRepository.createComment(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
2
3
|
OauthTokenCandidate,
|
|
3
4
|
OauthTokenSelectUseCase,
|
|
4
5
|
OauthTokenWindowSnapshot,
|
|
@@ -550,3 +551,101 @@ describe('sevenDayUrgencyFactor', () => {
|
|
|
550
551
|
expect(almostReset).toBe(SEVEN_DAY_WINDOW_HOURS);
|
|
551
552
|
});
|
|
552
553
|
});
|
|
554
|
+
|
|
555
|
+
describe('OauthTokenSelectUseCase with CL script thresholds', () => {
|
|
556
|
+
const useCase = new OauthTokenSelectUseCase();
|
|
557
|
+
|
|
558
|
+
it('excludes a token whose 5h window is less than 60% free when using CL script thresholds', () => {
|
|
559
|
+
const result = useCase.run(
|
|
560
|
+
[
|
|
561
|
+
candidate('busy5h', snapshot({ fiveHourUtilization: 0.41 })),
|
|
562
|
+
candidate('ok', snapshot({ fiveHourUtilization: 0.4 })),
|
|
563
|
+
],
|
|
564
|
+
NOW,
|
|
565
|
+
Math.random,
|
|
566
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
expect(result.selected?.name).toBe('ok');
|
|
570
|
+
const busy = result.metrics.find((m) => m.name === 'busy5h');
|
|
571
|
+
expect(busy?.eligible).toBe(false);
|
|
572
|
+
expect(busy?.exclusionReason).toContain('5h window');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('treats exactly 40% used 5h utilization as eligible under CL script thresholds (boundary)', () => {
|
|
576
|
+
const result = useCase.run(
|
|
577
|
+
[candidate('boundary', snapshot({ fiveHourUtilization: 0.4 }))],
|
|
578
|
+
NOW,
|
|
579
|
+
Math.random,
|
|
580
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
expect(result.selected?.name).toBe('boundary');
|
|
584
|
+
const boundary = result.metrics.find((m) => m.name === 'boundary');
|
|
585
|
+
expect(boundary?.fiveHourFreeRatio).toBeCloseTo(0.6, 9);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('excludes a token whose 7d window is less than 14% free when using CL script thresholds', () => {
|
|
589
|
+
const result = useCase.run(
|
|
590
|
+
[
|
|
591
|
+
candidate('busy7d', snapshot({ sevenDayUtilization: 0.87 })),
|
|
592
|
+
candidate('ok', snapshot({ sevenDayUtilization: 0.86 })),
|
|
593
|
+
],
|
|
594
|
+
NOW,
|
|
595
|
+
Math.random,
|
|
596
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
expect(result.selected?.name).toBe('ok');
|
|
600
|
+
const busy = result.metrics.find((m) => m.name === 'busy7d');
|
|
601
|
+
expect(busy?.eligible).toBe(false);
|
|
602
|
+
expect(busy?.exclusionReason).toContain('7d window');
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('treats exactly 86% used 7d utilization as eligible under CL script thresholds (boundary)', () => {
|
|
606
|
+
const result = useCase.run(
|
|
607
|
+
[candidate('boundary', snapshot({ sevenDayUtilization: 0.86 }))],
|
|
608
|
+
NOW,
|
|
609
|
+
Math.random,
|
|
610
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
expect(result.selected?.name).toBe('boundary');
|
|
614
|
+
const boundary = result.metrics.find((m) => m.name === 'boundary');
|
|
615
|
+
expect(boundary?.sevenDayFreeRatio).toBeCloseTo(0.14, 9);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('returns null when only a token with 59% 5h free is available under CL script thresholds', () => {
|
|
619
|
+
const result = useCase.run(
|
|
620
|
+
[candidate('marginal', snapshot({ fiveHourUtilization: 0.41 }))],
|
|
621
|
+
NOW,
|
|
622
|
+
Math.random,
|
|
623
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
expect(result.selected).toBeNull();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('returns null when only a token with 13% 7d free is available under CL script thresholds', () => {
|
|
630
|
+
const result = useCase.run(
|
|
631
|
+
[candidate('marginal', snapshot({ sevenDayUtilization: 0.87 }))],
|
|
632
|
+
NOW,
|
|
633
|
+
Math.random,
|
|
634
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
expect(result.selected).toBeNull();
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('includes the correct threshold percentages in the exclusion reason message', () => {
|
|
641
|
+
const result = useCase.run(
|
|
642
|
+
[candidate('low5h', snapshot({ fiveHourUtilization: 0.5 }))],
|
|
643
|
+
NOW,
|
|
644
|
+
Math.random,
|
|
645
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
const low5h = result.metrics.find((m) => m.name === 'low5h');
|
|
649
|
+
expect(low5h?.exclusionReason).toContain('60%');
|
|
650
|
+
});
|
|
651
|
+
});
|