diff --git a/apps/server/src/routes/worktree/routes/switch-branch.ts b/apps/server/src/routes/worktree/routes/switch-branch.ts index d087341b..63be752b 100644 --- a/apps/server/src/routes/worktree/routes/switch-branch.ts +++ b/apps/server/src/routes/worktree/routes/switch-branch.ts @@ -16,6 +16,21 @@ import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); +function isUntrackedLine(line: string): boolean { + return line.startsWith('?? '); +} + +function isExcludedWorktreeLine(line: string): boolean { + return line.includes('.worktrees/') || line.endsWith('.worktrees'); +} + +function isBlockingChangeLine(line: string): boolean { + if (!line.trim()) return false; + if (isExcludedWorktreeLine(line)) return false; + if (isUntrackedLine(line)) return false; + return true; +} + /** * Check if there are uncommitted changes in the working directory * Excludes .worktrees/ directory which is created by automaker @@ -23,15 +38,7 @@ const execAsync = promisify(exec); async function hasUncommittedChanges(cwd: string): Promise { try { const { stdout } = await execAsync('git status --porcelain', { cwd }); - const lines = stdout - .trim() - .split('\n') - .filter((line) => { - if (!line.trim()) return false; - // Exclude .worktrees/ directory (created by automaker) - if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false; - return true; - }); + const lines = stdout.trim().split('\n').filter(isBlockingChangeLine); return lines.length > 0; } catch { return false; @@ -45,15 +52,7 @@ async function hasUncommittedChanges(cwd: string): Promise { async function getChangesSummary(cwd: string): Promise { try { const { stdout } = await execAsync('git status --short', { cwd }); - const lines = stdout - .trim() - .split('\n') - .filter((line) => { - if (!line.trim()) return false; - // Exclude .worktrees/ directory - if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false; - return true; - }); + const lines = stdout.trim().split('\n').filter(isBlockingChangeLine); if (lines.length === 0) return ''; if (lines.length <= 5) return lines.join(', '); return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`; diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 0c7416b9..ffb87591 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -3718,13 +3718,14 @@ Format your response as a structured markdown document.`; // Recovery cases: // 1. Standard pending/ready/backlog statuses // 2. Features with approved plans that have incomplete tasks (crash recovery) - // 3. Features stuck in 'in_progress' status (crash recovery) + // 3. Features stuck in 'in_progress' or 'interrupted' status (crash recovery) // 4. Features with 'generating' planSpec status (spec generation was interrupted) const needsRecovery = feature.status === 'pending' || feature.status === 'ready' || feature.status === 'backlog' || feature.status === 'in_progress' || // Recover features that were in progress when server crashed + feature.status === 'interrupted' || // Recover features explicitly marked interrupted on shutdown (feature.planSpec?.status === 'approved' && (feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) || feature.planSpec?.status === 'generating'; // Recover interrupted spec generation @@ -3764,7 +3765,7 @@ Format your response as a structured markdown document.`; const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( - `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/approved_with_pending_tasks/generating) for ${worktreeDesc}` + `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/interrupted/approved_with_pending_tasks/generating) for ${worktreeDesc}` ); if (pendingFeatures.length === 0) { @@ -5536,9 +5537,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. continue; } - // Check if feature was interrupted (in_progress or pipeline_*) + // Check if feature was interrupted (in_progress/interrupted or pipeline_*) if ( feature.status === 'in_progress' || + feature.status === 'interrupted' || (feature.status && feature.status.startsWith('pipeline_')) ) { // Check if context (agent-output.md) exists diff --git a/apps/server/tests/unit/routes/worktree/switch-branch.test.ts b/apps/server/tests/unit/routes/worktree/switch-branch.test.ts new file mode 100644 index 00000000..2cd868c6 --- /dev/null +++ b/apps/server/tests/unit/routes/worktree/switch-branch.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import type { Request, Response } from 'express'; +import { createMockExpressContext } from '../../../utils/mocks.js'; + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + exec: vi.fn(), + }; +}); + +vi.mock('util', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promisify: (fn: unknown) => fn, + }; +}); + +import { exec } from 'child_process'; +import { createSwitchBranchHandler } from '@/routes/worktree/routes/switch-branch.js'; + +const mockExec = exec as Mock; + +describe('switch-branch route', () => { + let req: Request; + let res: Response; + + beforeEach(() => { + vi.clearAllMocks(); + const context = createMockExpressContext(); + req = context.req; + res = context.res; + }); + + it('should allow switching when only untracked files exist', async () => { + req.body = { + worktreePath: '/repo/path', + branchName: 'feature/test', + }; + + mockExec.mockImplementation(async (command: string) => { + if (command === 'git rev-parse --abbrev-ref HEAD') { + return { stdout: 'main\n', stderr: '' }; + } + if (command === 'git rev-parse --verify feature/test') { + return { stdout: 'abc123\n', stderr: '' }; + } + if (command === 'git status --porcelain') { + return { stdout: '?? .automaker/\n?? notes.txt\n', stderr: '' }; + } + if (command === 'git checkout "feature/test"') { + return { stdout: '', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }); + + const handler = createSwitchBranchHandler(); + await handler(req, res); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + result: { + previousBranch: 'main', + currentBranch: 'feature/test', + message: "Switched to branch 'feature/test'", + }, + }); + expect(mockExec).toHaveBeenCalledWith('git checkout "feature/test"', { cwd: '/repo/path' }); + }); + + it('should block switching when tracked files are modified', async () => { + req.body = { + worktreePath: '/repo/path', + branchName: 'feature/test', + }; + + mockExec.mockImplementation(async (command: string) => { + if (command === 'git rev-parse --abbrev-ref HEAD') { + return { stdout: 'main\n', stderr: '' }; + } + if (command === 'git rev-parse --verify feature/test') { + return { stdout: 'abc123\n', stderr: '' }; + } + if (command === 'git status --porcelain') { + return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' }; + } + if (command === 'git status --short') { + return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }); + + const handler = createSwitchBranchHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: + 'Cannot switch branches: you have uncommitted changes (M src/index.ts). Please commit your changes first.', + code: 'UNCOMMITTED_CHANGES', + }); + }); +}); diff --git a/apps/server/tests/unit/services/auto-mode-service.test.ts b/apps/server/tests/unit/services/auto-mode-service.test.ts index 7f3f9af0..a8489033 100644 --- a/apps/server/tests/unit/services/auto-mode-service.test.ts +++ b/apps/server/tests/unit/services/auto-mode-service.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AutoModeService } from '@/services/auto-mode-service.js'; import type { Feature } from '@automaker/types'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; describe('auto-mode-service.ts', () => { let service: AutoModeService; @@ -842,4 +845,76 @@ describe('auto-mode-service.ts', () => { expect(service.isFeatureRunning('feature-3')).toBe(false); }); }); + + describe('interrupted recovery', () => { + async function createFeatureFixture( + projectPath: string, + feature: Partial & Pick + ): Promise { + const featureDir = path.join(projectPath, '.automaker', 'features', feature.id); + await fs.mkdir(featureDir, { recursive: true }); + await fs.writeFile( + path.join(featureDir, 'feature.json'), + JSON.stringify( + { + title: 'Feature', + description: 'Feature description', + category: 'implementation', + status: 'backlog', + ...feature, + }, + null, + 2 + ) + ); + return featureDir; + } + + it('should resume features marked as interrupted after restart', async () => { + const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-resume-')); + try { + const featureDir = await createFeatureFixture(projectPath, { + id: 'feature-interrupted', + status: 'interrupted', + }); + await fs.writeFile(path.join(featureDir, 'agent-output.md'), 'partial progress'); + await createFeatureFixture(projectPath, { + id: 'feature-complete', + status: 'completed', + }); + + const resumeFeatureMock = vi.fn().mockResolvedValue(undefined); + (service as any).resumeFeature = resumeFeatureMock; + + await (service as any).resumeInterruptedFeatures(projectPath); + + expect(resumeFeatureMock).toHaveBeenCalledTimes(1); + expect(resumeFeatureMock).toHaveBeenCalledWith(projectPath, 'feature-interrupted', true); + } finally { + await fs.rm(projectPath, { recursive: true, force: true }); + } + }); + + it('should include interrupted features in pending recovery candidates', async () => { + const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-pending-')); + try { + await createFeatureFixture(projectPath, { + id: 'feature-interrupted', + status: 'interrupted', + }); + await createFeatureFixture(projectPath, { + id: 'feature-waiting-approval', + status: 'waiting_approval', + }); + + const pendingFeatures = await (service as any).loadPendingFeatures(projectPath, null); + const pendingIds = pendingFeatures.map((feature: Feature) => feature.id); + + expect(pendingIds).toContain('feature-interrupted'); + expect(pendingIds).not.toContain('feature-waiting-approval'); + } finally { + await fs.rm(projectPath, { recursive: true, force: true }); + } + }); + }); });