github-issue-tower-defence-management 1.65.0 → 1.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -7
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +1 -1
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +5 -0
  6. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
  7. package/bin/domain/entities/WorkflowStatus.js +2 -6
  8. package/bin/domain/entities/WorkflowStatus.js.map +1 -1
  9. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js +14 -1
  10. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js.map +1 -1
  11. package/bin/domain/usecases/StartPreparationUseCase.js +59 -121
  12. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +1 -0
  15. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +14 -0
  16. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +5 -0
  17. package/src/domain/entities/ClaudeTokenUsage.ts +1 -0
  18. package/src/domain/entities/WorkflowStatus.ts +2 -5
  19. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +432 -36
  20. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.ts +31 -0
  21. package/src/domain/usecases/StartPreparationUseCase.test.ts +87 -306
  22. package/src/domain/usecases/StartPreparationUseCase.ts +80 -175
  23. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  24. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
  25. package/types/domain/entities/ClaudeTokenUsage.d.ts +1 -0
  26. package/types/domain/entities/ClaudeTokenUsage.d.ts.map +1 -1
  27. package/types/domain/entities/WorkflowStatus.d.ts +1 -1
  28. package/types/domain/entities/WorkflowStatus.d.ts.map +1 -1
  29. package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts +3 -1
  30. package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts.map +1 -1
  31. package/types/domain/usecases/StartPreparationUseCase.d.ts +2 -6
  32. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
@@ -1221,6 +1221,7 @@ describe('StartPreparationUseCase', () => {
1221
1221
  name: 'token-a',
1222
1222
  token: 'token-a',
1223
1223
  fiveHourUtilization: 0,
1224
+ sevenDayUtilization: 0,
1224
1225
  blocked: false,
1225
1226
  rejected: false,
1226
1227
  modelWeeklyLimits: {},
@@ -1229,6 +1230,7 @@ describe('StartPreparationUseCase', () => {
1229
1230
  name: 'token-b',
1230
1231
  token: 'token-b',
1231
1232
  fiveHourUtilization: 0,
1233
+ sevenDayUtilization: 0,
1232
1234
  blocked: false,
1233
1235
  rejected: false,
1234
1236
  modelWeeklyLimits: {},
@@ -1241,7 +1243,7 @@ describe('StartPreparationUseCase', () => {
1241
1243
  defaultLlmModelName: 'claude-sonnet-4-6',
1242
1244
  defaultLlmAgentName: null,
1243
1245
  configFilePath: '/path/to/config.yml',
1244
- maximumPreparingIssuesCount: null,
1246
+ maximumPreparingIssuesCount: 12,
1245
1247
  utilizationPercentageThreshold: 90,
1246
1248
  allowedIssueAuthors: null,
1247
1249
  codexHomeCandidates: null,
@@ -1284,6 +1286,7 @@ describe('StartPreparationUseCase', () => {
1284
1286
  name: 'token-a',
1285
1287
  token: 'token-a',
1286
1288
  fiveHourUtilization: 0,
1289
+ sevenDayUtilization: 0,
1287
1290
  blocked: false,
1288
1291
  rejected: false,
1289
1292
  modelWeeklyLimits: {},
@@ -1292,6 +1295,7 @@ describe('StartPreparationUseCase', () => {
1292
1295
  name: 'token-b',
1293
1296
  token: 'token-b',
1294
1297
  fiveHourUtilization: 0,
1298
+ sevenDayUtilization: 0,
1295
1299
  blocked: false,
1296
1300
  rejected: false,
1297
1301
  modelWeeklyLimits: {},
@@ -1712,9 +1716,6 @@ describe('StartPreparationUseCase', () => {
1712
1716
  stderr: '',
1713
1717
  exitCode: 0,
1714
1718
  });
1715
- const consoleWarnSpy = jest
1716
- .spyOn(console, 'warn')
1717
- .mockImplementation(() => {});
1718
1719
 
1719
1720
  await useCase.run({
1720
1721
  projectUrl: 'https://github.com/user/repo',
@@ -1735,10 +1736,6 @@ describe('StartPreparationUseCase', () => {
1735
1736
  status: 'Preparation',
1736
1737
  });
1737
1738
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1738
- expect(consoleWarnSpy).toHaveBeenCalledWith(
1739
- `Skipping issue https://github.com/user/repo/issues/2: author 'user3' is not in the allowedIssueAuthors list.`,
1740
- );
1741
- consoleWarnSpy.mockRestore();
1742
1739
  });
1743
1740
 
1744
1741
  it('should process all issues when allowedIssueAuthors is null', async () => {
@@ -1811,9 +1808,6 @@ describe('StartPreparationUseCase', () => {
1811
1808
  stderr: '',
1812
1809
  exitCode: 0,
1813
1810
  });
1814
- const consoleWarnSpy = jest
1815
- .spyOn(console, 'warn')
1816
- .mockImplementation(() => {});
1817
1811
 
1818
1812
  await useCase.run({
1819
1813
  projectUrl: 'https://github.com/user/repo',
@@ -1833,10 +1827,6 @@ describe('StartPreparationUseCase', () => {
1833
1827
  url: 'https://github.com/user/repo/issues/2',
1834
1828
  });
1835
1829
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1836
- expect(consoleWarnSpy).toHaveBeenCalledWith(
1837
- 'Skipping issue https://github.com/user/repo/issues/1: author is unknown (empty string); deny-by-default when allowedIssueAuthors is configured.',
1838
- );
1839
- consoleWarnSpy.mockRestore();
1840
1830
  });
1841
1831
 
1842
1832
  it('should skip issue with empty author when allowedIssueAuthors is set', async () => {
@@ -1857,9 +1847,6 @@ describe('StartPreparationUseCase', () => {
1857
1847
  stderr: '',
1858
1848
  exitCode: 0,
1859
1849
  });
1860
- const consoleWarnSpy = jest
1861
- .spyOn(console, 'warn')
1862
- .mockImplementation(() => {});
1863
1850
 
1864
1851
  await useCase.run({
1865
1852
  projectUrl: 'https://github.com/user/repo',
@@ -1876,10 +1863,6 @@ describe('StartPreparationUseCase', () => {
1876
1863
 
1877
1864
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
1878
1865
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
1879
- expect(consoleWarnSpy).toHaveBeenCalledWith(
1880
- 'Skipping issue https://github.com/user/repo/issues/1: author is unknown (empty string); deny-by-default when allowedIssueAuthors is configured.',
1881
- );
1882
- consoleWarnSpy.mockRestore();
1883
1866
  });
1884
1867
 
1885
1868
  it('should not pass --codexHome when codexHomeCandidates is null', async () => {
@@ -2299,6 +2282,7 @@ describe('StartPreparationUseCase', () => {
2299
2282
  name: 'token-a',
2300
2283
  token: 'token-a',
2301
2284
  fiveHourUtilization: 0,
2285
+ sevenDayUtilization: 0,
2302
2286
  blocked: false,
2303
2287
  rejected: false,
2304
2288
  modelWeeklyLimits: {},
@@ -2374,6 +2358,7 @@ describe('StartPreparationUseCase', () => {
2374
2358
  name: 'token-a',
2375
2359
  token: 'token-a',
2376
2360
  fiveHourUtilization: 0,
2361
+ sevenDayUtilization: 0,
2377
2362
  blocked: false,
2378
2363
  rejected: false,
2379
2364
  modelWeeklyLimits: {},
@@ -2382,6 +2367,7 @@ describe('StartPreparationUseCase', () => {
2382
2367
  name: 'token-b',
2383
2368
  token: 'token-b',
2384
2369
  fiveHourUtilization: 0,
2370
+ sevenDayUtilization: 0,
2385
2371
  blocked: false,
2386
2372
  rejected: false,
2387
2373
  modelWeeklyLimits: {},
@@ -2464,7 +2450,7 @@ describe('StartPreparationUseCase', () => {
2464
2450
  ).toHaveLength(0);
2465
2451
  });
2466
2452
 
2467
- it('should pick the least-utilized token first', async () => {
2453
+ it('should pick the token with the lowest 7-day utilization first', async () => {
2468
2454
  const awaitingIssue = createMockIssue({
2469
2455
  url: 'url1',
2470
2456
  title: 'Issue 1',
@@ -2484,17 +2470,19 @@ describe('StartPreparationUseCase', () => {
2484
2470
  });
2485
2471
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2486
2472
  {
2487
- name: 'token-high',
2488
- token: 'token-high',
2489
- fiveHourUtilization: 0.8,
2473
+ name: 'token-high-7d',
2474
+ token: 'token-high-7d',
2475
+ fiveHourUtilization: 0.1,
2476
+ sevenDayUtilization: 0.7,
2490
2477
  blocked: false,
2491
2478
  rejected: false,
2492
2479
  modelWeeklyLimits: {},
2493
2480
  },
2494
2481
  {
2495
- name: 'token-low',
2496
- token: 'token-low',
2497
- fiveHourUtilization: 0.1,
2482
+ name: 'token-low-7d',
2483
+ token: 'token-low-7d',
2484
+ fiveHourUtilization: 0.5,
2485
+ sevenDayUtilization: 0.2,
2498
2486
  blocked: false,
2499
2487
  rejected: false,
2500
2488
  modelWeeklyLimits: {},
@@ -2517,7 +2505,7 @@ describe('StartPreparationUseCase', () => {
2517
2505
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2518
2506
  expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2519
2507
  env: {
2520
- CLAUDE_CODE_OAUTH_TOKEN: 'token-low',
2508
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-low-7d',
2521
2509
  ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2522
2510
  },
2523
2511
  });
@@ -2546,6 +2534,7 @@ describe('StartPreparationUseCase', () => {
2546
2534
  name: 'token-blocked',
2547
2535
  token: 'token-blocked',
2548
2536
  fiveHourUtilization: 0.05,
2537
+ sevenDayUtilization: 0,
2549
2538
  blocked: true,
2550
2539
  rejected: false,
2551
2540
  modelWeeklyLimits: {},
@@ -2554,6 +2543,7 @@ describe('StartPreparationUseCase', () => {
2554
2543
  name: 'token-ok',
2555
2544
  token: 'token-ok',
2556
2545
  fiveHourUtilization: 0.5,
2546
+ sevenDayUtilization: 0,
2557
2547
  blocked: false,
2558
2548
  rejected: false,
2559
2549
  modelWeeklyLimits: {},
@@ -2605,6 +2595,7 @@ describe('StartPreparationUseCase', () => {
2605
2595
  name: 'token-a',
2606
2596
  token: 'token-a',
2607
2597
  fiveHourUtilization: 0.05,
2598
+ sevenDayUtilization: 0,
2608
2599
  blocked: true,
2609
2600
  rejected: false,
2610
2601
  modelWeeklyLimits: {},
@@ -2613,6 +2604,7 @@ describe('StartPreparationUseCase', () => {
2613
2604
  name: 'token-b',
2614
2605
  token: 'token-b',
2615
2606
  fiveHourUtilization: 0.08,
2607
+ sevenDayUtilization: 0,
2616
2608
  blocked: true,
2617
2609
  rejected: false,
2618
2610
  modelWeeklyLimits: {},
@@ -2670,6 +2662,7 @@ describe('StartPreparationUseCase', () => {
2670
2662
  name: 'token-a',
2671
2663
  token: 'token-a',
2672
2664
  fiveHourUtilization: 0.95,
2665
+ sevenDayUtilization: 0,
2673
2666
  blocked: false,
2674
2667
  rejected: false,
2675
2668
  modelWeeklyLimits: {},
@@ -2678,6 +2671,7 @@ describe('StartPreparationUseCase', () => {
2678
2671
  name: 'token-b',
2679
2672
  token: 'token-b',
2680
2673
  fiveHourUtilization: 0.97,
2674
+ sevenDayUtilization: 0,
2681
2675
  blocked: false,
2682
2676
  rejected: false,
2683
2677
  modelWeeklyLimits: {},
@@ -2707,12 +2701,12 @@ describe('StartPreparationUseCase', () => {
2707
2701
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
2708
2702
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
2709
2703
  expect(consoleWarnSpy).toHaveBeenCalledWith(
2710
- expect.stringContaining('5h utilization >= 95%'),
2704
+ expect.stringContaining('5h utilization >= 90%'),
2711
2705
  );
2712
2706
  consoleWarnSpy.mockRestore();
2713
2707
  });
2714
2708
 
2715
- it('should return all tokens sorted ascending when all have full process capacity', async () => {
2709
+ it('should sort tokens by 7-day utilization ascending when all have full process capacity', async () => {
2716
2710
  const awaitingIssues: Issue[] = [
2717
2711
  createMockIssue({
2718
2712
  url: 'url1',
@@ -2750,25 +2744,28 @@ describe('StartPreparationUseCase', () => {
2750
2744
  });
2751
2745
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2752
2746
  {
2753
- name: 'token-mid',
2754
- token: 'token-mid',
2755
- fiveHourUtilization: 0.5,
2747
+ name: 'token-7d-mid',
2748
+ token: 'token-7d-mid',
2749
+ fiveHourUtilization: 0.1,
2750
+ sevenDayUtilization: 0.5,
2756
2751
  blocked: false,
2757
2752
  rejected: false,
2758
2753
  modelWeeklyLimits: {},
2759
2754
  },
2760
2755
  {
2761
- name: 'token-low',
2762
- token: 'token-low',
2763
- fiveHourUtilization: 0.1,
2756
+ name: 'token-7d-low',
2757
+ token: 'token-7d-low',
2758
+ fiveHourUtilization: 0.5,
2759
+ sevenDayUtilization: 0.1,
2764
2760
  blocked: false,
2765
2761
  rejected: false,
2766
2762
  modelWeeklyLimits: {},
2767
2763
  },
2768
2764
  {
2769
- name: 'token-high',
2770
- token: 'token-high',
2771
- fiveHourUtilization: 0.8,
2765
+ name: 'token-7d-high',
2766
+ token: 'token-7d-high',
2767
+ fiveHourUtilization: 0.3,
2768
+ sevenDayUtilization: 0.7,
2772
2769
  blocked: false,
2773
2770
  rejected: false,
2774
2771
  modelWeeklyLimits: {},
@@ -2790,17 +2787,17 @@ describe('StartPreparationUseCase', () => {
2790
2787
 
2791
2788
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
2792
2789
  expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toMatchObject({
2793
- env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-low' },
2790
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-low' },
2794
2791
  });
2795
2792
  expect(mockLocalCommandRunner.runCommand.mock.calls[1][2]).toMatchObject({
2796
- env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-mid' },
2793
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-mid' },
2797
2794
  });
2798
2795
  expect(mockLocalCommandRunner.runCommand.mock.calls[2][2]).toMatchObject({
2799
- env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-high' },
2796
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-high' },
2800
2797
  });
2801
2798
  });
2802
2799
 
2803
- it('should reduce token process capacity exponentially above 80 percent 5h utilization', async () => {
2800
+ it('should cap total tasks to the sum of per-token 7-day adaptive concurrent limits', async () => {
2804
2801
  const awaitingIssues: Issue[] = Array.from({ length: 10 }, (_, i) =>
2805
2802
  createMockIssue({
2806
2803
  url: `url${i + 1}`,
@@ -2822,33 +2819,10 @@ describe('StartPreparationUseCase', () => {
2822
2819
  });
2823
2820
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2824
2821
  {
2825
- name: 'token-full-capacity',
2826
- token: 'token-full-capacity',
2827
- fiveHourUtilization: 0.8,
2828
- blocked: false,
2829
- rejected: false,
2830
- modelWeeklyLimits: {},
2831
- },
2832
- {
2833
- name: 'token-two-slots',
2834
- token: 'token-two-slots',
2835
- fiveHourUtilization: 0.85,
2836
- blocked: false,
2837
- rejected: false,
2838
- modelWeeklyLimits: {},
2839
- },
2840
- {
2841
- name: 'token-one-slot',
2842
- token: 'token-one-slot',
2843
- fiveHourUtilization: 0.9,
2844
- blocked: false,
2845
- rejected: false,
2846
- modelWeeklyLimits: {},
2847
- },
2848
- {
2849
- name: 'token-zero-slots',
2850
- token: 'token-zero-slots',
2851
- fiveHourUtilization: 0.95,
2822
+ name: 'token-at-90-percent-7d',
2823
+ token: 'token-at-90-percent-7d',
2824
+ fiveHourUtilization: 0.1,
2825
+ sevenDayUtilization: 0.9,
2852
2826
  blocked: false,
2853
2827
  rejected: false,
2854
2828
  modelWeeklyLimits: {},
@@ -2868,20 +2842,13 @@ describe('StartPreparationUseCase', () => {
2868
2842
  allowIssueCacheMinutes: 0,
2869
2843
  });
2870
2844
 
2871
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(9);
2845
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
2872
2846
  const spawnedTokens = mockLocalCommandRunner.runCommand.mock.calls.map(
2873
2847
  (call) => call[2]?.env?.CLAUDE_CODE_OAUTH_TOKEN,
2874
2848
  );
2875
2849
  expect(
2876
- spawnedTokens.filter((token) => token === 'token-full-capacity'),
2877
- ).toHaveLength(6);
2878
- expect(
2879
- spawnedTokens.filter((token) => token === 'token-two-slots'),
2880
- ).toHaveLength(2);
2881
- expect(
2882
- spawnedTokens.filter((token) => token === 'token-one-slot'),
2883
- ).toHaveLength(1);
2884
- expect(spawnedTokens).not.toContain('token-zero-slots');
2850
+ spawnedTokens.filter((token) => token === 'token-at-90-percent-7d'),
2851
+ ).toHaveLength(3);
2885
2852
  });
2886
2853
 
2887
2854
  it('should exclude a rejected token from rotation', async () => {
@@ -2907,6 +2874,7 @@ describe('StartPreparationUseCase', () => {
2907
2874
  name: 'token-rejected',
2908
2875
  token: 'token-rejected',
2909
2876
  fiveHourUtilization: 0.1,
2877
+ sevenDayUtilization: 0,
2910
2878
  blocked: false,
2911
2879
  rejected: true,
2912
2880
  modelWeeklyLimits: {},
@@ -2915,6 +2883,7 @@ describe('StartPreparationUseCase', () => {
2915
2883
  name: 'token-ok',
2916
2884
  token: 'token-ok',
2917
2885
  fiveHourUtilization: 0.5,
2886
+ sevenDayUtilization: 0,
2918
2887
  blocked: false,
2919
2888
  rejected: false,
2920
2889
  modelWeeklyLimits: {},
@@ -2966,6 +2935,7 @@ describe('StartPreparationUseCase', () => {
2966
2935
  name: 'token-reset',
2967
2936
  token: 'token-reset',
2968
2937
  fiveHourUtilization: 0,
2938
+ sevenDayUtilization: 0,
2969
2939
  blocked: false,
2970
2940
  rejected: false,
2971
2941
  modelWeeklyLimits: {},
@@ -2974,6 +2944,7 @@ describe('StartPreparationUseCase', () => {
2974
2944
  name: 'token-busy',
2975
2945
  token: 'token-busy',
2976
2946
  fiveHourUtilization: 0.5,
2947
+ sevenDayUtilization: 0,
2977
2948
  blocked: false,
2978
2949
  rejected: false,
2979
2950
  modelWeeklyLimits: {},
@@ -3025,6 +2996,7 @@ describe('StartPreparationUseCase', () => {
3025
2996
  name: 'token-saturated',
3026
2997
  token: 'token-saturated',
3027
2998
  fiveHourUtilization: 0.95,
2999
+ sevenDayUtilization: 0,
3028
3000
  blocked: false,
3029
3001
  rejected: true,
3030
3002
  modelWeeklyLimits: {},
@@ -3033,6 +3005,7 @@ describe('StartPreparationUseCase', () => {
3033
3005
  name: 'token-ok',
3034
3006
  token: 'token-ok',
3035
3007
  fiveHourUtilization: 0.2,
3008
+ sevenDayUtilization: 0,
3036
3009
  blocked: false,
3037
3010
  rejected: false,
3038
3011
  modelWeeklyLimits: {},
@@ -3084,6 +3057,7 @@ describe('StartPreparationUseCase', () => {
3084
3057
  name: 'token-a',
3085
3058
  token: 'token-a',
3086
3059
  fiveHourUtilization: 0.1,
3060
+ sevenDayUtilization: 0,
3087
3061
  blocked: false,
3088
3062
  rejected: true,
3089
3063
  modelWeeklyLimits: {},
@@ -3092,6 +3066,7 @@ describe('StartPreparationUseCase', () => {
3092
3066
  name: 'token-b',
3093
3067
  token: 'token-b',
3094
3068
  fiveHourUtilization: 0.2,
3069
+ sevenDayUtilization: 0,
3095
3070
  blocked: false,
3096
3071
  rejected: true,
3097
3072
  modelWeeklyLimits: {},
@@ -3169,7 +3144,7 @@ describe('StartPreparationUseCase', () => {
3169
3144
  expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toBeUndefined();
3170
3145
  });
3171
3146
 
3172
- it('should keep a token in rotation with opus model when seven_day_sonnet is rejected and seven_day_opus is available', async () => {
3147
+ it('should exclude a token whose seven_day_sonnet weekly limit is rejected when the model is sonnet', async () => {
3173
3148
  const awaitingIssue = createMockIssue({
3174
3149
  url: 'url1',
3175
3150
  title: 'Issue 1',
@@ -3193,6 +3168,7 @@ describe('StartPreparationUseCase', () => {
3193
3168
  name: 'token-sonnet-exhausted',
3194
3169
  token: 'token-sonnet-exhausted',
3195
3170
  fiveHourUtilization: 0.1,
3171
+ sevenDayUtilization: 0,
3196
3172
  blocked: false,
3197
3173
  rejected: false,
3198
3174
  modelWeeklyLimits: {
@@ -3203,6 +3179,7 @@ describe('StartPreparationUseCase', () => {
3203
3179
  name: 'token-ok',
3204
3180
  token: 'token-ok',
3205
3181
  fiveHourUtilization: 0.5,
3182
+ sevenDayUtilization: 0,
3206
3183
  blocked: false,
3207
3184
  rejected: false,
3208
3185
  modelWeeklyLimits: {},
@@ -3225,13 +3202,10 @@ describe('StartPreparationUseCase', () => {
3225
3202
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
3226
3203
  expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
3227
3204
  env: {
3228
- CLAUDE_CODE_OAUTH_TOKEN: 'token-sonnet-exhausted',
3205
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
3229
3206
  ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
3230
3207
  },
3231
3208
  });
3232
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][1][2]).toBe(
3233
- 'claude-opus-4-6',
3234
- );
3235
3209
  });
3236
3210
 
3237
3211
  it('should re-admit a token whose seven_day_sonnet rejection has been cleared by stale-reset expiry', async () => {
@@ -3258,6 +3232,7 @@ describe('StartPreparationUseCase', () => {
3258
3232
  name: 'token-recovered',
3259
3233
  token: 'token-recovered',
3260
3234
  fiveHourUtilization: 0.1,
3235
+ sevenDayUtilization: 0,
3261
3236
  blocked: false,
3262
3237
  rejected: false,
3263
3238
  modelWeeklyLimits: {
@@ -3268,6 +3243,7 @@ describe('StartPreparationUseCase', () => {
3268
3243
  name: 'token-busy',
3269
3244
  token: 'token-busy',
3270
3245
  fiveHourUtilization: 0.5,
3246
+ sevenDayUtilization: 0,
3271
3247
  blocked: false,
3272
3248
  rejected: false,
3273
3249
  modelWeeklyLimits: {},
@@ -3320,6 +3296,7 @@ describe('StartPreparationUseCase', () => {
3320
3296
  name: 'token-sonnet-exhausted',
3321
3297
  token: 'token-sonnet-exhausted',
3322
3298
  fiveHourUtilization: 0.1,
3299
+ sevenDayUtilization: 0,
3323
3300
  blocked: false,
3324
3301
  rejected: false,
3325
3302
  modelWeeklyLimits: {
@@ -3330,6 +3307,7 @@ describe('StartPreparationUseCase', () => {
3330
3307
  name: 'token-higher-util',
3331
3308
  token: 'token-higher-util',
3332
3309
  fiveHourUtilization: 0.5,
3310
+ sevenDayUtilization: 0,
3333
3311
  blocked: false,
3334
3312
  rejected: false,
3335
3313
  modelWeeklyLimits: {},
@@ -3382,6 +3360,7 @@ describe('StartPreparationUseCase', () => {
3382
3360
  name: 'token-weekly-exhausted',
3383
3361
  token: 'token-weekly-exhausted',
3384
3362
  fiveHourUtilization: 0.1,
3363
+ sevenDayUtilization: 0,
3385
3364
  blocked: false,
3386
3365
  rejected: false,
3387
3366
  modelWeeklyLimits: {
@@ -3392,6 +3371,7 @@ describe('StartPreparationUseCase', () => {
3392
3371
  name: 'token-ok',
3393
3372
  token: 'token-ok',
3394
3373
  fiveHourUtilization: 0.5,
3374
+ sevenDayUtilization: 0,
3395
3375
  blocked: false,
3396
3376
  rejected: false,
3397
3377
  modelWeeklyLimits: {},
@@ -3419,211 +3399,6 @@ describe('StartPreparationUseCase', () => {
3419
3399
  },
3420
3400
  });
3421
3401
  });
3422
-
3423
- it('should include a token in rotation with opus model when seven_day_sonnet is rejected but seven_day_opus is available', async () => {
3424
- const awaitingIssue = createMockIssue({
3425
- url: 'url1',
3426
- title: 'Issue 1',
3427
- labels: ['category:impl'],
3428
- status: 'Awaiting Workspace',
3429
- number: 1,
3430
- itemId: 'item-1',
3431
- });
3432
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
3433
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
3434
- createMockStoryObjectMap([awaitingIssue]),
3435
- );
3436
- mockLocalCommandRunner.runCommand.mockResolvedValue({
3437
- stdout: '',
3438
- stderr: '',
3439
- exitCode: 0,
3440
- });
3441
- const futureReset = Math.floor(Date.now() / 1000) + 3600;
3442
- mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
3443
- {
3444
- token: 'token-sonnet-exhausted',
3445
- fiveHourUtilization: 0.1,
3446
- blocked: false,
3447
- rejected: false,
3448
- modelWeeklyLimits: {
3449
- seven_day_sonnet: { rejected: true, resetsAt: futureReset },
3450
- },
3451
- },
3452
- ]);
3453
-
3454
- await useCase.run({
3455
- projectUrl: 'https://github.com/user/repo',
3456
- defaultAgentName: 'agent1',
3457
- defaultLlmModelName: 'claude-sonnet-4-6',
3458
- defaultLlmAgentName: null,
3459
- configFilePath: '/path/to/config.yml',
3460
- maximumPreparingIssuesCount: null,
3461
- utilizationPercentageThreshold: 90,
3462
- allowedIssueAuthors: null,
3463
- codexHomeCandidates: null,
3464
- allowIssueCacheMinutes: 0,
3465
- });
3466
-
3467
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
3468
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][1][2]).toBe(
3469
- 'claude-opus-4-6',
3470
- );
3471
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
3472
- env: {
3473
- CLAUDE_CODE_OAUTH_TOKEN: 'token-sonnet-exhausted',
3474
- ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
3475
- },
3476
- });
3477
- });
3478
-
3479
- it('should exclude a token from rotation when both seven_day_sonnet and seven_day_opus weekly limits are rejected', async () => {
3480
- const awaitingIssue = createMockIssue({
3481
- url: 'url1',
3482
- title: 'Issue 1',
3483
- labels: ['category:impl'],
3484
- status: 'Awaiting Workspace',
3485
- number: 1,
3486
- itemId: 'item-1',
3487
- });
3488
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
3489
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
3490
- createMockStoryObjectMap([awaitingIssue]),
3491
- );
3492
- mockLocalCommandRunner.runCommand.mockResolvedValue({
3493
- stdout: '',
3494
- stderr: '',
3495
- exitCode: 0,
3496
- });
3497
- const futureReset = Math.floor(Date.now() / 1000) + 3600;
3498
- mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
3499
- {
3500
- token: 'token-all-exhausted',
3501
- fiveHourUtilization: 0.1,
3502
- blocked: false,
3503
- rejected: false,
3504
- modelWeeklyLimits: {
3505
- seven_day_sonnet: { rejected: true, resetsAt: futureReset },
3506
- seven_day_opus: { rejected: true, resetsAt: futureReset },
3507
- },
3508
- },
3509
- ]);
3510
-
3511
- await useCase.run({
3512
- projectUrl: 'https://github.com/user/repo',
3513
- defaultAgentName: 'agent1',
3514
- defaultLlmModelName: 'claude-sonnet-4-6',
3515
- defaultLlmAgentName: null,
3516
- configFilePath: '/path/to/config.yml',
3517
- maximumPreparingIssuesCount: null,
3518
- utilizationPercentageThreshold: 90,
3519
- allowedIssueAuthors: null,
3520
- codexHomeCandidates: null,
3521
- allowIssueCacheMinutes: 0,
3522
- });
3523
-
3524
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
3525
- });
3526
-
3527
- it('should use default model for a token with no modelWeeklyLimits entries', async () => {
3528
- const awaitingIssue = createMockIssue({
3529
- url: 'url1',
3530
- title: 'Issue 1',
3531
- labels: ['category:impl'],
3532
- status: 'Awaiting Workspace',
3533
- number: 1,
3534
- itemId: 'item-1',
3535
- });
3536
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
3537
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
3538
- createMockStoryObjectMap([awaitingIssue]),
3539
- );
3540
- mockLocalCommandRunner.runCommand.mockResolvedValue({
3541
- stdout: '',
3542
- stderr: '',
3543
- exitCode: 0,
3544
- });
3545
- mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
3546
- {
3547
- token: 'token-no-limits',
3548
- fiveHourUtilization: 0.1,
3549
- blocked: false,
3550
- rejected: false,
3551
- modelWeeklyLimits: {},
3552
- },
3553
- ]);
3554
-
3555
- await useCase.run({
3556
- projectUrl: 'https://github.com/user/repo',
3557
- defaultAgentName: 'agent1',
3558
- defaultLlmModelName: 'claude-sonnet-4-6',
3559
- defaultLlmAgentName: null,
3560
- configFilePath: '/path/to/config.yml',
3561
- maximumPreparingIssuesCount: null,
3562
- utilizationPercentageThreshold: 90,
3563
- allowedIssueAuthors: null,
3564
- codexHomeCandidates: null,
3565
- allowIssueCacheMinutes: 0,
3566
- });
3567
-
3568
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
3569
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][1][2]).toBe(
3570
- 'claude-sonnet-4-6',
3571
- );
3572
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
3573
- env: {
3574
- CLAUDE_CODE_OAUTH_TOKEN: 'token-no-limits',
3575
- ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
3576
- },
3577
- });
3578
- });
3579
-
3580
- it('should exclude a token when seven_day general limit is rejected even if per-model limits are available', async () => {
3581
- const awaitingIssue = createMockIssue({
3582
- url: 'url1',
3583
- title: 'Issue 1',
3584
- labels: ['category:impl'],
3585
- status: 'Awaiting Workspace',
3586
- number: 1,
3587
- itemId: 'item-1',
3588
- });
3589
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
3590
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
3591
- createMockStoryObjectMap([awaitingIssue]),
3592
- );
3593
- mockLocalCommandRunner.runCommand.mockResolvedValue({
3594
- stdout: '',
3595
- stderr: '',
3596
- exitCode: 0,
3597
- });
3598
- const futureReset = Math.floor(Date.now() / 1000) + 3600;
3599
- mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
3600
- {
3601
- token: 'token-general-limit-rejected',
3602
- fiveHourUtilization: 0.1,
3603
- blocked: false,
3604
- rejected: false,
3605
- modelWeeklyLimits: {
3606
- seven_day: { rejected: true, resetsAt: futureReset },
3607
- seven_day_sonnet: { rejected: false, resetsAt: futureReset },
3608
- },
3609
- },
3610
- ]);
3611
-
3612
- await useCase.run({
3613
- projectUrl: 'https://github.com/user/repo',
3614
- defaultAgentName: 'agent1',
3615
- defaultLlmModelName: 'claude-sonnet-4-6',
3616
- defaultLlmAgentName: null,
3617
- configFilePath: '/path/to/config.yml',
3618
- maximumPreparingIssuesCount: null,
3619
- utilizationPercentageThreshold: 90,
3620
- allowedIssueAuthors: null,
3621
- codexHomeCandidates: null,
3622
- allowIssueCacheMinutes: 0,
3623
- });
3624
-
3625
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
3626
- });
3627
3402
  });
3628
3403
 
3629
3404
  describe('StartPreparationUseCase.buildRotationOrder', () => {
@@ -3669,20 +3444,22 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
3669
3444
  mockClaudeTokenUsageRepositoryForRotation,
3670
3445
  );
3671
3446
 
3672
- it('lists selected tokens first in ascending utilization order then excluded tokens', () => {
3447
+ it('lists selected tokens first in ascending 7-day utilization order then excluded tokens', () => {
3673
3448
  const tokenUsages = [
3674
3449
  {
3675
- name: 'high-util',
3450
+ name: 'high-7d-util',
3676
3451
  token: 'sk-ant-high',
3677
- fiveHourUtilization: 0.8,
3452
+ fiveHourUtilization: 0.1,
3453
+ sevenDayUtilization: 0.8,
3678
3454
  blocked: false,
3679
3455
  rejected: false,
3680
3456
  modelWeeklyLimits: {},
3681
3457
  },
3682
3458
  {
3683
- name: 'low-util',
3459
+ name: 'low-7d-util',
3684
3460
  token: 'sk-ant-low',
3685
- fiveHourUtilization: 0.1,
3461
+ fiveHourUtilization: 0.5,
3462
+ sevenDayUtilization: 0.1,
3686
3463
  blocked: false,
3687
3464
  rejected: false,
3688
3465
  modelWeeklyLimits: {},
@@ -3691,6 +3468,7 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
3691
3468
  name: 'blocked-token',
3692
3469
  token: 'sk-ant-blocked',
3693
3470
  fiveHourUtilization: 0.0,
3471
+ sevenDayUtilization: 0,
3694
3472
  blocked: true,
3695
3473
  rejected: false,
3696
3474
  modelWeeklyLimits: {},
@@ -3698,8 +3476,8 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
3698
3476
  ];
3699
3477
  const result = useCase.buildRotationOrder(tokenUsages, 90, null);
3700
3478
 
3701
- expect(result[0].name).toBe('low-util');
3702
- expect(result[1].name).toBe('high-util');
3479
+ expect(result[0].name).toBe('low-7d-util');
3480
+ expect(result[1].name).toBe('high-7d-util');
3703
3481
  expect(result[2].name).toBe('blocked-token');
3704
3482
  expect(result[2].blocked).toBe(true);
3705
3483
  expect(result[2].thresholdExcluded).toBe(false);
@@ -3711,6 +3489,7 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
3711
3489
  name: 'my-token',
3712
3490
  token: 'sk-ant-secret-value',
3713
3491
  fiveHourUtilization: 0.1,
3492
+ sevenDayUtilization: 0,
3714
3493
  blocked: false,
3715
3494
  rejected: false,
3716
3495
  modelWeeklyLimits: {},
@@ -3723,12 +3502,13 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
3723
3502
  expect(result[0].name).toBe('my-token');
3724
3503
  });
3725
3504
 
3726
- it('marks thresholdExcluded true when token is at or above 95 percent utilization', () => {
3505
+ it('marks thresholdExcluded true when token 5h utilization meets or exceeds the threshold', () => {
3727
3506
  const tokenUsages = [
3728
3507
  {
3729
3508
  name: 'over-threshold',
3730
3509
  token: 'sk-ant-over',
3731
3510
  fiveHourUtilization: 0.95,
3511
+ sevenDayUtilization: 0,
3732
3512
  blocked: false,
3733
3513
  rejected: false,
3734
3514
  modelWeeklyLimits: {},
@@ -3742,12 +3522,13 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
3742
3522
  expect(result[0].rejected).toBe(false);
3743
3523
  });
3744
3524
 
3745
- it('does not mark thresholdExcluded for tokens in the 90 to 94 percent utilization range because selectRotationTokens still assigns them slots', () => {
3525
+ it('marks thresholdExcluded true for tokens at or above the 5h utilization threshold', () => {
3746
3526
  const tokenUsages = [
3747
3527
  {
3748
- name: 'mid-util',
3749
- token: 'sk-ant-mid',
3750
- fiveHourUtilization: 0.92,
3528
+ name: 'at-threshold',
3529
+ token: 'sk-ant-at',
3530
+ fiveHourUtilization: 0.9,
3531
+ sevenDayUtilization: 0,
3751
3532
  blocked: false,
3752
3533
  rejected: false,
3753
3534
  modelWeeklyLimits: {},
@@ -3756,9 +3537,9 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
3756
3537
  const result = useCase.buildRotationOrder(tokenUsages, 90, null);
3757
3538
 
3758
3539
  expect(result).toHaveLength(1);
3759
- expect(result[0].thresholdExcluded).toBe(false);
3540
+ expect(result[0].thresholdExcluded).toBe(true);
3760
3541
  expect(result[0].blocked).toBe(false);
3761
3542
  expect(result[0].rejected).toBe(false);
3762
- expect(result[0].fiveHourUtilization).toBe(0.92);
3543
+ expect(result[0].fiveHourUtilization).toBe(0.9);
3763
3544
  });
3764
3545
  });