github-issue-tower-defence-management 1.82.1 → 1.84.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 (27) hide show
  1. package/.github/workflows/commit-lint.yml +7 -2
  2. package/.github/workflows/create-pr.yml +23 -5
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +67 -4
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +19 -2
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/consoleListsWriter.js +43 -0
  8. package/bin/adapter/entry-points/handlers/consoleListsWriter.js.map +1 -0
  9. package/bin/domain/usecases/StartPreparationUseCase.js +60 -46
  10. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  11. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +101 -0
  12. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -0
  13. package/package.json +1 -1
  14. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +18 -0
  15. package/src/adapter/entry-points/handlers/consoleListsWriter.test.ts +167 -0
  16. package/src/adapter/entry-points/handlers/consoleListsWriter.ts +60 -0
  17. package/src/domain/usecases/StartPreparationUseCase.test.ts +265 -68
  18. package/src/domain/usecases/StartPreparationUseCase.ts +94 -73
  19. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +372 -0
  20. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +206 -0
  21. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  22. package/types/adapter/entry-points/handlers/consoleListsWriter.d.ts +13 -0
  23. package/types/adapter/entry-points/handlers/consoleListsWriter.d.ts.map +1 -0
  24. package/types/domain/usecases/StartPreparationUseCase.d.ts +1 -0
  25. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  26. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +63 -0
  27. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -0
@@ -3776,74 +3776,6 @@ describe('StartPreparationUseCase', () => {
3776
3776
  expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toBeUndefined();
3777
3777
  });
3778
3778
 
3779
- it('should exclude a token whose seven_day_sonnet weekly limit is rejected when the model is sonnet', async () => {
3780
- const awaitingIssue = createMockIssue({
3781
- url: 'url1',
3782
- title: 'Issue 1',
3783
- labels: ['category:impl'],
3784
- status: 'Awaiting Workspace',
3785
- number: 1,
3786
- itemId: 'item-1',
3787
- });
3788
- mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
3789
- mockIssueRepository.getStoryObjectMap.mockResolvedValue(
3790
- createMockStoryObjectMap([awaitingIssue]),
3791
- );
3792
- mockLocalCommandRunner.runCommand.mockResolvedValue({
3793
- stdout: '',
3794
- stderr: '',
3795
- exitCode: 0,
3796
- });
3797
- const futureReset = Math.floor(Date.now() / 1000) + 3600;
3798
- mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
3799
- {
3800
- name: 'token-sonnet-exhausted',
3801
- token: 'token-sonnet-exhausted',
3802
- fiveHourUtilization: 0.1,
3803
- sevenDayUtilization: 0,
3804
- blocked: false,
3805
- rejected: false,
3806
- blockedUntilEpoch: 0,
3807
- modelWeeklyLimits: {
3808
- seven_day_sonnet: { rejected: true, resetsAt: futureReset },
3809
- },
3810
- },
3811
- {
3812
- name: 'token-ok',
3813
- token: 'token-ok',
3814
- fiveHourUtilization: 0.5,
3815
- sevenDayUtilization: 0,
3816
- blocked: false,
3817
- rejected: false,
3818
- blockedUntilEpoch: 0,
3819
- modelWeeklyLimits: {},
3820
- },
3821
- ]);
3822
-
3823
- await useCase.run({
3824
- projectUrl: 'https://github.com/user/repo',
3825
- defaultAgentName: 'agent1',
3826
- defaultLlmModelName: 'claude-sonnet-4-6',
3827
- fallbackLlmModelName: null,
3828
- defaultLlmAgentName: null,
3829
- configFilePath: '/path/to/config.yml',
3830
- maximumPreparingIssuesCount: null,
3831
- utilizationPercentageThreshold: 90,
3832
- allowedIssueAuthors: null,
3833
- codexHomeCandidates: null,
3834
- allowIssueCacheMinutes: 0,
3835
- labelsAsLlmAgentName: null,
3836
- });
3837
-
3838
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
3839
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][2]).toEqual({
3840
- env: {
3841
- CLAUDE_CODE_OAUTH_TOKEN: 'token-ok',
3842
- ANTHROPIC_BASE_URL: 'http://127.0.0.1:8787',
3843
- },
3844
- });
3845
- });
3846
-
3847
3779
  it('should re-admit a token whose seven_day_sonnet rejection has been cleared by stale-reset expiry', async () => {
3848
3780
  const awaitingIssue = createMockIssue({
3849
3781
  url: 'url1',
@@ -5015,6 +4947,271 @@ describe('StartPreparationUseCase', () => {
5015
4947
  });
5016
4948
  });
5017
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
+
5018
5215
  describe('per-token in-flight global concurrency enforcement', () => {
5019
5216
  it('should not spawn when the selected token already has its full in-flight limit occupied by processes from other projects', async () => {
5020
5217
  const awaitingIssues: Issue[] = [
@@ -61,6 +61,29 @@ export class StartPreparationUseCase {
61
61
  return general !== undefined && general.rejected;
62
62
  };
63
63
 
64
+ private selectModelForToken = (
65
+ usage: ClaudeTokenUsage,
66
+ defaultModelName: string | null,
67
+ fallbackModelName: string | null,
68
+ ): string | null => {
69
+ const generalWeeklyLimit = usage.modelWeeklyLimits['seven_day'];
70
+ if (generalWeeklyLimit !== undefined && generalWeeklyLimit.rejected) {
71
+ return null;
72
+ }
73
+ const candidateModelNames = [defaultModelName, fallbackModelName].filter(
74
+ (modelName): modelName is string =>
75
+ modelName !== null && modelName !== '',
76
+ );
77
+ for (const candidateModelName of candidateModelNames) {
78
+ const weeklyLimitType = this.weeklyLimitTypeForModel(candidateModelName);
79
+ const specificWeeklyLimit = usage.modelWeeklyLimits[weeklyLimitType];
80
+ if (specificWeeklyLimit === undefined || !specificWeeklyLimit.rejected) {
81
+ return candidateModelName;
82
+ }
83
+ }
84
+ return null;
85
+ };
86
+
64
87
  private secondsUntilSevenDayReset = (
65
88
  usage: ClaudeTokenUsage,
66
89
  weeklyLimitType: string,
@@ -79,18 +102,19 @@ export class StartPreparationUseCase {
79
102
 
80
103
  private compareBySevenDayDeadlineThenUtilization = (
81
104
  a: ClaudeTokenUsage,
105
+ aWeeklyLimitType: string,
82
106
  b: ClaudeTokenUsage,
83
- weeklyLimitType: string,
107
+ bWeeklyLimitType: string,
84
108
  nowEpochSeconds: number,
85
109
  ): number => {
86
110
  const aSecondsUntilReset = this.secondsUntilSevenDayReset(
87
111
  a,
88
- weeklyLimitType,
112
+ aWeeklyLimitType,
89
113
  nowEpochSeconds,
90
114
  );
91
115
  const bSecondsUntilReset = this.secondsUntilSevenDayReset(
92
116
  b,
93
- weeklyLimitType,
117
+ bWeeklyLimitType,
94
118
  nowEpochSeconds,
95
119
  );
96
120
  if (aSecondsUntilReset !== bSecondsUntilReset) {
@@ -128,35 +152,43 @@ export class StartPreparationUseCase {
128
152
  private selectRotationTokens = (
129
153
  tokenUsages: ClaudeTokenUsage[],
130
154
  utilizationPercentageThreshold: number,
131
- modelName: string | null,
155
+ defaultModelName: string | null,
156
+ fallbackModelName: string | null,
132
157
  maxConcurrent: number,
133
158
  ): {
134
159
  tokens: string[];
135
160
  effectiveCap: number;
136
161
  tokensWithLimits: Array<{
137
162
  token: string;
163
+ model: string;
138
164
  limit: number;
139
165
  secondsUntilSevenDayReset: number;
140
166
  }>;
141
167
  } => {
142
- const weeklyLimitType = this.weeklyLimitTypeForModel(modelName);
143
168
  const nowEpochSeconds = Date.now() / 1000;
144
169
  const eligibleTokens = tokenUsages
145
170
  .filter((usage) => !usage.blocked)
146
171
  .filter((usage) => !usage.rejected)
147
172
  .filter((usage) => !this.isWithinCooldown(usage, nowEpochSeconds))
148
- .filter(
149
- (usage) => !this.isModelWeeklyLimitRejected(usage, weeklyLimitType),
150
- )
151
173
  .filter(
152
174
  (usage) =>
153
175
  usage.fiveHourUtilization * 100 < utilizationPercentageThreshold,
154
176
  )
177
+ .flatMap((usage) => {
178
+ const model = this.selectModelForToken(
179
+ usage,
180
+ defaultModelName,
181
+ fallbackModelName,
182
+ );
183
+ if (model === null) return [];
184
+ return [{ usage, model }];
185
+ })
155
186
  .sort((a, b) =>
156
187
  this.compareBySevenDayDeadlineThenUtilization(
157
- a,
158
- b,
159
- weeklyLimitType,
188
+ a.usage,
189
+ this.weeklyLimitTypeForModel(a.model),
190
+ b.usage,
191
+ this.weeklyLimitTypeForModel(b.model),
160
192
  nowEpochSeconds,
161
193
  ),
162
194
  );
@@ -165,15 +197,16 @@ export class StartPreparationUseCase {
165
197
  return { tokens: [], effectiveCap: 0, tokensWithLimits: [] };
166
198
  }
167
199
 
168
- const tokensWithLimits = eligibleTokens.map((usage) => ({
200
+ const tokensWithLimits = eligibleTokens.map(({ usage, model }) => ({
169
201
  token: usage.token,
202
+ model,
170
203
  limit: this.getTokenConcurrentLimit(
171
204
  usage.fiveHourUtilization,
172
205
  usage.sevenDayUtilization,
173
206
  ),
174
207
  secondsUntilSevenDayReset: this.secondsUntilSevenDayReset(
175
208
  usage,
176
- weeklyLimitType,
209
+ this.weeklyLimitTypeForModel(model),
177
210
  nowEpochSeconds,
178
211
  ),
179
212
  }));
@@ -215,6 +248,7 @@ export class StartPreparationUseCase {
215
248
  .sort((a, b) =>
216
249
  this.compareBySevenDayDeadlineThenUtilization(
217
250
  a,
251
+ weeklyLimitType,
218
252
  b,
219
253
  weeklyLimitType,
220
254
  nowEpochSeconds,
@@ -272,6 +306,7 @@ export class StartPreparationUseCase {
272
306
  let proxyBaseUrl: string | null = null;
273
307
  let selectedTokensWithLimits: Array<{
274
308
  token: string;
309
+ model: string;
275
310
  limit: number;
276
311
  secondsUntilSevenDayReset: number;
277
312
  }> = [];
@@ -287,51 +322,23 @@ export class StartPreparationUseCase {
287
322
  const maximumPreparingIssuesCount =
288
323
  params.maximumPreparingIssuesCount ?? NORMAL_CONCURRENT_LIMIT;
289
324
  let effectiveMaxPreparingIssuesCount = maximumPreparingIssuesCount;
290
- let effectiveDefaultLlmModelName = params.defaultLlmModelName;
325
+ const fallbackLlmModelName =
326
+ params.fallbackLlmModelName ?? DEFAULT_FALLBACK_LLM_MODEL_NAME;
291
327
  if (tokenUsages.length > 0) {
292
328
  const {
293
- tokens: ranked,
294
- effectiveCap,
295
- tokensWithLimits: rankedTokensWithLimits,
329
+ tokens: selectedTokens,
330
+ effectiveCap: selectedCap,
331
+ tokensWithLimits: selectedTokensWithLimitsLocal,
296
332
  } = this.selectRotationTokens(
297
333
  tokenUsages,
298
334
  params.utilizationPercentageThreshold,
299
335
  params.defaultLlmModelName,
336
+ fallbackLlmModelName,
300
337
  maximumPreparingIssuesCount,
301
338
  );
302
- let selectedTokens = ranked;
303
- let selectedCap = effectiveCap;
304
- let selectedTokensWithLimitsLocal = rankedTokensWithLimits;
305
- if (
306
- selectedTokens.length === 0 &&
307
- this.weeklyLimitTypeForModel(params.defaultLlmModelName) ===
308
- 'seven_day_sonnet'
309
- ) {
310
- const fallbackModelName =
311
- params.fallbackLlmModelName ?? DEFAULT_FALLBACK_LLM_MODEL_NAME;
312
- const {
313
- tokens: fallbackRanked,
314
- effectiveCap: fallbackCap,
315
- tokensWithLimits: fallbackTokensWithLimits,
316
- } = this.selectRotationTokens(
317
- tokenUsages,
318
- params.utilizationPercentageThreshold,
319
- fallbackModelName,
320
- maximumPreparingIssuesCount,
321
- );
322
- if (fallbackRanked.length > 0) {
323
- console.warn(
324
- `Sonnet 7-day weekly limit (${this.weeklyLimitTypeForModel(params.defaultLlmModelName)}) is exhausted across all configured Claude OAuth token(s). Falling back to ${fallbackModelName}.`,
325
- );
326
- selectedTokens = fallbackRanked;
327
- selectedCap = fallbackCap;
328
- selectedTokensWithLimitsLocal = fallbackTokensWithLimits;
329
- effectiveDefaultLlmModelName = fallbackModelName;
330
- }
331
- }
332
339
  if (selectedTokens.length === 0) {
333
340
  console.warn(
334
- `All ${tokenUsages.length} configured Claude OAuth token(s) are unavailable (blocked, rejected, weekly limit for ${this.weeklyLimitTypeForModel(params.defaultLlmModelName)} exhausted, or 5h utilization >= ${params.utilizationPercentageThreshold}%). Skipping starting preparation.`,
341
+ `All ${tokenUsages.length} configured Claude OAuth token(s) are unavailable (blocked, rejected, weekly limits for the configured model(s) exhausted, or 5h utilization >= ${params.utilizationPercentageThreshold}%). Skipping starting preparation.`,
335
342
  );
336
343
  return { rotationOrder };
337
344
  }
@@ -434,12 +441,15 @@ export class StartPreparationUseCase {
434
441
  .trim() ||
435
442
  params.defaultLlmAgentName ||
436
443
  params.defaultAgentName;
437
- const model =
438
- issue.labels
439
- .find((label: string) => label.startsWith('llm-model:'))
440
- ?.replace('llm-model:', '')
441
- .trim() || effectiveDefaultLlmModelName;
442
- if (!model) {
444
+ const labelModelName = issue.labels
445
+ .find((label: string) => label.startsWith('llm-model:'))
446
+ ?.replace('llm-model:', '')
447
+ .trim();
448
+ if (
449
+ !labelModelName &&
450
+ !params.defaultLlmModelName &&
451
+ rotationTokens === null
452
+ ) {
443
453
  console.error(
444
454
  `No LLM model configured for issue ${issue.url}. Provide --defaultLlmModelName or add an llm-model: label.`,
445
455
  );
@@ -522,30 +532,13 @@ export class StartPreparationUseCase {
522
532
  );
523
533
  issue.status = PREPARATION_STATUS_NAME;
524
534
 
525
- const awArgs: string[] = [
526
- issue.url,
527
- agent,
528
- model,
529
- '--configFilePath',
530
- params.configFilePath,
531
- '--branch',
532
- branchName,
533
- ];
534
- if (
535
- params.codexHomeCandidates !== null &&
536
- params.codexHomeCandidates.length > 0
537
- ) {
538
- const codexHome =
539
- params.codexHomeCandidates[
540
- startedInThisRunCount % params.codexHomeCandidates.length
541
- ];
542
- awArgs.push('--codexHome', codexHome);
543
- }
544
535
  let spawnEnv: Record<string, string> | undefined;
536
+ let routedModelName: string | null = null;
545
537
  if (rotationTokens !== null && proxyBaseUrl !== null) {
546
538
  const tokenWithSoonestResetAmongAvailable = selectedTokensWithLimits
547
539
  .map((t) => ({
548
540
  token: t.token,
541
+ model: t.model,
549
542
  remaining:
550
543
  t.limit -
551
544
  (tokenInFlightCounts[t.token] ?? 0) -
@@ -563,6 +556,7 @@ export class StartPreparationUseCase {
563
556
  break;
564
557
  }
565
558
  const selected = tokenWithSoonestResetAmongAvailable.token;
559
+ routedModelName = tokenWithSoonestResetAmongAvailable.model;
566
560
  spawnedInThisRunByToken[selected] =
567
561
  (spawnedInThisRunByToken[selected] ?? 0) + 1;
568
562
  spawnEnv = {
@@ -570,6 +564,33 @@ export class StartPreparationUseCase {
570
564
  ANTHROPIC_BASE_URL: proxyBaseUrl,
571
565
  };
572
566
  }
567
+ const model =
568
+ labelModelName || routedModelName || params.defaultLlmModelName;
569
+ if (!model) {
570
+ console.error(
571
+ `No LLM model configured for issue ${issue.url}. Provide --defaultLlmModelName or add an llm-model: label.`,
572
+ );
573
+ continue;
574
+ }
575
+ const awArgs: string[] = [
576
+ issue.url,
577
+ agent,
578
+ model,
579
+ '--configFilePath',
580
+ params.configFilePath,
581
+ '--branch',
582
+ branchName,
583
+ ];
584
+ if (
585
+ params.codexHomeCandidates !== null &&
586
+ params.codexHomeCandidates.length > 0
587
+ ) {
588
+ const codexHome =
589
+ params.codexHomeCandidates[
590
+ startedInThisRunCount % params.codexHomeCandidates.length
591
+ ];
592
+ awArgs.push('--codexHome', codexHome);
593
+ }
573
594
  await this.localCommandRunner.runCommand(
574
595
  'aw',
575
596
  awArgs,