github-issue-tower-defence-management 1.82.0 → 1.83.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.
@@ -2551,6 +2551,232 @@ describe('StartPreparationUseCase', () => {
2551
2551
  ).toHaveLength(0);
2552
2552
  });
2553
2553
 
2554
+ it('should choose the sooner-7-day-reset token when two eligible tokens have equal remaining capacity', async () => {
2555
+ const awaitingIssue = createMockIssue({
2556
+ url: 'url1',
2557
+ title: 'Issue 1',
2558
+ labels: ['category:impl'],
2559
+ status: 'Awaiting Workspace',
2560
+ number: 1,
2561
+ itemId: 'item-1',
2562
+ });
2563
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2564
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2565
+ createMockStoryObjectMap([awaitingIssue]),
2566
+ );
2567
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2568
+ stdout: '',
2569
+ stderr: '',
2570
+ exitCode: 0,
2571
+ });
2572
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
2573
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2574
+ {
2575
+ name: 'token-far-reset',
2576
+ token: 'token-far-reset',
2577
+ fiveHourUtilization: 0.1,
2578
+ sevenDayUtilization: 0.1,
2579
+ blocked: false,
2580
+ rejected: false,
2581
+ blockedUntilEpoch: 0,
2582
+ modelWeeklyLimits: {
2583
+ seven_day_opus: {
2584
+ rejected: false,
2585
+ resetsAt: nowEpochSeconds + 100 * 3600,
2586
+ },
2587
+ },
2588
+ },
2589
+ {
2590
+ name: 'token-soon-reset',
2591
+ token: 'token-soon-reset',
2592
+ fiveHourUtilization: 0.1,
2593
+ sevenDayUtilization: 0.1,
2594
+ blocked: false,
2595
+ rejected: false,
2596
+ blockedUntilEpoch: 0,
2597
+ modelWeeklyLimits: {
2598
+ seven_day_opus: {
2599
+ rejected: false,
2600
+ resetsAt: nowEpochSeconds + 20 * 3600,
2601
+ },
2602
+ },
2603
+ },
2604
+ ]);
2605
+
2606
+ await useCase.run({
2607
+ projectUrl: 'https://github.com/user/repo',
2608
+ defaultAgentName: 'agent1',
2609
+ defaultLlmModelName: 'claude-opus',
2610
+ fallbackLlmModelName: null,
2611
+ defaultLlmAgentName: null,
2612
+ configFilePath: '/path/to/config.yml',
2613
+ maximumPreparingIssuesCount: null,
2614
+ utilizationPercentageThreshold: 90,
2615
+ allowedIssueAuthors: null,
2616
+ codexHomeCandidates: null,
2617
+ allowIssueCacheMinutes: 0,
2618
+ labelsAsLlmAgentName: null,
2619
+ });
2620
+
2621
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2622
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toMatchObject({
2623
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-soon-reset' },
2624
+ });
2625
+ });
2626
+
2627
+ it('should choose the larger-remaining-capacity token on the tie-break when two eligible tokens share the same 7-day reset', async () => {
2628
+ const awaitingIssue = createMockIssue({
2629
+ url: 'url1',
2630
+ title: 'Issue 1',
2631
+ labels: ['category:impl'],
2632
+ status: 'Awaiting Workspace',
2633
+ number: 1,
2634
+ itemId: 'item-1',
2635
+ });
2636
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2637
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2638
+ createMockStoryObjectMap([awaitingIssue]),
2639
+ );
2640
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2641
+ stdout: '',
2642
+ stderr: '',
2643
+ exitCode: 0,
2644
+ });
2645
+ mockClaudeTokenUsageRepository.getTokenInFlightCounts.mockResolvedValue({
2646
+ 'token-busy': 4,
2647
+ });
2648
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
2649
+ const sharedResetsAt = nowEpochSeconds + 30 * 3600;
2650
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2651
+ {
2652
+ name: 'token-busy',
2653
+ token: 'token-busy',
2654
+ fiveHourUtilization: 0.1,
2655
+ sevenDayUtilization: 0.1,
2656
+ blocked: false,
2657
+ rejected: false,
2658
+ blockedUntilEpoch: 0,
2659
+ modelWeeklyLimits: {
2660
+ seven_day_opus: {
2661
+ rejected: false,
2662
+ resetsAt: sharedResetsAt,
2663
+ },
2664
+ },
2665
+ },
2666
+ {
2667
+ name: 'token-idle',
2668
+ token: 'token-idle',
2669
+ fiveHourUtilization: 0.1,
2670
+ sevenDayUtilization: 0.1,
2671
+ blocked: false,
2672
+ rejected: false,
2673
+ blockedUntilEpoch: 0,
2674
+ modelWeeklyLimits: {
2675
+ seven_day_opus: {
2676
+ rejected: false,
2677
+ resetsAt: sharedResetsAt,
2678
+ },
2679
+ },
2680
+ },
2681
+ ]);
2682
+
2683
+ await useCase.run({
2684
+ projectUrl: 'https://github.com/user/repo',
2685
+ defaultAgentName: 'agent1',
2686
+ defaultLlmModelName: 'claude-opus',
2687
+ fallbackLlmModelName: null,
2688
+ defaultLlmAgentName: null,
2689
+ configFilePath: '/path/to/config.yml',
2690
+ maximumPreparingIssuesCount: null,
2691
+ utilizationPercentageThreshold: 90,
2692
+ allowedIssueAuthors: null,
2693
+ codexHomeCandidates: null,
2694
+ allowIssueCacheMinutes: 0,
2695
+ labelsAsLlmAgentName: null,
2696
+ });
2697
+
2698
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2699
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toMatchObject({
2700
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-idle' },
2701
+ });
2702
+ });
2703
+
2704
+ it('should exclude a token with zero remaining capacity even when its 7-day reset is the soonest', async () => {
2705
+ const awaitingIssue = createMockIssue({
2706
+ url: 'url1',
2707
+ title: 'Issue 1',
2708
+ labels: ['category:impl'],
2709
+ status: 'Awaiting Workspace',
2710
+ number: 1,
2711
+ itemId: 'item-1',
2712
+ });
2713
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
2714
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
2715
+ createMockStoryObjectMap([awaitingIssue]),
2716
+ );
2717
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2718
+ stdout: '',
2719
+ stderr: '',
2720
+ exitCode: 0,
2721
+ });
2722
+ mockClaudeTokenUsageRepository.getTokenInFlightCounts.mockResolvedValue({
2723
+ 'token-soon-but-full': 6,
2724
+ });
2725
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
2726
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
2727
+ {
2728
+ name: 'token-soon-but-full',
2729
+ token: 'token-soon-but-full',
2730
+ fiveHourUtilization: 0.1,
2731
+ sevenDayUtilization: 0.1,
2732
+ blocked: false,
2733
+ rejected: false,
2734
+ blockedUntilEpoch: 0,
2735
+ modelWeeklyLimits: {
2736
+ seven_day_opus: {
2737
+ rejected: false,
2738
+ resetsAt: nowEpochSeconds + 10 * 3600,
2739
+ },
2740
+ },
2741
+ },
2742
+ {
2743
+ name: 'token-later-with-capacity',
2744
+ token: 'token-later-with-capacity',
2745
+ fiveHourUtilization: 0.1,
2746
+ sevenDayUtilization: 0.1,
2747
+ blocked: false,
2748
+ rejected: false,
2749
+ blockedUntilEpoch: 0,
2750
+ modelWeeklyLimits: {
2751
+ seven_day_opus: {
2752
+ rejected: false,
2753
+ resetsAt: nowEpochSeconds + 100 * 3600,
2754
+ },
2755
+ },
2756
+ },
2757
+ ]);
2758
+
2759
+ await useCase.run({
2760
+ projectUrl: 'https://github.com/user/repo',
2761
+ defaultAgentName: 'agent1',
2762
+ defaultLlmModelName: 'claude-opus',
2763
+ fallbackLlmModelName: null,
2764
+ defaultLlmAgentName: null,
2765
+ configFilePath: '/path/to/config.yml',
2766
+ maximumPreparingIssuesCount: null,
2767
+ utilizationPercentageThreshold: 90,
2768
+ allowedIssueAuthors: null,
2769
+ codexHomeCandidates: null,
2770
+ allowIssueCacheMinutes: 0,
2771
+ labelsAsLlmAgentName: null,
2772
+ });
2773
+
2774
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
2775
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toMatchObject({
2776
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-later-with-capacity' },
2777
+ });
2778
+ });
2779
+
2554
2780
  it('should pick the token with the soonest 7-day reset deadline first', async () => {
2555
2781
  const awaitingIssue = createMockIssue({
2556
2782
  url: 'url1',
@@ -2986,7 +3212,7 @@ describe('StartPreparationUseCase', () => {
2986
3212
  consoleWarnSpy.mockRestore();
2987
3213
  });
2988
3214
 
2989
- it('should sort tokens by 7-day reset deadline ascending when all have full process capacity', async () => {
3215
+ it('should drain the soonest-7-day-reset token first across spawns until its remaining capacity is exhausted', async () => {
2990
3216
  const awaitingIssues: Issue[] = [
2991
3217
  createMockIssue({
2992
3218
  url: 'url1',
@@ -3091,7 +3317,91 @@ describe('StartPreparationUseCase', () => {
3091
3317
  env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-soon-reset' },
3092
3318
  });
3093
3319
  expect(mockLocalCommandRunner.runCommand.mock.calls[1][2]).toMatchObject({
3094
- env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-mid-reset' },
3320
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-soon-reset' },
3321
+ });
3322
+ expect(mockLocalCommandRunner.runCommand.mock.calls[2][2]).toMatchObject({
3323
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-soon-reset' },
3324
+ });
3325
+ });
3326
+
3327
+ it('should move to the next-soonest-reset token only after the soonest token capacity is exhausted', async () => {
3328
+ const awaitingIssues: Issue[] = Array.from({ length: 3 }, (_, i) =>
3329
+ createMockIssue({
3330
+ url: `url${i + 1}`,
3331
+ title: `Issue ${i + 1}`,
3332
+ labels: ['category:impl'],
3333
+ status: 'Awaiting Workspace',
3334
+ number: i + 1,
3335
+ itemId: `item-${i + 1}`,
3336
+ }),
3337
+ );
3338
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
3339
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
3340
+ createMockStoryObjectMap(awaitingIssues),
3341
+ );
3342
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
3343
+ stdout: '',
3344
+ stderr: '',
3345
+ exitCode: 0,
3346
+ });
3347
+ mockClaudeTokenUsageRepository.getTokenInFlightCounts.mockResolvedValue({
3348
+ 'token-7d-soon-reset': 5,
3349
+ });
3350
+ const nowEpochSeconds = Math.floor(Date.now() / 1000);
3351
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
3352
+ {
3353
+ name: 'token-7d-soon-reset',
3354
+ token: 'token-7d-soon-reset',
3355
+ fiveHourUtilization: 0.1,
3356
+ sevenDayUtilization: 0.1,
3357
+ blocked: false,
3358
+ rejected: false,
3359
+ blockedUntilEpoch: 0,
3360
+ modelWeeklyLimits: {
3361
+ seven_day_opus: {
3362
+ rejected: false,
3363
+ resetsAt: nowEpochSeconds + 10 * 3600,
3364
+ },
3365
+ },
3366
+ },
3367
+ {
3368
+ name: 'token-7d-far-reset',
3369
+ token: 'token-7d-far-reset',
3370
+ fiveHourUtilization: 0.1,
3371
+ sevenDayUtilization: 0.1,
3372
+ blocked: false,
3373
+ rejected: false,
3374
+ blockedUntilEpoch: 0,
3375
+ modelWeeklyLimits: {
3376
+ seven_day_opus: {
3377
+ rejected: false,
3378
+ resetsAt: nowEpochSeconds + 150 * 3600,
3379
+ },
3380
+ },
3381
+ },
3382
+ ]);
3383
+
3384
+ await useCase.run({
3385
+ projectUrl: 'https://github.com/user/repo',
3386
+ defaultAgentName: 'agent1',
3387
+ defaultLlmModelName: 'claude-opus',
3388
+ fallbackLlmModelName: null,
3389
+ defaultLlmAgentName: null,
3390
+ configFilePath: '/path/to/config.yml',
3391
+ maximumPreparingIssuesCount: null,
3392
+ utilizationPercentageThreshold: 90,
3393
+ allowedIssueAuthors: null,
3394
+ codexHomeCandidates: null,
3395
+ allowIssueCacheMinutes: 0,
3396
+ labelsAsLlmAgentName: null,
3397
+ });
3398
+
3399
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(3);
3400
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toMatchObject({
3401
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-soon-reset' },
3402
+ });
3403
+ expect(mockLocalCommandRunner.runCommand.mock.calls[1][2]).toMatchObject({
3404
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-far-reset' },
3095
3405
  });
3096
3406
  expect(mockLocalCommandRunner.runCommand.mock.calls[2][2]).toMatchObject({
3097
3407
  env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-far-reset' },
@@ -3435,85 +3745,17 @@ describe('StartPreparationUseCase', () => {
3435
3745
  );
3436
3746
  mockLocalCommandRunner.runCommand.mockResolvedValue({
3437
3747
  stdout: '',
3438
- stderr: '',
3439
- exitCode: 0,
3440
- });
3441
- mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue(
3442
- [],
3443
- );
3444
-
3445
- await useCase.run({
3446
- projectUrl: 'https://github.com/user/repo',
3447
- defaultAgentName: 'agent1',
3448
- defaultLlmModelName: 'claude-opus',
3449
- fallbackLlmModelName: null,
3450
- defaultLlmAgentName: null,
3451
- configFilePath: '/path/to/config.yml',
3452
- maximumPreparingIssuesCount: null,
3453
- utilizationPercentageThreshold: 90,
3454
- allowedIssueAuthors: null,
3455
- codexHomeCandidates: null,
3456
- allowIssueCacheMinutes: 0,
3457
- labelsAsLlmAgentName: null,
3458
- });
3459
-
3460
- expect(
3461
- mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
3462
- ).toHaveLength(0);
3463
- expect(mockProjectRepository.getByUrl).toHaveBeenCalled();
3464
- expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
3465
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
3466
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toBeUndefined();
3467
- });
3468
-
3469
- it('should exclude a token whose seven_day_sonnet weekly limit is rejected when the model is sonnet', async () => {
3470
- const awaitingIssue = createMockIssue({
3471
- url: 'url1',
3472
- title: 'Issue 1',
3473
- labels: ['category:impl'],
3474
- status: 'Awaiting Workspace',
3475
- number: 1,
3476
- itemId: 'item-1',
3477
- });
3478
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
3479
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
3480
- createMockStoryObjectMap([awaitingIssue]),
3481
- );
3482
- mockLocalCommandRunner.runCommand.mockResolvedValue({
3483
- stdout: '',
3484
- stderr: '',
3485
- exitCode: 0,
3486
- });
3487
- const futureReset = Math.floor(Date.now() / 1000) + 3600;
3488
- mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
3489
- {
3490
- name: 'token-sonnet-exhausted',
3491
- token: 'token-sonnet-exhausted',
3492
- fiveHourUtilization: 0.1,
3493
- sevenDayUtilization: 0,
3494
- blocked: false,
3495
- rejected: false,
3496
- blockedUntilEpoch: 0,
3497
- modelWeeklyLimits: {
3498
- seven_day_sonnet: { rejected: true, resetsAt: futureReset },
3499
- },
3500
- },
3501
- {
3502
- name: 'token-ok',
3503
- token: 'token-ok',
3504
- fiveHourUtilization: 0.5,
3505
- sevenDayUtilization: 0,
3506
- blocked: false,
3507
- rejected: false,
3508
- blockedUntilEpoch: 0,
3509
- modelWeeklyLimits: {},
3510
- },
3511
- ]);
3748
+ stderr: '',
3749
+ exitCode: 0,
3750
+ });
3751
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue(
3752
+ [],
3753
+ );
3512
3754
 
3513
3755
  await useCase.run({
3514
3756
  projectUrl: 'https://github.com/user/repo',
3515
3757
  defaultAgentName: 'agent1',
3516
- defaultLlmModelName: 'claude-sonnet-4-6',
3758
+ defaultLlmModelName: 'claude-opus',
3517
3759
  fallbackLlmModelName: null,
3518
3760
  defaultLlmAgentName: null,
3519
3761
  configFilePath: '/path/to/config.yml',
@@ -3525,13 +3767,13 @@ describe('StartPreparationUseCase', () => {
3525
3767
  labelsAsLlmAgentName: null,
3526
3768
  });
3527
3769
 
3770
+ expect(
3771
+ mockClaudeTokenUsageRepository.ensureObservable.mock.calls,
3772
+ ).toHaveLength(0);
3773
+ expect(mockProjectRepository.getByUrl).toHaveBeenCalled();
3774
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
3528
3775
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
3529
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
3530
- env: {
3531
- CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
3532
- ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
3533
- },
3534
- });
3776
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toBeUndefined();
3535
3777
  });
3536
3778
 
3537
3779
  it('should re-admit a token whose seven_day_sonnet rejection has been cleared by stale-reset expiry', async () => {
@@ -3967,11 +4209,11 @@ describe('StartPreparationUseCase', () => {
3967
4209
  env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-b-known-soon-reset' },
3968
4210
  });
3969
4211
  expect(mockLocalCommandRunner.runCommand.mock.calls[1][2]).toMatchObject({
3970
- env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-a-unknown-reset' },
4212
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-b-known-soon-reset' },
3971
4213
  });
3972
4214
  });
3973
4215
 
3974
- it('should sort tokens by 7-day reset deadline ascending when only the generic seven_day weekly limit (as bridged by the proxy from the snapshot top-level sevenDayReset) is present', async () => {
4216
+ it('should drain the soonest-reset token first when only the generic seven_day weekly limit (as bridged by the proxy from the snapshot top-level sevenDayReset) is present', async () => {
3975
4217
  const awaitingIssues: Issue[] = [
3976
4218
  createMockIssue({
3977
4219
  url: 'url1',
@@ -4076,10 +4318,10 @@ describe('StartPreparationUseCase', () => {
4076
4318
  env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-soon' },
4077
4319
  });
4078
4320
  expect(mockLocalCommandRunner.runCommand.mock.calls[1][2]).toMatchObject({
4079
- env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-mid' },
4321
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-soon' },
4080
4322
  });
4081
4323
  expect(mockLocalCommandRunner.runCommand.mock.calls[2][2]).toMatchObject({
4082
- env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-far' },
4324
+ env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-7d-soon' },
4083
4325
  });
4084
4326
  });
4085
4327
 
@@ -4705,6 +4947,271 @@ describe('StartPreparationUseCase', () => {
4705
4947
  });
4706
4948
  });
4707
4949
 
4950
+ describe('per-token model weekly-limit routing', () => {
4951
+ const futureReset = Math.floor(Date.now() / 1000) + 3600;
4952
+
4953
+ it('routes a token whose seven_day_sonnet weekly limit is rejected to Opus while a sibling token with Sonnet headroom uses Sonnet in the same pass', async () => {
4954
+ const issues = [
4955
+ createMockIssue({
4956
+ url: 'url1',
4957
+ title: 'Issue 1',
4958
+ labels: ['category:impl'],
4959
+ status: 'Awaiting Workspace',
4960
+ number: 1,
4961
+ itemId: 'item-1',
4962
+ }),
4963
+ createMockIssue({
4964
+ url: 'url2',
4965
+ title: 'Issue 2',
4966
+ labels: ['category:impl'],
4967
+ status: 'Awaiting Workspace',
4968
+ number: 2,
4969
+ itemId: 'item-2',
4970
+ }),
4971
+ ];
4972
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
4973
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
4974
+ createMockStoryObjectMap(issues),
4975
+ );
4976
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
4977
+ stdout: '',
4978
+ stderr: '',
4979
+ exitCode: 0,
4980
+ });
4981
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
4982
+ {
4983
+ name: 'token-sonnet-exhausted',
4984
+ token: 'token-sonnet-exhausted',
4985
+ fiveHourUtilization: 0.1,
4986
+ sevenDayUtilization: 0,
4987
+ blocked: false,
4988
+ rejected: false,
4989
+ blockedUntilEpoch: 0,
4990
+ modelWeeklyLimits: {
4991
+ seven_day_sonnet: { rejected: true, resetsAt: futureReset },
4992
+ },
4993
+ },
4994
+ {
4995
+ name: 'token-sonnet-ok',
4996
+ token: 'token-sonnet-ok',
4997
+ fiveHourUtilization: 0.5,
4998
+ sevenDayUtilization: 0,
4999
+ blocked: false,
5000
+ rejected: false,
5001
+ blockedUntilEpoch: 0,
5002
+ modelWeeklyLimits: {},
5003
+ },
5004
+ ]);
5005
+
5006
+ await useCase.run({
5007
+ projectUrl: 'https://github.com/user/repo',
5008
+ defaultAgentName: 'agent1',
5009
+ defaultLlmModelName: 'claude-sonnet-4-6',
5010
+ fallbackLlmModelName: 'claude-opus-4-8',
5011
+ defaultLlmAgentName: null,
5012
+ configFilePath: '/path/to/config.yml',
5013
+ maximumPreparingIssuesCount: null,
5014
+ utilizationPercentageThreshold: 90,
5015
+ allowedIssueAuthors: null,
5016
+ codexHomeCandidates: null,
5017
+ allowIssueCacheMinutes: 0,
5018
+ labelsAsLlmAgentName: null,
5019
+ });
5020
+
5021
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(2);
5022
+ const callForExhausted =
5023
+ mockLocalCommandRunner.runCommand.mock.calls.find(
5024
+ (call) =>
5025
+ call[2]?.env?.CLAUDE_CODE_OAUTH_TOKEN === 'token-sonnet-exhausted',
5026
+ );
5027
+ const callForOk = mockLocalCommandRunner.runCommand.mock.calls.find(
5028
+ (call) => call[2]?.env?.CLAUDE_CODE_OAUTH_TOKEN === 'token-sonnet-ok',
5029
+ );
5030
+ expect(callForExhausted).toBeDefined();
5031
+ expect(callForOk).toBeDefined();
5032
+ expect(callForExhausted?.[1][2]).toBe('claude-opus-4-8');
5033
+ expect(callForOk?.[1][2]).toBe('claude-sonnet-4-6');
5034
+ });
5035
+
5036
+ it('excludes a token whose seven_day_sonnet and seven_day_opus weekly limits are both rejected', async () => {
5037
+ const awaitingIssue = createMockIssue({
5038
+ url: 'url1',
5039
+ title: 'Issue 1',
5040
+ labels: ['category:impl'],
5041
+ status: 'Awaiting Workspace',
5042
+ number: 1,
5043
+ itemId: 'item-1',
5044
+ });
5045
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
5046
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
5047
+ createMockStoryObjectMap([awaitingIssue]),
5048
+ );
5049
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
5050
+ stdout: '',
5051
+ stderr: '',
5052
+ exitCode: 0,
5053
+ });
5054
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
5055
+ {
5056
+ name: 'token-both-exhausted',
5057
+ token: 'token-both-exhausted',
5058
+ fiveHourUtilization: 0.1,
5059
+ sevenDayUtilization: 0,
5060
+ blocked: false,
5061
+ rejected: false,
5062
+ blockedUntilEpoch: 0,
5063
+ modelWeeklyLimits: {
5064
+ seven_day_sonnet: { rejected: true, resetsAt: futureReset },
5065
+ seven_day_opus: { rejected: true, resetsAt: futureReset },
5066
+ },
5067
+ },
5068
+ ]);
5069
+ const consoleWarnSpy = jest
5070
+ .spyOn(console, 'warn')
5071
+ .mockImplementation(() => {});
5072
+
5073
+ await useCase.run({
5074
+ projectUrl: 'https://github.com/user/repo',
5075
+ defaultAgentName: 'agent1',
5076
+ defaultLlmModelName: 'claude-sonnet-4-6',
5077
+ fallbackLlmModelName: 'claude-opus-4-8',
5078
+ defaultLlmAgentName: null,
5079
+ configFilePath: '/path/to/config.yml',
5080
+ maximumPreparingIssuesCount: null,
5081
+ utilizationPercentageThreshold: 90,
5082
+ allowedIssueAuthors: null,
5083
+ codexHomeCandidates: null,
5084
+ allowIssueCacheMinutes: 0,
5085
+ labelsAsLlmAgentName: null,
5086
+ });
5087
+
5088
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
5089
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
5090
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
5091
+ expect.stringContaining('Skipping starting preparation'),
5092
+ );
5093
+ consoleWarnSpy.mockRestore();
5094
+ });
5095
+
5096
+ it('excludes a token whose generic seven_day weekly limit is rejected even when the per-model windows are open', async () => {
5097
+ const awaitingIssue = createMockIssue({
5098
+ url: 'url1',
5099
+ title: 'Issue 1',
5100
+ labels: ['category:impl'],
5101
+ status: 'Awaiting Workspace',
5102
+ number: 1,
5103
+ itemId: 'item-1',
5104
+ });
5105
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
5106
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
5107
+ createMockStoryObjectMap([awaitingIssue]),
5108
+ );
5109
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
5110
+ stdout: '',
5111
+ stderr: '',
5112
+ exitCode: 0,
5113
+ });
5114
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
5115
+ {
5116
+ name: 'token-general-exhausted',
5117
+ token: 'token-general-exhausted',
5118
+ fiveHourUtilization: 0.1,
5119
+ sevenDayUtilization: 0,
5120
+ blocked: false,
5121
+ rejected: false,
5122
+ blockedUntilEpoch: 0,
5123
+ modelWeeklyLimits: {
5124
+ seven_day: { rejected: true, resetsAt: futureReset },
5125
+ },
5126
+ },
5127
+ ]);
5128
+ const consoleWarnSpy = jest
5129
+ .spyOn(console, 'warn')
5130
+ .mockImplementation(() => {});
5131
+
5132
+ await useCase.run({
5133
+ projectUrl: 'https://github.com/user/repo',
5134
+ defaultAgentName: 'agent1',
5135
+ defaultLlmModelName: 'claude-sonnet-4-6',
5136
+ fallbackLlmModelName: 'claude-opus-4-8',
5137
+ defaultLlmAgentName: null,
5138
+ configFilePath: '/path/to/config.yml',
5139
+ maximumPreparingIssuesCount: null,
5140
+ utilizationPercentageThreshold: 90,
5141
+ allowedIssueAuthors: null,
5142
+ codexHomeCandidates: null,
5143
+ allowIssueCacheMinutes: 0,
5144
+ labelsAsLlmAgentName: null,
5145
+ });
5146
+
5147
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
5148
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
5149
+ expect.stringContaining('Skipping starting preparation'),
5150
+ );
5151
+ consoleWarnSpy.mockRestore();
5152
+ });
5153
+
5154
+ it('lets a per-issue llm-model label override the per-token routed model', async () => {
5155
+ const awaitingIssue = createMockIssue({
5156
+ url: 'url1',
5157
+ title: 'Issue 1',
5158
+ labels: ['category:impl', 'llm-model:claude-3-5-haiku-20241022'],
5159
+ status: 'Awaiting Workspace',
5160
+ number: 1,
5161
+ itemId: 'item-1',
5162
+ });
5163
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
5164
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
5165
+ createMockStoryObjectMap([awaitingIssue]),
5166
+ );
5167
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
5168
+ stdout: '',
5169
+ stderr: '',
5170
+ exitCode: 0,
5171
+ });
5172
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
5173
+ {
5174
+ name: 'token-sonnet-exhausted',
5175
+ token: 'token-sonnet-exhausted',
5176
+ fiveHourUtilization: 0.1,
5177
+ sevenDayUtilization: 0,
5178
+ blocked: false,
5179
+ rejected: false,
5180
+ blockedUntilEpoch: 0,
5181
+ modelWeeklyLimits: {
5182
+ seven_day_sonnet: { rejected: true, resetsAt: futureReset },
5183
+ },
5184
+ },
5185
+ ]);
5186
+
5187
+ await useCase.run({
5188
+ projectUrl: 'https://github.com/user/repo',
5189
+ defaultAgentName: 'agent1',
5190
+ defaultLlmModelName: 'claude-sonnet-4-6',
5191
+ fallbackLlmModelName: 'claude-opus-4-8',
5192
+ defaultLlmAgentName: null,
5193
+ configFilePath: '/path/to/config.yml',
5194
+ maximumPreparingIssuesCount: null,
5195
+ utilizationPercentageThreshold: 90,
5196
+ allowedIssueAuthors: null,
5197
+ codexHomeCandidates: null,
5198
+ allowIssueCacheMinutes: 0,
5199
+ labelsAsLlmAgentName: null,
5200
+ });
5201
+
5202
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
5203
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][1][2]).toBe(
5204
+ 'claude-3-5-haiku-20241022',
5205
+ );
5206
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
5207
+ env: {
5208
+ CLAUDE_CODE_OAUTH_TOKEN: 'token-sonnet-exhausted',
5209
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
5210
+ },
5211
+ });
5212
+ });
5213
+ });
5214
+
4708
5215
  describe('per-token in-flight global concurrency enforcement', () => {
4709
5216
  it('should not spawn when the selected token already has its full in-flight limit occupied by processes from other projects', async () => {
4710
5217
  const awaitingIssues: Issue[] = [