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