github-issue-tower-defence-management 1.192.0 → 1.194.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +4 -0
  3. package/bin/adapter/entry-points/cli/index.js +19 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/consoleOperationApi.js +9 -2
  6. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  7. package/bin/adapter/entry-points/console/ui-dist/assets/{index-RaFqjB7u.js → index-CYwLv9pf.js} +2 -2
  8. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  9. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +1 -0
  10. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
  11. package/bin/adapter/repositories/NodeLocalCommandRunner.js +5 -0
  12. package/bin/adapter/repositories/NodeLocalCommandRunner.js.map +1 -1
  13. package/bin/adapter/repositories/NodeTmuxSessionRepository.js +29 -0
  14. package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
  15. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +4 -2
  16. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
  17. package/bin/domain/usecases/StartPreparationUseCase.js +70 -22
  18. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  19. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +1 -3
  20. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
  21. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +12 -4
  22. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -1
  23. package/package.json +1 -1
  24. package/src/adapter/entry-points/cli/index.test.ts +102 -0
  25. package/src/adapter/entry-points/cli/index.ts +43 -0
  26. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +19 -5
  27. package/src/adapter/entry-points/console/consoleOperationApi.ts +18 -1
  28. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +45 -0
  29. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +7 -0
  30. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.stories.tsx +31 -0
  31. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.test.tsx +96 -0
  32. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.tsx +20 -2
  33. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +159 -0
  34. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +17 -1
  35. package/src/adapter/entry-points/console/ui-dist/assets/{index-RaFqjB7u.js → index-CYwLv9pf.js} +2 -2
  36. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  37. package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +1 -0
  38. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -0
  39. package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.test.ts +4 -1
  40. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +2 -0
  41. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +1 -0
  42. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +1 -0
  43. package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.test.ts +4 -0
  44. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +1 -0
  45. package/src/adapter/repositories/NodeLocalCommandRunner.test.ts +16 -0
  46. package/src/adapter/repositories/NodeLocalCommandRunner.ts +5 -1
  47. package/src/adapter/repositories/NodeSubAgentProcessLister.test.ts +1 -0
  48. package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +132 -0
  49. package/src/adapter/repositories/NodeTmuxSessionRepository.ts +37 -0
  50. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +38 -0
  51. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +6 -2
  52. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +2 -0
  53. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +1 -0
  54. package/src/domain/usecases/StartPreparationUseCase.test.ts +213 -2
  55. package/src/domain/usecases/StartPreparationUseCase.ts +155 -48
  56. package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +1 -0
  57. package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +4 -0
  58. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +5 -4
  59. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +9 -11
  60. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +63 -0
  61. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +13 -4
  62. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +1 -0
  63. package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +1 -0
  64. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  65. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +1 -0
  66. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  67. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
  68. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts +1 -0
  69. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts.map +1 -1
  70. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +1 -0
  71. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
  72. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
  73. package/types/domain/usecases/StartPreparationUseCase.d.ts +10 -1
  74. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  75. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +1 -0
  76. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -1
  77. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +1 -0
  78. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
  79. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
  80. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +1 -0
  81. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -1
@@ -1,4 +1,7 @@
1
- import { StartPreparationUseCase } from './StartPreparationUseCase';
1
+ import {
2
+ SPAWN_CANDIDATE_BRANCH_SOURCE_CONCURRENCY,
3
+ StartPreparationUseCase,
4
+ } from './StartPreparationUseCase';
2
5
  import {
3
6
  IssueRepository,
4
7
  RelatedPullRequest,
@@ -129,6 +132,7 @@ describe('StartPreparationUseCase', () => {
129
132
  };
130
133
  mockLocalCommandRunner = {
131
134
  runCommand: jest.fn(),
135
+ spawnInteractive: jest.fn(),
132
136
  };
133
137
  mockClaudeTokenUsageRepository = {
134
138
  ensureObservable: jest.fn().mockResolvedValue(undefined),
@@ -6888,6 +6892,112 @@ describe('StartPreparationUseCase', () => {
6888
6892
  expect(storyUnsetWarningCalls).toHaveLength(0);
6889
6893
  consoleWarnSpy.mockRestore();
6890
6894
  });
6895
+
6896
+ describe('token in-flight count refresh', () => {
6897
+ it('refreshes the token in-flight counts once before abandoning a run so a freed slot is used', async () => {
6898
+ const awaitingIssue = createMockIssue({
6899
+ url: 'url1',
6900
+ title: 'Issue 1',
6901
+ labels: ['category:impl'],
6902
+ status: 'Awaiting Workspace',
6903
+ number: 1,
6904
+ itemId: 'item-1',
6905
+ });
6906
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
6907
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
6908
+ createMockStoryObjectMap([awaitingIssue]),
6909
+ );
6910
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
6911
+ stdout: '',
6912
+ stderr: '',
6913
+ exitCode: 0,
6914
+ });
6915
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
6916
+ {
6917
+ name: 'token-a',
6918
+ token: 'token-a',
6919
+ fiveHourUtilization: 0.1,
6920
+ sevenDayUtilization: 0.1,
6921
+ blocked: false,
6922
+ rejected: false,
6923
+ fiveHourRejected: false,
6924
+ blockedUntilEpoch: 0,
6925
+ modelWeeklyLimits: {},
6926
+ },
6927
+ ]);
6928
+ mockClaudeTokenUsageRepository.getTokenInFlightCounts
6929
+ .mockResolvedValueOnce({ 'token-a': 6 })
6930
+ .mockResolvedValueOnce({ 'token-a': 0 });
6931
+
6932
+ await useCase.run({
6933
+ projectUrl: 'https://github.com/user/repo',
6934
+ defaultAgentName: 'agent1',
6935
+ defaultLlmModelName: 'claude-opus',
6936
+ fallbackLlmModelName: null,
6937
+ defaultLlmAgentName: null,
6938
+ configFilePath: '/path/to/config.yml',
6939
+ maximumPreparingIssuesCount: null,
6940
+ utilizationPercentageThreshold: 90,
6941
+ allowedIssueAuthors: ['testuser'],
6942
+ manager: 'manager-user',
6943
+ codexHomeCandidates: null,
6944
+ labelsAsLlmAgentName: null,
6945
+ });
6946
+
6947
+ expect(
6948
+ mockClaudeTokenUsageRepository.getTokenInFlightCounts.mock.calls,
6949
+ ).toHaveLength(2);
6950
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
6951
+ });
6952
+
6953
+ it('abandons the run when the refreshed in-flight counts still leave no token slot', async () => {
6954
+ const awaitingIssue = createMockIssue({
6955
+ url: 'url1',
6956
+ title: 'Issue 1',
6957
+ labels: ['category:impl'],
6958
+ status: 'Awaiting Workspace',
6959
+ number: 1,
6960
+ itemId: 'item-1',
6961
+ });
6962
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
6963
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
6964
+ createMockStoryObjectMap([awaitingIssue]),
6965
+ );
6966
+ mockClaudeTokenUsageRepository.getAvailableTokenUsages.mockResolvedValue([
6967
+ {
6968
+ name: 'token-a',
6969
+ token: 'token-a',
6970
+ fiveHourUtilization: 0.1,
6971
+ sevenDayUtilization: 0.1,
6972
+ blocked: false,
6973
+ rejected: false,
6974
+ fiveHourRejected: false,
6975
+ blockedUntilEpoch: 0,
6976
+ modelWeeklyLimits: {},
6977
+ },
6978
+ ]);
6979
+ mockClaudeTokenUsageRepository.getTokenInFlightCounts.mockResolvedValue({
6980
+ 'token-a': 6,
6981
+ });
6982
+
6983
+ await useCase.run({
6984
+ projectUrl: 'https://github.com/user/repo',
6985
+ defaultAgentName: 'agent1',
6986
+ defaultLlmModelName: 'claude-opus',
6987
+ fallbackLlmModelName: null,
6988
+ defaultLlmAgentName: null,
6989
+ configFilePath: '/path/to/config.yml',
6990
+ maximumPreparingIssuesCount: null,
6991
+ utilizationPercentageThreshold: 90,
6992
+ allowedIssueAuthors: ['testuser'],
6993
+ manager: 'manager-user',
6994
+ codexHomeCandidates: null,
6995
+ labelsAsLlmAgentName: null,
6996
+ });
6997
+
6998
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
6999
+ });
7000
+ });
6891
7001
  });
6892
7002
 
6893
7003
  describe('StartPreparationUseCase.buildRotationOrder', () => {
@@ -6926,6 +7036,7 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
6926
7036
  };
6927
7037
  const mockLocalCommandRunnerForRotation: Mocked<LocalCommandRunner> = {
6928
7038
  runCommand: jest.fn(),
7039
+ spawnInteractive: jest.fn(),
6929
7040
  };
6930
7041
  const mockClaudeTokenUsageRepositoryForRotation: Mocked<ClaudeTokenUsageRepository> =
6931
7042
  {
@@ -7232,7 +7343,7 @@ describe('StartPreparationUseCase.getTokenConcurrentLimit', () => {
7232
7343
  setIssueAgentField: jest.fn(),
7233
7344
  removeLabel: jest.fn(),
7234
7345
  },
7235
- { runCommand: jest.fn() },
7346
+ { runCommand: jest.fn(), spawnInteractive: jest.fn() },
7236
7347
  {
7237
7348
  ensureObservable: jest.fn(),
7238
7349
  getAvailableTokenUsages: jest.fn(),
@@ -7283,3 +7394,103 @@ describe('StartPreparationUseCase.getTokenConcurrentLimit', () => {
7283
7394
  expect(useCase.getTokenConcurrentLimit(0.1, 0.1, 0.01)).toBe(1);
7284
7395
  });
7285
7396
  });
7397
+
7398
+ describe('StartPreparationUseCase.fetchSpawnCandidateBranchSources', () => {
7399
+ const buildRelatedPullRequest = (url: string): RelatedPullRequest => ({
7400
+ url,
7401
+ branchName: 'feature',
7402
+ createdAt: new Date('2026-01-01T00:00:00Z'),
7403
+ isDraft: false,
7404
+ isConflicted: false,
7405
+ mergeable: 'MERGEABLE',
7406
+ isPassedAllCiJob: true,
7407
+ isCiStateSuccess: true,
7408
+ isResolvedAllReviewComments: true,
7409
+ isBranchOutOfDate: false,
7410
+ missingRequiredCheckNames: [],
7411
+ });
7412
+
7413
+ const buildUseCase = (
7414
+ issueRepositoryOverrides: Partial<
7415
+ ConstructorParameters<typeof StartPreparationUseCase>[1]
7416
+ >,
7417
+ ): StartPreparationUseCase =>
7418
+ new StartPreparationUseCase(
7419
+ {
7420
+ getByUrl: jest.fn(),
7421
+ createField: jest.fn(),
7422
+ updateAgentList: jest.fn(),
7423
+ },
7424
+ {
7425
+ getStoryObjectMap: jest.fn(),
7426
+ getAllOpened: jest.fn(),
7427
+ updateStatus: jest.fn(),
7428
+ findRelatedOpenPRs: jest.fn(),
7429
+ getOpenPullRequest: jest.fn(),
7430
+ closePullRequest: jest.fn(),
7431
+ deletePullRequestBranch: jest.fn(),
7432
+ createCommentByUrl: jest.fn(),
7433
+ setIssueAgentField: jest.fn(),
7434
+ removeLabel: jest.fn(),
7435
+ ...issueRepositoryOverrides,
7436
+ },
7437
+ { runCommand: jest.fn(), spawnInteractive: jest.fn() },
7438
+ {
7439
+ ensureObservable: jest.fn(),
7440
+ getAvailableTokenUsages: jest.fn(),
7441
+ getTokenInFlightCounts: jest.fn(),
7442
+ proxyBaseUrl: jest.fn(),
7443
+ },
7444
+ {
7445
+ listSpawns: jest.fn().mockReturnValue([]),
7446
+ listRunningIssueUrls: jest.fn().mockReturnValue([]),
7447
+ },
7448
+ );
7449
+
7450
+ it('looks up related open pull requests for issue urls concurrently up to the configured limit', async () => {
7451
+ const issueUrls = Array.from(
7452
+ { length: 20 },
7453
+ (_, index) => `https://github.com/owner/repo/issues/${index + 1}`,
7454
+ );
7455
+ let inFlightCount = 0;
7456
+ let observedMaxInFlight = 0;
7457
+ const findRelatedOpenPRs = jest.fn(async (issueUrl: string) => {
7458
+ inFlightCount += 1;
7459
+ observedMaxInFlight = Math.max(observedMaxInFlight, inFlightCount);
7460
+ await new Promise((resolve) => setTimeout(resolve, 1));
7461
+ inFlightCount -= 1;
7462
+ return [buildRelatedPullRequest(issueUrl.replace('/issues/', '/pull/'))];
7463
+ });
7464
+ const useCase = buildUseCase({ findRelatedOpenPRs });
7465
+
7466
+ const branchSources =
7467
+ await useCase.fetchSpawnCandidateBranchSources(issueUrls);
7468
+
7469
+ expect(findRelatedOpenPRs).toHaveBeenCalledTimes(20);
7470
+ expect(observedMaxInFlight).toBe(SPAWN_CANDIDATE_BRANCH_SOURCE_CONCURRENCY);
7471
+ expect(branchSources.size).toBe(20);
7472
+ for (const issueUrl of issueUrls) {
7473
+ expect(branchSources.get(issueUrl)?.relatedOpenPullRequests).toHaveLength(
7474
+ 1,
7475
+ );
7476
+ expect(branchSources.get(issueUrl)?.openPullRequest).toBeNull();
7477
+ }
7478
+ });
7479
+
7480
+ it('resolves pull request urls through the open pull request lookup instead of the timeline query', async () => {
7481
+ const prUrl = 'https://github.com/owner/repo/pull/7';
7482
+ const openPullRequest = buildRelatedPullRequest(prUrl);
7483
+ const getOpenPullRequest = jest.fn().mockResolvedValue(openPullRequest);
7484
+ const findRelatedOpenPRs = jest.fn();
7485
+ const useCase = buildUseCase({ getOpenPullRequest, findRelatedOpenPRs });
7486
+
7487
+ const branchSources = await useCase.fetchSpawnCandidateBranchSources([
7488
+ prUrl,
7489
+ ]);
7490
+
7491
+ expect(getOpenPullRequest).toHaveBeenCalledWith(prUrl);
7492
+ expect(findRelatedOpenPRs).not.toHaveBeenCalled();
7493
+ expect(branchSources.get(prUrl)?.openPullRequest).toBe(openPullRequest);
7494
+ expect(branchSources.get(prUrl)?.relatedOpenPullRequests).toEqual([]);
7495
+ });
7496
+ });
@@ -1,4 +1,8 @@
1
- import { IssueRepository } from './adapter-interfaces/IssueRepository';
1
+ import {
2
+ IssueRepository,
3
+ RelatedPullRequest,
4
+ } from './adapter-interfaces/IssueRepository';
5
+ import { Issue } from '../entities/Issue';
2
6
  import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
3
7
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
4
8
  import { ClaudeTokenUsageRepository } from './adapter-interfaces/ClaudeTokenUsageRepository';
@@ -19,6 +23,19 @@ const SEVEN_DAY_THROTTLE_START_THRESHOLD = 0.8;
19
23
  const FIVE_HOUR_THROTTLE_START_THRESHOLD = 0.8;
20
24
  export const DEFAULT_FALLBACK_LLM_MODEL_NAME = 'claude-opus-4-8';
21
25
  const LLM_AGENT_LABEL_PREFIX = 'llm-agent:';
26
+ export const SPAWN_CANDIDATE_BRANCH_SOURCE_CONCURRENCY = 8;
27
+
28
+ export type SpawnCandidateExclusionReason =
29
+ | 'dependedIssueUrls'
30
+ | 'futureNextActionDate'
31
+ | 'nextActionHourNotReached'
32
+ | 'authorNotAllowed'
33
+ | 'notAssignedToManager';
34
+
35
+ export type SpawnCandidateBranchSource = {
36
+ openPullRequest: RelatedPullRequest | null;
37
+ relatedOpenPullRequests: RelatedPullRequest[];
38
+ };
22
39
 
23
40
  export const agentNameFromDesignation = (designation: string): string =>
24
41
  designation.startsWith(LLM_AGENT_LABEL_PREFIX)
@@ -173,6 +190,79 @@ export class StartPreparationUseCase {
173
190
  );
174
191
  };
175
192
 
193
+ spawnCandidateExclusionReasonOf = (
194
+ issue: Issue,
195
+ allowedIssueAuthors: string[] | null,
196
+ manager: string,
197
+ now: Date,
198
+ ): SpawnCandidateExclusionReason | null => {
199
+ if (issue.dependedIssueUrls.length > 0) {
200
+ return 'dependedIssueUrls';
201
+ }
202
+ if (issueReactivationTriggerIsPending(issue, now)) {
203
+ const startOfTomorrow = new Date(
204
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1),
205
+ );
206
+ return issue.nextActionDate !== null &&
207
+ issue.nextActionDate >= startOfTomorrow
208
+ ? 'futureNextActionDate'
209
+ : 'nextActionHourNotReached';
210
+ }
211
+ if (
212
+ allowedIssueAuthors === null ||
213
+ allowedIssueAuthors.length === 0 ||
214
+ !allowedIssueAuthors.includes(issue.author)
215
+ ) {
216
+ return 'authorNotAllowed';
217
+ }
218
+ if (!issue.assignees.includes(manager)) {
219
+ return 'notAssignedToManager';
220
+ }
221
+ return null;
222
+ };
223
+
224
+ fetchSpawnCandidateBranchSources = async (
225
+ issueUrls: string[],
226
+ ): Promise<Map<string, SpawnCandidateBranchSource>> => {
227
+ const branchSourceByIssueUrl = new Map<
228
+ string,
229
+ SpawnCandidateBranchSource
230
+ >();
231
+ let nextIndex = 0;
232
+ const fetchSequentially = async (): Promise<void> => {
233
+ while (nextIndex < issueUrls.length) {
234
+ const issueUrl = issueUrls[nextIndex];
235
+ nextIndex += 1;
236
+ branchSourceByIssueUrl.set(
237
+ issueUrl,
238
+ issueUrl.includes('/pull/')
239
+ ? {
240
+ openPullRequest:
241
+ await this.issueRepository.getOpenPullRequest(issueUrl),
242
+ relatedOpenPullRequests: [],
243
+ }
244
+ : {
245
+ openPullRequest: null,
246
+ relatedOpenPullRequests:
247
+ await this.issueRepository.findRelatedOpenPRs(issueUrl),
248
+ },
249
+ );
250
+ }
251
+ };
252
+ await Promise.all(
253
+ Array.from(
254
+ {
255
+ length: Math.min(
256
+ SPAWN_CANDIDATE_BRANCH_SOURCE_CONCURRENCY,
257
+ issueUrls.length,
258
+ ),
259
+ },
260
+ fetchSequentially,
261
+ ),
262
+ );
263
+ return branchSourceByIssueUrl;
264
+ };
265
+
176
266
  private selectRotationTokens = (
177
267
  tokenUsages: ClaudeTokenUsage[],
178
268
  utilizationPercentageThreshold: number,
@@ -427,6 +517,7 @@ export class StartPreparationUseCase {
427
517
  let updatedCurrentPreparationIssueCount = currentPreparationIssueCount;
428
518
  let startedInThisRunCount = 0;
429
519
  const spawnedInThisRunByToken: Record<string, number> = {};
520
+ let tokenInFlightCountsRefreshed = false;
430
521
  const exclusionCounts = {
431
522
  dependedIssueUrls: 0,
432
523
  futureNextActionDate: 0,
@@ -437,6 +528,21 @@ export class StartPreparationUseCase {
437
528
 
438
529
  const now = new Date();
439
530
 
531
+ const branchSourceByIssueUrl = await this.fetchSpawnCandidateBranchSources(
532
+ awaitingWorkspaceIssues
533
+ .filter(
534
+ (issue) =>
535
+ !runningIssueUrls.has(issue.url) &&
536
+ this.spawnCandidateExclusionReasonOf(
537
+ issue,
538
+ params.allowedIssueAuthors,
539
+ params.manager,
540
+ now,
541
+ ) === null,
542
+ )
543
+ .map((issue) => issue.url),
544
+ );
545
+
440
546
  for (
441
547
  let i = 0;
442
548
  i < awaitingWorkspaceIssues.length &&
@@ -452,34 +558,21 @@ export class StartPreparationUseCase {
452
558
  console.warn(`Skipping ${issue.url}: worker already running.`);
453
559
  continue;
454
560
  }
455
- if (issueReactivationTriggerIsPending(issue, now)) {
456
- const startOfTomorrow = new Date(
457
- Date.UTC(
458
- now.getUTCFullYear(),
459
- now.getUTCMonth(),
460
- now.getUTCDate() + 1,
461
- ),
462
- );
463
- if (
464
- issue.nextActionDate !== null &&
465
- issue.nextActionDate >= startOfTomorrow
466
- ) {
467
- exclusionCounts.futureNextActionDate++;
468
- } else {
469
- exclusionCounts.nextActionHourNotReached++;
470
- }
471
- continue;
472
- }
473
- if (
474
- params.allowedIssueAuthors === null ||
475
- params.allowedIssueAuthors.length === 0 ||
476
- !params.allowedIssueAuthors.includes(issue.author)
477
- ) {
478
- exclusionCounts.authorNotAllowed++;
561
+ const exclusionReason = this.spawnCandidateExclusionReasonOf(
562
+ issue,
563
+ params.allowedIssueAuthors,
564
+ params.manager,
565
+ now,
566
+ );
567
+ if (exclusionReason !== null) {
568
+ exclusionCounts[exclusionReason]++;
479
569
  continue;
480
570
  }
481
- if (!issue.assignees.includes(params.manager)) {
482
- exclusionCounts.notAssignedToManager++;
571
+ const branchSource = branchSourceByIssueUrl.get(issue.url);
572
+ if (branchSource === undefined) {
573
+ console.error(
574
+ `Skipping ${issue.url}: no branch source was prefetched for this spawn candidate.`,
575
+ );
483
576
  continue;
484
577
  }
485
578
  await adoptIssueAgentDesignationLabel(
@@ -538,7 +631,7 @@ export class StartPreparationUseCase {
538
631
  const isPrUrl = issue.url.includes('/pull/');
539
632
  let branchName: string;
540
633
  if (isPrUrl) {
541
- const pr = await this.issueRepository.getOpenPullRequest(issue.url);
634
+ const pr = branchSource.openPullRequest;
542
635
  if (pr === null) {
543
636
  console.warn(
544
637
  `Skipping non-OPEN PR ${issue.url}: wrapper requires an open PR.`,
@@ -551,9 +644,7 @@ export class StartPreparationUseCase {
551
644
  }
552
645
  branchName = pr.branchName;
553
646
  } else {
554
- const relatedPRs = await this.issueRepository.findRelatedOpenPRs(
555
- issue.url,
556
- );
647
+ const relatedPRs = branchSource.relatedOpenPullRequests;
557
648
  const sameRepoRelatedPRs = relatedPRs.filter((pr) => {
558
649
  const match = /^https?:\/\/[^/]+\/([^/]+\/[^/]+)\//.exec(pr.url);
559
650
  return match === null || match[1] === issue.nameWithOwner;
@@ -640,23 +731,39 @@ export class StartPreparationUseCase {
640
731
  let routedModelName: string | null = null;
641
732
  let selectedTokenName: string | null = null;
642
733
  if (rotationTokens !== null && proxyBaseUrl !== null) {
643
- const tokenWithSoonestResetAmongAvailable = selectedTokensWithLimits
644
- .map((t) => ({
645
- token: t.token,
646
- model: t.model,
647
- remaining:
648
- t.limit -
649
- (tokenInFlightCounts[t.token] ?? 0) -
650
- (spawnedInThisRunByToken[t.token] ?? 0),
651
- secondsUntilSevenDayReset: t.secondsUntilSevenDayReset,
652
- }))
653
- .filter((t) => t.remaining > 0)
654
- .sort((a, b) => {
655
- if (a.secondsUntilSevenDayReset !== b.secondsUntilSevenDayReset) {
656
- return a.secondsUntilSevenDayReset - b.secondsUntilSevenDayReset;
657
- }
658
- return b.remaining - a.remaining;
659
- })[0];
734
+ const tokenWithSoonestResetAmongAvailableOf = ():
735
+ { token: string; model: string } | undefined =>
736
+ selectedTokensWithLimits
737
+ .map((t) => ({
738
+ token: t.token,
739
+ model: t.model,
740
+ remaining:
741
+ t.limit -
742
+ (tokenInFlightCounts[t.token] ?? 0) -
743
+ (spawnedInThisRunByToken[t.token] ?? 0),
744
+ secondsUntilSevenDayReset: t.secondsUntilSevenDayReset,
745
+ }))
746
+ .filter((t) => t.remaining > 0)
747
+ .sort((a, b) => {
748
+ if (a.secondsUntilSevenDayReset !== b.secondsUntilSevenDayReset) {
749
+ return (
750
+ a.secondsUntilSevenDayReset - b.secondsUntilSevenDayReset
751
+ );
752
+ }
753
+ return b.remaining - a.remaining;
754
+ })[0];
755
+ let tokenWithSoonestResetAmongAvailable =
756
+ tokenWithSoonestResetAmongAvailableOf();
757
+ if (
758
+ tokenWithSoonestResetAmongAvailable === undefined &&
759
+ !tokenInFlightCountsRefreshed
760
+ ) {
761
+ tokenInFlightCountsRefreshed = true;
762
+ tokenInFlightCounts =
763
+ await this.claudeTokenUsageRepository.getTokenInFlightCounts();
764
+ tokenWithSoonestResetAmongAvailable =
765
+ tokenWithSoonestResetAmongAvailableOf();
766
+ }
660
767
  if (tokenWithSoonestResetAmongAvailable === undefined) {
661
768
  await revertToAwaitingWorkspace(
662
769
  'every Claude OAuth token reached its concurrent worker limit',
@@ -12,4 +12,5 @@ export interface LocalCommandRunner {
12
12
  stderr: string;
13
13
  exitCode: number;
14
14
  }>;
15
+ spawnInteractive(program: string, args: string[]): void;
15
16
  }
@@ -13,4 +13,8 @@ export interface TmuxSessionRepository {
13
13
  killOwnSession: () => Promise<void>;
14
14
  sendKeys: (sessionName: string, literalText: string) => Promise<void>;
15
15
  launchBareNameLeaderSession: (name: string) => Promise<void>;
16
+ attachOrCreateInteractiveSession: (
17
+ issueUrl: string,
18
+ scopeLibPath: string | null,
19
+ ) => Promise<void>;
16
20
  }
@@ -800,19 +800,20 @@ describe('GenerateConsoleListsUseCase', () => {
800
800
  });
801
801
 
802
802
  describe('stories tab', () => {
803
- it('includes one entry per non-GRAY story option', () => {
803
+ it('includes one entry per story option including GRAY', () => {
804
804
  const result = run([]);
805
- expect(result.stories.stories).toHaveLength(2);
805
+ expect(result.stories.stories).toHaveLength(3);
806
806
  expect(result.stories.stories.map((s) => s.storyName)).toEqual([
807
+ 'regular / NO STORY; SET STORY FIELD',
807
808
  'Story Alpha',
808
809
  'Story Beta',
809
810
  ]);
810
811
  });
811
812
 
812
- it('excludes story options whose color is GRAY', () => {
813
+ it('includes story options whose color is GRAY', () => {
813
814
  const result = run([]);
814
815
  const names = result.stories.stories.map((s) => s.storyName);
815
- expect(names).not.toContain('regular / NO STORY; SET STORY FIELD');
816
+ expect(names).toContain('regular / NO STORY; SET STORY FIELD');
816
817
  });
817
818
 
818
819
  it('counts open items per story from all open issues on the board', () => {
@@ -173,17 +173,15 @@ export class GenerateConsoleListsUseCase {
173
173
  const defaultNameWithOwner =
174
174
  issues.find((issue) => issue.nameWithOwner !== '')?.nameWithOwner ?? null;
175
175
 
176
- const storyEntries: ConsoleStoryEntry[] = storyOptions
177
- .filter((option) => option.color !== 'GRAY')
178
- .map((option) => ({
179
- storyName: option.name,
180
- storyOptionId: option.id,
181
- color: option.color,
182
- openItemCount: openItemCountByStory.get(option.name) ?? 0,
183
- storyViewUrl: urlOfStoryView
184
- ? `${urlOfStoryView}?sliceBy%5Bvalue%5D=${encodeForURI(option.name)}`
185
- : null,
186
- }));
176
+ const storyEntries: ConsoleStoryEntry[] = storyOptions.map((option) => ({
177
+ storyName: option.name,
178
+ storyOptionId: option.id,
179
+ color: option.color,
180
+ openItemCount: openItemCountByStory.get(option.name) ?? 0,
181
+ storyViewUrl: urlOfStoryView
182
+ ? `${urlOfStoryView}?sliceBy%5Bvalue%5D=${encodeForURI(option.name)}`
183
+ : null,
184
+ }));
187
185
 
188
186
  return {
189
187
  'workflow-blocker': buildStatusTabFromSource(
@@ -111,6 +111,7 @@ const snapshot = (
111
111
  sevenDaySonnetRejected: false,
112
112
  sevenDayOpusRejected: false,
113
113
  hasWindowData: true,
114
+ lastUpdatedEpoch: 1_000_000,
114
115
  ...overrides,
115
116
  });
116
117
 
@@ -280,3 +281,65 @@ describe('computeSevenDayWindowAggregate', () => {
280
281
  expect(computeSevenDayWindowAggregate([])).toBeNull();
281
282
  });
282
283
  });
284
+
285
+ describe('GenerateTokenStatusUseCase window rollover inference', () => {
286
+ const usecase = new GenerateTokenStatusUseCase();
287
+ const now = 2_000_000;
288
+
289
+ const runFor = (
290
+ snapshotOverrides: Partial<TokenRateLimitSnapshot>,
291
+ ): TokenStatus =>
292
+ usecase.run({
293
+ tokens: [
294
+ {
295
+ name: 'alice',
296
+ token: 'token-a',
297
+ snapshot: snapshot(snapshotOverrides),
298
+ },
299
+ ],
300
+ prepCountByToken: new Map(),
301
+ humCountByToken: new Map(),
302
+ nowEpochSeconds: now,
303
+ })[0];
304
+
305
+ it('keeps the last observed utilization when the snapshot predates the reset it reports', () => {
306
+ const result = runFor({
307
+ sevenDayUtilization: 0.98,
308
+ sevenDayReset: now - 3600,
309
+ lastUpdatedEpoch: now - 72000,
310
+ });
311
+
312
+ expect(result.sevenDayUtilizationPercent).toBe(98);
313
+ });
314
+
315
+ it('keeps a seven day rejection visible when the snapshot predates the reset it reports', () => {
316
+ const result = runFor({
317
+ sevenDayUtilization: 0.98,
318
+ sevenDayReset: now - 3600,
319
+ sevenDayRejected: true,
320
+ lastUpdatedEpoch: now - 72000,
321
+ });
322
+
323
+ expect(result.color).toBe('K');
324
+ });
325
+
326
+ it('reports a rolled over window as unused when the snapshot was captured after that reset', () => {
327
+ const result = runFor({
328
+ sevenDayUtilization: 0.98,
329
+ sevenDayReset: now - 3600,
330
+ lastUpdatedEpoch: now - 1800,
331
+ });
332
+
333
+ expect(result.sevenDayUtilizationPercent).toBe(0);
334
+ });
335
+
336
+ it('keeps the last observed five hour utilization when the snapshot predates the five hour reset', () => {
337
+ const result = runFor({
338
+ fiveHourUtilization: 0.91,
339
+ fiveHourReset: now - 600,
340
+ lastUpdatedEpoch: now - 72000,
341
+ });
342
+
343
+ expect(result.fiveHourUtilizationPercent).toBe(91);
344
+ });
345
+ });