github-issue-tower-defence-management 1.67.6 → 1.69.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 (25) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +6 -1
  3. package/bin/adapter/entry-points/cli/index.js +1 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/projectConfig.js +4 -0
  6. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +20 -8
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -0
  10. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  11. package/bin/domain/usecases/StartPreparationUseCase.js +29 -2
  12. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/adapter/entry-points/cli/index.test.ts +2 -0
  15. package/src/adapter/entry-points/cli/index.ts +1 -0
  16. package/src/adapter/entry-points/cli/projectConfig.ts +6 -0
  17. package/src/domain/usecases/HandleScheduledEventUseCase.ts +3 -0
  18. package/src/domain/usecases/StartPreparationUseCase.test.ts +556 -31
  19. package/src/domain/usecases/StartPreparationUseCase.ts +66 -2
  20. package/types/adapter/entry-points/cli/projectConfig.d.ts +1 -0
  21. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  22. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +1 -0
  23. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  24. package/types/domain/usecases/StartPreparationUseCase.d.ts +3 -0
  25. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
@@ -6,6 +6,7 @@ import {
6
6
  import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
7
7
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
8
8
  import { ClaudeTokenUsageRepository } from './adapter-interfaces/ClaudeTokenUsageRepository';
9
+ import { ClaudeTokenUsage } from '../entities/ClaudeTokenUsage';
9
10
  import { Issue } from '../entities/Issue';
10
11
  import { Project } from '../entities/Project';
11
12
  import { StoryObjectMap } from '../entities/StoryObjectMap';
@@ -152,6 +153,7 @@ describe('StartPreparationUseCase', () => {
152
153
  allowedIssueAuthors: null,
153
154
  codexHomeCandidates: null,
154
155
  allowIssueCacheMinutes: 0,
156
+ labelsAsLlmAgentName: null,
155
157
  });
156
158
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
157
159
  expect(mockIssueRepository.updateStatus.mock.calls[0][0]).toBe(mockProject);
@@ -216,6 +218,7 @@ describe('StartPreparationUseCase', () => {
216
218
  allowedIssueAuthors: null,
217
219
  codexHomeCandidates: null,
218
220
  allowIssueCacheMinutes: 0,
221
+ labelsAsLlmAgentName: null,
219
222
  });
220
223
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
221
224
  expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
@@ -272,6 +275,7 @@ describe('StartPreparationUseCase', () => {
272
275
  allowedIssueAuthors: null,
273
276
  codexHomeCandidates: null,
274
277
  allowIssueCacheMinutes: 0,
278
+ labelsAsLlmAgentName: null,
275
279
  });
276
280
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
277
281
  expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
@@ -319,6 +323,7 @@ describe('StartPreparationUseCase', () => {
319
323
  allowedIssueAuthors: null,
320
324
  codexHomeCandidates: null,
321
325
  allowIssueCacheMinutes: 0,
326
+ labelsAsLlmAgentName: null,
322
327
  });
323
328
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
324
329
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
@@ -367,6 +372,7 @@ describe('StartPreparationUseCase', () => {
367
372
  allowedIssueAuthors: null,
368
373
  codexHomeCandidates: null,
369
374
  allowIssueCacheMinutes: 0,
375
+ labelsAsLlmAgentName: null,
370
376
  });
371
377
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
372
378
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
@@ -414,6 +420,7 @@ describe('StartPreparationUseCase', () => {
414
420
  allowedIssueAuthors: null,
415
421
  codexHomeCandidates: null,
416
422
  allowIssueCacheMinutes: 0,
423
+ labelsAsLlmAgentName: null,
417
424
  });
418
425
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
419
426
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
@@ -479,6 +486,7 @@ describe('StartPreparationUseCase', () => {
479
486
  allowedIssueAuthors: null,
480
487
  codexHomeCandidates: null,
481
488
  allowIssueCacheMinutes: 0,
489
+ labelsAsLlmAgentName: null,
482
490
  });
483
491
  expect(mockIssueRepository.closePullRequest).toHaveBeenCalledWith(
484
492
  newerPR.url,
@@ -567,6 +575,7 @@ describe('StartPreparationUseCase', () => {
567
575
  allowedIssueAuthors: null,
568
576
  codexHomeCandidates: null,
569
577
  allowIssueCacheMinutes: 0,
578
+ labelsAsLlmAgentName: null,
570
579
  });
571
580
  expect(mockIssueRepository.closePullRequest).toHaveBeenCalledWith(
572
581
  newerPR.url,
@@ -629,6 +638,7 @@ describe('StartPreparationUseCase', () => {
629
638
  allowedIssueAuthors: null,
630
639
  codexHomeCandidates: null,
631
640
  allowIssueCacheMinutes: 0,
641
+ labelsAsLlmAgentName: null,
632
642
  });
633
643
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
634
644
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
@@ -672,6 +682,7 @@ describe('StartPreparationUseCase', () => {
672
682
  allowedIssueAuthors: null,
673
683
  codexHomeCandidates: null,
674
684
  allowIssueCacheMinutes: 0,
685
+ labelsAsLlmAgentName: null,
675
686
  });
676
687
  // Both awaiting issues should be updated (forward iteration: url1 first, then url2)
677
688
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(2);
@@ -728,6 +739,7 @@ describe('StartPreparationUseCase', () => {
728
739
  allowedIssueAuthors: null,
729
740
  codexHomeCandidates: null,
730
741
  allowIssueCacheMinutes: 0,
742
+ labelsAsLlmAgentName: null,
731
743
  });
732
744
  // Loop doesn't run because we're already at max (6 >= 6)
733
745
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
@@ -762,6 +774,7 @@ describe('StartPreparationUseCase', () => {
762
774
  allowedIssueAuthors: null,
763
775
  codexHomeCandidates: null,
764
776
  allowIssueCacheMinutes: 0,
777
+ labelsAsLlmAgentName: null,
765
778
  });
766
779
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
767
780
  expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
@@ -806,6 +819,7 @@ describe('StartPreparationUseCase', () => {
806
819
  allowedIssueAuthors: null,
807
820
  codexHomeCandidates: null,
808
821
  allowIssueCacheMinutes: 0,
822
+ labelsAsLlmAgentName: null,
809
823
  });
810
824
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
811
825
  expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
@@ -850,6 +864,7 @@ describe('StartPreparationUseCase', () => {
850
864
  allowedIssueAuthors: null,
851
865
  codexHomeCandidates: null,
852
866
  allowIssueCacheMinutes: 0,
867
+ labelsAsLlmAgentName: null,
853
868
  });
854
869
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
855
870
  expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
@@ -894,6 +909,7 @@ describe('StartPreparationUseCase', () => {
894
909
  allowedIssueAuthors: null,
895
910
  codexHomeCandidates: null,
896
911
  allowIssueCacheMinutes: 0,
912
+ labelsAsLlmAgentName: null,
897
913
  });
898
914
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
899
915
  expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
@@ -938,6 +954,7 @@ describe('StartPreparationUseCase', () => {
938
954
  allowedIssueAuthors: null,
939
955
  codexHomeCandidates: null,
940
956
  allowIssueCacheMinutes: 0,
957
+ labelsAsLlmAgentName: null,
941
958
  });
942
959
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
943
960
  expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
@@ -982,6 +999,7 @@ describe('StartPreparationUseCase', () => {
982
999
  allowedIssueAuthors: null,
983
1000
  codexHomeCandidates: null,
984
1001
  allowIssueCacheMinutes: 0,
1002
+ labelsAsLlmAgentName: null,
985
1003
  });
986
1004
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
987
1005
  expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
@@ -1029,6 +1047,7 @@ describe('StartPreparationUseCase', () => {
1029
1047
  allowedIssueAuthors: null,
1030
1048
  codexHomeCandidates: null,
1031
1049
  allowIssueCacheMinutes: 0,
1050
+ labelsAsLlmAgentName: null,
1032
1051
  });
1033
1052
  expect(consoleErrorSpy).toHaveBeenCalledWith(
1034
1053
  'No LLM model configured for issue url1. Provide --defaultLlmModelName or add an llm-model: label.',
@@ -1076,6 +1095,7 @@ describe('StartPreparationUseCase', () => {
1076
1095
  allowedIssueAuthors: null,
1077
1096
  codexHomeCandidates: null,
1078
1097
  allowIssueCacheMinutes: 0,
1098
+ labelsAsLlmAgentName: null,
1079
1099
  });
1080
1100
  expect(consoleErrorSpy).toHaveBeenCalledWith(
1081
1101
  'No LLM model configured for issue url1. Provide --defaultLlmModelName or add an llm-model: label.',
@@ -1125,6 +1145,7 @@ describe('StartPreparationUseCase', () => {
1125
1145
  allowedIssueAuthors: null,
1126
1146
  codexHomeCandidates: null,
1127
1147
  allowIssueCacheMinutes: 0,
1148
+ labelsAsLlmAgentName: null,
1128
1149
  });
1129
1150
  // No issues are in 'Awaiting Workspace' status, so no updates should happen
1130
1151
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
@@ -1159,6 +1180,7 @@ describe('StartPreparationUseCase', () => {
1159
1180
  allowedIssueAuthors: null,
1160
1181
  codexHomeCandidates: null,
1161
1182
  allowIssueCacheMinutes: 0,
1183
+ labelsAsLlmAgentName: null,
1162
1184
  });
1163
1185
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(3);
1164
1186
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
@@ -1192,6 +1214,7 @@ describe('StartPreparationUseCase', () => {
1192
1214
  allowedIssueAuthors: null,
1193
1215
  codexHomeCandidates: null,
1194
1216
  allowIssueCacheMinutes: 0,
1217
+ labelsAsLlmAgentName: null,
1195
1218
  });
1196
1219
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(6);
1197
1220
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(6);
@@ -1248,6 +1271,7 @@ describe('StartPreparationUseCase', () => {
1248
1271
  allowedIssueAuthors: null,
1249
1272
  codexHomeCandidates: null,
1250
1273
  allowIssueCacheMinutes: 0,
1274
+ labelsAsLlmAgentName: null,
1251
1275
  });
1252
1276
 
1253
1277
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(12);
@@ -1313,6 +1337,7 @@ describe('StartPreparationUseCase', () => {
1313
1337
  allowedIssueAuthors: null,
1314
1338
  codexHomeCandidates: null,
1315
1339
  allowIssueCacheMinutes: 0,
1340
+ labelsAsLlmAgentName: null,
1316
1341
  });
1317
1342
 
1318
1343
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(12);
@@ -1376,6 +1401,7 @@ describe('StartPreparationUseCase', () => {
1376
1401
  allowedIssueAuthors: null,
1377
1402
  codexHomeCandidates: null,
1378
1403
  allowIssueCacheMinutes: 0,
1404
+ labelsAsLlmAgentName: null,
1379
1405
  });
1380
1406
 
1381
1407
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(2);
@@ -1423,6 +1449,7 @@ describe('StartPreparationUseCase', () => {
1423
1449
  allowedIssueAuthors: null,
1424
1450
  codexHomeCandidates: null,
1425
1451
  allowIssueCacheMinutes: 0,
1452
+ labelsAsLlmAgentName: null,
1426
1453
  });
1427
1454
 
1428
1455
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
@@ -1477,6 +1504,7 @@ describe('StartPreparationUseCase', () => {
1477
1504
  allowedIssueAuthors: null,
1478
1505
  codexHomeCandidates: null,
1479
1506
  allowIssueCacheMinutes: 0,
1507
+ labelsAsLlmAgentName: null,
1480
1508
  });
1481
1509
 
1482
1510
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
@@ -1534,6 +1562,7 @@ describe('StartPreparationUseCase', () => {
1534
1562
  allowedIssueAuthors: null,
1535
1563
  codexHomeCandidates: null,
1536
1564
  allowIssueCacheMinutes: 0,
1565
+ labelsAsLlmAgentName: null,
1537
1566
  });
1538
1567
 
1539
1568
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
@@ -1581,6 +1610,7 @@ describe('StartPreparationUseCase', () => {
1581
1610
  allowedIssueAuthors: null,
1582
1611
  codexHomeCandidates: null,
1583
1612
  allowIssueCacheMinutes: 0,
1613
+ labelsAsLlmAgentName: null,
1584
1614
  });
1585
1615
 
1586
1616
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
@@ -1628,6 +1658,7 @@ describe('StartPreparationUseCase', () => {
1628
1658
  allowedIssueAuthors: null,
1629
1659
  codexHomeCandidates: null,
1630
1660
  allowIssueCacheMinutes: 0,
1661
+ labelsAsLlmAgentName: null,
1631
1662
  });
1632
1663
 
1633
1664
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
@@ -1675,6 +1706,7 @@ describe('StartPreparationUseCase', () => {
1675
1706
  allowedIssueAuthors: null,
1676
1707
  codexHomeCandidates: null,
1677
1708
  allowIssueCacheMinutes: 0,
1709
+ labelsAsLlmAgentName: null,
1678
1710
  });
1679
1711
 
1680
1712
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
@@ -1728,6 +1760,7 @@ describe('StartPreparationUseCase', () => {
1728
1760
  allowedIssueAuthors: ['user1', 'user2'],
1729
1761
  codexHomeCandidates: null,
1730
1762
  allowIssueCacheMinutes: 0,
1763
+ labelsAsLlmAgentName: null,
1731
1764
  });
1732
1765
 
1733
1766
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
@@ -1775,6 +1808,7 @@ describe('StartPreparationUseCase', () => {
1775
1808
  allowedIssueAuthors: null,
1776
1809
  codexHomeCandidates: null,
1777
1810
  allowIssueCacheMinutes: 0,
1811
+ labelsAsLlmAgentName: null,
1778
1812
  });
1779
1813
 
1780
1814
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(2);
@@ -1820,6 +1854,7 @@ describe('StartPreparationUseCase', () => {
1820
1854
  allowedIssueAuthors: ['user1', 'user2'],
1821
1855
  codexHomeCandidates: null,
1822
1856
  allowIssueCacheMinutes: 0,
1857
+ labelsAsLlmAgentName: null,
1823
1858
  });
1824
1859
 
1825
1860
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
@@ -1859,6 +1894,7 @@ describe('StartPreparationUseCase', () => {
1859
1894
  allowedIssueAuthors: ['user1'],
1860
1895
  codexHomeCandidates: null,
1861
1896
  allowIssueCacheMinutes: 0,
1897
+ labelsAsLlmAgentName: null,
1862
1898
  });
1863
1899
 
1864
1900
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
@@ -1895,6 +1931,7 @@ describe('StartPreparationUseCase', () => {
1895
1931
  allowedIssueAuthors: null,
1896
1932
  codexHomeCandidates: null,
1897
1933
  allowIssueCacheMinutes: 0,
1934
+ labelsAsLlmAgentName: null,
1898
1935
  });
1899
1936
 
1900
1937
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
@@ -1942,6 +1979,7 @@ describe('StartPreparationUseCase', () => {
1942
1979
  allowedIssueAuthors: null,
1943
1980
  codexHomeCandidates: [],
1944
1981
  allowIssueCacheMinutes: 0,
1982
+ labelsAsLlmAgentName: null,
1945
1983
  });
1946
1984
 
1947
1985
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
@@ -1989,6 +2027,7 @@ describe('StartPreparationUseCase', () => {
1989
2027
  allowedIssueAuthors: null,
1990
2028
  codexHomeCandidates: ['.codex-dev1'],
1991
2029
  allowIssueCacheMinutes: 0,
2030
+ labelsAsLlmAgentName: null,
1992
2031
  });
1993
2032
 
1994
2033
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
@@ -2056,6 +2095,7 @@ describe('StartPreparationUseCase', () => {
2056
2095
  allowedIssueAuthors: null,
2057
2096
  codexHomeCandidates: ['.codex-dev1', '.codex-dev2'],
2058
2097
  allowIssueCacheMinutes: 0,
2098
+ labelsAsLlmAgentName: null,
2059
2099
  });
2060
2100
 
2061
2101
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
@@ -2120,6 +2160,7 @@ describe('StartPreparationUseCase', () => {
2120
2160
  allowedIssueAuthors: null,
2121
2161
  codexHomeCandidates: null,
2122
2162
  allowIssueCacheMinutes: 0,
2163
+ labelsAsLlmAgentName: null,
2123
2164
  });
2124
2165
 
2125
2166
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
@@ -2178,6 +2219,7 @@ describe('StartPreparationUseCase', () => {
2178
2219
  allowedIssueAuthors: null,
2179
2220
  codexHomeCandidates: null,
2180
2221
  allowIssueCacheMinutes: 0,
2222
+ labelsAsLlmAgentName: null,
2181
2223
  });
2182
2224
 
2183
2225
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
@@ -2205,6 +2247,7 @@ describe('StartPreparationUseCase', () => {
2205
2247
  allowedIssueAuthors: null,
2206
2248
  codexHomeCandidates: null,
2207
2249
  allowIssueCacheMinutes: 5,
2250
+ labelsAsLlmAgentName: null,
2208
2251
  });
2209
2252
 
2210
2253
  expect(mockIssueRepository.getStoryObjectMap).toHaveBeenCalledWith(
@@ -2251,6 +2294,7 @@ describe('StartPreparationUseCase', () => {
2251
2294
  allowedIssueAuthors: null,
2252
2295
  codexHomeCandidates: null,
2253
2296
  allowIssueCacheMinutes: 0,
2297
+ labelsAsLlmAgentName: null,
2254
2298
  });
2255
2299
 
2256
2300
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
@@ -2300,6 +2344,7 @@ describe('StartPreparationUseCase', () => {
2300
2344
  allowedIssueAuthors: null,
2301
2345
  codexHomeCandidates: null,
2302
2346
  allowIssueCacheMinutes: 0,
2347
+ labelsAsLlmAgentName: null,
2303
2348
  });
2304
2349
 
2305
2350
  expect(
@@ -2385,6 +2430,7 @@ describe('StartPreparationUseCase', () => {
2385
2430
  allowedIssueAuthors: null,
2386
2431
  codexHomeCandidates: null,
2387
2432
  allowIssueCacheMinutes: 0,
2433
+ labelsAsLlmAgentName: null,
2388
2434
  });
2389
2435
 
2390
2436
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
@@ -2441,6 +2487,7 @@ describe('StartPreparationUseCase', () => {
2441
2487
  allowedIssueAuthors: null,
2442
2488
  codexHomeCandidates: null,
2443
2489
  allowIssueCacheMinutes: 0,
2490
+ labelsAsLlmAgentName: null,
2444
2491
  });
2445
2492
 
2446
2493
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
@@ -2450,7 +2497,7 @@ describe('StartPreparationUseCase', () => {
2450
2497
  ).toHaveLength(0);
2451
2498
  });
2452
2499
 
2453
- it('should pick the token with the lowest 7-day utilization first', async () => {
2500
+ it('should pick the token with the soonest 7-day reset deadline first', async () => {
2454
2501
  const awaitingIssue = createMockIssue({
2455
2502
  url: 'url1',
2456
2503
  title: 'Issue 1',
@@ -2468,24 +2515,35 @@ describe('StartPreparationUseCase', () => {
2468
2515
  stderr: '',
2469
2516
  exitCode: 0,
2470
2517
  });
2518
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
2471
2519
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2472
2520
  {
2473
- name: 'token-high-7d',
2474
- token: 'token-high-7d',
2521
+ name: 'token-far-reset',
2522
+ token: 'token-far-reset',
2475
2523
  fiveHourUtilization: 0.1,
2476
2524
  sevenDayUtilization: 0.7,
2477
2525
  blocked: false,
2478
2526
  rejected: false,
2479
- modelWeeklyLimits: {},
2527
+ modelWeeklyLimits: {
2528
+ seven_day_opus: {
2529
+ rejected: false,
2530
+ resetsAt: nowEpochSeconds + 100 * 3600,
2531
+ },
2532
+ },
2480
2533
  },
2481
2534
  {
2482
- name: 'token-low-7d',
2483
- token: 'token-low-7d',
2535
+ name: 'token-soon-reset',
2536
+ token: 'token-soon-reset',
2484
2537
  fiveHourUtilization: 0.5,
2485
2538
  sevenDayUtilization: 0.2,
2486
2539
  blocked: false,
2487
2540
  rejected: false,
2488
- modelWeeklyLimits: {},
2541
+ modelWeeklyLimits: {
2542
+ seven_day_opus: {
2543
+ rejected: false,
2544
+ resetsAt: nowEpochSeconds + 20 * 3600,
2545
+ },
2546
+ },
2489
2547
  },
2490
2548
  ]);
2491
2549
 
@@ -2500,12 +2558,13 @@ describe('StartPreparationUseCase', () => {
2500
2558
  allowedIssueAuthors: null,
2501
2559
  codexHomeCandidates: null,
2502
2560
  allowIssueCacheMinutes: 0,
2561
+ labelsAsLlmAgentName: null,
2503
2562
  });
2504
2563
 
2505
2564
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2506
2565
  expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2507
2566
  env: {
2508
- CLAUDE_CODE_OAUTH_TOKEN: 'token-low-7d',
2567
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-soon-reset',
2509
2568
  ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2510
2569
  },
2511
2570
  });
@@ -2561,6 +2620,7 @@ describe('StartPreparationUseCase', () => {
2561
2620
  allowedIssueAuthors: null,
2562
2621
  codexHomeCandidates: null,
2563
2622
  allowIssueCacheMinutes: 0,
2623
+ labelsAsLlmAgentName: null,
2564
2624
  });
2565
2625
 
2566
2626
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
@@ -2625,6 +2685,7 @@ describe('StartPreparationUseCase', () => {
2625
2685
  allowedIssueAuthors: null,
2626
2686
  codexHomeCandidates: null,
2627
2687
  allowIssueCacheMinutes: 0,
2688
+ labelsAsLlmAgentName: null,
2628
2689
  });
2629
2690
 
2630
2691
  expect(
@@ -2692,6 +2753,7 @@ describe('StartPreparationUseCase', () => {
2692
2753
  allowedIssueAuthors: null,
2693
2754
  codexHomeCandidates: null,
2694
2755
  allowIssueCacheMinutes: 0,
2756
+ labelsAsLlmAgentName: null,
2695
2757
  });
2696
2758
 
2697
2759
  expect(
@@ -2706,7 +2768,7 @@ describe('StartPreparationUseCase', () => {
2706
2768
  consoleWarnSpy.mockRestore();
2707
2769
  });
2708
2770
 
2709
- it('should sort tokens by 7-day utilization ascending when all have full process capacity', async () => {
2771
+ it('should sort tokens by 7-day reset deadline ascending when all have full process capacity', async () => {
2710
2772
  const awaitingIssues: Issue[] = [
2711
2773
  createMockIssue({
2712
2774
  url: 'url1',
@@ -2742,33 +2804,49 @@ describe('StartPreparationUseCase', () => {
2742
2804
  stderr: '',
2743
2805
  exitCode: 0,
2744
2806
  });
2807
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
2745
2808
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2746
2809
  {
2747
- name: 'token-7d-mid',
2748
- token: 'token-7d-mid',
2810
+ name: 'token-7d-mid-reset',
2811
+ token: 'token-7d-mid-reset',
2749
2812
  fiveHourUtilization: 0.1,
2750
2813
  sevenDayUtilization: 0.5,
2751
2814
  blocked: false,
2752
2815
  rejected: false,
2753
- modelWeeklyLimits: {},
2816
+ modelWeeklyLimits: {
2817
+ seven_day_opus: {
2818
+ rejected: false,
2819
+ resetsAt: nowEpochSeconds + 50 * 3600,
2820
+ },
2821
+ },
2754
2822
  },
2755
2823
  {
2756
- name: 'token-7d-low',
2757
- token: 'token-7d-low',
2824
+ name: 'token-7d-soon-reset',
2825
+ token: 'token-7d-soon-reset',
2758
2826
  fiveHourUtilization: 0.5,
2759
2827
  sevenDayUtilization: 0.1,
2760
2828
  blocked: false,
2761
2829
  rejected: false,
2762
- modelWeeklyLimits: {},
2830
+ modelWeeklyLimits: {
2831
+ seven_day_opus: {
2832
+ rejected: false,
2833
+ resetsAt: nowEpochSeconds + 10 * 3600,
2834
+ },
2835
+ },
2763
2836
  },
2764
2837
  {
2765
- name: 'token-7d-high',
2766
- token: 'token-7d-high',
2838
+ name: 'token-7d-far-reset',
2839
+ token: 'token-7d-far-reset',
2767
2840
  fiveHourUtilization: 0.3,
2768
2841
  sevenDayUtilization: 0.7,
2769
2842
  blocked: false,
2770
2843
  rejected: false,
2771
- modelWeeklyLimits: {},
2844
+ modelWeeklyLimits: {
2845
+ seven_day_opus: {
2846
+ rejected: false,
2847
+ resetsAt: nowEpochSeconds + 150 * 3600,
2848
+ },
2849
+ },
2772
2850
  },
2773
2851
  ]);
2774
2852
 
@@ -2783,17 +2861,18 @@ describe('StartPreparationUseCase', () => {
2783
2861
  allowedIssueAuthors: null,
2784
2862
  codexHomeCandidates: null,
2785
2863
  allowIssueCacheMinutes: 0,
2864
+ labelsAsLlmAgentName: null,
2786
2865
  });
2787
2866
 
2788
2867
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
2789
2868
  expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toMatchObject({
2790
- env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-low' },
2869
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-soon-reset' },
2791
2870
  });
2792
2871
  expect(mockLocalCommandRunner.runCommand.mock.calls[1][2]).toMatchObject({
2793
- env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-mid' },
2872
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-mid-reset' },
2794
2873
  });
2795
2874
  expect(mockLocalCommandRunner.runCommand.mock.calls[2][2]).toMatchObject({
2796
- env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-high' },
2875
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-far-reset' },
2797
2876
  });
2798
2877
  });
2799
2878
 
@@ -2840,6 +2919,7 @@ describe('StartPreparationUseCase', () => {
2840
2919
  allowedIssueAuthors: null,
2841
2920
  codexHomeCandidates: null,
2842
2921
  allowIssueCacheMinutes: 0,
2922
+ labelsAsLlmAgentName: null,
2843
2923
  });
2844
2924
 
2845
2925
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
@@ -2901,6 +2981,7 @@ describe('StartPreparationUseCase', () => {
2901
2981
  allowedIssueAuthors: null,
2902
2982
  codexHomeCandidates: null,
2903
2983
  allowIssueCacheMinutes: 0,
2984
+ labelsAsLlmAgentName: null,
2904
2985
  });
2905
2986
 
2906
2987
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
@@ -2962,6 +3043,7 @@ describe('StartPreparationUseCase', () => {
2962
3043
  allowedIssueAuthors: null,
2963
3044
  codexHomeCandidates: null,
2964
3045
  allowIssueCacheMinutes: 0,
3046
+ labelsAsLlmAgentName: null,
2965
3047
  });
2966
3048
 
2967
3049
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
@@ -3023,6 +3105,7 @@ describe('StartPreparationUseCase', () => {
3023
3105
  allowedIssueAuthors: null,
3024
3106
  codexHomeCandidates: null,
3025
3107
  allowIssueCacheMinutes: 0,
3108
+ labelsAsLlmAgentName: null,
3026
3109
  });
3027
3110
 
3028
3111
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
@@ -3087,6 +3170,7 @@ describe('StartPreparationUseCase', () => {
3087
3170
  allowedIssueAuthors: null,
3088
3171
  codexHomeCandidates: null,
3089
3172
  allowIssueCacheMinutes: 0,
3173
+ labelsAsLlmAgentName: null,
3090
3174
  });
3091
3175
 
3092
3176
  expect(
@@ -3133,6 +3217,7 @@ describe('StartPreparationUseCase', () => {
3133
3217
  allowedIssueAuthors: null,
3134
3218
  codexHomeCandidates: null,
3135
3219
  allowIssueCacheMinutes: 0,
3220
+ labelsAsLlmAgentName: null,
3136
3221
  });
3137
3222
 
3138
3223
  expect(
@@ -3197,6 +3282,7 @@ describe('StartPreparationUseCase', () => {
3197
3282
  allowedIssueAuthors: null,
3198
3283
  codexHomeCandidates: null,
3199
3284
  allowIssueCacheMinutes: 0,
3285
+ labelsAsLlmAgentName: null,
3200
3286
  });
3201
3287
 
3202
3288
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
@@ -3261,6 +3347,7 @@ describe('StartPreparationUseCase', () => {
3261
3347
  allowedIssueAuthors: null,
3262
3348
  codexHomeCandidates: null,
3263
3349
  allowIssueCacheMinutes: 0,
3350
+ labelsAsLlmAgentName: null,
3264
3351
  });
3265
3352
 
3266
3353
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
@@ -3325,6 +3412,7 @@ describe('StartPreparationUseCase', () => {
3325
3412
  allowedIssueAuthors: null,
3326
3413
  codexHomeCandidates: null,
3327
3414
  allowIssueCacheMinutes: 0,
3415
+ labelsAsLlmAgentName: null,
3328
3416
  });
3329
3417
 
3330
3418
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
@@ -3389,6 +3477,7 @@ describe('StartPreparationUseCase', () => {
3389
3477
  allowedIssueAuthors: null,
3390
3478
  codexHomeCandidates: null,
3391
3479
  allowIssueCacheMinutes: 0,
3480
+ labelsAsLlmAgentName: null,
3392
3481
  });
3393
3482
 
3394
3483
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
@@ -3399,6 +3488,431 @@ describe('StartPreparationUseCase', () => {
3399
3488
  },
3400
3489
  });
3401
3490
  });
3491
+
3492
+ it('should pick the token whose 7-day reset is sooner before the token whose 7-day reset is farther', async () => {
3493
+ const awaitingIssue = createMockIssue({
3494
+ url: 'url1',
3495
+ title: 'Issue 1',
3496
+ labels: ['category:impl'],
3497
+ status: 'Awaiting Workspace',
3498
+ number: 1,
3499
+ itemId: 'item-1',
3500
+ });
3501
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
3502
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
3503
+ createMockStoryObjectMap([awaitingIssue]),
3504
+ );
3505
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
3506
+ stdout: '',
3507
+ stderr: '',
3508
+ exitCode: 0,
3509
+ });
3510
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
3511
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
3512
+ {
3513
+ name: 'token-b-far-reset',
3514
+ token: 'token-b-far-reset',
3515
+ fiveHourUtilization: 0.1,
3516
+ sevenDayUtilization: 0.1,
3517
+ blocked: false,
3518
+ rejected: false,
3519
+ modelWeeklyLimits: {
3520
+ seven_day_opus: {
3521
+ rejected: false,
3522
+ resetsAt: nowEpochSeconds + 100 * 3600,
3523
+ },
3524
+ },
3525
+ },
3526
+ {
3527
+ name: 'token-a-soon-reset',
3528
+ token: 'token-a-soon-reset',
3529
+ fiveHourUtilization: 0.1,
3530
+ sevenDayUtilization: 0.1,
3531
+ blocked: false,
3532
+ rejected: false,
3533
+ modelWeeklyLimits: {
3534
+ seven_day_opus: {
3535
+ rejected: false,
3536
+ resetsAt: nowEpochSeconds + 20 * 3600,
3537
+ },
3538
+ },
3539
+ },
3540
+ ]);
3541
+
3542
+ await useCase.run({
3543
+ projectUrl: 'https://github.com/user/repo',
3544
+ defaultAgentName: 'agent1',
3545
+ defaultLlmModelName: 'claude-opus',
3546
+ defaultLlmAgentName: null,
3547
+ configFilePath: '/path/to/config.yml',
3548
+ maximumPreparingIssuesCount: null,
3549
+ utilizationPercentageThreshold: 90,
3550
+ allowedIssueAuthors: null,
3551
+ codexHomeCandidates: null,
3552
+ allowIssueCacheMinutes: 0,
3553
+ labelsAsLlmAgentName: null,
3554
+ });
3555
+
3556
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
3557
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
3558
+ env: {
3559
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-a-soon-reset',
3560
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
3561
+ },
3562
+ });
3563
+ });
3564
+
3565
+ it('should exclude a blocked token even when it has a sooner 7-day reset than an eligible token', async () => {
3566
+ const awaitingIssue = createMockIssue({
3567
+ url: 'url1',
3568
+ title: 'Issue 1',
3569
+ labels: ['category:impl'],
3570
+ status: 'Awaiting Workspace',
3571
+ number: 1,
3572
+ itemId: 'item-1',
3573
+ });
3574
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
3575
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
3576
+ createMockStoryObjectMap([awaitingIssue]),
3577
+ );
3578
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
3579
+ stdout: '',
3580
+ stderr: '',
3581
+ exitCode: 0,
3582
+ });
3583
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
3584
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
3585
+ {
3586
+ name: 'token-a-blocked-soon-reset',
3587
+ token: 'token-a-blocked-soon-reset',
3588
+ fiveHourUtilization: 0.1,
3589
+ sevenDayUtilization: 0.1,
3590
+ blocked: true,
3591
+ rejected: false,
3592
+ modelWeeklyLimits: {
3593
+ seven_day_opus: {
3594
+ rejected: false,
3595
+ resetsAt: nowEpochSeconds + 20 * 3600,
3596
+ },
3597
+ },
3598
+ },
3599
+ {
3600
+ name: 'token-b-far-reset',
3601
+ token: 'token-b-far-reset',
3602
+ fiveHourUtilization: 0.1,
3603
+ sevenDayUtilization: 0.1,
3604
+ blocked: false,
3605
+ rejected: false,
3606
+ modelWeeklyLimits: {
3607
+ seven_day_opus: {
3608
+ rejected: false,
3609
+ resetsAt: nowEpochSeconds + 100 * 3600,
3610
+ },
3611
+ },
3612
+ },
3613
+ ]);
3614
+
3615
+ await useCase.run({
3616
+ projectUrl: 'https://github.com/user/repo',
3617
+ defaultAgentName: 'agent1',
3618
+ defaultLlmModelName: 'claude-opus',
3619
+ defaultLlmAgentName: null,
3620
+ configFilePath: '/path/to/config.yml',
3621
+ maximumPreparingIssuesCount: null,
3622
+ utilizationPercentageThreshold: 90,
3623
+ allowedIssueAuthors: null,
3624
+ codexHomeCandidates: null,
3625
+ allowIssueCacheMinutes: 0,
3626
+ labelsAsLlmAgentName: null,
3627
+ });
3628
+
3629
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
3630
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
3631
+ env: {
3632
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-b-far-reset',
3633
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
3634
+ },
3635
+ });
3636
+ });
3637
+
3638
+ it('should place a token whose 7-day reset is unknown after a token whose 7-day reset is known', async () => {
3639
+ const awaitingIssues: Issue[] = [
3640
+ createMockIssue({
3641
+ url: 'url1',
3642
+ title: 'Issue 1',
3643
+ labels: ['category:impl'],
3644
+ status: 'Awaiting Workspace',
3645
+ number: 1,
3646
+ itemId: 'item-1',
3647
+ }),
3648
+ createMockIssue({
3649
+ url: 'url2',
3650
+ title: 'Issue 2',
3651
+ labels: ['category:impl'],
3652
+ status: 'Awaiting Workspace',
3653
+ number: 2,
3654
+ itemId: 'item-2',
3655
+ }),
3656
+ ];
3657
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
3658
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
3659
+ createMockStoryObjectMap(awaitingIssues),
3660
+ );
3661
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
3662
+ stdout: '',
3663
+ stderr: '',
3664
+ exitCode: 0,
3665
+ });
3666
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
3667
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
3668
+ {
3669
+ name: 'token-a-unknown-reset',
3670
+ token: 'token-a-unknown-reset',
3671
+ fiveHourUtilization: 0.1,
3672
+ sevenDayUtilization: 0.1,
3673
+ blocked: false,
3674
+ rejected: false,
3675
+ modelWeeklyLimits: {},
3676
+ },
3677
+ {
3678
+ name: 'token-b-known-soon-reset',
3679
+ token: 'token-b-known-soon-reset',
3680
+ fiveHourUtilization: 0.1,
3681
+ sevenDayUtilization: 0.1,
3682
+ blocked: false,
3683
+ rejected: false,
3684
+ modelWeeklyLimits: {
3685
+ seven_day_opus: {
3686
+ rejected: false,
3687
+ resetsAt: nowEpochSeconds + 20 * 3600,
3688
+ },
3689
+ },
3690
+ },
3691
+ ]);
3692
+
3693
+ await useCase.run({
3694
+ projectUrl: 'https://github.com/user/repo',
3695
+ defaultAgentName: 'agent1',
3696
+ defaultLlmModelName: 'claude-opus',
3697
+ defaultLlmAgentName: null,
3698
+ configFilePath: '/path/to/config.yml',
3699
+ maximumPreparingIssuesCount: null,
3700
+ utilizationPercentageThreshold: 90,
3701
+ allowedIssueAuthors: null,
3702
+ codexHomeCandidates: null,
3703
+ allowIssueCacheMinutes: 0,
3704
+ labelsAsLlmAgentName: null,
3705
+ });
3706
+
3707
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(2);
3708
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toMatchObject({
3709
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-b-known-soon-reset' },
3710
+ });
3711
+ expect(mockLocalCommandRunner.runCommand.mock.calls[1][2]).toMatchObject({
3712
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-a-unknown-reset' },
3713
+ });
3714
+ });
3715
+
3716
+ it('should fall back to 5-hour utilization ascending as tiebreaker when 7-day reset deadlines are identical', async () => {
3717
+ const awaitingIssue = createMockIssue({
3718
+ url: 'url1',
3719
+ title: 'Issue 1',
3720
+ labels: ['category:impl'],
3721
+ status: 'Awaiting Workspace',
3722
+ number: 1,
3723
+ itemId: 'item-1',
3724
+ });
3725
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
3726
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
3727
+ createMockStoryObjectMap([awaitingIssue]),
3728
+ );
3729
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
3730
+ stdout: '',
3731
+ stderr: '',
3732
+ exitCode: 0,
3733
+ });
3734
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
3735
+ const sharedResetsAt = nowEpochSeconds + 50 * 3600;
3736
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
3737
+ {
3738
+ name: 'token-busy-5h',
3739
+ token: 'token-busy-5h',
3740
+ fiveHourUtilization: 0.6,
3741
+ sevenDayUtilization: 0.1,
3742
+ blocked: false,
3743
+ rejected: false,
3744
+ modelWeeklyLimits: {
3745
+ seven_day_opus: { rejected: false, resetsAt: sharedResetsAt },
3746
+ },
3747
+ },
3748
+ {
3749
+ name: 'token-idle-5h',
3750
+ token: 'token-idle-5h',
3751
+ fiveHourUtilization: 0.1,
3752
+ sevenDayUtilization: 0.1,
3753
+ blocked: false,
3754
+ rejected: false,
3755
+ modelWeeklyLimits: {
3756
+ seven_day_opus: { rejected: false, resetsAt: sharedResetsAt },
3757
+ },
3758
+ },
3759
+ ]);
3760
+
3761
+ await useCase.run({
3762
+ projectUrl: 'https://github.com/user/repo',
3763
+ defaultAgentName: 'agent1',
3764
+ defaultLlmModelName: 'claude-opus',
3765
+ defaultLlmAgentName: null,
3766
+ configFilePath: '/path/to/config.yml',
3767
+ maximumPreparingIssuesCount: null,
3768
+ utilizationPercentageThreshold: 90,
3769
+ allowedIssueAuthors: null,
3770
+ codexHomeCandidates: null,
3771
+ allowIssueCacheMinutes: 0,
3772
+ labelsAsLlmAgentName: null,
3773
+ });
3774
+
3775
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
3776
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
3777
+ env: {
3778
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-idle-5h',
3779
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
3780
+ },
3781
+ });
3782
+ });
3783
+
3784
+ describe('agent selection precedence', () => {
3785
+ const runWithIssueLabels = async (params: {
3786
+ labels: string[];
3787
+ defaultAgentName: string;
3788
+ defaultLlmAgentName: string | null;
3789
+ labelsAsLlmAgentName: string[] | null;
3790
+ }): Promise<string> => {
3791
+ const awaitingIssues: Issue[] = [
3792
+ createMockIssue({
3793
+ url: 'url1',
3794
+ title: 'Issue 1',
3795
+ labels: params.labels,
3796
+ status: 'Awaiting Workspace',
3797
+ }),
3798
+ ];
3799
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
3800
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
3801
+ createMockStoryObjectMap(awaitingIssues),
3802
+ );
3803
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
3804
+ stdout: '',
3805
+ stderr: '',
3806
+ exitCode: 0,
3807
+ });
3808
+ await useCase.run({
3809
+ projectUrl: 'https://github.com/user/repo',
3810
+ defaultAgentName: params.defaultAgentName,
3811
+ defaultLlmModelName: 'claude-opus',
3812
+ defaultLlmAgentName: params.defaultLlmAgentName,
3813
+ configFilePath: '/path/to/config.yml',
3814
+ maximumPreparingIssuesCount: null,
3815
+ utilizationPercentageThreshold: 90,
3816
+ allowedIssueAuthors: null,
3817
+ codexHomeCandidates: null,
3818
+ allowIssueCacheMinutes: 0,
3819
+ labelsAsLlmAgentName: params.labelsAsLlmAgentName,
3820
+ });
3821
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
3822
+ const awArgs = mockLocalCommandRunner.runCommand.mock.calls[0][1];
3823
+ return awArgs[1];
3824
+ };
3825
+
3826
+ it('selects explicit llm-agent: label over labelsAsLlmAgentName mapping', async () => {
3827
+ const selectedAgent = await runWithIssueLabels({
3828
+ labels: ['llm-agent:explicit-agent', 'story', 'category:impl'],
3829
+ defaultAgentName: 'default-agent',
3830
+ defaultLlmAgentName: 'default-llm-agent',
3831
+ labelsAsLlmAgentName: ['story'],
3832
+ });
3833
+ expect(selectedAgent).toBe('explicit-agent');
3834
+ });
3835
+
3836
+ it('uses the label name as the agent name when an issue label is listed in labelsAsLlmAgentName, over category: label', async () => {
3837
+ const selectedAgent = await runWithIssueLabels({
3838
+ labels: ['story', 'category:impl'],
3839
+ defaultAgentName: 'default-agent',
3840
+ defaultLlmAgentName: 'default-llm-agent',
3841
+ labelsAsLlmAgentName: ['story'],
3842
+ });
3843
+ expect(selectedAgent).toBe('story');
3844
+ });
3845
+
3846
+ it('matches labelsAsLlmAgentName entries exactly including colons in the label name', async () => {
3847
+ const selectedAgent = await runWithIssueLabels({
3848
+ labels: ['story:body-condition', 'category:impl'],
3849
+ defaultAgentName: 'default-agent',
3850
+ defaultLlmAgentName: 'default-llm-agent',
3851
+ labelsAsLlmAgentName: ['story', 'story:body-condition'],
3852
+ });
3853
+ expect(selectedAgent).toBe('story:body-condition');
3854
+ });
3855
+
3856
+ it('falls through to category: label when no llm-agent: label and no issue label is in labelsAsLlmAgentName', async () => {
3857
+ const selectedAgent = await runWithIssueLabels({
3858
+ labels: ['unrelated-label', 'category:impl'],
3859
+ defaultAgentName: 'default-agent',
3860
+ defaultLlmAgentName: 'default-llm-agent',
3861
+ labelsAsLlmAgentName: ['story'],
3862
+ });
3863
+ expect(selectedAgent).toBe('impl');
3864
+ });
3865
+
3866
+ it('falls through to defaultLlmAgentName when no llm-agent:, no labelsAsLlmAgentName match, and no category: label', async () => {
3867
+ const selectedAgent = await runWithIssueLabels({
3868
+ labels: ['unrelated-label'],
3869
+ defaultAgentName: 'default-agent',
3870
+ defaultLlmAgentName: 'default-llm-agent',
3871
+ labelsAsLlmAgentName: ['story'],
3872
+ });
3873
+ expect(selectedAgent).toBe('default-llm-agent');
3874
+ });
3875
+
3876
+ it('falls through to defaultAgentName when no llm-agent:, no labelsAsLlmAgentName match, no category: label, and no defaultLlmAgentName', async () => {
3877
+ const selectedAgent = await runWithIssueLabels({
3878
+ labels: ['unrelated-label'],
3879
+ defaultAgentName: 'default-agent',
3880
+ defaultLlmAgentName: null,
3881
+ labelsAsLlmAgentName: ['story'],
3882
+ });
3883
+ expect(selectedAgent).toBe('default-agent');
3884
+ });
3885
+
3886
+ it('ignores labels that are not listed in labelsAsLlmAgentName', async () => {
3887
+ const selectedAgent = await runWithIssueLabels({
3888
+ labels: ['untracked-label'],
3889
+ defaultAgentName: 'default-agent',
3890
+ defaultLlmAgentName: 'default-llm-agent',
3891
+ labelsAsLlmAgentName: ['story'],
3892
+ });
3893
+ expect(selectedAgent).toBe('default-llm-agent');
3894
+ });
3895
+
3896
+ it('ignores labels that are not listed in labelsAsLlmAgentName when other entries are present', async () => {
3897
+ const selectedAgent = await runWithIssueLabels({
3898
+ labels: ['untracked-label', 'another-untracked-label'],
3899
+ defaultAgentName: 'default-agent',
3900
+ defaultLlmAgentName: 'default-llm-agent',
3901
+ labelsAsLlmAgentName: ['story', 'story:body-condition'],
3902
+ });
3903
+ expect(selectedAgent).toBe('default-llm-agent');
3904
+ });
3905
+
3906
+ it('does not affect selection when labelsAsLlmAgentName is null and only category: label is present', async () => {
3907
+ const selectedAgent = await runWithIssueLabels({
3908
+ labels: ['category:impl'],
3909
+ defaultAgentName: 'default-agent',
3910
+ defaultLlmAgentName: 'default-llm-agent',
3911
+ labelsAsLlmAgentName: null,
3912
+ });
3913
+ expect(selectedAgent).toBe('impl');
3914
+ });
3915
+ });
3402
3916
  });
3403
3917
 
3404
3918
  describe('StartPreparationUseCase.buildRotationOrder', () => {
@@ -3444,25 +3958,36 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
3444
3958
  mockClaudeTokenUsageRepositoryForRotation,
3445
3959
  );
3446
3960
 
3447
- it('lists selected tokens first in ascending 7-day utilization order then excluded tokens', () => {
3448
- const tokenUsages = [
3961
+ it('lists selected tokens first in ascending 7-day reset deadline order then excluded tokens', () => {
3962
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
3963
+ const tokenUsages: ClaudeTokenUsage[] = [
3449
3964
  {
3450
- name: 'high-7d-util',
3451
- token: 'sk-ant-high',
3965
+ name: 'far-7d-reset',
3966
+ token: 'sk-ant-far',
3452
3967
  fiveHourUtilization: 0.1,
3453
3968
  sevenDayUtilization: 0.8,
3454
3969
  blocked: false,
3455
3970
  rejected: false,
3456
- modelWeeklyLimits: {},
3971
+ modelWeeklyLimits: {
3972
+ seven_day: {
3973
+ rejected: false,
3974
+ resetsAt: nowEpochSeconds + 100 * 3600,
3975
+ },
3976
+ },
3457
3977
  },
3458
3978
  {
3459
- name: 'low-7d-util',
3460
- token: 'sk-ant-low',
3979
+ name: 'soon-7d-reset',
3980
+ token: 'sk-ant-soon',
3461
3981
  fiveHourUtilization: 0.5,
3462
3982
  sevenDayUtilization: 0.1,
3463
3983
  blocked: false,
3464
3984
  rejected: false,
3465
- modelWeeklyLimits: {},
3985
+ modelWeeklyLimits: {
3986
+ seven_day: {
3987
+ rejected: false,
3988
+ resetsAt: nowEpochSeconds + 20 * 3600,
3989
+ },
3990
+ },
3466
3991
  },
3467
3992
  {
3468
3993
  name: 'blocked-token',
@@ -3476,8 +4001,8 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
3476
4001
  ];
3477
4002
  const result = useCase.buildRotationOrder(tokenUsages, 90, null);
3478
4003
 
3479
- expect(result[0].name).toBe('low-7d-util');
3480
- expect(result[1].name).toBe('high-7d-util');
4004
+ expect(result[0].name).toBe('soon-7d-reset');
4005
+ expect(result[1].name).toBe('far-7d-reset');
3481
4006
  expect(result[2].name).toBe('blocked-token');
3482
4007
  expect(result[2].blocked).toBe(true);
3483
4008
  expect(result[2].thresholdExcluded).toBe(false);