github-issue-tower-defence-management 2.10.2 → 2.12.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 (85) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +9 -1
  3. package/bin/adapter/entry-points/cli/fleetConfig.js +7 -1
  4. package/bin/adapter/entry-points/cli/fleetConfig.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/index.js +11 -3
  6. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  7. package/bin/adapter/entry-points/console/consoleTabNames.js +1 -0
  8. package/bin/adapter/entry-points/console/consoleTabNames.js.map +1 -1
  9. package/bin/adapter/entry-points/console/ui-dist/assets/index-CvJgSVoN.js +88 -0
  10. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  11. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +2 -1
  12. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  13. package/bin/adapter/repositories/CliGitHubGraphqlRateLimitRepository.js +24 -0
  14. package/bin/adapter/repositories/CliGitHubGraphqlRateLimitRepository.js.map +1 -0
  15. package/bin/adapter/repositories/KySlackRepository.js +2 -1
  16. package/bin/adapter/repositories/KySlackRepository.js.map +1 -1
  17. package/bin/domain/usecases/StartPreparationUseCase.js +22 -1
  18. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  19. package/bin/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.js +3 -0
  20. package/bin/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.js.map +1 -0
  21. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +14 -0
  22. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
  23. package/package.json +1 -1
  24. package/src/adapter/entry-points/cli/fleetConfig.test.ts +99 -0
  25. package/src/adapter/entry-points/cli/fleetConfig.ts +25 -0
  26. package/src/adapter/entry-points/cli/index.test.ts +4 -0
  27. package/src/adapter/entry-points/cli/index.ts +16 -2
  28. package/src/adapter/entry-points/console/consoleTabNames.ts +1 -0
  29. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +41 -0
  30. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +52 -2
  31. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +4 -0
  32. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +1 -0
  33. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemList.stories.tsx +5 -1
  34. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemList.tsx +4 -0
  35. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemSummary.stories.tsx +9 -2
  36. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemSummary.test.tsx +37 -1
  37. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemSummary.tsx +13 -2
  38. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleQueuedItemRow.stories.tsx +50 -0
  39. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleQueuedItemRow.test.tsx +98 -0
  40. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleQueuedItemRow.tsx +66 -0
  41. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleQueuedList.stories.tsx +69 -0
  42. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleQueuedList.test.tsx +101 -0
  43. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleQueuedList.tsx +75 -0
  44. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleNavigation.test.ts +1 -0
  45. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +71 -0
  46. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +2 -0
  47. package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.test.ts +26 -0
  48. package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.ts +6 -0
  49. package/src/adapter/entry-points/console/ui/src/features/console/logic/colors.ts +12 -0
  50. package/src/adapter/entry-points/console/ui/src/features/console/logic/tabAdvance.test.ts +1 -0
  51. package/src/adapter/entry-points/console/ui/src/features/console/logic/types.ts +11 -1
  52. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +58 -0
  53. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +25 -9
  54. package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +14 -0
  55. package/src/adapter/entry-points/console/ui-dist/assets/index-CvJgSVoN.js +88 -0
  56. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  57. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -0
  58. package/src/adapter/repositories/CliGitHubGraphqlRateLimitRepository.test.ts +71 -0
  59. package/src/adapter/repositories/CliGitHubGraphqlRateLimitRepository.ts +21 -0
  60. package/src/adapter/repositories/KySlackRepository.test.ts +49 -1
  61. package/src/adapter/repositories/KySlackRepository.ts +3 -2
  62. package/src/domain/usecases/ConflictedIssueRevertUseCase.test.ts +74 -0
  63. package/src/domain/usecases/StartPreparationUseCase.test.ts +229 -0
  64. package/src/domain/usecases/StartPreparationUseCase.ts +35 -0
  65. package/src/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.ts +3 -0
  66. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +84 -0
  67. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +39 -0
  68. package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +1 -0
  69. package/types/adapter/entry-points/cli/fleetConfig.d.ts +4 -0
  70. package/types/adapter/entry-points/cli/fleetConfig.d.ts.map +1 -1
  71. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  72. package/types/adapter/entry-points/console/consoleTabNames.d.ts.map +1 -1
  73. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  74. package/types/adapter/repositories/CliGitHubGraphqlRateLimitRepository.d.ts +8 -0
  75. package/types/adapter/repositories/CliGitHubGraphqlRateLimitRepository.d.ts.map +1 -0
  76. package/types/adapter/repositories/KySlackRepository.d.ts +1 -1
  77. package/types/adapter/repositories/KySlackRepository.d.ts.map +1 -1
  78. package/types/domain/usecases/StartPreparationUseCase.d.ts +5 -1
  79. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  80. package/types/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.d.ts +4 -0
  81. package/types/domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository.d.ts.map +1 -0
  82. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +13 -1
  83. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
  84. package/bin/adapter/entry-points/console/ui-dist/assets/index-BSa2JTna.js +0 -88
  85. package/src/adapter/entry-points/console/ui-dist/assets/index-BSa2JTna.js +0 -88
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-BSa2JTna.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-CvJgSVoN.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-CLHvrafO.css">
9
9
  </head>
10
10
  <body>
@@ -68,6 +68,7 @@ import { UpdateIssueStatusByLabelUseCase } from '../../../domain/usecases/Update
68
68
  import { IssueNoStatusUpdateUseCase } from '../../../domain/usecases/IssueNoStatusUpdateUseCase';
69
69
  import { StartPreparationUseCase } from '../../../domain/usecases/StartPreparationUseCase';
70
70
  import { NodeLocalCommandRunner } from '../../repositories/NodeLocalCommandRunner';
71
+ import { CliGitHubGraphqlRateLimitRepository } from '../../repositories/CliGitHubGraphqlRateLimitRepository';
71
72
  import { ProcTakeOwnershipSpawnRepository } from '../../repositories/ProcTakeOwnershipSpawnRepository';
72
73
  import { ProxyClaudeTokenUsageRepository } from '../../repositories/ProxyClaudeTokenUsageRepository';
73
74
  import { ProxyRateLimitCacheRepository } from '../../repositories/ProxyRateLimitCacheRepository';
@@ -499,6 +500,7 @@ export class HandleScheduledEventUseCaseHandler {
499
500
  nodeLocalCommandRunner,
500
501
  claudeTokenUsageRepository,
501
502
  new ProcTakeOwnershipSpawnRepository(),
503
+ new CliGitHubGraphqlRateLimitRepository(nodeLocalCommandRunner),
502
504
  );
503
505
  const proxyRateLimitCacheRepository = new ProxyRateLimitCacheRepository(
504
506
  mergedInput.claudeCodeOauthTokenListJsonPath ?? null,
@@ -0,0 +1,71 @@
1
+ import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
2
+ import { CliGitHubGraphqlRateLimitRepository } from './CliGitHubGraphqlRateLimitRepository';
3
+
4
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
5
+
6
+ describe('CliGitHubGraphqlRateLimitRepository', () => {
7
+ let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
8
+ let repository: CliGitHubGraphqlRateLimitRepository;
9
+
10
+ beforeEach(() => {
11
+ jest.resetAllMocks();
12
+ mockLocalCommandRunner = {
13
+ runCommand: jest.fn(),
14
+ spawnInteractive: jest.fn(),
15
+ };
16
+ repository = new CliGitHubGraphqlRateLimitRepository(
17
+ mockLocalCommandRunner,
18
+ );
19
+ });
20
+
21
+ it('returns the remaining GraphQL request count when gh succeeds', async () => {
22
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
23
+ stdout: '4873\n',
24
+ stderr: '',
25
+ exitCode: 0,
26
+ });
27
+
28
+ const result = await repository.getRemainingRequestCount();
29
+
30
+ expect(result).toBe(4873);
31
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toEqual([
32
+ ['gh', ['api', 'rate_limit', '--jq', '.resources.graphql.remaining']],
33
+ ]);
34
+ });
35
+
36
+ it('returns null when gh exits with a non-zero code', async () => {
37
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
38
+ stdout: '',
39
+ stderr: 'error: authentication required',
40
+ exitCode: 1,
41
+ });
42
+
43
+ const result = await repository.getRemainingRequestCount();
44
+
45
+ expect(result).toBeNull();
46
+ });
47
+
48
+ it('returns null when the output is not a valid integer', async () => {
49
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
50
+ stdout: 'not-a-number\n',
51
+ stderr: '',
52
+ exitCode: 0,
53
+ });
54
+
55
+ const result = await repository.getRemainingRequestCount();
56
+
57
+ expect(result).toBeNull();
58
+ });
59
+
60
+ it('returns zero when the remaining count is zero', async () => {
61
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
62
+ stdout: '0\n',
63
+ stderr: '',
64
+ exitCode: 0,
65
+ });
66
+
67
+ const result = await repository.getRemainingRequestCount();
68
+
69
+ expect(result).toBe(0);
70
+ });
71
+ });
@@ -0,0 +1,21 @@
1
+ import { GitHubGraphqlRateLimitRepository } from '../../domain/usecases/adapter-interfaces/GitHubGraphqlRateLimitRepository';
2
+ import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
3
+
4
+ export class CliGitHubGraphqlRateLimitRepository implements GitHubGraphqlRateLimitRepository {
5
+ constructor(private readonly localCommandRunner: LocalCommandRunner) {}
6
+
7
+ getRemainingRequestCount = async (): Promise<number | null> => {
8
+ const result = await this.localCommandRunner.runCommand('gh', [
9
+ 'api',
10
+ 'rate_limit',
11
+ '--jq',
12
+ '.resources.graphql.remaining',
13
+ ]);
14
+ if (result.exitCode !== 0) {
15
+ return null;
16
+ }
17
+ const trimmed = result.stdout.trim();
18
+ const parsed = Number(trimmed);
19
+ return trimmed !== '' && Number.isInteger(parsed) ? parsed : null;
20
+ };
21
+ }
@@ -1,8 +1,56 @@
1
1
  import dotenv from 'dotenv';
2
- import { KySlackRepository } from './KySlackRepository';
3
2
  import fs from 'fs';
4
3
  import https from 'https';
5
4
  import path from 'path';
5
+ import { KySlackRepository } from './KySlackRepository';
6
+
7
+ describe('KySlackRepository', () => {
8
+ afterEach(() => {
9
+ jest.useRealTimers();
10
+ jest.restoreAllMocks();
11
+ });
12
+
13
+ describe('postMessageToDirectMessage', () => {
14
+ it('does not hang on 429 Retry-After responses from Slack', async () => {
15
+ jest.useFakeTimers();
16
+
17
+ jest.spyOn(globalThis, 'fetch').mockResolvedValue(
18
+ new Response('{"ok":false}', {
19
+ status: 429,
20
+ headers: { 'Retry-After': '30' },
21
+ }),
22
+ );
23
+
24
+ const slackRepository = new KySlackRepository('xoxp-test-token-unit');
25
+
26
+ // Attach .catch() immediately to prevent unhandled rejection when the
27
+ // promise rejects during jest.advanceTimersByTimeAsync below.
28
+ const settledValue: { value: unknown } = { value: 'not-yet-settled' };
29
+ slackRepository
30
+ .postMessageToDirectMessage('test message', 'non-existent-user')
31
+ .then(() => {
32
+ settledValue.value = 'resolved';
33
+ })
34
+ .catch((e: unknown) => {
35
+ settledValue.value = e;
36
+ });
37
+
38
+ // Advance 60 seconds of fake time.
39
+ // With maxRetryAfter bounded (e.g. 10s), all 3 retry delays (3×10s=30s) fire
40
+ // within this window, exhausting retries and settling the promise.
41
+ // With the default maxRetryAfter=INFINITY, only 2 of 3 delays fire (2×30s=60s),
42
+ // leaving the promise pending — causing CI to hang for the full Jest timeout.
43
+ await jest.advanceTimersByTimeAsync(60000);
44
+ jest.clearAllTimers();
45
+ jest.useRealTimers();
46
+
47
+ // Drain microtasks so the .then()/.catch() callbacks above have run.
48
+ await Promise.resolve();
49
+
50
+ expect(settledValue.value).not.toBe('not-yet-settled');
51
+ }, 5000);
52
+ });
53
+ });
6
54
 
7
55
  dotenv.config();
8
56
 
@@ -1,6 +1,6 @@
1
- import ky from 'ky';
2
1
  import fs from 'fs';
3
- import { SlackRepository } from '../../domain/usecases/adapter-interfaces/SlackRepository';
2
+ import ky from 'ky';
3
+ import type { SlackRepository } from '../../domain/usecases/adapter-interfaces/SlackRepository';
4
4
 
5
5
  type ConversationsListResponse = {
6
6
  ok: boolean;
@@ -87,6 +87,7 @@ export class KySlackRepository implements SlackRepository {
87
87
  limit: 3,
88
88
  methods: ['get', 'post'],
89
89
  statusCodes: [429, 500, 502, 503, 504],
90
+ maxRetryAfter: 10000,
90
91
  },
91
92
  });
92
93
  }
@@ -194,6 +194,9 @@ describe('ConflictedIssueRevertUseCase', () => {
194
194
 
195
195
  await useCase.run({ projectUrl });
196
196
 
197
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
198
+ conflictedPr.url,
199
+ );
197
200
  expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
198
201
  mockProject,
199
202
  issue,
@@ -205,6 +208,36 @@ describe('ConflictedIssueRevertUseCase', () => {
205
208
  );
206
209
  });
207
210
 
211
+ it('should not update status or post comment when Awaiting Workspace PR update-branch succeeds', async () => {
212
+ const issue = createMockIssue({ status: 'Awaiting Workspace' });
213
+ const prItem = createMockPrItem({
214
+ url: 'https://github.com/user/repo/pull/1',
215
+ closingIssueReferenceUrls: [issue.url],
216
+ });
217
+ mockIssueRepository.getAllIssues.mockResolvedValue({
218
+ project: mockProject,
219
+ issues: [issue, prItem],
220
+ cacheUsed: false,
221
+ });
222
+ const conflictedPr = createMockRelatedPullRequest({
223
+ url: prItem.url,
224
+ isConflicted: true,
225
+ mergeable: 'CONFLICTING',
226
+ });
227
+ mockIssueRepository.getOpenPullRequests.mockResolvedValue(
228
+ new Map([[conflictedPr.url, conflictedPr]]),
229
+ );
230
+ mockIssueRepository.updateBranch.mockResolvedValue(true);
231
+
232
+ await useCase.run({ projectUrl });
233
+
234
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
235
+ conflictedPr.url,
236
+ );
237
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
238
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
239
+ });
240
+
208
241
  it('should skip PR items when scanning for target task issues', async () => {
209
242
  const prIssue = createMockIssue({
210
243
  url: 'https://github.com/user/repo/issues/1',
@@ -317,6 +350,9 @@ describe('ConflictedIssueRevertUseCase', () => {
317
350
 
318
351
  await useCase.run({ projectUrl });
319
352
 
353
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
354
+ conflictedPr.url,
355
+ );
320
356
  expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
321
357
  mockProject,
322
358
  issue,
@@ -328,6 +364,44 @@ describe('ConflictedIssueRevertUseCase', () => {
328
364
  );
329
365
  });
330
366
 
367
+ it('should propagate errors thrown by updateBranch', async () => {
368
+ const issue = createMockIssue({
369
+ url: 'https://github.com/user/repo/issues/1',
370
+ status: 'Preparation',
371
+ });
372
+ const prItem = createMockPrItem({
373
+ url: 'https://github.com/user/repo/pull/1',
374
+ closingIssueReferenceUrls: [issue.url],
375
+ });
376
+ mockIssueRepository.getAllIssues.mockResolvedValue({
377
+ project: mockProject,
378
+ issues: [issue, prItem],
379
+ cacheUsed: false,
380
+ });
381
+ const conflictedPr = createMockRelatedPullRequest({
382
+ url: prItem.url,
383
+ isConflicted: true,
384
+ mergeable: 'CONFLICTING',
385
+ });
386
+ mockIssueRepository.getOpenPullRequests.mockResolvedValue(
387
+ new Map([[conflictedPr.url, conflictedPr]]),
388
+ );
389
+ mockIssueRepository.updateBranch.mockRejectedValue(
390
+ new Error(
391
+ 'Failed to update branch for PR https://github.com/user/repo/pull/1: 500 Internal Server Error',
392
+ ),
393
+ );
394
+
395
+ await expect(useCase.run({ projectUrl })).rejects.toThrow(
396
+ 'Failed to update branch for PR',
397
+ );
398
+ expect(mockIssueRepository.updateBranch).toHaveBeenCalledWith(
399
+ conflictedPr.url,
400
+ );
401
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
402
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
403
+ });
404
+
331
405
  it('should not update status or post comment when update-branch succeeds for the conflicted PR', async () => {
332
406
  const issue = createMockIssue({
333
407
  url: 'https://github.com/user/repo/issues/1',
@@ -10,6 +10,7 @@ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
10
10
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
11
11
  import { ClaudeTokenUsageRepository } from './adapter-interfaces/ClaudeTokenUsageRepository';
12
12
  import { TakeOwnershipSpawnRepository } from './adapter-interfaces/TakeOwnershipSpawnRepository';
13
+ import { GitHubGraphqlRateLimitRepository } from './adapter-interfaces/GitHubGraphqlRateLimitRepository';
13
14
  import { ClaudeTokenUsage } from '../entities/ClaudeTokenUsage';
14
15
  import { Issue } from '../entities/Issue';
15
16
  import { FieldOption, Project } from '../entities/Project';
@@ -109,6 +110,7 @@ describe('StartPreparationUseCase', () => {
109
110
  let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
110
111
  let mockClaudeTokenUsageRepository: Mocked<ClaudeTokenUsageRepository>;
111
112
  let mockTakeOwnershipSpawnRepository: Mocked<TakeOwnershipSpawnRepository>;
113
+ let mockGitHubGraphqlRateLimitRepository: Mocked<GitHubGraphqlRateLimitRepository>;
112
114
  let mockProject: Project;
113
115
  beforeEach(() => {
114
116
  jest.resetAllMocks();
@@ -144,12 +146,16 @@ describe('StartPreparationUseCase', () => {
144
146
  listSpawns: jest.fn().mockReturnValue([]),
145
147
  listRunningIssueUrls: jest.fn().mockReturnValue([]),
146
148
  };
149
+ mockGitHubGraphqlRateLimitRepository = {
150
+ getRemainingRequestCount: jest.fn().mockResolvedValue(null),
151
+ };
147
152
  useCase = new StartPreparationUseCase(
148
153
  mockProjectRepository,
149
154
  mockIssueRepository,
150
155
  mockLocalCommandRunner,
151
156
  mockClaudeTokenUsageRepository,
152
157
  mockTakeOwnershipSpawnRepository,
158
+ mockGitHubGraphqlRateLimitRepository,
153
159
  );
154
160
  });
155
161
  it('should run aw command for awaiting workspace issues', async () => {
@@ -627,6 +633,225 @@ describe('StartPreparationUseCase', () => {
627
633
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
628
634
  });
629
635
 
636
+ it('skips all spawning when active worker count meets the maxConcurrentWorkers cap', async () => {
637
+ const awaitingIssues: Issue[] = [
638
+ createMockIssue({
639
+ url: 'https://github.com/user/repo/issues/10',
640
+ status: 'Awaiting Workspace',
641
+ author: 'testuser',
642
+ }),
643
+ createMockIssue({
644
+ number: 2,
645
+ url: 'https://github.com/user/repo/issues/11',
646
+ status: 'Awaiting Workspace',
647
+ author: 'testuser',
648
+ }),
649
+ ];
650
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
651
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
652
+ createMockStoryObjectMap(awaitingIssues),
653
+ );
654
+ mockTakeOwnershipSpawnRepository.listRunningIssueUrls.mockReturnValue([
655
+ 'https://github.com/user/repo/issues/1',
656
+ 'https://github.com/user/repo/issues/2',
657
+ ]);
658
+
659
+ await useCase.run({
660
+ projectUrl: 'https://github.com/user/repo',
661
+ defaultAgentName: 'agent1',
662
+ defaultLlmModelName: 'claude-opus',
663
+ fallbackLlmModelName: null,
664
+ defaultLlmAgentName: null,
665
+ configFilePath: '/path/to/config.yml',
666
+ maximumPreparingIssuesCount: null,
667
+ utilizationPercentageThreshold: 90,
668
+ allowedIssueAuthors: ['testuser'],
669
+ manager: 'manager-user',
670
+ codexHomeCandidates: null,
671
+ labelsAsLlmAgentName: null,
672
+ maxConcurrentWorkers: 2,
673
+ });
674
+
675
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
676
+ expect(
677
+ mockLocalCommandRunner.runCommand.mock.calls.map((c) => c[0]),
678
+ ).not.toContain('aw');
679
+ });
680
+
681
+ it('stops spawning mid-loop when workers started in the run bring the count to the cap', async () => {
682
+ const awaitingIssues: Issue[] = [
683
+ createMockIssue({
684
+ url: 'https://github.com/user/repo/issues/10',
685
+ status: 'Awaiting Workspace',
686
+ author: 'testuser',
687
+ }),
688
+ createMockIssue({
689
+ number: 2,
690
+ url: 'https://github.com/user/repo/issues/11',
691
+ status: 'Awaiting Workspace',
692
+ author: 'testuser',
693
+ }),
694
+ ];
695
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
696
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
697
+ createMockStoryObjectMap(awaitingIssues),
698
+ );
699
+ mockIssueRepository.getAllOpened.mockResolvedValue([]);
700
+ mockTakeOwnershipSpawnRepository.listRunningIssueUrls.mockReturnValue([]);
701
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
702
+ stdout: '',
703
+ stderr: '',
704
+ exitCode: 0,
705
+ });
706
+
707
+ await useCase.run({
708
+ projectUrl: 'https://github.com/user/repo',
709
+ defaultAgentName: 'agent1',
710
+ defaultLlmModelName: 'claude-opus',
711
+ fallbackLlmModelName: null,
712
+ defaultLlmAgentName: null,
713
+ configFilePath: '/path/to/config.yml',
714
+ maximumPreparingIssuesCount: null,
715
+ utilizationPercentageThreshold: 90,
716
+ allowedIssueAuthors: ['testuser'],
717
+ manager: 'manager-user',
718
+ codexHomeCandidates: null,
719
+ labelsAsLlmAgentName: null,
720
+ maxConcurrentWorkers: 1,
721
+ });
722
+
723
+ const awCalls = mockLocalCommandRunner.runCommand.mock.calls.filter(
724
+ (call) => call[0] === 'aw',
725
+ );
726
+ expect(awCalls).toHaveLength(1);
727
+ });
728
+
729
+ it('skips the preparation cycle when GraphQL rate limit is below the floor', async () => {
730
+ const awaitingIssues: Issue[] = [
731
+ createMockIssue({
732
+ url: 'https://github.com/user/repo/issues/10',
733
+ status: 'Awaiting Workspace',
734
+ author: 'testuser',
735
+ }),
736
+ ];
737
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
738
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
739
+ createMockStoryObjectMap(awaitingIssues),
740
+ );
741
+ mockGitHubGraphqlRateLimitRepository.getRemainingRequestCount.mockResolvedValue(
742
+ 499,
743
+ );
744
+
745
+ await useCase.run({
746
+ projectUrl: 'https://github.com/user/repo',
747
+ defaultAgentName: 'agent1',
748
+ defaultLlmModelName: 'claude-opus',
749
+ fallbackLlmModelName: null,
750
+ defaultLlmAgentName: null,
751
+ configFilePath: '/path/to/config.yml',
752
+ maximumPreparingIssuesCount: null,
753
+ utilizationPercentageThreshold: 90,
754
+ allowedIssueAuthors: ['testuser'],
755
+ manager: 'manager-user',
756
+ codexHomeCandidates: null,
757
+ labelsAsLlmAgentName: null,
758
+ graphqlRateLimitFloor: 500,
759
+ });
760
+
761
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
762
+ expect(
763
+ mockLocalCommandRunner.runCommand.mock.calls.map((c) => c[0]),
764
+ ).not.toContain('aw');
765
+ });
766
+
767
+ it('proceeds with spawning when GraphQL remaining equals the floor exactly', async () => {
768
+ const awaitingIssues: Issue[] = [
769
+ createMockIssue({
770
+ url: 'https://github.com/user/repo/issues/10',
771
+ status: 'Awaiting Workspace',
772
+ author: 'testuser',
773
+ }),
774
+ ];
775
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
776
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
777
+ createMockStoryObjectMap(awaitingIssues),
778
+ );
779
+ mockIssueRepository.getAllOpened.mockResolvedValue([]);
780
+ mockGitHubGraphqlRateLimitRepository.getRemainingRequestCount.mockResolvedValue(
781
+ 500,
782
+ );
783
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
784
+ stdout: '',
785
+ stderr: '',
786
+ exitCode: 0,
787
+ });
788
+
789
+ await useCase.run({
790
+ projectUrl: 'https://github.com/user/repo',
791
+ defaultAgentName: 'agent1',
792
+ defaultLlmModelName: 'claude-opus',
793
+ fallbackLlmModelName: null,
794
+ defaultLlmAgentName: null,
795
+ configFilePath: '/path/to/config.yml',
796
+ maximumPreparingIssuesCount: null,
797
+ utilizationPercentageThreshold: 90,
798
+ allowedIssueAuthors: ['testuser'],
799
+ manager: 'manager-user',
800
+ codexHomeCandidates: null,
801
+ labelsAsLlmAgentName: null,
802
+ graphqlRateLimitFloor: 500,
803
+ });
804
+
805
+ const awCalls = mockLocalCommandRunner.runCommand.mock.calls.filter(
806
+ (call) => call[0] === 'aw',
807
+ );
808
+ expect(awCalls).toHaveLength(1);
809
+ });
810
+
811
+ it('proceeds with spawning when GraphQL rate limit check returns null', async () => {
812
+ const awaitingIssues: Issue[] = [
813
+ createMockIssue({
814
+ url: 'https://github.com/user/repo/issues/10',
815
+ status: 'Awaiting Workspace',
816
+ author: 'testuser',
817
+ }),
818
+ ];
819
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
820
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
821
+ createMockStoryObjectMap(awaitingIssues),
822
+ );
823
+ mockIssueRepository.getAllOpened.mockResolvedValue([]);
824
+ mockGitHubGraphqlRateLimitRepository.getRemainingRequestCount.mockResolvedValue(
825
+ null,
826
+ );
827
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
828
+ stdout: '',
829
+ stderr: '',
830
+ exitCode: 0,
831
+ });
832
+
833
+ await useCase.run({
834
+ projectUrl: 'https://github.com/user/repo',
835
+ defaultAgentName: 'agent1',
836
+ defaultLlmModelName: 'claude-opus',
837
+ fallbackLlmModelName: null,
838
+ defaultLlmAgentName: null,
839
+ configFilePath: '/path/to/config.yml',
840
+ maximumPreparingIssuesCount: null,
841
+ utilizationPercentageThreshold: 90,
842
+ allowedIssueAuthors: ['testuser'],
843
+ manager: 'manager-user',
844
+ codexHomeCandidates: null,
845
+ labelsAsLlmAgentName: null,
846
+ graphqlRateLimitFloor: 500,
847
+ });
848
+
849
+ const awCalls = mockLocalCommandRunner.runCommand.mock.calls.filter(
850
+ (call) => call[0] === 'aw',
851
+ );
852
+ expect(awCalls).toHaveLength(1);
853
+ });
854
+
630
855
  it('should pass --branch to aw command when issue has an existing linked PR', async () => {
631
856
  const awaitingIssues: Issue[] = [
632
857
  createMockIssue({
@@ -7055,6 +7280,7 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
7055
7280
  listSpawns: jest.fn().mockReturnValue([]),
7056
7281
  listRunningIssueUrls: jest.fn().mockReturnValue([]),
7057
7282
  },
7283
+ { getRemainingRequestCount: jest.fn().mockResolvedValue(null) },
7058
7284
  );
7059
7285
 
7060
7286
  it('lists selected tokens first in ascending 7-day reset deadline order then excluded tokens', () => {
@@ -7354,6 +7580,7 @@ describe('StartPreparationUseCase.getTokenConcurrentLimit', () => {
7354
7580
  listSpawns: jest.fn().mockReturnValue([]),
7355
7581
  listRunningIssueUrls: jest.fn().mockReturnValue([]),
7356
7582
  },
7583
+ { getRemainingRequestCount: jest.fn().mockResolvedValue(null) },
7357
7584
  );
7358
7585
  });
7359
7586
 
@@ -7465,6 +7692,7 @@ describe('StartPreparationUseCase.run normalConcurrentLimit', () => {
7465
7692
  mockLocalCommandRunner,
7466
7693
  mockClaudeTokenUsageRepository,
7467
7694
  mockTakeOwnershipSpawnRepository,
7695
+ { getRemainingRequestCount: jest.fn().mockResolvedValue(null) },
7468
7696
  );
7469
7697
 
7470
7698
  await useCase.run({
@@ -7542,6 +7770,7 @@ describe('StartPreparationUseCase.fetchSpawnCandidateBranchSources', () => {
7542
7770
  listSpawns: jest.fn().mockReturnValue([]),
7543
7771
  listRunningIssueUrls: jest.fn().mockReturnValue([]),
7544
7772
  },
7773
+ { getRemainingRequestCount: jest.fn().mockResolvedValue(null) },
7545
7774
  );
7546
7775
 
7547
7776
  it('looks up related open pull requests for issue urls concurrently up to the configured limit', async () => {
@@ -7,6 +7,7 @@ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
7
7
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
8
8
  import { ClaudeTokenUsageRepository } from './adapter-interfaces/ClaudeTokenUsageRepository';
9
9
  import { TakeOwnershipSpawnRepository } from './adapter-interfaces/TakeOwnershipSpawnRepository';
10
+ import { GitHubGraphqlRateLimitRepository } from './adapter-interfaces/GitHubGraphqlRateLimitRepository';
10
11
  import { ClaudeTokenUsage } from '../entities/ClaudeTokenUsage';
11
12
  import { DEFAULT_SELECTION_WEIGHT } from './OauthTokenSelectUseCase';
12
13
  import {
@@ -74,6 +75,7 @@ export class StartPreparationUseCase {
74
75
  private readonly localCommandRunner: LocalCommandRunner,
75
76
  private readonly claudeTokenUsageRepository: ClaudeTokenUsageRepository,
76
77
  private readonly takeOwnershipSpawnRepository: TakeOwnershipSpawnRepository,
78
+ private readonly gitHubGraphqlRateLimitRepository: GitHubGraphqlRateLimitRepository,
77
79
  ) {}
78
80
 
79
81
  private weeklyLimitTypeForModel = (modelName: string | null): string => {
@@ -421,6 +423,8 @@ export class StartPreparationUseCase {
421
423
  labelsAsLlmAgentName: string[] | null;
422
424
  agents?: string[] | null;
423
425
  normalConcurrentLimit?: number;
426
+ maxConcurrentWorkers?: number | null;
427
+ graphqlRateLimitFloor?: number | null;
424
428
  }): Promise<{ rotationOrder: RotationOrderEntry[] | null }> => {
425
429
  const normalConcurrentLimit =
426
430
  params.normalConcurrentLimit ?? NORMAL_CONCURRENT_LIMIT;
@@ -537,6 +541,28 @@ export class StartPreparationUseCase {
537
541
 
538
542
  const now = new Date();
539
543
 
544
+ const maxConcurrentWorkers = params.maxConcurrentWorkers ?? null;
545
+ const graphqlRateLimitFloor = params.graphqlRateLimitFloor ?? null;
546
+
547
+ if (graphqlRateLimitFloor !== null) {
548
+ const graphqlRemaining =
549
+ await this.gitHubGraphqlRateLimitRepository.getRemainingRequestCount();
550
+ if (
551
+ graphqlRemaining !== null &&
552
+ graphqlRemaining < graphqlRateLimitFloor
553
+ ) {
554
+ console.warn(
555
+ `GraphQL rate limit low (${graphqlRemaining} remaining, floor: ${graphqlRateLimitFloor}); skipping preparation cycle.`,
556
+ );
557
+ return { rotationOrder };
558
+ }
559
+ if (graphqlRemaining === null) {
560
+ console.warn(
561
+ 'GraphQL rate limit check failed; proceeding with spawning.',
562
+ );
563
+ }
564
+ }
565
+
540
566
  const branchSourceByIssueUrl = await this.fetchSpawnCandidateBranchSources(
541
567
  awaitingWorkspaceIssues
542
568
  .filter(
@@ -567,6 +593,15 @@ export class StartPreparationUseCase {
567
593
  console.warn(`Skipping ${issue.url}: worker already running.`);
568
594
  continue;
569
595
  }
596
+ if (maxConcurrentWorkers !== null) {
597
+ const activeWorkerCount = runningIssueUrls.size + startedInThisRunCount;
598
+ if (activeWorkerCount >= maxConcurrentWorkers) {
599
+ console.warn(
600
+ `Spawn cap reached (${activeWorkerCount}/${maxConcurrentWorkers} active workers); skipping remaining candidates.`,
601
+ );
602
+ break;
603
+ }
604
+ }
570
605
  const exclusionReason = this.spawnCandidateExclusionReasonOf(
571
606
  issue,
572
607
  params.allowedIssueAuthors,
@@ -0,0 +1,3 @@
1
+ export interface GitHubGraphqlRateLimitRepository {
2
+ getRemainingRequestCount(): Promise<number | null>;
3
+ }