Fix concurrency limits and remote branch fetching issues (#788)

* Changes from fix/bug-fixes

* feat: Refactor worktree iteration and improve error logging across services

* feat: Extract URL/port patterns to module level and fix abort condition

* fix: Improve IPv6 loopback handling, select component layout, and terminal UI

* feat: Add thinking level defaults and adjust list row padding

* Update apps/ui/src/store/app-store.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* feat: Add worktree-aware terminal creation and split options, fix npm security issues from audit

* feat: Add tracked remote detection to pull dialog flow

* feat: Add merge state tracking to git operations

* feat: Improve merge detection and add post-merge action preferences

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Pass merge detection info to stash reapplication and handle merge state consistently

* fix: Call onPulled callback in merge handlers and add validation checks

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
gsxdsm
2026-02-20 13:48:22 -08:00
committed by GitHub
parent 7df2182818
commit 0a5540c9a2
70 changed files with 4525 additions and 857 deletions

View File

@@ -328,6 +328,86 @@ describe('auto-loop-coordinator.ts', () => {
// Should not have executed features because at capacity
expect(mockExecuteFeature).not.toHaveBeenCalled();
});
it('counts all running features (auto + manual) against concurrency limit', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]);
// 2 manual features running — total count is 2
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(2);
await coordinator.startAutoLoopForProject('/test/project', null, 2);
await vi.advanceTimersByTimeAsync(6000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should NOT execute because total running count (2) meets the concurrency limit (2)
expect(mockExecuteFeature).not.toHaveBeenCalled();
// Verify it was called WITHOUT autoModeOnly (counts all tasks)
// The coordinator's wrapper passes options through as undefined when not specified
expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith(
'/test/project',
null,
undefined
);
});
it('allows auto dispatch when manual tasks finish and capacity becomes available', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]);
// First call: at capacity (2 manual features running)
// Second call: capacity freed (1 feature running)
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree)
.mockResolvedValueOnce(2) // at capacity
.mockResolvedValueOnce(1); // capacity available after manual task completes
await coordinator.startAutoLoopForProject('/test/project', null, 2);
// First iteration: at capacity, should wait
await vi.advanceTimersByTimeAsync(5000);
// Second iteration: capacity available, should execute
await vi.advanceTimersByTimeAsync(6000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should execute after capacity freed
expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true);
});
it('waits when manually started tasks already fill concurrency limit at auto mode activation', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]);
// Manual tasks already fill the limit
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(3);
await coordinator.startAutoLoopForProject('/test/project', null, 3);
await vi.advanceTimersByTimeAsync(6000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// Auto mode should remain waiting, not dispatch
expect(mockExecuteFeature).not.toHaveBeenCalled();
});
it('resumes dispatching when all running tasks complete simultaneously', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]);
// First check: all 3 slots occupied
// Second check: all tasks completed simultaneously
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree)
.mockResolvedValueOnce(3) // all slots full
.mockResolvedValueOnce(0); // all tasks completed at once
await coordinator.startAutoLoopForProject('/test/project', null, 3);
// First iteration: at capacity
await vi.advanceTimersByTimeAsync(5000);
// Second iteration: all freed
await vi.advanceTimersByTimeAsync(6000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should execute after all tasks freed capacity
expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true);
});
});
describe('priority-based feature selection', () => {
@@ -788,7 +868,23 @@ describe('auto-loop-coordinator.ts', () => {
expect(count).toBe(3);
expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith(
'/test/project',
null
null,
undefined
);
});
it('passes autoModeOnly option to ConcurrencyManager', async () => {
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(1);
const count = await coordinator.getRunningCountForWorktree('/test/project', null, {
autoModeOnly: true,
});
expect(count).toBe(1);
expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith(
'/test/project',
null,
{ autoModeOnly: true }
);
});
});

View File

@@ -416,6 +416,90 @@ describe('ConcurrencyManager', () => {
expect(mainCount).toBe(2);
});
it('should count only auto-mode features when autoModeOnly is true', async () => {
// Auto-mode feature on main worktree
manager.acquire({
featureId: 'feature-auto',
projectPath: '/test/project',
isAutoMode: true,
});
// Manual feature on main worktree
manager.acquire({
featureId: 'feature-manual',
projectPath: '/test/project',
isAutoMode: false,
});
// Without autoModeOnly: counts both
const totalCount = await manager.getRunningCountForWorktree('/test/project', null);
expect(totalCount).toBe(2);
// With autoModeOnly: counts only auto-mode features
const autoModeCount = await manager.getRunningCountForWorktree('/test/project', null, {
autoModeOnly: true,
});
expect(autoModeCount).toBe(1);
});
it('should count only auto-mode features on specific worktree when autoModeOnly is true', async () => {
// Auto-mode feature on feature branch
manager.acquire({
featureId: 'feature-auto',
projectPath: '/test/project',
isAutoMode: true,
});
manager.updateRunningFeature('feature-auto', { branchName: 'feature-branch' });
// Manual feature on same feature branch
manager.acquire({
featureId: 'feature-manual',
projectPath: '/test/project',
isAutoMode: false,
});
manager.updateRunningFeature('feature-manual', { branchName: 'feature-branch' });
// Another auto-mode feature on different branch (should not be counted)
manager.acquire({
featureId: 'feature-other',
projectPath: '/test/project',
isAutoMode: true,
});
manager.updateRunningFeature('feature-other', { branchName: 'other-branch' });
const autoModeCount = await manager.getRunningCountForWorktree(
'/test/project',
'feature-branch',
{ autoModeOnly: true }
);
expect(autoModeCount).toBe(1);
const totalCount = await manager.getRunningCountForWorktree(
'/test/project',
'feature-branch'
);
expect(totalCount).toBe(2);
});
it('should return 0 when autoModeOnly is true and only manual features are running', async () => {
manager.acquire({
featureId: 'feature-manual-1',
projectPath: '/test/project',
isAutoMode: false,
});
manager.acquire({
featureId: 'feature-manual-2',
projectPath: '/test/project',
isAutoMode: false,
});
const autoModeCount = await manager.getRunningCountForWorktree('/test/project', null, {
autoModeOnly: true,
});
expect(autoModeCount).toBe(0);
});
it('should filter by both projectPath and branchName', async () => {
manager.acquire({
featureId: 'feature-1',

View File

@@ -486,7 +486,7 @@ describe('dev-server-service.ts', () => {
await service.startDevServer(testDir, testDir);
// Simulate HTTPS dev server
mockProcess.stdout.emit('data', Buffer.from('Server at https://localhost:3443\n'));
mockProcess.stdout.emit('data', Buffer.from('Server listening at https://localhost:3443\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
@@ -521,6 +521,368 @@ describe('dev-server-service.ts', () => {
expect(serverInfo?.url).toBe(firstUrl);
expect(serverInfo?.url).toBe('http://localhost:5173/');
});
it('should detect Astro format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Astro uses the same "Local:" prefix as Vite
mockProcess.stdout.emit('data', Buffer.from(' 🚀 astro v4.0.0 started in 200ms\n'));
mockProcess.stdout.emit('data', Buffer.from(' ┃ Local http://localhost:4321/\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
// Astro doesn't use "Local:" with colon, so it should be caught by the localhost URL pattern
expect(serverInfo?.url).toBe('http://localhost:4321/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Remix format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit(
'data',
Buffer.from('Remix App Server started at http://localhost:3000\n')
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:3000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Django format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit(
'data',
Buffer.from('Starting development server at http://127.0.0.1:8000/\n')
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://127.0.0.1:8000/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Webpack Dev Server format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit(
'data',
Buffer.from('<i> [webpack-dev-server] Project is running at http://localhost:8080/\n')
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:8080/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect PHP built-in server format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit(
'data',
Buffer.from('Development Server (http://localhost:8000) started\n')
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:8000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect "listening on port" format (port-only detection)', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Some servers only print the port number, not a full URL
mockProcess.stdout.emit('data', Buffer.from('Server listening on port 4000\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:4000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect "running on port" format (port-only detection)', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit('data', Buffer.from('Application running on port 9000\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:9000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should strip ANSI escape codes before detecting URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Simulate Vite output with ANSI color codes
mockProcess.stdout.emit(
'data',
Buffer.from(
' \x1B[32m➜\x1B[0m \x1B[1mLocal:\x1B[0m \x1B[36mhttp://localhost:5173/\x1B[0m\n'
)
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:5173/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should normalize 0.0.0.0 to localhost', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit('data', Buffer.from('Server listening at http://0.0.0.0:3000\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:3000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should normalize [::] to localhost', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit('data', Buffer.from('Local: http://[::]:4000/\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:4000/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should update port field when detected URL has different port', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = await service.startDevServer(testDir, testDir);
const allocatedPort = result.result?.port;
// Server starts on a completely different port (ignoring PORT env var)
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:9999/\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:9999/');
expect(serverInfo?.port).toBe(9999);
// The port should be different from what was initially allocated
if (allocatedPort !== 9999) {
expect(serverInfo?.port).not.toBe(allocatedPort);
}
});
it('should detect URL from stderr output', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Some servers output URL info to stderr
mockProcess.stderr.emit('data', Buffer.from('Local: http://localhost:3000/\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:3000/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should not match URLs without a port (non-dev-server URLs)', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
const result = await service.startDevServer(testDir, testDir);
// CDN/external URLs should not be detected
mockProcess.stdout.emit(
'data',
Buffer.from('Downloading from https://cdn.example.com/bundle.js\n')
);
mockProcess.stdout.emit('data', Buffer.from('Fetching https://registry.npmjs.org/package\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
// Should keep the initial allocated URL since external URLs don't match
expect(serverInfo?.url).toBe(result.result?.url);
expect(serverInfo?.urlDetected).toBe(false);
});
it('should handle URLs with trailing punctuation', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// URL followed by punctuation
mockProcess.stdout.emit('data', Buffer.from('Server started at http://localhost:3000.\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:3000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Express/Fastify format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit('data', Buffer.from('Server listening on http://localhost:3000\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:3000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Angular CLI format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Angular CLI output
mockProcess.stderr.emit(
'data',
Buffer.from(
'** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **\n'
)
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:4200/');
expect(serverInfo?.urlDetected).toBe(true);
});
});
});
@@ -531,6 +893,7 @@ function createMockProcess() {
mockProcess.stderr = new EventEmitter();
mockProcess.kill = vi.fn();
mockProcess.killed = false;
mockProcess.pid = 12345;
// Don't exit immediately - let the test control the lifecycle
return mockProcess;