github-issue-tower-defence-management 1.54.0 → 1.56.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 (81) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +23 -0
  3. package/bin/adapter/entry-points/cli/index.js +3 -1
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/projectConfig.js +5 -0
  6. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -1
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/proxy/RateLimitCache.js +123 -0
  10. package/bin/adapter/proxy/RateLimitCache.js.map +1 -0
  11. package/bin/adapter/proxy/TokenListLoader.js +72 -0
  12. package/bin/adapter/proxy/TokenListLoader.js.map +1 -0
  13. package/bin/adapter/proxy/ensureProxyRunning.js +73 -0
  14. package/bin/adapter/proxy/ensureProxyRunning.js.map +1 -0
  15. package/bin/adapter/proxy/proxyEntry.js +96 -0
  16. package/bin/adapter/proxy/proxyEntry.js.map +1 -0
  17. package/bin/adapter/repositories/NodeLocalCommandRunner.js +10 -4
  18. package/bin/adapter/repositories/NodeLocalCommandRunner.js.map +1 -1
  19. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +35 -0
  20. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -0
  21. package/bin/domain/entities/ClaudeTokenUsage.js +3 -0
  22. package/bin/domain/entities/ClaudeTokenUsage.js.map +1 -0
  23. package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js +1 -1
  24. package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js.map +1 -1
  25. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -0
  26. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  27. package/bin/domain/usecases/StartPreparationUseCase.js +26 -2
  28. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  29. package/bin/domain/usecases/adapter-interfaces/ClaudeTokenUsageRepository.js +3 -0
  30. package/bin/domain/usecases/adapter-interfaces/ClaudeTokenUsageRepository.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/adapter/entry-points/cli/index.ts +5 -0
  33. package/src/adapter/entry-points/cli/projectConfig.ts +13 -0
  34. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +5 -0
  35. package/src/adapter/proxy/RateLimitCache.test.ts +131 -0
  36. package/src/adapter/proxy/RateLimitCache.ts +112 -0
  37. package/src/adapter/proxy/TokenListLoader.test.ts +82 -0
  38. package/src/adapter/proxy/TokenListLoader.ts +35 -0
  39. package/src/adapter/proxy/ensureProxyRunning.test.ts +85 -0
  40. package/src/adapter/proxy/ensureProxyRunning.ts +41 -0
  41. package/src/adapter/proxy/proxyEntry.test.ts +48 -0
  42. package/src/adapter/proxy/proxyEntry.ts +69 -0
  43. package/src/adapter/repositories/NodeLocalCommandRunner.test.ts +3 -1
  44. package/src/adapter/repositories/NodeLocalCommandRunner.ts +18 -4
  45. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +127 -0
  46. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +36 -0
  47. package/src/domain/entities/ClaudeTokenUsage.ts +5 -0
  48. package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.test.ts +26 -15
  49. package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.ts +3 -1
  50. package/src/domain/usecases/HandleScheduledEventUseCase.ts +1 -0
  51. package/src/domain/usecases/StartPreparationUseCase.test.ts +308 -0
  52. package/src/domain/usecases/StartPreparationUseCase.ts +37 -1
  53. package/src/domain/usecases/adapter-interfaces/ClaudeTokenUsageRepository.ts +7 -0
  54. package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +5 -0
  55. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  56. package/types/adapter/entry-points/cli/projectConfig.d.ts +1 -0
  57. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  58. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  59. package/types/adapter/proxy/RateLimitCache.d.ts +14 -0
  60. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -0
  61. package/types/adapter/proxy/TokenListLoader.d.ts +2 -0
  62. package/types/adapter/proxy/TokenListLoader.d.ts.map +1 -0
  63. package/types/adapter/proxy/ensureProxyRunning.d.ts +2 -0
  64. package/types/adapter/proxy/ensureProxyRunning.d.ts.map +1 -0
  65. package/types/adapter/proxy/proxyEntry.d.ts +4 -0
  66. package/types/adapter/proxy/proxyEntry.d.ts.map +1 -0
  67. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts +2 -2
  68. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts.map +1 -1
  69. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts +11 -0
  70. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -0
  71. package/types/domain/entities/ClaudeTokenUsage.d.ts +6 -0
  72. package/types/domain/entities/ClaudeTokenUsage.d.ts.map +1 -0
  73. package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts +2 -0
  74. package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts.map +1 -1
  75. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  76. package/types/domain/usecases/StartPreparationUseCase.d.ts +4 -1
  77. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  78. package/types/domain/usecases/adapter-interfaces/ClaudeTokenUsageRepository.d.ts +7 -0
  79. package/types/domain/usecases/adapter-interfaces/ClaudeTokenUsageRepository.d.ts.map +1 -0
  80. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +4 -1
  81. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -1
@@ -100,6 +100,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
100
100
  cacheUsed: boolean;
101
101
  urlOfStoryView: string;
102
102
  storyObjectMap: StoryObjectMap;
103
+ manager: string;
103
104
  };
104
105
  expectedThrowError?: Error;
105
106
  expectedCreateNewIssueCalls: [
@@ -122,6 +123,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
122
123
  cacheUsed: false,
123
124
  urlOfStoryView: 'https://example.com',
124
125
  storyObjectMap: basicStoryObjectMap,
126
+ manager: 'manager',
125
127
  },
126
128
  expectedCreateNewIssueCalls: [],
127
129
  expectedUpdateIssueCalls: [],
@@ -136,6 +138,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
136
138
  cacheUsed: true,
137
139
  urlOfStoryView: 'https://example.com',
138
140
  storyObjectMap: basicStoryObjectMap,
141
+ manager: 'manager',
139
142
  },
140
143
  expectedCreateNewIssueCalls: [
141
144
  [
@@ -143,7 +146,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
143
146
  'repo',
144
147
  'Task 1',
145
148
  '- Parent issue: https://github.com/org/repo/issues/123',
146
- [],
149
+ ['manager'],
147
150
  [],
148
151
  ],
149
152
  [
@@ -151,7 +154,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
151
154
  'repo',
152
155
  'Task 2',
153
156
  '- Parent issue: https://github.com/org/repo/issues/123',
154
- [],
157
+ ['manager'],
155
158
  [],
156
159
  ],
157
160
  [
@@ -159,7 +162,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
159
162
  'repo',
160
163
  'Task 3',
161
164
  '- Parent issue: https://github.com/org/repo/issues/456',
162
- [],
165
+ ['manager'],
163
166
  [],
164
167
  ],
165
168
  [
@@ -167,7 +170,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
167
170
  'repo',
168
171
  'Task 4',
169
172
  '- Parent issue: https://github.com/org/repo/issues/456',
170
- [],
173
+ ['manager'],
171
174
  [],
172
175
  ],
173
176
  ],
@@ -278,6 +281,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
278
281
  cacheUsed: false,
279
282
  urlOfStoryView: 'https://example.com',
280
283
  storyObjectMap: regularStoryObjectMap,
284
+ manager: 'manager',
281
285
  },
282
286
  expectedCreateNewIssueCalls: [],
283
287
  expectedUpdateIssueCalls: [],
@@ -292,6 +296,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
292
296
  cacheUsed: false,
293
297
  urlOfStoryView: 'https://example.com',
294
298
  storyObjectMap: basicStoryObjectMap,
299
+ manager: 'manager',
295
300
  },
296
301
  expectedThrowError: new Error('Story issue not found: Story 1'),
297
302
  expectedCreateNewIssueCalls: [],
@@ -318,6 +323,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
318
323
  cacheUsed: false,
319
324
  urlOfStoryView: 'https://example.com',
320
325
  storyObjectMap: basicStoryObjectMap,
326
+ manager: 'manager',
321
327
  },
322
328
  expectedCreateNewIssueCalls: [],
323
329
  expectedUpdateIssueCalls: [],
@@ -332,6 +338,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
332
338
  cacheUsed: false,
333
339
  urlOfStoryView: 'https://example.com',
334
340
  storyObjectMap: basicStoryObjectMap,
341
+ manager: 'manager',
335
342
  },
336
343
  expectedCreateNewIssueCalls: [
337
344
  [
@@ -339,7 +346,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
339
346
  'repo',
340
347
  'Task 1',
341
348
  '- Parent issue: https://github.com/org/repo/issues/123',
342
- [],
349
+ ['manager'],
343
350
  [],
344
351
  ],
345
352
  [
@@ -347,7 +354,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
347
354
  'repo',
348
355
  'Task 2',
349
356
  '- Parent issue: https://github.com/org/repo/issues/123',
350
- [],
357
+ ['manager'],
351
358
  [],
352
359
  ],
353
360
  [
@@ -355,7 +362,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
355
362
  'repo',
356
363
  'Task 3',
357
364
  '- Parent issue: https://github.com/org/repo/issues/456',
358
- [],
365
+ ['manager'],
359
366
  [],
360
367
  ],
361
368
  [
@@ -363,7 +370,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
363
370
  'repo',
364
371
  'Task 4',
365
372
  '- Parent issue: https://github.com/org/repo/issues/456',
366
- [],
373
+ ['manager'],
367
374
  [],
368
375
  ],
369
376
  ],
@@ -489,6 +496,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
489
496
  cacheUsed: false,
490
497
  urlOfStoryView: 'https://example.com',
491
498
  storyObjectMap: new Map([['Story 1', basicStoryObject1]]),
499
+ manager: 'manager',
492
500
  },
493
501
  expectedCreateNewIssueCalls: [
494
502
  [
@@ -496,7 +504,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
496
504
  'repo',
497
505
  'Task 1',
498
506
  '- Parent issue: https://github.com/org/repo/issues/123',
499
- [],
507
+ ['manager'],
500
508
  [],
501
509
  ],
502
510
  ],
@@ -565,6 +573,7 @@ describe('ConvertCheckboxToIssueInStoryIssueUseCase', () => {
565
573
  },
566
574
  ],
567
575
  ]),
576
+ manager: 'manager',
568
577
  },
569
578
  expectedCreateNewIssueCalls: [],
570
579
  expectedUpdateIssueCalls: [
@@ -597,6 +606,7 @@ Some description without checkboxes`,
597
606
  cacheUsed: false,
598
607
  urlOfStoryView: 'https://example.com',
599
608
  storyObjectMap: basicStoryObjectMap,
609
+ manager: 'manager',
600
610
  },
601
611
  expectedCreateNewIssueCalls: [
602
612
  [
@@ -604,7 +614,7 @@ Some description without checkboxes`,
604
614
  'repo',
605
615
  'Task 1',
606
616
  '- Parent issue: https://github.com/org/repo/issues/123',
607
- [],
617
+ ['manager'],
608
618
  [],
609
619
  ],
610
620
  [
@@ -612,7 +622,7 @@ Some description without checkboxes`,
612
622
  'repo',
613
623
  'Task 2 for `Story 1 #123`',
614
624
  '- Parent issue: https://github.com/org/repo/issues/123',
615
- [],
625
+ ['manager'],
616
626
  [],
617
627
  ],
618
628
  [
@@ -620,7 +630,7 @@ Some description without checkboxes`,
620
630
  'repo',
621
631
  'Task 3',
622
632
  '- Parent issue: https://github.com/org/repo/issues/456',
623
- [],
633
+ ['manager'],
624
634
  [],
625
635
  ],
626
636
  [
@@ -628,7 +638,7 @@ Some description without checkboxes`,
628
638
  'repo',
629
639
  'Task 4',
630
640
  '- Parent issue: https://github.com/org/repo/issues/456',
631
- [],
641
+ ['manager'],
632
642
  [],
633
643
  ],
634
644
  ],
@@ -787,6 +797,7 @@ Some description without checkboxes`,
787
797
  },
788
798
  ],
789
799
  ]),
800
+ manager: 'manager',
790
801
  },
791
802
  expectedCreateNewIssueCalls: [
792
803
  [
@@ -794,7 +805,7 @@ Some description without checkboxes`,
794
805
  'repoA',
795
806
  'Task 1',
796
807
  '- Parent issue: https://github.com/org/repo/issues/123',
797
- [],
808
+ ['manager'],
798
809
  [],
799
810
  ],
800
811
  [
@@ -802,7 +813,7 @@ Some description without checkboxes`,
802
813
  'repoB',
803
814
  'Task 2',
804
815
  '- Parent issue: https://github.com/org/repo/issues/456',
805
- [],
816
+ ['manager'],
806
817
  [],
807
818
  ],
808
819
  ],
@@ -4,6 +4,7 @@ import { Project } from '../entities/Project';
4
4
  import { StoryObjectMap } from '../entities/StoryObjectMap';
5
5
  import { encodeForURI } from './utils';
6
6
  import { ICEBOX_STATUS_NAME } from '../entities/WorkflowStatus';
7
+ import { Member } from '../entities/Member';
7
8
 
8
9
  export class ConvertCheckboxToIssueInStoryIssueUseCase {
9
10
  constructor(
@@ -19,6 +20,7 @@ export class ConvertCheckboxToIssueInStoryIssueUseCase {
19
20
  cacheUsed: boolean;
20
21
  urlOfStoryView: string;
21
22
  storyObjectMap: StoryObjectMap;
23
+ manager: Member['name'];
22
24
  }): Promise<void> => {
23
25
  const story = input.project.story;
24
26
  if (!story) {
@@ -74,7 +76,7 @@ export class ConvertCheckboxToIssueInStoryIssueUseCase {
74
76
  freshStoryIssue.repo,
75
77
  issueTitle,
76
78
  newIssueBody,
77
- [],
79
+ [input.manager],
78
80
  [],
79
81
  );
80
82
  const newIssueUrl = `https://github.com/${freshStoryIssue.org}/${freshStoryIssue.repo}/issues/${newIssueNumber}`;
@@ -366,6 +366,7 @@ ${JSON.stringify(e)}
366
366
  cacheUsed,
367
367
  urlOfStoryView: input.urlOfStoryView,
368
368
  storyObjectMap: storyObjectMap,
369
+ manager: input.manager,
369
370
  });
370
371
  await this.changeStatusByStoryColorUseCase.run({
371
372
  project,
@@ -6,6 +6,7 @@ import {
6
6
  import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
7
7
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
8
8
  import { ClaudeRepository } from './adapter-interfaces/ClaudeRepository';
9
+ import { ClaudeTokenUsageRepository } from './adapter-interfaces/ClaudeTokenUsageRepository';
9
10
  import { Issue } from '../entities/Issue';
10
11
  import { Project } from '../entities/Project';
11
12
  import { StoryObjectMap } from '../entities/StoryObjectMap';
@@ -92,6 +93,7 @@ describe('StartPreparationUseCase', () => {
92
93
  >;
93
94
  let mockClaudeRepository: Mocked<Pick<ClaudeRepository, 'getUsage'>>;
94
95
  let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
96
+ let mockClaudeTokenUsageRepository: Mocked<ClaudeTokenUsageRepository>;
95
97
  let mockProject: Project;
96
98
  beforeEach(() => {
97
99
  jest.resetAllMocks();
@@ -114,11 +116,17 @@ describe('StartPreparationUseCase', () => {
114
116
  mockLocalCommandRunner = {
115
117
  runCommand: jest.fn(),
116
118
  };
119
+ mockClaudeTokenUsageRepository = {
120
+ ensureObservable: jest.fn().mockResolvedValue(undefined),
121
+ getAvailableTokenUsages: jest.fn().mockResolvedValue([]),
122
+ proxyBaseUrl: jest.fn().mockReturnValue('http://127.0.0.1:8787'),
123
+ };
117
124
  useCase = new StartPreparationUseCase(
118
125
  mockProjectRepository,
119
126
  mockIssueRepository,
120
127
  mockClaudeRepository,
121
128
  mockLocalCommandRunner,
129
+ mockClaudeTokenUsageRepository,
122
130
  );
123
131
  });
124
132
  it('should run aw command for awaiting workspace issues', async () => {
@@ -2516,4 +2524,304 @@ describe('StartPreparationUseCase', () => {
2516
2524
  url: 'https://github.com/user/repo/issues/2',
2517
2525
  });
2518
2526
  });
2527
+
2528
+ it('should pass CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_BASE_URL to runCommand when tokens are available', async () => {
2529
+ const awaitingIssue = createMockIssue({
2530
+ url: 'url1',
2531
+ title: 'Issue 1',
2532
+ labels: ['category:impl'],
2533
+ status: 'Awaiting Workspace',
2534
+ number: 1,
2535
+ itemId: 'item-1',
2536
+ });
2537
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2538
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2539
+ createMockStoryObjectMap([awaitingIssue]),
2540
+ );
2541
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2542
+ stdout: '',
2543
+ stderr: '',
2544
+ exitCode: 0,
2545
+ });
2546
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2547
+ { token: 'token-a', fiveHourUtilization: 0, blocked: false },
2548
+ ]);
2549
+
2550
+ await useCase.run({
2551
+ projectUrl: 'https://github.com/user/repo',
2552
+ defaultAgentName: 'agent1',
2553
+ defaultLlmModelName: 'claude-opus',
2554
+ defaultLlmAgentName: null,
2555
+ configFilePath: '/path/to/config.yml',
2556
+ maximumPreparingIssuesCount: null,
2557
+ utilizationPercentageThreshold: 90,
2558
+ allowedIssueAuthors: null,
2559
+ codexHomeCandidates: null,
2560
+ allowIssueCacheMinutes: 0,
2561
+ });
2562
+
2563
+ expect(
2564
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mock.calls,
2565
+ ).toHaveLength(1);
2566
+ expect(
2567
+ mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
2568
+ ).toHaveLength(1);
2569
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2570
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2571
+ env: {
2572
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
2573
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2574
+ },
2575
+ });
2576
+ });
2577
+
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
+ ];
2605
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2606
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2607
+ createMockStoryObjectMap(awaitingIssues),
2608
+ );
2609
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2610
+ stdout: '',
2611
+ stderr: '',
2612
+ exitCode: 0,
2613
+ });
2614
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2615
+ { token: 'token-a', fiveHourUtilization: 0, blocked: false },
2616
+ { token: 'token-b', fiveHourUtilization: 0, blocked: false },
2617
+ ]);
2618
+
2619
+ await useCase.run({
2620
+ projectUrl: 'https://github.com/user/repo',
2621
+ defaultAgentName: 'agent1',
2622
+ defaultLlmModelName: 'claude-opus',
2623
+ defaultLlmAgentName: null,
2624
+ configFilePath: '/path/to/config.yml',
2625
+ maximumPreparingIssuesCount: null,
2626
+ utilizationPercentageThreshold: 90,
2627
+ allowedIssueAuthors: null,
2628
+ codexHomeCandidates: null,
2629
+ allowIssueCacheMinutes: 0,
2630
+ });
2631
+
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
+ });
2651
+ });
2652
+
2653
+ it('should not inject env when no tokens are available', async () => {
2654
+ const awaitingIssue = createMockIssue({
2655
+ url: 'url1',
2656
+ title: 'Issue 1',
2657
+ labels: ['category:impl'],
2658
+ status: 'Awaiting Workspace',
2659
+ number: 1,
2660
+ itemId: 'item-1',
2661
+ });
2662
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2663
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2664
+ createMockStoryObjectMap([awaitingIssue]),
2665
+ );
2666
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2667
+ stdout: '',
2668
+ stderr: '',
2669
+ exitCode: 0,
2670
+ });
2671
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue(
2672
+ [],
2673
+ );
2674
+
2675
+ await useCase.run({
2676
+ projectUrl: 'https://github.com/user/repo',
2677
+ defaultAgentName: 'agent1',
2678
+ defaultLlmModelName: 'claude-opus',
2679
+ defaultLlmAgentName: null,
2680
+ configFilePath: '/path/to/config.yml',
2681
+ maximumPreparingIssuesCount: null,
2682
+ utilizationPercentageThreshold: 90,
2683
+ allowedIssueAuthors: null,
2684
+ codexHomeCandidates: null,
2685
+ allowIssueCacheMinutes: 0,
2686
+ });
2687
+
2688
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2689
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toBeUndefined();
2690
+ expect(
2691
+ mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
2692
+ ).toHaveLength(0);
2693
+ });
2694
+
2695
+ it('should pick the least-utilized token first', async () => {
2696
+ const awaitingIssue = createMockIssue({
2697
+ url: 'url1',
2698
+ title: 'Issue 1',
2699
+ labels: ['category:impl'],
2700
+ status: 'Awaiting Workspace',
2701
+ number: 1,
2702
+ itemId: 'item-1',
2703
+ });
2704
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2705
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2706
+ createMockStoryObjectMap([awaitingIssue]),
2707
+ );
2708
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2709
+ stdout: '',
2710
+ stderr: '',
2711
+ exitCode: 0,
2712
+ });
2713
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2714
+ { token: 'token-high', fiveHourUtilization: 80, blocked: false },
2715
+ { token: 'token-low', fiveHourUtilization: 10, blocked: false },
2716
+ ]);
2717
+
2718
+ await useCase.run({
2719
+ projectUrl: 'https://github.com/user/repo',
2720
+ defaultAgentName: 'agent1',
2721
+ defaultLlmModelName: 'claude-opus',
2722
+ defaultLlmAgentName: null,
2723
+ configFilePath: '/path/to/config.yml',
2724
+ maximumPreparingIssuesCount: null,
2725
+ utilizationPercentageThreshold: 90,
2726
+ allowedIssueAuthors: null,
2727
+ codexHomeCandidates: null,
2728
+ allowIssueCacheMinutes: 0,
2729
+ });
2730
+
2731
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2732
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2733
+ env: {
2734
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-low',
2735
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2736
+ },
2737
+ });
2738
+ });
2739
+
2740
+ it('should exclude blocked tokens from rotation', async () => {
2741
+ const awaitingIssue = createMockIssue({
2742
+ url: 'url1',
2743
+ title: 'Issue 1',
2744
+ labels: ['category:impl'],
2745
+ status: 'Awaiting Workspace',
2746
+ number: 1,
2747
+ itemId: 'item-1',
2748
+ });
2749
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2750
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2751
+ createMockStoryObjectMap([awaitingIssue]),
2752
+ );
2753
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2754
+ stdout: '',
2755
+ stderr: '',
2756
+ exitCode: 0,
2757
+ });
2758
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2759
+ { token: 'token-blocked', fiveHourUtilization: 5, blocked: true },
2760
+ { token: 'token-ok', fiveHourUtilization: 50, blocked: false },
2761
+ ]);
2762
+
2763
+ await useCase.run({
2764
+ projectUrl: 'https://github.com/user/repo',
2765
+ defaultAgentName: 'agent1',
2766
+ defaultLlmModelName: 'claude-opus',
2767
+ defaultLlmAgentName: null,
2768
+ configFilePath: '/path/to/config.yml',
2769
+ maximumPreparingIssuesCount: null,
2770
+ utilizationPercentageThreshold: 90,
2771
+ allowedIssueAuthors: null,
2772
+ codexHomeCandidates: null,
2773
+ allowIssueCacheMinutes: 0,
2774
+ });
2775
+
2776
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2777
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
2778
+ env: {
2779
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
2780
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
2781
+ },
2782
+ });
2783
+ });
2784
+
2785
+ it('should not inject env when every available token is blocked', async () => {
2786
+ const awaitingIssue = createMockIssue({
2787
+ url: 'url1',
2788
+ title: 'Issue 1',
2789
+ labels: ['category:impl'],
2790
+ status: 'Awaiting Workspace',
2791
+ number: 1,
2792
+ itemId: 'item-1',
2793
+ });
2794
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2795
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2796
+ createMockStoryObjectMap([awaitingIssue]),
2797
+ );
2798
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2799
+ stdout: '',
2800
+ stderr: '',
2801
+ exitCode: 0,
2802
+ });
2803
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2804
+ { token: 'token-a', fiveHourUtilization: 5, blocked: true },
2805
+ { token: 'token-b', fiveHourUtilization: 8, blocked: true },
2806
+ ]);
2807
+
2808
+ await useCase.run({
2809
+ projectUrl: 'https://github.com/user/repo',
2810
+ defaultAgentName: 'agent1',
2811
+ defaultLlmModelName: 'claude-opus',
2812
+ defaultLlmAgentName: null,
2813
+ configFilePath: '/path/to/config.yml',
2814
+ maximumPreparingIssuesCount: null,
2815
+ utilizationPercentageThreshold: 90,
2816
+ allowedIssueAuthors: null,
2817
+ codexHomeCandidates: null,
2818
+ allowIssueCacheMinutes: 0,
2819
+ });
2820
+
2821
+ expect(
2822
+ mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
2823
+ ).toHaveLength(0);
2824
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2825
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toBeUndefined();
2826
+ });
2519
2827
  });
@@ -2,6 +2,8 @@ import { IssueRepository } from './adapter-interfaces/IssueRepository';
2
2
  import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
3
3
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
4
4
  import { ClaudeRepository } from './adapter-interfaces/ClaudeRepository';
5
+ import { ClaudeTokenUsageRepository } from './adapter-interfaces/ClaudeTokenUsageRepository';
6
+ import { ClaudeTokenUsage } from '../entities/ClaudeTokenUsage';
5
7
  import {
6
8
  AWAITING_WORKSPACE_STATUS_NAME,
7
9
  PREPARATION_STATUS_NAME,
@@ -22,8 +24,15 @@ export class StartPreparationUseCase {
22
24
  >,
23
25
  private readonly claudeRepository: Pick<ClaudeRepository, 'getUsage'>,
24
26
  private readonly localCommandRunner: LocalCommandRunner,
27
+ private readonly claudeTokenUsageRepository: ClaudeTokenUsageRepository,
25
28
  ) {}
26
29
 
30
+ private selectRotationTokens = (tokenUsages: ClaudeTokenUsage[]): string[] =>
31
+ tokenUsages
32
+ .filter((usage) => !usage.blocked)
33
+ .sort((a, b) => a.fiveHourUtilization - b.fiveHourUtilization)
34
+ .map((usage) => usage.token);
35
+
27
36
  run = async (params: {
28
37
  projectUrl: string;
29
38
  defaultAgentName: string;
@@ -84,6 +93,20 @@ export class StartPreparationUseCase {
84
93
  );
85
94
  }
86
95
  }
96
+
97
+ const tokenUsages =
98
+ await this.claudeTokenUsageRepository.getAvailableTokenUsages();
99
+ let rotationTokens: string[] | null = null;
100
+ let proxyBaseUrl: string | null = null;
101
+ if (tokenUsages.length > 0) {
102
+ const ranked = this.selectRotationTokens(tokenUsages);
103
+ if (ranked.length > 0) {
104
+ await this.claudeTokenUsageRepository.ensureObservable();
105
+ rotationTokens = ranked;
106
+ proxyBaseUrl = this.claudeTokenUsageRepository.proxyBaseUrl();
107
+ }
108
+ }
109
+
87
110
  const project = await this.projectRepository.getByUrl(params.projectUrl);
88
111
  const storyObjectMap = await this.issueRepository.getStoryObjectMap(
89
112
  project,
@@ -271,7 +294,20 @@ export class StartPreparationUseCase {
271
294
  ];
272
295
  awArgs.push('--codexHome', codexHome);
273
296
  }
274
- await this.localCommandRunner.runCommand('aw', awArgs);
297
+ let spawnEnv: Record<string, string> | undefined;
298
+ if (rotationTokens !== null && proxyBaseUrl !== null) {
299
+ const selected =
300
+ rotationTokens[startedInThisRunCount % rotationTokens.length];
301
+ spawnEnv = {
302
+ CLAUDE_CODE_OAUTH_TOKEN: selected,
303
+ ANTHROPIC_BASE_URL: proxyBaseUrl,
304
+ };
305
+ }
306
+ await this.localCommandRunner.runCommand(
307
+ 'aw',
308
+ awArgs,
309
+ spawnEnv ? { env: spawnEnv } : undefined,
310
+ );
275
311
  startedInThisRunCount++;
276
312
  updatedCurrentPreparationIssueCount++;
277
313
  }
@@ -0,0 +1,7 @@
1
+ import { ClaudeTokenUsage } from '../../entities/ClaudeTokenUsage';
2
+
3
+ export interface ClaudeTokenUsageRepository {
4
+ ensureObservable(): Promise<void>;
5
+ getAvailableTokenUsages(): Promise<ClaudeTokenUsage[]>;
6
+ proxyBaseUrl(): string;
7
+ }
@@ -1,7 +1,12 @@
1
+ export interface LocalCommandRunnerOptions {
2
+ env?: Record<string, string>;
3
+ }
4
+
1
5
  export interface LocalCommandRunner {
2
6
  runCommand(
3
7
  program: string,
4
8
  args: string[],
9
+ options?: LocalCommandRunnerOptions,
5
10
  ): Promise<{
6
11
  stdout: string;
7
12
  stderr: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/cli/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EACL,UAAU,EACV,cAAc,EACd,wBAAwB,EACxB,YAAY,EACZ,kBAAkB,GACnB,MAAM,iBAAiB,CAAC;AAiEzB,eAAO,MAAM,OAAO,SAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/cli/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EACL,UAAU,EACV,cAAc,EACd,wBAAwB,EACxB,YAAY,EACZ,kBAAkB,GACnB,MAAM,iBAAiB,CAAC;AAkEzB,eAAO,MAAM,OAAO,SAAgB,CAAC"}
@@ -12,6 +12,7 @@ export type ConfigFile = {
12
12
  projectName?: string;
13
13
  preparationProcessCheckCommand?: string;
14
14
  codexHomeCandidates?: string[];
15
+ claudeCodeOauthTokenListJsonPath?: string;
15
16
  awLogDirectoryPath?: string;
16
17
  awLogStaleThresholdMinutes?: number;
17
18
  };