github-issue-tower-defence-management 1.58.2 → 1.59.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 (61) hide show
  1. package/.gitattributes +2 -0
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +3 -3
  5. package/bin/adapter/entry-points/cli/index.js +1 -3
  6. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +8 -4
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/proxy/RateLimitCache.js +104 -12
  10. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  11. package/bin/adapter/proxy/proxyEntry.js +19 -0
  12. package/bin/adapter/proxy/proxyEntry.js.map +1 -1
  13. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +33 -2
  14. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
  15. package/bin/adapter/repositories/ProxyRateLimitCacheRepository.js +92 -0
  16. package/bin/adapter/repositories/ProxyRateLimitCacheRepository.js.map +1 -0
  17. package/bin/domain/usecases/HandleScheduledEventUseCase.js +7 -1
  18. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  19. package/bin/domain/usecases/StartPreparationUseCase.js +34 -35
  20. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  21. package/bin/domain/usecases/UpdateRateLimitCacheUseCase.js +18 -0
  22. package/bin/domain/usecases/UpdateRateLimitCacheUseCase.js.map +1 -0
  23. package/bin/domain/usecases/adapter-interfaces/RateLimitCacheRepository.js +3 -0
  24. package/bin/domain/usecases/adapter-interfaces/RateLimitCacheRepository.js.map +1 -0
  25. package/package.json +1 -1
  26. package/src/adapter/entry-points/cli/index.ts +0 -3
  27. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +6 -0
  28. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +9 -3
  29. package/src/adapter/proxy/RateLimitCache.test.ts +250 -0
  30. package/src/adapter/proxy/RateLimitCache.ts +114 -25
  31. package/src/adapter/proxy/proxyEntry.ts +24 -1
  32. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +354 -7
  33. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +42 -2
  34. package/src/adapter/repositories/ProxyRateLimitCacheRepository.test.ts +101 -0
  35. package/src/adapter/repositories/ProxyRateLimitCacheRepository.ts +66 -0
  36. package/src/domain/entities/ClaudeTokenUsage.ts +7 -0
  37. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +63 -0
  38. package/src/domain/usecases/HandleScheduledEventUseCase.ts +7 -0
  39. package/src/domain/usecases/StartPreparationUseCase.test.ts +1212 -887
  40. package/src/domain/usecases/StartPreparationUseCase.ts +47 -57
  41. package/src/domain/usecases/UpdateRateLimitCacheUseCase.test.ts +81 -0
  42. package/src/domain/usecases/UpdateRateLimitCacheUseCase.ts +16 -0
  43. package/src/domain/usecases/adapter-interfaces/RateLimitCacheRepository.ts +9 -0
  44. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  45. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  46. package/types/adapter/proxy/RateLimitCache.d.ts +11 -0
  47. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  48. package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
  49. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
  50. package/types/adapter/repositories/ProxyRateLimitCacheRepository.d.ts +10 -0
  51. package/types/adapter/repositories/ProxyRateLimitCacheRepository.d.ts.map +1 -0
  52. package/types/domain/entities/ClaudeTokenUsage.d.ts +6 -0
  53. package/types/domain/entities/ClaudeTokenUsage.d.ts.map +1 -1
  54. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -1
  55. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  56. package/types/domain/usecases/StartPreparationUseCase.d.ts +3 -3
  57. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  58. package/types/domain/usecases/UpdateRateLimitCacheUseCase.d.ts +9 -0
  59. package/types/domain/usecases/UpdateRateLimitCacheUseCase.d.ts.map +1 -0
  60. package/types/domain/usecases/adapter-interfaces/RateLimitCacheRepository.d.ts +9 -0
  61. package/types/domain/usecases/adapter-interfaces/RateLimitCacheRepository.d.ts.map +1 -0
@@ -5,7 +5,6 @@ import {
5
5
  } from './adapter-interfaces/IssueRepository';
6
6
  import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
7
7
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
8
- import { ClaudeRepository } from './adapter-interfaces/ClaudeRepository';
9
8
  import { ClaudeTokenUsageRepository } from './adapter-interfaces/ClaudeTokenUsageRepository';
10
9
  import { Issue } from '../entities/Issue';
11
10
  import { Project } from '../entities/Project';
@@ -91,7 +90,6 @@ describe('StartPreparationUseCase', () => {
91
90
  | 'createCommentByUrl'
92
91
  >
93
92
  >;
94
- let mockClaudeRepository: Mocked<Pick<ClaudeRepository, 'getUsage'>>;
95
93
  let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
96
94
  let mockClaudeTokenUsageRepository: Mocked<ClaudeTokenUsageRepository>;
97
95
  let mockProject: Project;
@@ -110,9 +108,6 @@ describe('StartPreparationUseCase', () => {
110
108
  deletePullRequestBranch: jest.fn().mockResolvedValue(undefined),
111
109
  createCommentByUrl: jest.fn().mockResolvedValue(undefined),
112
110
  };
113
- mockClaudeRepository = {
114
- getUsage: jest.fn().mockResolvedValue([]),
115
- };
116
111
  mockLocalCommandRunner = {
117
112
  runCommand: jest.fn(),
118
113
  };
@@ -124,7 +119,6 @@ describe('StartPreparationUseCase', () => {
124
119
  useCase = new StartPreparationUseCase(
125
120
  mockProjectRepository,
126
121
  mockIssueRepository,
127
- mockClaudeRepository,
128
122
  mockLocalCommandRunner,
129
123
  mockClaudeTokenUsageRepository,
130
124
  );
@@ -1262,59 +1256,25 @@ describe('StartPreparationUseCase', () => {
1262
1256
  expect(updatedUrls).toContain('https://github.com/user/repo/issues/101');
1263
1257
  });
1264
1258
 
1265
- it('should skip preparation when Claude usage is over 90%', async () => {
1266
- mockClaudeRepository.getUsage.mockResolvedValue([
1267
- { hour: 5, utilizationPercentage: 95, resetsAt: new Date() },
1268
- ]);
1269
-
1270
- const awaitingIssues: Issue[] = [
1271
- createMockIssue({
1272
- url: 'url1',
1273
- title: 'Issue 1',
1274
- labels: [],
1275
- status: 'Awaiting Workspace',
1276
- }),
1277
- ];
1278
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1279
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1280
- createMockStoryObjectMap(awaitingIssues),
1281
- );
1282
-
1283
- await useCase.run({
1284
- projectUrl: 'https://github.com/user/repo',
1285
- defaultAgentName: 'agent1',
1286
- defaultLlmModelName: null,
1287
- defaultLlmAgentName: null,
1288
- configFilePath: '/path/to/config.yml',
1289
- maximumPreparingIssuesCount: null,
1290
- utilizationPercentageThreshold: 90,
1291
- allowedIssueAuthors: null,
1292
- codexHomeCandidates: null,
1293
- allowIssueCacheMinutes: 0,
1259
+ it('should skip issues that have dependedIssueUrls', async () => {
1260
+ const issueWithDependency = createMockIssue({
1261
+ url: 'https://github.com/user/repo/issues/1',
1262
+ title: 'Issue with dependency',
1263
+ labels: [],
1264
+ status: 'Awaiting Workspace',
1265
+ dependedIssueUrls: ['https://github.com/user/repo/issues/2'],
1266
+ });
1267
+ const issueWithoutDependency = createMockIssue({
1268
+ url: 'https://github.com/user/repo/issues/3',
1269
+ title: 'Issue without dependency',
1270
+ labels: [],
1271
+ status: 'Awaiting Workspace',
1272
+ dependedIssueUrls: [],
1294
1273
  });
1295
1274
 
1296
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
1297
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
1298
- expect(mockProjectRepository.getByUrl).not.toHaveBeenCalled();
1299
- });
1300
-
1301
- it('should proceed with preparation when Claude usage is under 90%', async () => {
1302
- mockClaudeRepository.getUsage.mockResolvedValue([
1303
- { hour: 5, utilizationPercentage: 50, resetsAt: new Date() },
1304
- { hour: 168, utilizationPercentage: 30, resetsAt: new Date() },
1305
- ]);
1306
-
1307
- const awaitingIssues: Issue[] = [
1308
- createMockIssue({
1309
- url: 'url1',
1310
- title: 'Issue 1',
1311
- labels: ['category:impl'],
1312
- status: 'Awaiting Workspace',
1313
- }),
1314
- ];
1315
1275
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1316
1276
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1317
- createMockStoryObjectMap(awaitingIssues),
1277
+ createMockStoryObjectMap([issueWithDependency, issueWithoutDependency]),
1318
1278
  );
1319
1279
  mockLocalCommandRunner.runCommand.mockResolvedValue({
1320
1280
  stdout: '',
@@ -1336,232 +1296,201 @@ describe('StartPreparationUseCase', () => {
1336
1296
  });
1337
1297
 
1338
1298
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1299
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1300
+ url: 'https://github.com/user/repo/issues/3',
1301
+ status: 'Preparation',
1302
+ });
1339
1303
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1340
1304
  });
1341
1305
 
1342
- it('should reduce maximumPreparingIssuesCount gradually when weekly usage window exceeds threshold', async () => {
1343
- mockClaudeRepository.getUsage.mockResolvedValue([
1344
- { hour: 5, utilizationPercentage: 50, resetsAt: new Date() },
1345
- { hour: 168, utilizationPercentage: 91, resetsAt: new Date() },
1346
- ]);
1306
+ it('should skip issues where nextActionHour is in the future', async () => {
1307
+ jest.useFakeTimers();
1308
+ try {
1309
+ jest.setSystemTime(new Date('2024-01-01T10:00:00'));
1347
1310
 
1348
- const awaitingIssues: Issue[] = Array.from({ length: 10 }, (_, i) =>
1349
- createMockIssue({
1350
- url: `url${i + 1}`,
1351
- title: `Issue ${i + 1}`,
1311
+ const issueWithFutureNextActionHour = createMockIssue({
1312
+ url: 'https://github.com/user/repo/issues/1',
1313
+ title: 'Issue with future next action hour',
1352
1314
  labels: [],
1353
1315
  status: 'Awaiting Workspace',
1354
- }),
1355
- );
1356
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1357
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1358
- createMockStoryObjectMap(awaitingIssues),
1359
- );
1360
- mockLocalCommandRunner.runCommand.mockResolvedValue({
1361
- stdout: '',
1362
- stderr: '',
1363
- exitCode: 0,
1364
- });
1316
+ nextActionHour: 15,
1317
+ });
1318
+ const issueWithoutNextActionHour = createMockIssue({
1319
+ url: 'https://github.com/user/repo/issues/2',
1320
+ title: 'Issue without next action hour',
1321
+ labels: [],
1322
+ status: 'Awaiting Workspace',
1323
+ nextActionHour: null,
1324
+ });
1365
1325
 
1366
- await useCase.run({
1367
- projectUrl: 'https://github.com/user/repo',
1368
- defaultAgentName: 'agent1',
1369
- defaultLlmModelName: 'claude-sonnet-4-6',
1370
- defaultLlmAgentName: null,
1371
- configFilePath: '/path/to/config.yml',
1372
- maximumPreparingIssuesCount: null,
1373
- utilizationPercentageThreshold: 90,
1374
- allowedIssueAuthors: null,
1375
- codexHomeCandidates: null,
1376
- allowIssueCacheMinutes: 0,
1377
- });
1326
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1327
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1328
+ createMockStoryObjectMap([
1329
+ issueWithFutureNextActionHour,
1330
+ issueWithoutNextActionHour,
1331
+ ]),
1332
+ );
1333
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1334
+ stdout: '',
1335
+ stderr: '',
1336
+ exitCode: 0,
1337
+ });
1378
1338
 
1379
- const weeklyUtilization = 91;
1380
- const threshold = 90;
1381
- const defaultMax = 6;
1382
- const normalizedUtilizationBeyondThreshold =
1383
- (weeklyUtilization - threshold) / (100 - threshold);
1384
- const expectedMax = Math.floor(
1385
- defaultMax * Math.pow(1 - normalizedUtilizationBeyondThreshold, 2),
1386
- );
1387
- expect(mockProjectRepository.getByUrl).toHaveBeenCalled();
1388
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(
1389
- expectedMax,
1390
- );
1391
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(
1392
- expectedMax,
1393
- );
1339
+ await useCase.run({
1340
+ projectUrl: 'https://github.com/user/repo',
1341
+ defaultAgentName: 'agent1',
1342
+ defaultLlmModelName: 'claude-sonnet-4-6',
1343
+ defaultLlmAgentName: null,
1344
+ configFilePath: '/path/to/config.yml',
1345
+ maximumPreparingIssuesCount: null,
1346
+ utilizationPercentageThreshold: 90,
1347
+ allowedIssueAuthors: null,
1348
+ codexHomeCandidates: null,
1349
+ allowIssueCacheMinutes: 0,
1350
+ });
1351
+
1352
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1353
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1354
+ url: 'https://github.com/user/repo/issues/2',
1355
+ status: 'Preparation',
1356
+ });
1357
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1358
+ } finally {
1359
+ jest.useRealTimers();
1360
+ }
1394
1361
  });
1395
1362
 
1396
- it('should skip all preparation when weekly window usage reduces maximumPreparingIssuesCount to 0', async () => {
1397
- mockClaudeRepository.getUsage.mockResolvedValue([
1398
- { hour: 168, utilizationPercentage: 100, resetsAt: new Date() },
1399
- ]);
1363
+ it('should skip issues where nextActionDate is tomorrow or more future', async () => {
1364
+ jest.useFakeTimers();
1365
+ try {
1366
+ jest.setSystemTime(new Date('2024-01-15T10:00:00'));
1400
1367
 
1401
- const awaitingIssues: Issue[] = [
1402
- createMockIssue({
1403
- url: 'url1',
1368
+ const issueWithFutureNextActionDate = createMockIssue({
1369
+ url: 'https://github.com/user/repo/issues/1',
1370
+ title: 'Issue with future next action date',
1404
1371
  labels: [],
1405
1372
  status: 'Awaiting Workspace',
1406
- }),
1407
- ];
1408
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1409
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1410
- createMockStoryObjectMap(awaitingIssues),
1411
- );
1373
+ nextActionDate: new Date('2024-01-16'),
1374
+ });
1375
+ const issueWithoutNextActionDate = createMockIssue({
1376
+ url: 'https://github.com/user/repo/issues/2',
1377
+ title: 'Issue without next action date',
1378
+ labels: [],
1379
+ status: 'Awaiting Workspace',
1380
+ nextActionDate: null,
1381
+ });
1412
1382
 
1413
- await useCase.run({
1414
- projectUrl: 'https://github.com/user/repo',
1415
- defaultAgentName: 'agent1',
1416
- defaultLlmModelName: null,
1417
- defaultLlmAgentName: null,
1418
- configFilePath: '/path/to/config.yml',
1419
- maximumPreparingIssuesCount: null,
1420
- utilizationPercentageThreshold: 90,
1421
- allowedIssueAuthors: null,
1422
- codexHomeCandidates: null,
1423
- allowIssueCacheMinutes: 0,
1424
- });
1383
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1384
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1385
+ createMockStoryObjectMap([
1386
+ issueWithFutureNextActionDate,
1387
+ issueWithoutNextActionDate,
1388
+ ]),
1389
+ );
1390
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1391
+ stdout: '',
1392
+ stderr: '',
1393
+ exitCode: 0,
1394
+ });
1425
1395
 
1426
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
1427
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
1396
+ await useCase.run({
1397
+ projectUrl: 'https://github.com/user/repo',
1398
+ defaultAgentName: 'agent1',
1399
+ defaultLlmModelName: 'claude-sonnet-4-6',
1400
+ defaultLlmAgentName: null,
1401
+ configFilePath: '/path/to/config.yml',
1402
+ maximumPreparingIssuesCount: null,
1403
+ utilizationPercentageThreshold: 90,
1404
+ allowedIssueAuthors: null,
1405
+ codexHomeCandidates: null,
1406
+ allowIssueCacheMinutes: 0,
1407
+ });
1408
+
1409
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1410
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1411
+ url: 'https://github.com/user/repo/issues/2',
1412
+ status: 'Preparation',
1413
+ });
1414
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1415
+ } finally {
1416
+ jest.useRealTimers();
1417
+ }
1428
1418
  });
1429
1419
 
1430
- it('should not apply weekly reduction when threshold is 100', async () => {
1431
- mockClaudeRepository.getUsage.mockResolvedValue([
1432
- { hour: 168, utilizationPercentage: 99, resetsAt: new Date() },
1433
- ]);
1420
+ it('should not skip issues where nextActionDate is today', async () => {
1421
+ jest.useFakeTimers();
1422
+ try {
1423
+ jest.setSystemTime(new Date('2024-01-15T10:00:00'));
1434
1424
 
1435
- const awaitingIssues: Issue[] = Array.from({ length: 10 }, (_, i) =>
1436
- createMockIssue({
1437
- url: `url${i + 1}`,
1438
- title: `Issue ${i + 1}`,
1425
+ const issueWithTodayNextActionDate = createMockIssue({
1426
+ url: 'https://github.com/user/repo/issues/1',
1427
+ title: 'Issue with today next action date',
1439
1428
  labels: [],
1440
1429
  status: 'Awaiting Workspace',
1441
- }),
1442
- );
1443
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1444
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1445
- createMockStoryObjectMap(awaitingIssues),
1446
- );
1447
- mockLocalCommandRunner.runCommand.mockResolvedValue({
1448
- stdout: '',
1449
- stderr: '',
1450
- exitCode: 0,
1451
- });
1452
-
1453
- await useCase.run({
1454
- projectUrl: 'https://github.com/user/repo',
1455
- defaultAgentName: 'agent1',
1456
- defaultLlmModelName: 'claude-sonnet-4-6',
1457
- defaultLlmAgentName: null,
1458
- configFilePath: '/path/to/config.yml',
1459
- maximumPreparingIssuesCount: null,
1460
- utilizationPercentageThreshold: 100,
1461
- allowedIssueAuthors: null,
1462
- codexHomeCandidates: null,
1463
- allowIssueCacheMinutes: 0,
1464
- });
1430
+ nextActionDate: new Date('2024-01-15'),
1431
+ });
1465
1432
 
1466
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(6);
1467
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(6);
1468
- });
1469
-
1470
- it('should still skip immediately when non-weekly window exceeds threshold', async () => {
1471
- mockClaudeRepository.getUsage.mockResolvedValue([
1472
- { hour: 5, utilizationPercentage: 95, resetsAt: new Date() },
1473
- { hour: 168, utilizationPercentage: 50, resetsAt: new Date() },
1474
- ]);
1475
-
1476
- const awaitingIssues: Issue[] = [
1477
- createMockIssue({
1478
- url: 'url1',
1479
- labels: [],
1480
- status: 'Awaiting Workspace',
1481
- }),
1482
- ];
1483
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1484
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1485
- createMockStoryObjectMap(awaitingIssues),
1486
- );
1433
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1434
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1435
+ createMockStoryObjectMap([issueWithTodayNextActionDate]),
1436
+ );
1437
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1438
+ stdout: '',
1439
+ stderr: '',
1440
+ exitCode: 0,
1441
+ });
1487
1442
 
1488
- await useCase.run({
1489
- projectUrl: 'https://github.com/user/repo',
1490
- defaultAgentName: 'agent1',
1491
- defaultLlmModelName: null,
1492
- defaultLlmAgentName: null,
1493
- configFilePath: '/path/to/config.yml',
1494
- maximumPreparingIssuesCount: null,
1495
- utilizationPercentageThreshold: 90,
1496
- allowedIssueAuthors: null,
1497
- codexHomeCandidates: null,
1498
- allowIssueCacheMinutes: 0,
1499
- });
1443
+ await useCase.run({
1444
+ projectUrl: 'https://github.com/user/repo',
1445
+ defaultAgentName: 'agent1',
1446
+ defaultLlmModelName: 'claude-sonnet-4-6',
1447
+ defaultLlmAgentName: null,
1448
+ configFilePath: '/path/to/config.yml',
1449
+ maximumPreparingIssuesCount: null,
1450
+ utilizationPercentageThreshold: 90,
1451
+ allowedIssueAuthors: null,
1452
+ codexHomeCandidates: null,
1453
+ allowIssueCacheMinutes: 0,
1454
+ });
1500
1455
 
1501
- expect(mockProjectRepository.getByUrl).not.toHaveBeenCalled();
1502
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
1503
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
1456
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1457
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1458
+ url: 'https://github.com/user/repo/issues/1',
1459
+ status: 'Preparation',
1460
+ });
1461
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1462
+ } finally {
1463
+ jest.useRealTimers();
1464
+ }
1504
1465
  });
1505
1466
 
1506
- it('should use maximum utilization across multiple weekly window entries for reduction', async () => {
1507
- mockClaudeRepository.getUsage.mockResolvedValue([
1508
- { hour: 168, utilizationPercentage: 91, resetsAt: new Date() },
1509
- { hour: 168, utilizationPercentage: 95, resetsAt: new Date() },
1510
- ]);
1467
+ it('should not skip issues where nextActionDate is in the past', async () => {
1468
+ jest.useFakeTimers();
1469
+ try {
1470
+ jest.setSystemTime(new Date('2024-01-15T10:00:00'));
1511
1471
 
1512
- const awaitingIssues: Issue[] = Array.from({ length: 10 }, (_, i) =>
1513
- createMockIssue({
1514
- url: `url${i + 1}`,
1515
- title: `Issue ${i + 1}`,
1472
+ const issueWithPastNextActionDate = createMockIssue({
1473
+ url: 'https://github.com/user/repo/issues/1',
1474
+ title: 'Issue with past next action date',
1516
1475
  labels: [],
1517
1476
  status: 'Awaiting Workspace',
1518
- }),
1519
- );
1520
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1521
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1522
- createMockStoryObjectMap(awaitingIssues),
1523
- );
1524
- mockLocalCommandRunner.runCommand.mockResolvedValue({
1525
- stdout: '',
1526
- stderr: '',
1527
- exitCode: 0,
1528
- });
1529
-
1530
- await useCase.run({
1531
- projectUrl: 'https://github.com/user/repo',
1532
- defaultAgentName: 'agent1',
1533
- defaultLlmModelName: 'claude-sonnet-4-6',
1534
- defaultLlmAgentName: null,
1535
- configFilePath: '/path/to/config.yml',
1536
- maximumPreparingIssuesCount: null,
1537
- utilizationPercentageThreshold: 90,
1538
- allowedIssueAuthors: null,
1539
- codexHomeCandidates: null,
1540
- allowIssueCacheMinutes: 0,
1541
- });
1542
-
1543
- const normalizedUtilizationBeyondThreshold = (95 - 90) / (100 - 90);
1544
- const expectedMax = Math.floor(
1545
- 6 * Math.pow(1 - normalizedUtilizationBeyondThreshold, 2),
1546
- );
1547
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(
1548
- expectedMax,
1549
- );
1550
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(
1551
- expectedMax,
1552
- );
1553
- });
1477
+ nextActionDate: new Date('2024-01-14'),
1478
+ });
1554
1479
 
1555
- it('should throw error when Claude usage check fails', async () => {
1556
- mockClaudeRepository.getUsage.mockRejectedValue(
1557
- new Error('Claude credentials file not found'),
1558
- );
1480
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1481
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1482
+ createMockStoryObjectMap([issueWithPastNextActionDate]),
1483
+ );
1484
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1485
+ stdout: '',
1486
+ stderr: '',
1487
+ exitCode: 0,
1488
+ });
1559
1489
 
1560
- await expect(
1561
- useCase.run({
1490
+ await useCase.run({
1562
1491
  projectUrl: 'https://github.com/user/repo',
1563
1492
  defaultAgentName: 'agent1',
1564
- defaultLlmModelName: null,
1493
+ defaultLlmModelName: 'claude-sonnet-4-6',
1565
1494
  defaultLlmAgentName: null,
1566
1495
  configFilePath: '/path/to/config.yml',
1567
1496
  maximumPreparingIssuesCount: null,
@@ -1569,65 +1498,88 @@ describe('StartPreparationUseCase', () => {
1569
1498
  allowedIssueAuthors: null,
1570
1499
  codexHomeCandidates: null,
1571
1500
  allowIssueCacheMinutes: 0,
1572
- }),
1573
- ).rejects.toThrow('Claude credentials file not found');
1501
+ });
1574
1502
 
1575
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
1576
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
1503
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1504
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1505
+ url: 'https://github.com/user/repo/issues/1',
1506
+ status: 'Preparation',
1507
+ });
1508
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1509
+ } finally {
1510
+ jest.useRealTimers();
1511
+ }
1577
1512
  });
1578
1513
 
1579
- it('should skip preparation when Claude usage exceeds custom threshold', async () => {
1580
- mockClaudeRepository.getUsage.mockResolvedValue([
1581
- { hour: 5, utilizationPercentage: 75, resetsAt: new Date() },
1582
- ]);
1514
+ it('should not skip issues where nextActionHour is in the past or current hour', async () => {
1515
+ jest.useFakeTimers();
1516
+ try {
1517
+ jest.setSystemTime(new Date('2024-01-01T15:00:00'));
1583
1518
 
1584
- const awaitingIssues: Issue[] = [
1585
- createMockIssue({
1586
- url: 'url1',
1587
- title: 'Issue 1',
1519
+ const issueWithPastNextActionHour = createMockIssue({
1520
+ url: 'https://github.com/user/repo/issues/1',
1521
+ title: 'Issue with past next action hour',
1588
1522
  labels: [],
1589
1523
  status: 'Awaiting Workspace',
1590
- }),
1591
- ];
1592
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1593
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1594
- createMockStoryObjectMap(awaitingIssues),
1595
- );
1524
+ nextActionHour: 10,
1525
+ });
1596
1526
 
1597
- await useCase.run({
1598
- projectUrl: 'https://github.com/user/repo',
1599
- defaultAgentName: 'agent1',
1600
- defaultLlmModelName: null,
1601
- defaultLlmAgentName: null,
1602
- configFilePath: '/path/to/config.yml',
1603
- maximumPreparingIssuesCount: null,
1604
- utilizationPercentageThreshold: 70,
1605
- allowedIssueAuthors: null,
1606
- codexHomeCandidates: null,
1607
- allowIssueCacheMinutes: 0,
1608
- });
1527
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1528
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1529
+ createMockStoryObjectMap([issueWithPastNextActionHour]),
1530
+ );
1531
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1532
+ stdout: '',
1533
+ stderr: '',
1534
+ exitCode: 0,
1535
+ });
1609
1536
 
1610
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
1611
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
1612
- expect(mockProjectRepository.getByUrl).not.toHaveBeenCalled();
1537
+ await useCase.run({
1538
+ projectUrl: 'https://github.com/user/repo',
1539
+ defaultAgentName: 'agent1',
1540
+ defaultLlmModelName: 'claude-sonnet-4-6',
1541
+ defaultLlmAgentName: null,
1542
+ configFilePath: '/path/to/config.yml',
1543
+ maximumPreparingIssuesCount: null,
1544
+ utilizationPercentageThreshold: 90,
1545
+ allowedIssueAuthors: null,
1546
+ codexHomeCandidates: null,
1547
+ allowIssueCacheMinutes: 0,
1548
+ });
1549
+
1550
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1551
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1552
+ url: 'https://github.com/user/repo/issues/1',
1553
+ status: 'Preparation',
1554
+ });
1555
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1556
+ } finally {
1557
+ jest.useRealTimers();
1558
+ }
1613
1559
  });
1614
1560
 
1615
- it('should proceed with preparation when Claude usage is under custom threshold', async () => {
1616
- mockClaudeRepository.getUsage.mockResolvedValue([
1617
- { hour: 5, utilizationPercentage: 75, resetsAt: new Date() },
1618
- ]);
1561
+ it('should skip issues from non-allowed authors', async () => {
1562
+ const issueFromAllowedAuthor = createMockIssue({
1563
+ url: 'https://github.com/user/repo/issues/1',
1564
+ title: 'Issue from allowed author',
1565
+ labels: [],
1566
+ status: 'Awaiting Workspace',
1567
+ author: 'user1',
1568
+ });
1569
+ const issueFromNonAllowedAuthor = createMockIssue({
1570
+ url: 'https://github.com/user/repo/issues/2',
1571
+ title: 'Issue from non-allowed author',
1572
+ labels: [],
1573
+ status: 'Awaiting Workspace',
1574
+ author: 'user3',
1575
+ });
1619
1576
 
1620
- const awaitingIssues: Issue[] = [
1621
- createMockIssue({
1622
- url: 'url1',
1623
- title: 'Issue 1',
1624
- labels: ['category:impl'],
1625
- status: 'Awaiting Workspace',
1626
- }),
1627
- ];
1628
1577
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1629
1578
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1630
- createMockStoryObjectMap(awaitingIssues),
1579
+ createMockStoryObjectMap([
1580
+ issueFromAllowedAuthor,
1581
+ issueFromNonAllowedAuthor,
1582
+ ]),
1631
1583
  );
1632
1584
  mockLocalCommandRunner.runCommand.mockResolvedValue({
1633
1585
  stdout: '',
@@ -1642,35 +1594,39 @@ describe('StartPreparationUseCase', () => {
1642
1594
  defaultLlmAgentName: null,
1643
1595
  configFilePath: '/path/to/config.yml',
1644
1596
  maximumPreparingIssuesCount: null,
1645
- utilizationPercentageThreshold: 80,
1646
- allowedIssueAuthors: null,
1597
+ utilizationPercentageThreshold: 90,
1598
+ allowedIssueAuthors: ['user1', 'user2'],
1647
1599
  codexHomeCandidates: null,
1648
1600
  allowIssueCacheMinutes: 0,
1649
1601
  });
1650
1602
 
1651
1603
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1604
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1605
+ url: 'https://github.com/user/repo/issues/1',
1606
+ status: 'Preparation',
1607
+ });
1652
1608
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1653
1609
  });
1654
1610
 
1655
- it('should skip issues that have dependedIssueUrls', async () => {
1656
- const issueWithDependency = createMockIssue({
1611
+ it('should process all issues when allowedIssueAuthors is null', async () => {
1612
+ const issue1 = createMockIssue({
1657
1613
  url: 'https://github.com/user/repo/issues/1',
1658
- title: 'Issue with dependency',
1614
+ title: 'Issue 1',
1659
1615
  labels: [],
1660
1616
  status: 'Awaiting Workspace',
1661
- dependedIssueUrls: ['https://github.com/user/repo/issues/2'],
1617
+ author: 'user1',
1662
1618
  });
1663
- const issueWithoutDependency = createMockIssue({
1664
- url: 'https://github.com/user/repo/issues/3',
1665
- title: 'Issue without dependency',
1619
+ const issue2 = createMockIssue({
1620
+ url: 'https://github.com/user/repo/issues/2',
1621
+ title: 'Issue 2',
1666
1622
  labels: [],
1667
1623
  status: 'Awaiting Workspace',
1668
- dependedIssueUrls: [],
1624
+ author: 'user2',
1669
1625
  });
1670
1626
 
1671
1627
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1672
1628
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1673
- createMockStoryObjectMap([issueWithDependency, issueWithoutDependency]),
1629
+ createMockStoryObjectMap([issue1, issue2]),
1674
1630
  );
1675
1631
  mockLocalCommandRunner.runCommand.mockResolvedValue({
1676
1632
  stdout: '',
@@ -1691,291 +1647,462 @@ describe('StartPreparationUseCase', () => {
1691
1647
  allowIssueCacheMinutes: 0,
1692
1648
  });
1693
1649
 
1694
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1695
- expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1696
- url: 'https://github.com/user/repo/issues/3',
1697
- status: 'Preparation',
1698
- });
1699
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1650
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(2);
1651
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(2);
1700
1652
  });
1701
1653
 
1702
- it('should skip issues where nextActionHour is in the future', async () => {
1703
- jest.useFakeTimers();
1704
- try {
1705
- jest.setSystemTime(new Date('2024-01-01T10:00:00'));
1706
-
1707
- const issueWithFutureNextActionHour = createMockIssue({
1708
- url: 'https://github.com/user/repo/issues/1',
1709
- title: 'Issue with future next action hour',
1710
- labels: [],
1711
- status: 'Awaiting Workspace',
1712
- nextActionHour: 15,
1713
- });
1714
- const issueWithoutNextActionHour = createMockIssue({
1715
- url: 'https://github.com/user/repo/issues/2',
1716
- title: 'Issue without next action hour',
1717
- labels: [],
1718
- status: 'Awaiting Workspace',
1719
- nextActionHour: null,
1720
- });
1654
+ it('should skip issues with empty author when allowedIssueAuthors is configured (deny-by-default)', async () => {
1655
+ const issueWithEmptyAuthor = createMockIssue({
1656
+ url: 'https://github.com/user/repo/issues/1',
1657
+ title: 'Issue with empty author',
1658
+ labels: [],
1659
+ status: 'Awaiting Workspace',
1660
+ author: '',
1661
+ });
1662
+ const issueWithKnownAuthor = createMockIssue({
1663
+ url: 'https://github.com/user/repo/issues/2',
1664
+ title: 'Issue with known author',
1665
+ labels: [],
1666
+ status: 'Awaiting Workspace',
1667
+ author: 'user1',
1668
+ number: 2,
1669
+ itemId: 'item-2',
1670
+ });
1721
1671
 
1722
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1723
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1724
- createMockStoryObjectMap([
1725
- issueWithFutureNextActionHour,
1726
- issueWithoutNextActionHour,
1727
- ]),
1728
- );
1729
- mockLocalCommandRunner.runCommand.mockResolvedValue({
1730
- stdout: '',
1731
- stderr: '',
1732
- exitCode: 0,
1733
- });
1672
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1673
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1674
+ createMockStoryObjectMap([issueWithEmptyAuthor, issueWithKnownAuthor]),
1675
+ );
1676
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1677
+ stdout: '',
1678
+ stderr: '',
1679
+ exitCode: 0,
1680
+ });
1734
1681
 
1735
- await useCase.run({
1736
- projectUrl: 'https://github.com/user/repo',
1737
- defaultAgentName: 'agent1',
1738
- defaultLlmModelName: 'claude-sonnet-4-6',
1739
- defaultLlmAgentName: null,
1740
- configFilePath: '/path/to/config.yml',
1741
- maximumPreparingIssuesCount: null,
1742
- utilizationPercentageThreshold: 90,
1743
- allowedIssueAuthors: null,
1744
- codexHomeCandidates: null,
1745
- allowIssueCacheMinutes: 0,
1746
- });
1682
+ await useCase.run({
1683
+ projectUrl: 'https://github.com/user/repo',
1684
+ defaultAgentName: 'agent1',
1685
+ defaultLlmModelName: 'claude-sonnet-4-6',
1686
+ defaultLlmAgentName: null,
1687
+ configFilePath: '/path/to/config.yml',
1688
+ maximumPreparingIssuesCount: null,
1689
+ utilizationPercentageThreshold: 90,
1690
+ allowedIssueAuthors: ['user1', 'user2'],
1691
+ codexHomeCandidates: null,
1692
+ allowIssueCacheMinutes: 0,
1693
+ });
1747
1694
 
1748
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1749
- expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1750
- url: 'https://github.com/user/repo/issues/2',
1751
- status: 'Preparation',
1752
- });
1753
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1754
- } finally {
1755
- jest.useRealTimers();
1756
- }
1695
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1696
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1697
+ url: 'https://github.com/user/repo/issues/2',
1698
+ });
1699
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1757
1700
  });
1758
1701
 
1759
- it('should skip issues where nextActionDate is tomorrow or more future', async () => {
1760
- jest.useFakeTimers();
1761
- try {
1762
- jest.setSystemTime(new Date('2024-01-15T10:00:00'));
1763
-
1764
- const issueWithFutureNextActionDate = createMockIssue({
1765
- url: 'https://github.com/user/repo/issues/1',
1766
- title: 'Issue with future next action date',
1767
- labels: [],
1768
- status: 'Awaiting Workspace',
1769
- nextActionDate: new Date('2024-01-16'),
1770
- });
1771
- const issueWithoutNextActionDate = createMockIssue({
1772
- url: 'https://github.com/user/repo/issues/2',
1773
- title: 'Issue without next action date',
1774
- labels: [],
1775
- status: 'Awaiting Workspace',
1776
- nextActionDate: null,
1777
- });
1702
+ it('should skip issue with empty author when allowedIssueAuthors is set', async () => {
1703
+ const issueWithEmptyAuthor = createMockIssue({
1704
+ url: 'https://github.com/user/repo/issues/1',
1705
+ title: 'Issue with empty author',
1706
+ labels: [],
1707
+ status: 'Awaiting Workspace',
1708
+ author: '',
1709
+ });
1778
1710
 
1779
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1780
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1781
- createMockStoryObjectMap([
1782
- issueWithFutureNextActionDate,
1783
- issueWithoutNextActionDate,
1784
- ]),
1785
- );
1786
- mockLocalCommandRunner.runCommand.mockResolvedValue({
1787
- stdout: '',
1788
- stderr: '',
1789
- exitCode: 0,
1790
- });
1711
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1712
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1713
+ createMockStoryObjectMap([issueWithEmptyAuthor]),
1714
+ );
1715
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1716
+ stdout: '',
1717
+ stderr: '',
1718
+ exitCode: 0,
1719
+ });
1791
1720
 
1792
- await useCase.run({
1793
- projectUrl: 'https://github.com/user/repo',
1794
- defaultAgentName: 'agent1',
1795
- defaultLlmModelName: 'claude-sonnet-4-6',
1796
- defaultLlmAgentName: null,
1797
- configFilePath: '/path/to/config.yml',
1798
- maximumPreparingIssuesCount: null,
1799
- utilizationPercentageThreshold: 90,
1800
- allowedIssueAuthors: null,
1801
- codexHomeCandidates: null,
1802
- allowIssueCacheMinutes: 0,
1803
- });
1721
+ await useCase.run({
1722
+ projectUrl: 'https://github.com/user/repo',
1723
+ defaultAgentName: 'agent1',
1724
+ defaultLlmModelName: 'claude-sonnet-4-6',
1725
+ defaultLlmAgentName: null,
1726
+ configFilePath: '/path/to/config.yml',
1727
+ maximumPreparingIssuesCount: null,
1728
+ utilizationPercentageThreshold: 90,
1729
+ allowedIssueAuthors: ['user1'],
1730
+ codexHomeCandidates: null,
1731
+ allowIssueCacheMinutes: 0,
1732
+ });
1804
1733
 
1805
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1806
- expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1807
- url: 'https://github.com/user/repo/issues/2',
1808
- status: 'Preparation',
1809
- });
1810
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1811
- } finally {
1812
- jest.useRealTimers();
1813
- }
1734
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
1735
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
1814
1736
  });
1815
1737
 
1816
- it('should not skip issues where nextActionDate is today', async () => {
1817
- jest.useFakeTimers();
1818
- try {
1819
- jest.setSystemTime(new Date('2024-01-15T10:00:00'));
1820
-
1821
- const issueWithTodayNextActionDate = createMockIssue({
1822
- url: 'https://github.com/user/repo/issues/1',
1823
- title: 'Issue with today next action date',
1824
- labels: [],
1738
+ it('should not pass --codexHome when codexHomeCandidates is null', async () => {
1739
+ const awaitingIssues: Issue[] = [
1740
+ createMockIssue({
1741
+ url: 'url1',
1742
+ title: 'Issue 1',
1743
+ labels: ['category:impl'],
1825
1744
  status: 'Awaiting Workspace',
1826
- nextActionDate: new Date('2024-01-15'),
1827
- });
1745
+ }),
1746
+ ];
1747
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1748
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1749
+ createMockStoryObjectMap(awaitingIssues),
1750
+ );
1751
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1752
+ stdout: '',
1753
+ stderr: '',
1754
+ exitCode: 0,
1755
+ });
1828
1756
 
1829
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1830
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1831
- createMockStoryObjectMap([issueWithTodayNextActionDate]),
1832
- );
1833
- mockLocalCommandRunner.runCommand.mockResolvedValue({
1834
- stdout: '',
1835
- stderr: '',
1836
- exitCode: 0,
1837
- });
1757
+ await useCase.run({
1758
+ projectUrl: 'https://github.com/user/repo',
1759
+ defaultAgentName: 'agent1',
1760
+ defaultLlmModelName: 'claude-opus',
1761
+ defaultLlmAgentName: null,
1762
+ configFilePath: '/path/to/config.yml',
1763
+ maximumPreparingIssuesCount: null,
1764
+ utilizationPercentageThreshold: 90,
1765
+ allowedIssueAuthors: null,
1766
+ codexHomeCandidates: null,
1767
+ allowIssueCacheMinutes: 0,
1768
+ });
1838
1769
 
1839
- await useCase.run({
1840
- projectUrl: 'https://github.com/user/repo',
1841
- defaultAgentName: 'agent1',
1842
- defaultLlmModelName: 'claude-sonnet-4-6',
1843
- defaultLlmAgentName: null,
1844
- configFilePath: '/path/to/config.yml',
1845
- maximumPreparingIssuesCount: null,
1846
- utilizationPercentageThreshold: 90,
1847
- allowedIssueAuthors: null,
1848
- codexHomeCandidates: null,
1849
- allowIssueCacheMinutes: 0,
1850
- });
1770
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1771
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
1772
+ 'aw',
1773
+ [
1774
+ 'url1',
1775
+ 'impl',
1776
+ 'claude-opus',
1777
+ '--configFilePath',
1778
+ '/path/to/config.yml',
1779
+ '--branch',
1780
+ 'i1',
1781
+ ],
1782
+ ]);
1783
+ });
1851
1784
 
1852
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1853
- expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1854
- url: 'https://github.com/user/repo/issues/1',
1855
- status: 'Preparation',
1856
- });
1857
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1858
- } finally {
1859
- jest.useRealTimers();
1860
- }
1785
+ it('should not pass --codexHome when codexHomeCandidates is empty array', async () => {
1786
+ const awaitingIssues: Issue[] = [
1787
+ createMockIssue({
1788
+ url: 'url1',
1789
+ title: 'Issue 1',
1790
+ labels: ['category:impl'],
1791
+ status: 'Awaiting Workspace',
1792
+ }),
1793
+ ];
1794
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1795
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1796
+ createMockStoryObjectMap(awaitingIssues),
1797
+ );
1798
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1799
+ stdout: '',
1800
+ stderr: '',
1801
+ exitCode: 0,
1802
+ });
1803
+
1804
+ await useCase.run({
1805
+ projectUrl: 'https://github.com/user/repo',
1806
+ defaultAgentName: 'agent1',
1807
+ defaultLlmModelName: 'claude-opus',
1808
+ defaultLlmAgentName: null,
1809
+ configFilePath: '/path/to/config.yml',
1810
+ maximumPreparingIssuesCount: null,
1811
+ utilizationPercentageThreshold: 90,
1812
+ allowedIssueAuthors: null,
1813
+ codexHomeCandidates: [],
1814
+ allowIssueCacheMinutes: 0,
1815
+ });
1816
+
1817
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1818
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
1819
+ 'aw',
1820
+ [
1821
+ 'url1',
1822
+ 'impl',
1823
+ 'claude-opus',
1824
+ '--configFilePath',
1825
+ '/path/to/config.yml',
1826
+ '--branch',
1827
+ 'i1',
1828
+ ],
1829
+ ]);
1861
1830
  });
1862
1831
 
1863
- it('should not skip issues where nextActionDate is in the past', async () => {
1864
- jest.useFakeTimers();
1865
- try {
1866
- jest.setSystemTime(new Date('2024-01-15T10:00:00'));
1832
+ it('should pass --codexHome with the candidate when codexHomeCandidates has one entry', async () => {
1833
+ const awaitingIssues: Issue[] = [
1834
+ createMockIssue({
1835
+ url: 'url1',
1836
+ title: 'Issue 1',
1837
+ labels: ['category:impl'],
1838
+ status: 'Awaiting Workspace',
1839
+ }),
1840
+ ];
1841
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1842
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1843
+ createMockStoryObjectMap(awaitingIssues),
1844
+ );
1845
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1846
+ stdout: '',
1847
+ stderr: '',
1848
+ exitCode: 0,
1849
+ });
1867
1850
 
1868
- const issueWithPastNextActionDate = createMockIssue({
1869
- url: 'https://github.com/user/repo/issues/1',
1870
- title: 'Issue with past next action date',
1871
- labels: [],
1851
+ await useCase.run({
1852
+ projectUrl: 'https://github.com/user/repo',
1853
+ defaultAgentName: 'agent1',
1854
+ defaultLlmModelName: 'claude-opus',
1855
+ defaultLlmAgentName: null,
1856
+ configFilePath: '/path/to/config.yml',
1857
+ maximumPreparingIssuesCount: null,
1858
+ utilizationPercentageThreshold: 90,
1859
+ allowedIssueAuthors: null,
1860
+ codexHomeCandidates: ['.codex-dev1'],
1861
+ allowIssueCacheMinutes: 0,
1862
+ });
1863
+
1864
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1865
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
1866
+ 'aw',
1867
+ [
1868
+ 'url1',
1869
+ 'impl',
1870
+ 'claude-opus',
1871
+ '--configFilePath',
1872
+ '/path/to/config.yml',
1873
+ '--branch',
1874
+ 'i1',
1875
+ '--codexHome',
1876
+ '.codex-dev1',
1877
+ ],
1878
+ ]);
1879
+ });
1880
+
1881
+ it('should cycle through codexHomeCandidates across multiple issues', async () => {
1882
+ const awaitingIssues: Issue[] = [
1883
+ createMockIssue({
1884
+ url: 'url1',
1885
+ title: 'Issue 1',
1886
+ labels: ['category:impl'],
1872
1887
  status: 'Awaiting Workspace',
1873
- nextActionDate: new Date('2024-01-14'),
1874
- });
1888
+ number: 1,
1889
+ itemId: 'item-1',
1890
+ }),
1891
+ createMockIssue({
1892
+ url: 'url2',
1893
+ title: 'Issue 2',
1894
+ labels: ['category:impl'],
1895
+ status: 'Awaiting Workspace',
1896
+ number: 2,
1897
+ itemId: 'item-2',
1898
+ }),
1899
+ createMockIssue({
1900
+ url: 'url3',
1901
+ title: 'Issue 3',
1902
+ labels: ['category:impl'],
1903
+ status: 'Awaiting Workspace',
1904
+ number: 3,
1905
+ itemId: 'item-3',
1906
+ }),
1907
+ ];
1908
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1909
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1910
+ createMockStoryObjectMap(awaitingIssues),
1911
+ );
1912
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1913
+ stdout: '',
1914
+ stderr: '',
1915
+ exitCode: 0,
1916
+ });
1875
1917
 
1876
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1877
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1878
- createMockStoryObjectMap([issueWithPastNextActionDate]),
1879
- );
1880
- mockLocalCommandRunner.runCommand.mockResolvedValue({
1881
- stdout: '',
1882
- stderr: '',
1883
- exitCode: 0,
1884
- });
1918
+ await useCase.run({
1919
+ projectUrl: 'https://github.com/user/repo',
1920
+ defaultAgentName: 'agent1',
1921
+ defaultLlmModelName: 'claude-opus',
1922
+ defaultLlmAgentName: null,
1923
+ configFilePath: '/path/to/config.yml',
1924
+ maximumPreparingIssuesCount: null,
1925
+ utilizationPercentageThreshold: 90,
1926
+ allowedIssueAuthors: null,
1927
+ codexHomeCandidates: ['.codex-dev1', '.codex-dev2'],
1928
+ allowIssueCacheMinutes: 0,
1929
+ });
1885
1930
 
1886
- await useCase.run({
1887
- projectUrl: 'https://github.com/user/repo',
1888
- defaultAgentName: 'agent1',
1889
- defaultLlmModelName: 'claude-sonnet-4-6',
1890
- defaultLlmAgentName: null,
1891
- configFilePath: '/path/to/config.yml',
1892
- maximumPreparingIssuesCount: null,
1893
- utilizationPercentageThreshold: 90,
1894
- allowedIssueAuthors: null,
1895
- codexHomeCandidates: null,
1896
- allowIssueCacheMinutes: 0,
1897
- });
1931
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
1932
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][1]).toContain(
1933
+ '--codexHome',
1934
+ );
1935
+ expect(
1936
+ mockLocalCommandRunner.runCommand.mock.calls[0][1][
1937
+ mockLocalCommandRunner.runCommand.mock.calls[0][1].indexOf(
1938
+ '--codexHome',
1939
+ ) + 1
1940
+ ],
1941
+ ).toBe('.codex-dev1');
1942
+ expect(mockLocalCommandRunner.runCommand.mock.calls[1][1]).toContain(
1943
+ '--codexHome',
1944
+ );
1945
+ expect(
1946
+ mockLocalCommandRunner.runCommand.mock.calls[1][1][
1947
+ mockLocalCommandRunner.runCommand.mock.calls[1][1].indexOf(
1948
+ '--codexHome',
1949
+ ) + 1
1950
+ ],
1951
+ ).toBe('.codex-dev2');
1952
+ expect(mockLocalCommandRunner.runCommand.mock.calls[2][1]).toContain(
1953
+ '--codexHome',
1954
+ );
1955
+ expect(
1956
+ mockLocalCommandRunner.runCommand.mock.calls[2][1][
1957
+ mockLocalCommandRunner.runCommand.mock.calls[2][1].indexOf(
1958
+ '--codexHome',
1959
+ ) + 1
1960
+ ],
1961
+ ).toBe('.codex-dev1');
1962
+ });
1898
1963
 
1899
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1900
- expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1901
- url: 'https://github.com/user/repo/issues/1',
1902
- status: 'Preparation',
1903
- });
1904
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1905
- } finally {
1906
- jest.useRealTimers();
1907
- }
1964
+ it('should persist Preparation status via updateStatus with resolved status option id (regression for issue #519)', async () => {
1965
+ const awaitingIssue = createMockIssue({
1966
+ url: 'https://github.com/user/repo/issues/519',
1967
+ title: 'Regression issue',
1968
+ labels: ['category:impl'],
1969
+ status: 'Awaiting Workspace',
1970
+ itemId: 'item-regression',
1971
+ });
1972
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1973
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1974
+ createMockStoryObjectMap([awaitingIssue]),
1975
+ );
1976
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1977
+ stdout: '',
1978
+ stderr: '',
1979
+ exitCode: 0,
1980
+ });
1981
+
1982
+ await useCase.run({
1983
+ projectUrl: 'https://github.com/user/repo',
1984
+ defaultAgentName: 'agent1',
1985
+ defaultLlmModelName: 'claude-opus',
1986
+ defaultLlmAgentName: null,
1987
+ configFilePath: '/path/to/config.yml',
1988
+ maximumPreparingIssuesCount: null,
1989
+ utilizationPercentageThreshold: 90,
1990
+ allowedIssueAuthors: null,
1991
+ codexHomeCandidates: null,
1992
+ allowIssueCacheMinutes: 0,
1993
+ });
1994
+
1995
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1996
+ expect(mockIssueRepository.updateStatus.mock.calls[0][0]).toBe(mockProject);
1997
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1998
+ url: 'https://github.com/user/repo/issues/519',
1999
+ itemId: 'item-regression',
2000
+ });
2001
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('2');
2002
+ const updateStatusCallOrder =
2003
+ mockIssueRepository.updateStatus.mock.invocationCallOrder[0];
2004
+ const runCommandCallOrder =
2005
+ mockLocalCommandRunner.runCommand.mock.invocationCallOrder[0];
2006
+ expect(updateStatusCallOrder).toBeLessThan(runCommandCallOrder);
1908
2007
  });
1909
2008
 
1910
- it('should not skip issues where nextActionHour is in the past or current hour', async () => {
1911
- jest.useFakeTimers();
1912
- try {
1913
- jest.setSystemTime(new Date('2024-01-01T15:00:00'));
2009
+ it('should return early and log an error when preparationStatus option is not in the project', async () => {
2010
+ const projectWithoutPreparation: Project = {
2011
+ ...createMockProject(),
2012
+ status: {
2013
+ name: 'Status',
2014
+ fieldId: 'status-field-id',
2015
+ statuses: [
2016
+ {
2017
+ id: '1',
2018
+ name: 'Awaiting Workspace',
2019
+ color: 'GRAY',
2020
+ description: '',
2021
+ },
2022
+ { id: '3', name: 'Done', color: 'GREEN', description: '' },
2023
+ ],
2024
+ },
2025
+ };
2026
+ const awaitingIssue = createMockIssue({
2027
+ url: 'url-missing-option',
2028
+ title: 'Missing Preparation Option',
2029
+ labels: ['category:impl'],
2030
+ status: 'Awaiting Workspace',
2031
+ });
2032
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithoutPreparation);
2033
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2034
+ createMockStoryObjectMap([awaitingIssue]),
2035
+ );
2036
+ const consoleErrorSpy = jest
2037
+ .spyOn(console, 'error')
2038
+ .mockImplementation(() => {});
2039
+
2040
+ await useCase.run({
2041
+ projectUrl: 'https://github.com/user/repo',
2042
+ defaultAgentName: 'agent1',
2043
+ defaultLlmModelName: 'claude-opus',
2044
+ defaultLlmAgentName: null,
2045
+ configFilePath: '/path/to/config.yml',
2046
+ maximumPreparingIssuesCount: null,
2047
+ utilizationPercentageThreshold: 90,
2048
+ allowedIssueAuthors: null,
2049
+ codexHomeCandidates: null,
2050
+ allowIssueCacheMinutes: 0,
2051
+ });
1914
2052
 
1915
- const issueWithPastNextActionHour = createMockIssue({
1916
- url: 'https://github.com/user/repo/issues/1',
1917
- title: 'Issue with past next action hour',
1918
- labels: [],
1919
- status: 'Awaiting Workspace',
1920
- nextActionHour: 10,
1921
- });
2053
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
2054
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
2055
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
2056
+ "Preparation status option 'Preparation' not found in project.",
2057
+ );
2058
+ consoleErrorSpy.mockRestore();
2059
+ });
1922
2060
 
1923
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1924
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1925
- createMockStoryObjectMap([issueWithPastNextActionHour]),
1926
- );
1927
- mockLocalCommandRunner.runCommand.mockResolvedValue({
1928
- stdout: '',
1929
- stderr: '',
1930
- exitCode: 0,
1931
- });
2061
+ it('should pass allowIssueCacheMinutes to getStoryObjectMap', async () => {
2062
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2063
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2064
+ createMockStoryObjectMap([]),
2065
+ );
1932
2066
 
1933
- await useCase.run({
1934
- projectUrl: 'https://github.com/user/repo',
1935
- defaultAgentName: 'agent1',
1936
- defaultLlmModelName: 'claude-sonnet-4-6',
1937
- defaultLlmAgentName: null,
1938
- configFilePath: '/path/to/config.yml',
1939
- maximumPreparingIssuesCount: null,
1940
- utilizationPercentageThreshold: 90,
1941
- allowedIssueAuthors: null,
1942
- codexHomeCandidates: null,
1943
- allowIssueCacheMinutes: 0,
1944
- });
2067
+ await useCase.run({
2068
+ projectUrl: 'https://github.com/user/repo',
2069
+ defaultAgentName: 'agent1',
2070
+ defaultLlmModelName: null,
2071
+ defaultLlmAgentName: null,
2072
+ configFilePath: '/path/to/config.yml',
2073
+ maximumPreparingIssuesCount: null,
2074
+ utilizationPercentageThreshold: 90,
2075
+ allowedIssueAuthors: null,
2076
+ codexHomeCandidates: null,
2077
+ allowIssueCacheMinutes: 5,
2078
+ });
1945
2079
 
1946
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1947
- expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
1948
- url: 'https://github.com/user/repo/issues/1',
1949
- status: 'Preparation',
1950
- });
1951
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1952
- } finally {
1953
- jest.useRealTimers();
1954
- }
2080
+ expect(mockIssueRepository.getStoryObjectMap).toHaveBeenCalledWith(
2081
+ expect.objectContaining({ id: 'project-1' }),
2082
+ 5,
2083
+ );
1955
2084
  });
1956
2085
 
1957
- it('should skip issues from non-allowed authors', async () => {
1958
- const issueFromAllowedAuthor = createMockIssue({
2086
+ it('should skip closed issues in awaiting workspace status', async () => {
2087
+ const closedIssue = createMockIssue({
1959
2088
  url: 'https://github.com/user/repo/issues/1',
1960
- title: 'Issue from allowed author',
2089
+ title: 'Closed issue',
1961
2090
  labels: [],
1962
2091
  status: 'Awaiting Workspace',
1963
- author: 'user1',
2092
+ isClosed: true,
1964
2093
  });
1965
- const issueFromNonAllowedAuthor = createMockIssue({
2094
+ const openIssue = createMockIssue({
1966
2095
  url: 'https://github.com/user/repo/issues/2',
1967
- title: 'Issue from non-allowed author',
2096
+ number: 2,
2097
+ title: 'Open issue',
1968
2098
  labels: [],
1969
2099
  status: 'Awaiting Workspace',
1970
- author: 'user3',
2100
+ isClosed: false,
1971
2101
  });
1972
2102
 
1973
2103
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1974
2104
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1975
- createMockStoryObjectMap([
1976
- issueFromAllowedAuthor,
1977
- issueFromNonAllowedAuthor,
1978
- ]),
2105
+ createMockStoryObjectMap([closedIssue, openIssue]),
1979
2106
  );
1980
2107
  mockLocalCommandRunner.runCommand.mockResolvedValue({
1981
2108
  stdout: '',
@@ -1991,49 +2118,49 @@ describe('StartPreparationUseCase', () => {
1991
2118
  configFilePath: '/path/to/config.yml',
1992
2119
  maximumPreparingIssuesCount: null,
1993
2120
  utilizationPercentageThreshold: 90,
1994
- allowedIssueAuthors: ['user1', 'user2'],
2121
+ allowedIssueAuthors: null,
1995
2122
  codexHomeCandidates: null,
1996
2123
  allowIssueCacheMinutes: 0,
1997
2124
  });
1998
2125
 
1999
2126
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
2000
2127
  expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
2001
- url: 'https://github.com/user/repo/issues/1',
2002
- status: 'Preparation',
2128
+ url: 'https://github.com/user/repo/issues/2',
2003
2129
  });
2004
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2005
2130
  });
2006
2131
 
2007
- it('should process all issues when allowedIssueAuthors is null', async () => {
2008
- const issue1 = createMockIssue({
2009
- url: 'https://github.com/user/repo/issues/1',
2132
+ it('should pass CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_BASE_URL to runCommand when tokens are available', async () => {
2133
+ const awaitingIssue = createMockIssue({
2134
+ url: 'url1',
2010
2135
  title: 'Issue 1',
2011
- labels: [],
2012
- status: 'Awaiting Workspace',
2013
- author: 'user1',
2014
- });
2015
- const issue2 = createMockIssue({
2016
- url: 'https://github.com/user/repo/issues/2',
2017
- title: 'Issue 2',
2018
- labels: [],
2136
+ labels: ['category:impl'],
2019
2137
  status: 'Awaiting Workspace',
2020
- author: 'user2',
2138
+ number: 1,
2139
+ itemId: 'item-1',
2021
2140
  });
2022
-
2023
2141
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2024
2142
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2025
- createMockStoryObjectMap([issue1, issue2]),
2143
+ createMockStoryObjectMap([awaitingIssue]),
2026
2144
  );
2027
2145
  mockLocalCommandRunner.runCommand.mockResolvedValue({
2028
2146
  stdout: '',
2029
2147
  stderr: '',
2030
2148
  exitCode: 0,
2031
2149
  });
2150
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2151
+ {
2152
+ token: 'token-a',
2153
+ fiveHourUtilization: 0,
2154
+ blocked: false,
2155
+ rejected: false,
2156
+ modelWeeklyLimits: {},
2157
+ },
2158
+ ]);
2032
2159
 
2033
2160
  await useCase.run({
2034
2161
  projectUrl: 'https://github.com/user/repo',
2035
2162
  defaultAgentName: 'agent1',
2036
- defaultLlmModelName: 'claude-sonnet-4-6',
2163
+ defaultLlmModelName: 'claude-opus',
2037
2164
  defaultLlmAgentName: null,
2038
2165
  configFilePath: '/path/to/config.yml',
2039
2166
  maximumPreparingIssuesCount: null,
@@ -2043,112 +2170,184 @@ describe('StartPreparationUseCase', () => {
2043
2170
  allowIssueCacheMinutes: 0,
2044
2171
  });
2045
2172
 
2046
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(2);
2047
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(2);
2048
- });
2049
-
2050
- it('should skip issues with empty author when allowedIssueAuthors is configured (deny-by-default)', async () => {
2051
- const issueWithEmptyAuthor = createMockIssue({
2052
- url: 'https://github.com/user/repo/issues/1',
2053
- title: 'Issue with empty author',
2054
- labels: [],
2055
- status: 'Awaiting Workspace',
2056
- author: '',
2057
- });
2058
- const issueWithKnownAuthor = createMockIssue({
2059
- url: 'https://github.com/user/repo/issues/2',
2060
- title: 'Issue with known author',
2061
- labels: [],
2062
- status: 'Awaiting Workspace',
2063
- author: 'user1',
2064
- number: 2,
2065
- itemId: 'item-2',
2173
+ expect(
2174
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mock.calls,
2175
+ ).toHaveLength(1);
2176
+ expect(
2177
+ mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
2178
+ ).toHaveLength(1);
2179
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2180
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2181
+ env: {
2182
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
2183
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2184
+ },
2066
2185
  });
2186
+ });
2067
2187
 
2188
+ it('should rotate Claude OAuth tokens round-robin across multiple awaiting issues', async () => {
2189
+ const awaitingIssues: Issue[] = [
2190
+ createMockIssue({
2191
+ url: 'url1',
2192
+ title: 'Issue 1',
2193
+ labels: ['category:impl'],
2194
+ status: 'Awaiting Workspace',
2195
+ number: 1,
2196
+ itemId: 'item-1',
2197
+ }),
2198
+ createMockIssue({
2199
+ url: 'url2',
2200
+ title: 'Issue 2',
2201
+ labels: ['category:impl'],
2202
+ status: 'Awaiting Workspace',
2203
+ number: 2,
2204
+ itemId: 'item-2',
2205
+ }),
2206
+ createMockIssue({
2207
+ url: 'url3',
2208
+ title: 'Issue 3',
2209
+ labels: ['category:impl'],
2210
+ status: 'Awaiting Workspace',
2211
+ number: 3,
2212
+ itemId: 'item-3',
2213
+ }),
2214
+ ];
2068
2215
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2069
2216
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2070
- createMockStoryObjectMap([issueWithEmptyAuthor, issueWithKnownAuthor]),
2217
+ createMockStoryObjectMap(awaitingIssues),
2071
2218
  );
2072
2219
  mockLocalCommandRunner.runCommand.mockResolvedValue({
2073
2220
  stdout: '',
2074
2221
  stderr: '',
2075
2222
  exitCode: 0,
2076
2223
  });
2224
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2225
+ {
2226
+ token: 'token-a',
2227
+ fiveHourUtilization: 0,
2228
+ blocked: false,
2229
+ rejected: false,
2230
+ modelWeeklyLimits: {},
2231
+ },
2232
+ {
2233
+ token: 'token-b',
2234
+ fiveHourUtilization: 0,
2235
+ blocked: false,
2236
+ rejected: false,
2237
+ modelWeeklyLimits: {},
2238
+ },
2239
+ ]);
2077
2240
 
2078
2241
  await useCase.run({
2079
2242
  projectUrl: 'https://github.com/user/repo',
2080
2243
  defaultAgentName: 'agent1',
2081
- defaultLlmModelName: 'claude-sonnet-4-6',
2244
+ defaultLlmModelName: 'claude-opus',
2082
2245
  defaultLlmAgentName: null,
2083
2246
  configFilePath: '/path/to/config.yml',
2084
2247
  maximumPreparingIssuesCount: null,
2085
2248
  utilizationPercentageThreshold: 90,
2086
- allowedIssueAuthors: ['user1', 'user2'],
2249
+ allowedIssueAuthors: null,
2087
2250
  codexHomeCandidates: null,
2088
2251
  allowIssueCacheMinutes: 0,
2089
2252
  });
2090
2253
 
2091
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
2092
- expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
2093
- url: 'https://github.com/user/repo/issues/2',
2254
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
2255
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toMatchObject({
2256
+ env: {
2257
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
2258
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2259
+ },
2260
+ });
2261
+ expect(mockLocalCommandRunner.runCommand.mock.calls[1][2]).toMatchObject({
2262
+ env: {
2263
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-b',
2264
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2265
+ },
2266
+ });
2267
+ expect(mockLocalCommandRunner.runCommand.mock.calls[2][2]).toMatchObject({
2268
+ env: {
2269
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
2270
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2271
+ },
2094
2272
  });
2095
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2096
2273
  });
2097
2274
 
2098
- it('should skip issue with empty author when allowedIssueAuthors is set', async () => {
2099
- const issueWithEmptyAuthor = createMockIssue({
2100
- url: 'https://github.com/user/repo/issues/1',
2101
- title: 'Issue with empty author',
2102
- labels: [],
2275
+ it('should not inject env when no tokens are available', async () => {
2276
+ const awaitingIssue = createMockIssue({
2277
+ url: 'url1',
2278
+ title: 'Issue 1',
2279
+ labels: ['category:impl'],
2103
2280
  status: 'Awaiting Workspace',
2104
- author: '',
2281
+ number: 1,
2282
+ itemId: 'item-1',
2105
2283
  });
2106
-
2107
2284
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2108
2285
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2109
- createMockStoryObjectMap([issueWithEmptyAuthor]),
2286
+ createMockStoryObjectMap([awaitingIssue]),
2110
2287
  );
2111
2288
  mockLocalCommandRunner.runCommand.mockResolvedValue({
2112
2289
  stdout: '',
2113
2290
  stderr: '',
2114
2291
  exitCode: 0,
2115
2292
  });
2293
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue(
2294
+ [],
2295
+ );
2116
2296
 
2117
2297
  await useCase.run({
2118
2298
  projectUrl: 'https://github.com/user/repo',
2119
2299
  defaultAgentName: 'agent1',
2120
- defaultLlmModelName: 'claude-sonnet-4-6',
2300
+ defaultLlmModelName: 'claude-opus',
2121
2301
  defaultLlmAgentName: null,
2122
2302
  configFilePath: '/path/to/config.yml',
2123
2303
  maximumPreparingIssuesCount: null,
2124
2304
  utilizationPercentageThreshold: 90,
2125
- allowedIssueAuthors: ['user1'],
2305
+ allowedIssueAuthors: null,
2126
2306
  codexHomeCandidates: null,
2127
2307
  allowIssueCacheMinutes: 0,
2128
2308
  });
2129
2309
 
2130
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
2131
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
2310
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2311
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toBeUndefined();
2312
+ expect(
2313
+ mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
2314
+ ).toHaveLength(0);
2132
2315
  });
2133
2316
 
2134
- it('should not pass --codexHome when codexHomeCandidates is null', async () => {
2135
- const awaitingIssues: Issue[] = [
2136
- createMockIssue({
2137
- url: 'url1',
2138
- title: 'Issue 1',
2139
- labels: ['category:impl'],
2140
- status: 'Awaiting Workspace',
2141
- }),
2142
- ];
2317
+ it('should pick the least-utilized token first', async () => {
2318
+ const awaitingIssue = createMockIssue({
2319
+ url: 'url1',
2320
+ title: 'Issue 1',
2321
+ labels: ['category:impl'],
2322
+ status: 'Awaiting Workspace',
2323
+ number: 1,
2324
+ itemId: 'item-1',
2325
+ });
2143
2326
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2144
2327
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2145
- createMockStoryObjectMap(awaitingIssues),
2328
+ createMockStoryObjectMap([awaitingIssue]),
2146
2329
  );
2147
2330
  mockLocalCommandRunner.runCommand.mockResolvedValue({
2148
2331
  stdout: '',
2149
2332
  stderr: '',
2150
2333
  exitCode: 0,
2151
2334
  });
2335
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2336
+ {
2337
+ token: 'token-high',
2338
+ fiveHourUtilization: 0.8,
2339
+ blocked: false,
2340
+ rejected: false,
2341
+ modelWeeklyLimits: {},
2342
+ },
2343
+ {
2344
+ token: 'token-low',
2345
+ fiveHourUtilization: 0.1,
2346
+ blocked: false,
2347
+ rejected: false,
2348
+ modelWeeklyLimits: {},
2349
+ },
2350
+ ]);
2152
2351
 
2153
2352
  await useCase.run({
2154
2353
  projectUrl: 'https://github.com/user/repo',
@@ -2164,38 +2363,48 @@ describe('StartPreparationUseCase', () => {
2164
2363
  });
2165
2364
 
2166
2365
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2167
- expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
2168
- 'aw',
2169
- [
2170
- 'url1',
2171
- 'impl',
2172
- 'claude-opus',
2173
- '--configFilePath',
2174
- '/path/to/config.yml',
2175
- '--branch',
2176
- 'i1',
2177
- ],
2178
- ]);
2366
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2367
+ env: {
2368
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-low',
2369
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2370
+ },
2371
+ });
2179
2372
  });
2180
2373
 
2181
- it('should not pass --codexHome when codexHomeCandidates is empty array', async () => {
2182
- const awaitingIssues: Issue[] = [
2183
- createMockIssue({
2184
- url: 'url1',
2185
- title: 'Issue 1',
2186
- labels: ['category:impl'],
2187
- status: 'Awaiting Workspace',
2188
- }),
2189
- ];
2374
+ it('should exclude blocked tokens from rotation', async () => {
2375
+ const awaitingIssue = createMockIssue({
2376
+ url: 'url1',
2377
+ title: 'Issue 1',
2378
+ labels: ['category:impl'],
2379
+ status: 'Awaiting Workspace',
2380
+ number: 1,
2381
+ itemId: 'item-1',
2382
+ });
2190
2383
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2191
2384
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2192
- createMockStoryObjectMap(awaitingIssues),
2385
+ createMockStoryObjectMap([awaitingIssue]),
2193
2386
  );
2194
2387
  mockLocalCommandRunner.runCommand.mockResolvedValue({
2195
2388
  stdout: '',
2196
2389
  stderr: '',
2197
2390
  exitCode: 0,
2198
2391
  });
2392
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2393
+ {
2394
+ token: 'token-blocked',
2395
+ fiveHourUtilization: 0.05,
2396
+ blocked: true,
2397
+ rejected: false,
2398
+ modelWeeklyLimits: {},
2399
+ },
2400
+ {
2401
+ token: 'token-ok',
2402
+ fiveHourUtilization: 0.5,
2403
+ blocked: false,
2404
+ rejected: false,
2405
+ modelWeeklyLimits: {},
2406
+ },
2407
+ ]);
2199
2408
 
2200
2409
  await useCase.run({
2201
2410
  projectUrl: 'https://github.com/user/repo',
@@ -2206,43 +2415,56 @@ describe('StartPreparationUseCase', () => {
2206
2415
  maximumPreparingIssuesCount: null,
2207
2416
  utilizationPercentageThreshold: 90,
2208
2417
  allowedIssueAuthors: null,
2209
- codexHomeCandidates: [],
2418
+ codexHomeCandidates: null,
2210
2419
  allowIssueCacheMinutes: 0,
2211
2420
  });
2212
2421
 
2213
2422
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2214
- expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
2215
- 'aw',
2216
- [
2217
- 'url1',
2218
- 'impl',
2219
- 'claude-opus',
2220
- '--configFilePath',
2221
- '/path/to/config.yml',
2222
- '--branch',
2223
- 'i1',
2224
- ],
2225
- ]);
2423
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2424
+ env: {
2425
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
2426
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2427
+ },
2428
+ });
2226
2429
  });
2227
2430
 
2228
- it('should pass --codexHome with the candidate when codexHomeCandidates has one entry', async () => {
2229
- const awaitingIssues: Issue[] = [
2230
- createMockIssue({
2231
- url: 'url1',
2232
- title: 'Issue 1',
2233
- labels: ['category:impl'],
2234
- status: 'Awaiting Workspace',
2235
- }),
2236
- ];
2431
+ it('should skip preparation when every configured token is blocked', async () => {
2432
+ const awaitingIssue = createMockIssue({
2433
+ url: 'url1',
2434
+ title: 'Issue 1',
2435
+ labels: ['category:impl'],
2436
+ status: 'Awaiting Workspace',
2437
+ number: 1,
2438
+ itemId: 'item-1',
2439
+ });
2237
2440
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2238
2441
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2239
- createMockStoryObjectMap(awaitingIssues),
2442
+ createMockStoryObjectMap([awaitingIssue]),
2240
2443
  );
2241
2444
  mockLocalCommandRunner.runCommand.mockResolvedValue({
2242
2445
  stdout: '',
2243
2446
  stderr: '',
2244
2447
  exitCode: 0,
2245
2448
  });
2449
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2450
+ {
2451
+ token: 'token-a',
2452
+ fiveHourUtilization: 0.05,
2453
+ blocked: true,
2454
+ rejected: false,
2455
+ modelWeeklyLimits: {},
2456
+ },
2457
+ {
2458
+ token: 'token-b',
2459
+ fiveHourUtilization: 0.08,
2460
+ blocked: true,
2461
+ rejected: false,
2462
+ modelWeeklyLimits: {},
2463
+ },
2464
+ ]);
2465
+ const consoleWarnSpy = jest
2466
+ .spyOn(console, 'warn')
2467
+ .mockImplementation(() => {});
2246
2468
 
2247
2469
  await useCase.run({
2248
2470
  projectUrl: 'https://github.com/user/repo',
@@ -2253,28 +2475,23 @@ describe('StartPreparationUseCase', () => {
2253
2475
  maximumPreparingIssuesCount: null,
2254
2476
  utilizationPercentageThreshold: 90,
2255
2477
  allowedIssueAuthors: null,
2256
- codexHomeCandidates: ['.codex-dev1'],
2478
+ codexHomeCandidates: null,
2257
2479
  allowIssueCacheMinutes: 0,
2258
2480
  });
2259
2481
 
2260
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2261
- expect(mockLocalCommandRunner.runCommand.mock.calls[0]).toEqual([
2262
- 'aw',
2263
- [
2264
- 'url1',
2265
- 'impl',
2266
- 'claude-opus',
2267
- '--configFilePath',
2268
- '/path/to/config.yml',
2269
- '--branch',
2270
- 'i1',
2271
- '--codexHome',
2272
- '.codex-dev1',
2273
- ],
2274
- ]);
2482
+ expect(
2483
+ mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
2484
+ ).toHaveLength(0);
2485
+ expect(mockProjectRepository.getByUrl).not.toHaveBeenCalled();
2486
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
2487
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
2488
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
2489
+ expect.stringContaining('Skipping starting preparation'),
2490
+ );
2491
+ consoleWarnSpy.mockRestore();
2275
2492
  });
2276
2493
 
2277
- it('should cycle through codexHomeCandidates across multiple issues', async () => {
2494
+ it('should return all tokens sorted ascending when all are below the threshold', async () => {
2278
2495
  const awaitingIssues: Issue[] = [
2279
2496
  createMockIssue({
2280
2497
  url: 'url1',
@@ -2310,6 +2527,29 @@ describe('StartPreparationUseCase', () => {
2310
2527
  stderr: '',
2311
2528
  exitCode: 0,
2312
2529
  });
2530
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2531
+ {
2532
+ token: 'token-mid',
2533
+ fiveHourUtilization: 0.5,
2534
+ blocked: false,
2535
+ rejected: false,
2536
+ modelWeeklyLimits: {},
2537
+ },
2538
+ {
2539
+ token: 'token-low',
2540
+ fiveHourUtilization: 0.1,
2541
+ blocked: false,
2542
+ rejected: false,
2543
+ modelWeeklyLimits: {},
2544
+ },
2545
+ {
2546
+ token: 'token-high',
2547
+ fiveHourUtilization: 0.8,
2548
+ blocked: false,
2549
+ rejected: false,
2550
+ modelWeeklyLimits: {},
2551
+ },
2552
+ ]);
2313
2553
 
2314
2554
  await useCase.run({
2315
2555
  projectUrl: 'https://github.com/user/repo',
@@ -2320,118 +2560,56 @@ describe('StartPreparationUseCase', () => {
2320
2560
  maximumPreparingIssuesCount: null,
2321
2561
  utilizationPercentageThreshold: 90,
2322
2562
  allowedIssueAuthors: null,
2323
- codexHomeCandidates: ['.codex-dev1', '.codex-dev2'],
2563
+ codexHomeCandidates: null,
2324
2564
  allowIssueCacheMinutes: 0,
2325
2565
  });
2326
2566
 
2327
2567
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
2328
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][1]).toContain(
2329
- '--codexHome',
2330
- );
2331
- expect(
2332
- mockLocalCommandRunner.runCommand.mock.calls[0][1][
2333
- mockLocalCommandRunner.runCommand.mock.calls[0][1].indexOf(
2334
- '--codexHome',
2335
- ) + 1
2336
- ],
2337
- ).toBe('.codex-dev1');
2338
- expect(mockLocalCommandRunner.runCommand.mock.calls[1][1]).toContain(
2339
- '--codexHome',
2340
- );
2341
- expect(
2342
- mockLocalCommandRunner.runCommand.mock.calls[1][1][
2343
- mockLocalCommandRunner.runCommand.mock.calls[1][1].indexOf(
2344
- '--codexHome',
2345
- ) + 1
2346
- ],
2347
- ).toBe('.codex-dev2');
2348
- expect(mockLocalCommandRunner.runCommand.mock.calls[2][1]).toContain(
2349
- '--codexHome',
2350
- );
2351
- expect(
2352
- mockLocalCommandRunner.runCommand.mock.calls[2][1][
2353
- mockLocalCommandRunner.runCommand.mock.calls[2][1].indexOf(
2354
- '--codexHome',
2355
- ) + 1
2356
- ],
2357
- ).toBe('.codex-dev1');
2358
- });
2359
-
2360
- it('should persist Preparation status via updateStatus with resolved status option id (regression for issue #519)', async () => {
2361
- const awaitingIssue = createMockIssue({
2362
- url: 'https://github.com/user/repo/issues/519',
2363
- title: 'Regression issue',
2364
- labels: ['category:impl'],
2365
- status: 'Awaiting Workspace',
2366
- itemId: 'item-regression',
2367
- });
2368
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2369
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2370
- createMockStoryObjectMap([awaitingIssue]),
2371
- );
2372
- mockLocalCommandRunner.runCommand.mockResolvedValue({
2373
- stdout: '',
2374
- stderr: '',
2375
- exitCode: 0,
2568
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toMatchObject({
2569
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-low' },
2376
2570
  });
2377
-
2378
- await useCase.run({
2379
- projectUrl: 'https://github.com/user/repo',
2380
- defaultAgentName: 'agent1',
2381
- defaultLlmModelName: 'claude-opus',
2382
- defaultLlmAgentName: null,
2383
- configFilePath: '/path/to/config.yml',
2384
- maximumPreparingIssuesCount: null,
2385
- utilizationPercentageThreshold: 90,
2386
- allowedIssueAuthors: null,
2387
- codexHomeCandidates: null,
2388
- allowIssueCacheMinutes: 0,
2571
+ expect(mockLocalCommandRunner.runCommand.mock.calls[1][2]).toMatchObject({
2572
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-mid' },
2389
2573
  });
2390
-
2391
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
2392
- expect(mockIssueRepository.updateStatus.mock.calls[0][0]).toBe(mockProject);
2393
- expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
2394
- url: 'https://github.com/user/repo/issues/519',
2395
- itemId: 'item-regression',
2574
+ expect(mockLocalCommandRunner.runCommand.mock.calls[2][2]).toMatchObject({
2575
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-high' },
2396
2576
  });
2397
- expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('2');
2398
- const updateStatusCallOrder =
2399
- mockIssueRepository.updateStatus.mock.invocationCallOrder[0];
2400
- const runCommandCallOrder =
2401
- mockLocalCommandRunner.runCommand.mock.invocationCallOrder[0];
2402
- expect(updateStatusCallOrder).toBeLessThan(runCommandCallOrder);
2403
2577
  });
2404
2578
 
2405
- it('should return early and log an error when preparationStatus option is not in the project', async () => {
2406
- const projectWithoutPreparation: Project = {
2407
- ...createMockProject(),
2408
- status: {
2409
- name: 'Status',
2410
- fieldId: 'status-field-id',
2411
- statuses: [
2412
- {
2413
- id: '1',
2414
- name: 'Awaiting Workspace',
2415
- color: 'GRAY',
2416
- description: '',
2417
- },
2418
- { id: '3', name: 'Done', color: 'GREEN', description: '' },
2419
- ],
2420
- },
2421
- };
2422
- const awaitingIssue = createMockIssue({
2423
- url: 'url-missing-option',
2424
- title: 'Missing Preparation Option',
2579
+ it('should exclude a token whose 5h utilization is at or above the threshold', async () => {
2580
+ const awaitingIssue = createMockIssue({
2581
+ url: 'url1',
2582
+ title: 'Issue 1',
2425
2583
  labels: ['category:impl'],
2426
2584
  status: 'Awaiting Workspace',
2585
+ number: 1,
2586
+ itemId: 'item-1',
2427
2587
  });
2428
- mockProjectRepository.getByUrl.mockResolvedValue(projectWithoutPreparation);
2588
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2429
2589
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2430
2590
  createMockStoryObjectMap([awaitingIssue]),
2431
2591
  );
2432
- const consoleErrorSpy = jest
2433
- .spyOn(console, 'error')
2434
- .mockImplementation(() => {});
2592
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2593
+ stdout: '',
2594
+ stderr: '',
2595
+ exitCode: 0,
2596
+ });
2597
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2598
+ {
2599
+ token: 'token-at-threshold',
2600
+ fiveHourUtilization: 0.9,
2601
+ blocked: false,
2602
+ rejected: false,
2603
+ modelWeeklyLimits: {},
2604
+ },
2605
+ {
2606
+ token: 'token-below',
2607
+ fiveHourUtilization: 0.4,
2608
+ blocked: false,
2609
+ rejected: false,
2610
+ modelWeeklyLimits: {},
2611
+ },
2612
+ ]);
2435
2613
 
2436
2614
  await useCase.run({
2437
2615
  projectUrl: 'https://github.com/user/repo',
@@ -2446,70 +2624,111 @@ describe('StartPreparationUseCase', () => {
2446
2624
  allowIssueCacheMinutes: 0,
2447
2625
  });
2448
2626
 
2449
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
2450
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
2451
- expect(consoleErrorSpy).toHaveBeenCalledWith(
2452
- "Preparation status option 'Preparation' not found in project.",
2453
- );
2454
- consoleErrorSpy.mockRestore();
2627
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2628
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2629
+ env: {
2630
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-below',
2631
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2632
+ },
2633
+ });
2455
2634
  });
2456
2635
 
2457
- it('should pass allowIssueCacheMinutes to getStoryObjectMap', async () => {
2636
+ it('should exclude a rejected token from rotation', async () => {
2637
+ const awaitingIssue = createMockIssue({
2638
+ url: 'url1',
2639
+ title: 'Issue 1',
2640
+ labels: ['category:impl'],
2641
+ status: 'Awaiting Workspace',
2642
+ number: 1,
2643
+ itemId: 'item-1',
2644
+ });
2458
2645
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2459
2646
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2460
- createMockStoryObjectMap([]),
2647
+ createMockStoryObjectMap([awaitingIssue]),
2461
2648
  );
2649
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2650
+ stdout: '',
2651
+ stderr: '',
2652
+ exitCode: 0,
2653
+ });
2654
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2655
+ {
2656
+ token: 'token-rejected',
2657
+ fiveHourUtilization: 0.1,
2658
+ blocked: false,
2659
+ rejected: true,
2660
+ modelWeeklyLimits: {},
2661
+ },
2662
+ {
2663
+ token: 'token-ok',
2664
+ fiveHourUtilization: 0.5,
2665
+ blocked: false,
2666
+ rejected: false,
2667
+ modelWeeklyLimits: {},
2668
+ },
2669
+ ]);
2462
2670
 
2463
2671
  await useCase.run({
2464
2672
  projectUrl: 'https://github.com/user/repo',
2465
2673
  defaultAgentName: 'agent1',
2466
- defaultLlmModelName: null,
2674
+ defaultLlmModelName: 'claude-opus',
2467
2675
  defaultLlmAgentName: null,
2468
2676
  configFilePath: '/path/to/config.yml',
2469
2677
  maximumPreparingIssuesCount: null,
2470
2678
  utilizationPercentageThreshold: 90,
2471
2679
  allowedIssueAuthors: null,
2472
2680
  codexHomeCandidates: null,
2473
- allowIssueCacheMinutes: 5,
2681
+ allowIssueCacheMinutes: 0,
2474
2682
  });
2475
2683
 
2476
- expect(mockIssueRepository.getStoryObjectMap).toHaveBeenCalledWith(
2477
- expect.objectContaining({ id: 'project-1' }),
2478
- 5,
2479
- );
2684
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2685
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2686
+ env: {
2687
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
2688
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2689
+ },
2690
+ });
2480
2691
  });
2481
2692
 
2482
- it('should skip closed issues in awaiting workspace status', async () => {
2483
- const closedIssue = createMockIssue({
2484
- url: 'https://github.com/user/repo/issues/1',
2485
- title: 'Closed issue',
2486
- labels: [],
2487
- status: 'Awaiting Workspace',
2488
- isClosed: true,
2489
- });
2490
- const openIssue = createMockIssue({
2491
- url: 'https://github.com/user/repo/issues/2',
2492
- number: 2,
2493
- title: 'Open issue',
2494
- labels: [],
2693
+ it('should re-admit a token after its 5h window reset normalizes utilization to 0 and clears rejection', async () => {
2694
+ const awaitingIssue = createMockIssue({
2695
+ url: 'url1',
2696
+ title: 'Issue 1',
2697
+ labels: ['category:impl'],
2495
2698
  status: 'Awaiting Workspace',
2496
- isClosed: false,
2699
+ number: 1,
2700
+ itemId: 'item-1',
2497
2701
  });
2498
-
2499
2702
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2500
2703
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2501
- createMockStoryObjectMap([closedIssue, openIssue]),
2704
+ createMockStoryObjectMap([awaitingIssue]),
2502
2705
  );
2503
2706
  mockLocalCommandRunner.runCommand.mockResolvedValue({
2504
2707
  stdout: '',
2505
2708
  stderr: '',
2506
2709
  exitCode: 0,
2507
2710
  });
2711
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2712
+ {
2713
+ token: 'token-reset',
2714
+ fiveHourUtilization: 0,
2715
+ blocked: false,
2716
+ rejected: false,
2717
+ modelWeeklyLimits: {},
2718
+ },
2719
+ {
2720
+ token: 'token-busy',
2721
+ fiveHourUtilization: 0.5,
2722
+ blocked: false,
2723
+ rejected: false,
2724
+ modelWeeklyLimits: {},
2725
+ },
2726
+ ]);
2508
2727
 
2509
2728
  await useCase.run({
2510
2729
  projectUrl: 'https://github.com/user/repo',
2511
2730
  defaultAgentName: 'agent1',
2512
- defaultLlmModelName: 'claude-sonnet-4-6',
2731
+ defaultLlmModelName: 'claude-opus',
2513
2732
  defaultLlmAgentName: null,
2514
2733
  configFilePath: '/path/to/config.yml',
2515
2734
  maximumPreparingIssuesCount: null,
@@ -2519,13 +2738,16 @@ describe('StartPreparationUseCase', () => {
2519
2738
  allowIssueCacheMinutes: 0,
2520
2739
  });
2521
2740
 
2522
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
2523
- expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toMatchObject({
2524
- url: 'https://github.com/user/repo/issues/2',
2741
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2742
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2743
+ env: {
2744
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-reset',
2745
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2746
+ },
2525
2747
  });
2526
2748
  });
2527
2749
 
2528
- it('should pass CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_BASE_URL to runCommand when tokens are available', async () => {
2750
+ it('should exclude a token whose 5h window has not reset and remains at or above threshold', async () => {
2529
2751
  const awaitingIssue = createMockIssue({
2530
2752
  url: 'url1',
2531
2753
  title: 'Issue 1',
@@ -2544,7 +2766,20 @@ describe('StartPreparationUseCase', () => {
2544
2766
  exitCode: 0,
2545
2767
  });
2546
2768
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2547
- { token: 'token-a', fiveHourUtilization: 0, blocked: false },
2769
+ {
2770
+ token: 'token-saturated',
2771
+ fiveHourUtilization: 0.95,
2772
+ blocked: false,
2773
+ rejected: true,
2774
+ modelWeeklyLimits: {},
2775
+ },
2776
+ {
2777
+ token: 'token-ok',
2778
+ fiveHourUtilization: 0.2,
2779
+ blocked: false,
2780
+ rejected: false,
2781
+ modelWeeklyLimits: {},
2782
+ },
2548
2783
  ]);
2549
2784
 
2550
2785
  await useCase.run({
@@ -2560,51 +2795,27 @@ describe('StartPreparationUseCase', () => {
2560
2795
  allowIssueCacheMinutes: 0,
2561
2796
  });
2562
2797
 
2563
- expect(
2564
- mockClaudeTokenUsageRepository.getAvailableTokenUsages.mock.calls,
2565
- ).toHaveLength(1);
2566
- expect(
2567
- mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
2568
- ).toHaveLength(1);
2569
2798
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2570
2799
  expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2571
2800
  env: {
2572
- CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
2801
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
2573
2802
  ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2574
2803
  },
2575
2804
  });
2576
2805
  });
2577
2806
 
2578
- it('should rotate Claude OAuth tokens round-robin across multiple awaiting issues', async () => {
2579
- const awaitingIssues: Issue[] = [
2580
- createMockIssue({
2581
- url: 'url1',
2582
- title: 'Issue 1',
2583
- labels: ['category:impl'],
2584
- status: 'Awaiting Workspace',
2585
- number: 1,
2586
- itemId: 'item-1',
2587
- }),
2588
- createMockIssue({
2589
- url: 'url2',
2590
- title: 'Issue 2',
2591
- labels: ['category:impl'],
2592
- status: 'Awaiting Workspace',
2593
- number: 2,
2594
- itemId: 'item-2',
2595
- }),
2596
- createMockIssue({
2597
- url: 'url3',
2598
- title: 'Issue 3',
2599
- labels: ['category:impl'],
2600
- status: 'Awaiting Workspace',
2601
- number: 3,
2602
- itemId: 'item-3',
2603
- }),
2604
- ];
2807
+ it('should skip preparation when every configured token is rejected', async () => {
2808
+ const awaitingIssue = createMockIssue({
2809
+ url: 'url1',
2810
+ title: 'Issue 1',
2811
+ labels: ['category:impl'],
2812
+ status: 'Awaiting Workspace',
2813
+ number: 1,
2814
+ itemId: 'item-1',
2815
+ });
2605
2816
  mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2606
2817
  mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2607
- createMockStoryObjectMap(awaitingIssues),
2818
+ createMockStoryObjectMap([awaitingIssue]),
2608
2819
  );
2609
2820
  mockLocalCommandRunner.runCommand.mockResolvedValue({
2610
2821
  stdout: '',
@@ -2612,9 +2823,24 @@ describe('StartPreparationUseCase', () => {
2612
2823
  exitCode: 0,
2613
2824
  });
2614
2825
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2615
- { token: 'token-a', fiveHourUtilization: 0, blocked: false },
2616
- { token: 'token-b', fiveHourUtilization: 0, blocked: false },
2826
+ {
2827
+ token: 'token-a',
2828
+ fiveHourUtilization: 0.1,
2829
+ blocked: false,
2830
+ rejected: true,
2831
+ modelWeeklyLimits: {},
2832
+ },
2833
+ {
2834
+ token: 'token-b',
2835
+ fiveHourUtilization: 0.2,
2836
+ blocked: false,
2837
+ rejected: true,
2838
+ modelWeeklyLimits: {},
2839
+ },
2617
2840
  ]);
2841
+ const consoleWarnSpy = jest
2842
+ .spyOn(console, 'warn')
2843
+ .mockImplementation(() => {});
2618
2844
 
2619
2845
  await useCase.run({
2620
2846
  projectUrl: 'https://github.com/user/repo',
@@ -2629,28 +2855,18 @@ describe('StartPreparationUseCase', () => {
2629
2855
  allowIssueCacheMinutes: 0,
2630
2856
  });
2631
2857
 
2632
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
2633
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toMatchObject({
2634
- env: {
2635
- CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
2636
- ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2637
- },
2638
- });
2639
- expect(mockLocalCommandRunner.runCommand.mock.calls[1][2]).toMatchObject({
2640
- env: {
2641
- CLAUDE_CODE_OAUTH_TOKEN: 'token-b',
2642
- ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2643
- },
2644
- });
2645
- expect(mockLocalCommandRunner.runCommand.mock.calls[2][2]).toMatchObject({
2646
- env: {
2647
- CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
2648
- ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2649
- },
2650
- });
2858
+ expect(
2859
+ mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
2860
+ ).toHaveLength(0);
2861
+ expect(mockProjectRepository.getByUrl).not.toHaveBeenCalled();
2862
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
2863
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
2864
+ expect.stringContaining('Skipping starting preparation'),
2865
+ );
2866
+ consoleWarnSpy.mockRestore();
2651
2867
  });
2652
2868
 
2653
- it('should not inject env when no tokens are available', async () => {
2869
+ it('should proceed with legacy spawn without env injection when no token list is configured', async () => {
2654
2870
  const awaitingIssue = createMockIssue({
2655
2871
  url: 'url1',
2656
2872
  title: 'Issue 1',
@@ -2685,14 +2901,76 @@ describe('StartPreparationUseCase', () => {
2685
2901
  allowIssueCacheMinutes: 0,
2686
2902
  });
2687
2903
 
2688
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2689
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toBeUndefined();
2690
2904
  expect(
2691
2905
  mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
2692
2906
  ).toHaveLength(0);
2907
+ expect(mockProjectRepository.getByUrl).toHaveBeenCalled();
2908
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
2909
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2910
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toBeUndefined();
2693
2911
  });
2694
2912
 
2695
- it('should pick the least-utilized token first', async () => {
2913
+ it('should exclude a token whose seven_day_sonnet weekly limit is rejected when the model is sonnet', async () => {
2914
+ const awaitingIssue = createMockIssue({
2915
+ url: 'url1',
2916
+ title: 'Issue 1',
2917
+ labels: ['category:impl'],
2918
+ status: 'Awaiting Workspace',
2919
+ number: 1,
2920
+ itemId: 'item-1',
2921
+ });
2922
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2923
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2924
+ createMockStoryObjectMap([awaitingIssue]),
2925
+ );
2926
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2927
+ stdout: '',
2928
+ stderr: '',
2929
+ exitCode: 0,
2930
+ });
2931
+ const futureReset = Math.floor(Date.now() / 1000) + 3600;
2932
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2933
+ {
2934
+ token: 'token-sonnet-exhausted',
2935
+ fiveHourUtilization: 0.1,
2936
+ blocked: false,
2937
+ rejected: false,
2938
+ modelWeeklyLimits: {
2939
+ seven_day_sonnet: { rejected: true, resetsAt: futureReset },
2940
+ },
2941
+ },
2942
+ {
2943
+ token: 'token-ok',
2944
+ fiveHourUtilization: 0.5,
2945
+ blocked: false,
2946
+ rejected: false,
2947
+ modelWeeklyLimits: {},
2948
+ },
2949
+ ]);
2950
+
2951
+ await useCase.run({
2952
+ projectUrl: 'https://github.com/user/repo',
2953
+ defaultAgentName: 'agent1',
2954
+ defaultLlmModelName: 'claude-sonnet-4-6',
2955
+ defaultLlmAgentName: null,
2956
+ configFilePath: '/path/to/config.yml',
2957
+ maximumPreparingIssuesCount: null,
2958
+ utilizationPercentageThreshold: 90,
2959
+ allowedIssueAuthors: null,
2960
+ codexHomeCandidates: null,
2961
+ allowIssueCacheMinutes: 0,
2962
+ });
2963
+
2964
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2965
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2966
+ env: {
2967
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
2968
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2969
+ },
2970
+ });
2971
+ });
2972
+
2973
+ it('should re-admit a token whose seven_day_sonnet rejection has been cleared by stale-reset expiry', async () => {
2696
2974
  const awaitingIssue = createMockIssue({
2697
2975
  url: 'url1',
2698
2976
  title: 'Issue 1',
@@ -2710,15 +2988,30 @@ describe('StartPreparationUseCase', () => {
2710
2988
  stderr: '',
2711
2989
  exitCode: 0,
2712
2990
  });
2991
+ const pastReset = Math.floor(Date.now() / 1000) - 3600;
2713
2992
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2714
- { token: 'token-high', fiveHourUtilization: 80, blocked: false },
2715
- { token: 'token-low', fiveHourUtilization: 10, blocked: false },
2993
+ {
2994
+ token: 'token-recovered',
2995
+ fiveHourUtilization: 0.1,
2996
+ blocked: false,
2997
+ rejected: false,
2998
+ modelWeeklyLimits: {
2999
+ seven_day_sonnet: { rejected: false, resetsAt: pastReset },
3000
+ },
3001
+ },
3002
+ {
3003
+ token: 'token-busy',
3004
+ fiveHourUtilization: 0.5,
3005
+ blocked: false,
3006
+ rejected: false,
3007
+ modelWeeklyLimits: {},
3008
+ },
2716
3009
  ]);
2717
3010
 
2718
3011
  await useCase.run({
2719
3012
  projectUrl: 'https://github.com/user/repo',
2720
3013
  defaultAgentName: 'agent1',
2721
- defaultLlmModelName: 'claude-opus',
3014
+ defaultLlmModelName: 'claude-sonnet-4-6',
2722
3015
  defaultLlmAgentName: null,
2723
3016
  configFilePath: '/path/to/config.yml',
2724
3017
  maximumPreparingIssuesCount: null,
@@ -2731,13 +3024,13 @@ describe('StartPreparationUseCase', () => {
2731
3024
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2732
3025
  expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2733
3026
  env: {
2734
- CLAUDE_CODE_OAUTH_TOKEN: 'token-low',
3027
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-recovered',
2735
3028
  ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2736
3029
  },
2737
3030
  });
2738
3031
  });
2739
3032
 
2740
- it('should exclude blocked tokens from rotation', async () => {
3033
+ it('should not exclude a token for a sonnet-only rejection when the model is opus', async () => {
2741
3034
  const awaitingIssue = createMockIssue({
2742
3035
  url: 'url1',
2743
3036
  title: 'Issue 1',
@@ -2755,9 +3048,24 @@ describe('StartPreparationUseCase', () => {
2755
3048
  stderr: '',
2756
3049
  exitCode: 0,
2757
3050
  });
3051
+ const futureReset = Math.floor(Date.now() / 1000) + 3600;
2758
3052
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2759
- { token: 'token-blocked', fiveHourUtilization: 5, blocked: true },
2760
- { token: 'token-ok', fiveHourUtilization: 50, blocked: false },
3053
+ {
3054
+ token: 'token-sonnet-exhausted',
3055
+ fiveHourUtilization: 0.1,
3056
+ blocked: false,
3057
+ rejected: false,
3058
+ modelWeeklyLimits: {
3059
+ seven_day_sonnet: { rejected: true, resetsAt: futureReset },
3060
+ },
3061
+ },
3062
+ {
3063
+ token: 'token-higher-util',
3064
+ fiveHourUtilization: 0.5,
3065
+ blocked: false,
3066
+ rejected: false,
3067
+ modelWeeklyLimits: {},
3068
+ },
2761
3069
  ]);
2762
3070
 
2763
3071
  await useCase.run({
@@ -2776,13 +3084,13 @@ describe('StartPreparationUseCase', () => {
2776
3084
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2777
3085
  expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2778
3086
  env: {
2779
- CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
3087
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-sonnet-exhausted',
2780
3088
  ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2781
3089
  },
2782
3090
  });
2783
3091
  });
2784
3092
 
2785
- it('should not inject env when every available token is blocked', async () => {
3093
+ it('should exclude a token whose generic seven_day weekly limit is rejected regardless of model', async () => {
2786
3094
  const awaitingIssue = createMockIssue({
2787
3095
  url: 'url1',
2788
3096
  title: 'Issue 1',
@@ -2800,9 +3108,24 @@ describe('StartPreparationUseCase', () => {
2800
3108
  stderr: '',
2801
3109
  exitCode: 0,
2802
3110
  });
3111
+ const futureReset = Math.floor(Date.now() / 1000) + 3600;
2803
3112
  mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2804
- { token: 'token-a', fiveHourUtilization: 5, blocked: true },
2805
- { token: 'token-b', fiveHourUtilization: 8, blocked: true },
3113
+ {
3114
+ token: 'token-weekly-exhausted',
3115
+ fiveHourUtilization: 0.1,
3116
+ blocked: false,
3117
+ rejected: false,
3118
+ modelWeeklyLimits: {
3119
+ seven_day: { rejected: true, resetsAt: futureReset },
3120
+ },
3121
+ },
3122
+ {
3123
+ token: 'token-ok',
3124
+ fiveHourUtilization: 0.5,
3125
+ blocked: false,
3126
+ rejected: false,
3127
+ modelWeeklyLimits: {},
3128
+ },
2806
3129
  ]);
2807
3130
 
2808
3131
  await useCase.run({
@@ -2818,10 +3141,12 @@ describe('StartPreparationUseCase', () => {
2818
3141
  allowIssueCacheMinutes: 0,
2819
3142
  });
2820
3143
 
2821
- expect(
2822
- mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
2823
- ).toHaveLength(0);
2824
3144
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2825
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toBeUndefined();
3145
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
3146
+ env: {
3147
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
3148
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
3149
+ },
3150
+ });
2826
3151
  });
2827
3152
  });