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.
- package/.github/workflows/commit-lint.yml +7 -2
- package/.github/workflows/create-pr.yml +23 -5
- package/CHANGELOG.md +14 -0
- package/README.md +4 -4
- package/bin/domain/usecases/StartPreparationUseCase.js +70 -49
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/domain/usecases/StartPreparationUseCase.test.ts +594 -87
- package/src/domain/usecases/StartPreparationUseCase.ts +118 -78
- package/types/domain/usecases/StartPreparationUseCase.d.ts +1 -0
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
|
@@ -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
|
|
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-
|
|
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-
|
|
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]).
|
|
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-
|
|
4212
|
+
env: { CLAUDE_CODE_OAUTH_TOKEN: 'token-b-known-soon-reset' },
|
|
3971
4213
|
});
|
|
3972
4214
|
});
|
|
3973
4215
|
|
|
3974
|
-
it('should
|
|
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-
|
|
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-
|
|
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[] = [
|