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.
- package/CHANGELOG.md +26 -0
- package/README.md +4 -0
- package/bin/adapter/entry-points/cli/index.js +19 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleOperationApi.js +9 -2
- package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-RaFqjB7u.js → index-CYwLv9pf.js} +2 -2
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +1 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
- package/bin/adapter/repositories/NodeLocalCommandRunner.js +5 -0
- package/bin/adapter/repositories/NodeLocalCommandRunner.js.map +1 -1
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js +29 -0
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +4 -2
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js +70 -22
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +1 -3
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +12 -4
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +102 -0
- package/src/adapter/entry-points/cli/index.ts +43 -0
- package/src/adapter/entry-points/console/consoleOperationApi.test.ts +19 -5
- package/src/adapter/entry-points/console/consoleOperationApi.ts +18 -1
- package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +45 -0
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +7 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.stories.tsx +31 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.test.tsx +96 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.tsx +20 -2
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +159 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +17 -1
- package/src/adapter/entry-points/console/ui-dist/assets/{index-RaFqjB7u.js → index-CYwLv9pf.js} +2 -2
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +1 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -0
- package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.test.ts +4 -1
- package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +2 -0
- package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +1 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +1 -0
- package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.test.ts +4 -0
- package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +1 -0
- package/src/adapter/repositories/NodeLocalCommandRunner.test.ts +16 -0
- package/src/adapter/repositories/NodeLocalCommandRunner.ts +5 -1
- package/src/adapter/repositories/NodeSubAgentProcessLister.test.ts +1 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +132 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.ts +37 -0
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +38 -0
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +6 -2
- package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +2 -0
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +1 -0
- package/src/domain/usecases/StartPreparationUseCase.test.ts +213 -2
- package/src/domain/usecases/StartPreparationUseCase.ts +155 -48
- package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +1 -0
- package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +4 -0
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +5 -4
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +9 -11
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +63 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +13 -4
- package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +1 -0
- package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +1 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts +1 -0
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
- package/types/adapter/repositories/NodeLocalCommandRunner.d.ts +1 -0
- package/types/adapter/repositories/NodeLocalCommandRunner.d.ts.map +1 -1
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +1 -0
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
- package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts +10 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +1 -0
- package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +1 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
- package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +1 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
t.
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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',
|
|
@@ -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
|
|
803
|
+
it('includes one entry per story option including GRAY', () => {
|
|
804
804
|
const result = run([]);
|
|
805
|
-
expect(result.stories.stories).toHaveLength(
|
|
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('
|
|
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).
|
|
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
|
-
|
|
178
|
-
.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
});
|