refactor(01-02): wire WorktreeResolver and FeatureStateManager into AutoModeService

- Add WorktreeResolver and FeatureStateManager as constructor parameters
- Remove top-level getCurrentBranch function (now in WorktreeResolver)
- Delegate loadFeature, updateFeatureStatus to FeatureStateManager
- Delegate markFeatureInterrupted, resetStuckFeatures to FeatureStateManager
- Delegate updateFeaturePlanSpec, saveFeatureSummary, updateTaskStatus
- Replace findExistingWorktreeForBranch calls with worktreeResolver
- Update tests to mock featureStateManager instead of internal methods
- All 89 tests passing across 3 service files
This commit is contained in:
Shirone
2026-01-27 14:59:01 +01:00
parent e70f1d6d31
commit 8ef15f3abb
2 changed files with 90 additions and 502 deletions

View File

@@ -69,6 +69,8 @@ import {
type GetCurrentBranchFn, type GetCurrentBranchFn,
} from './concurrency-manager.js'; } from './concurrency-manager.js';
import { TypedEventBus } from './typed-event-bus.js'; import { TypedEventBus } from './typed-event-bus.js';
import { WorktreeResolver } from './worktree-resolver.js';
import { FeatureStateManager } from './feature-state-manager.js';
import type { SettingsService } from './settings-service.js'; import type { SettingsService } from './settings-service.js';
import { pipelineService, PipelineService } from './pipeline-service.js'; import { pipelineService, PipelineService } from './pipeline-service.js';
import { import {
@@ -83,21 +85,6 @@ import { getNotificationService } from './notification-service.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
/**
* Get the current branch name for a git repository
* @param projectPath - Path to the git repository
* @returns The current branch name, or null if not in a git repo or on detached HEAD
*/
async function getCurrentBranch(projectPath: string): Promise<string | null> {
try {
const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath });
const branch = stdout.trim();
return branch || null;
} catch {
return null;
}
}
// ParsedTask and PlanSpec types are imported from @automaker/types // ParsedTask and PlanSpec types are imported from @automaker/types
/** /**
@@ -424,6 +411,8 @@ export class AutoModeService {
private events: EventEmitter; private events: EventEmitter;
private eventBus: TypedEventBus; private eventBus: TypedEventBus;
private concurrencyManager: ConcurrencyManager; private concurrencyManager: ConcurrencyManager;
private worktreeResolver: WorktreeResolver;
private featureStateManager: FeatureStateManager;
private autoLoop: AutoLoopState | null = null; private autoLoop: AutoLoopState | null = null;
private featureLoader = new FeatureLoader(); private featureLoader = new FeatureLoader();
// Per-project autoloop state (supports multiple concurrent projects) // Per-project autoloop state (supports multiple concurrent projects)
@@ -444,13 +433,20 @@ export class AutoModeService {
events: EventEmitter, events: EventEmitter,
settingsService?: SettingsService, settingsService?: SettingsService,
concurrencyManager?: ConcurrencyManager, concurrencyManager?: ConcurrencyManager,
eventBus?: TypedEventBus eventBus?: TypedEventBus,
worktreeResolver?: WorktreeResolver,
featureStateManager?: FeatureStateManager
) { ) {
this.events = events; this.events = events;
this.eventBus = eventBus ?? new TypedEventBus(events); this.eventBus = eventBus ?? new TypedEventBus(events);
this.settingsService = settingsService ?? null; this.settingsService = settingsService ?? null;
// Pass the getCurrentBranch function to ConcurrencyManager for worktree counting this.worktreeResolver = worktreeResolver ?? new WorktreeResolver();
this.concurrencyManager = concurrencyManager ?? new ConcurrencyManager(getCurrentBranch); this.featureStateManager =
featureStateManager ?? new FeatureStateManager(events, this.featureLoader);
// Pass the WorktreeResolver's getCurrentBranch to ConcurrencyManager for worktree counting
this.concurrencyManager =
concurrencyManager ??
new ConcurrencyManager((projectPath) => this.worktreeResolver.getCurrentBranch(projectPath));
} }
/** /**
@@ -492,75 +488,7 @@ export class AutoModeService {
* @param projectPath - The project path to reset features for * @param projectPath - The project path to reset features for
*/ */
async resetStuckFeatures(projectPath: string): Promise<void> { async resetStuckFeatures(projectPath: string): Promise<void> {
const featuresDir = getFeaturesDir(projectPath); await this.featureStateManager.resetStuckFeatures(projectPath);
try {
const entries = await secureFs.readdir(featuresDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const featurePath = path.join(featuresDir, entry.name, 'feature.json');
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
const feature = result.data;
if (!feature) continue;
let needsUpdate = false;
// Reset in_progress features back to ready/backlog
if (feature.status === 'in_progress') {
const hasApprovedPlan = feature.planSpec?.status === 'approved';
feature.status = hasApprovedPlan ? 'ready' : 'backlog';
needsUpdate = true;
logger.info(
`[resetStuckFeatures] Reset feature ${feature.id} from in_progress to ${feature.status}`
);
}
// Reset generating planSpec status back to pending (spec generation was interrupted)
if (feature.planSpec?.status === 'generating') {
feature.planSpec.status = 'pending';
needsUpdate = true;
logger.info(
`[resetStuckFeatures] Reset feature ${feature.id} planSpec status from generating to pending`
);
}
// Reset any in_progress tasks back to pending (task execution was interrupted)
if (feature.planSpec?.tasks) {
for (const task of feature.planSpec.tasks) {
if (task.status === 'in_progress') {
task.status = 'pending';
needsUpdate = true;
logger.info(
`[resetStuckFeatures] Reset task ${task.id} for feature ${feature.id} from in_progress to pending`
);
// Clear currentTaskId if it points to this reverted task
if (feature.planSpec?.currentTaskId === task.id) {
feature.planSpec.currentTaskId = undefined;
logger.info(
`[resetStuckFeatures] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})`
);
}
}
}
}
if (needsUpdate) {
feature.updatedAt = new Date().toISOString();
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
}
}
} catch (error) {
// If features directory doesn't exist, that's fine
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(`[resetStuckFeatures] Error resetting features for ${projectPath}:`, error);
}
}
} }
/** /**
@@ -1369,7 +1297,7 @@ export class AutoModeService {
if (useWorktrees && branchName) { if (useWorktrees && branchName) {
// Try to find existing worktree for this branch // Try to find existing worktree for this branch
// Worktree should already exist (created when feature was added/edited) // Worktree should already exist (created when feature was added/edited)
worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
if (worktreePath) { if (worktreePath) {
logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
@@ -2133,7 +2061,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
const branchName = feature.branchName; const branchName = feature.branchName;
if (useWorktrees && branchName) { if (useWorktrees && branchName) {
worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
if (worktreePath) { if (worktreePath) {
logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
} else { } else {
@@ -2259,7 +2187,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
if (useWorktrees && branchName) { if (useWorktrees && branchName) {
// Try to find existing worktree for this branch // Try to find existing worktree for this branch
worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
if (worktreePath) { if (worktreePath) {
workDir = worktreePath; workDir = worktreePath;
@@ -3098,71 +3026,10 @@ Format your response as a structured markdown document.`;
return this.pendingApprovals.has(featureId); return this.pendingApprovals.has(featureId);
} }
// Private helpers // Private helpers - delegate to extracted services
/**
* Find an existing worktree for a given branch by checking git worktree list
*/
private async findExistingWorktreeForBranch(
projectPath: string,
branchName: string
): Promise<string | null> {
try {
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: projectPath,
});
const lines = stdout.split('\n');
let currentPath: string | null = null;
let currentBranch: string | null = null;
for (const line of lines) {
if (line.startsWith('worktree ')) {
currentPath = line.slice(9);
} else if (line.startsWith('branch ')) {
currentBranch = line.slice(7).replace('refs/heads/', '');
} else if (line === '' && currentPath && currentBranch) {
// End of a worktree entry
if (currentBranch === branchName) {
// Resolve to absolute path - git may return relative paths
// On Windows, this is critical for cwd to work correctly
// On all platforms, absolute paths ensure consistent behavior
const resolvedPath = path.isAbsolute(currentPath)
? path.resolve(currentPath)
: path.resolve(projectPath, currentPath);
return resolvedPath;
}
currentPath = null;
currentBranch = null;
}
}
// Check the last entry (if file doesn't end with newline)
if (currentPath && currentBranch && currentBranch === branchName) {
// Resolve to absolute path for cross-platform compatibility
const resolvedPath = path.isAbsolute(currentPath)
? path.resolve(currentPath)
: path.resolve(projectPath, currentPath);
return resolvedPath;
}
return null;
} catch {
return null;
}
}
private async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> { private async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> {
// Features are stored in .automaker directory return this.featureStateManager.loadFeature(projectPath, featureId);
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
try {
const data = (await secureFs.readFile(featurePath, 'utf-8')) as string;
return JSON.parse(data);
} catch {
return null;
}
} }
private async updateFeatureStatus( private async updateFeatureStatus(
@@ -3170,71 +3037,7 @@ Format your response as a structured markdown document.`;
featureId: string, featureId: string,
status: string status: string
): Promise<void> { ): Promise<void> {
// Features are stored in .automaker directory await this.featureStateManager.updateFeatureStatus(projectPath, featureId, status);
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
try {
// Use recovery-enabled read for corrupted file handling
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
logRecoveryWarning(result, `Feature ${featureId}`, logger);
const feature = result.data;
if (!feature) {
logger.warn(`Feature ${featureId} not found or could not be recovered`);
return;
}
feature.status = status;
feature.updatedAt = new Date().toISOString();
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
// Badge will show for 2 minutes after this timestamp
if (status === 'waiting_approval') {
feature.justFinishedAt = new Date().toISOString();
} else {
// Clear the timestamp when moving to other statuses
feature.justFinishedAt = undefined;
}
// Use atomic write with backup support
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
// Create notifications for important status changes
const notificationService = getNotificationService();
if (status === 'waiting_approval') {
await notificationService.createNotification({
type: 'feature_waiting_approval',
title: 'Feature Ready for Review',
message: `"${feature.name || featureId}" is ready for your review and approval.`,
featureId,
projectPath,
});
} else if (status === 'verified') {
await notificationService.createNotification({
type: 'feature_verified',
title: 'Feature Verified',
message: `"${feature.name || featureId}" has been verified and is complete.`,
featureId,
projectPath,
});
}
// Sync completed/verified features to app_spec.txt
if (status === 'verified' || status === 'completed') {
try {
await this.featureLoader.syncFeatureToAppSpec(projectPath, feature);
} catch (syncError) {
// Log but don't fail the status update if sync fails
logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError);
}
}
} catch (error) {
logger.error(`Failed to update feature status for ${featureId}:`, error);
}
} }
/** /**
@@ -3258,25 +3061,7 @@ Format your response as a structured markdown document.`;
featureId: string, featureId: string,
reason?: string reason?: string
): Promise<void> { ): Promise<void> {
// Load the feature to check its current status await this.featureStateManager.markFeatureInterrupted(projectPath, featureId, reason);
const feature = await this.loadFeature(projectPath, featureId);
const currentStatus = feature?.status;
// Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step
if (currentStatus && currentStatus.startsWith('pipeline_')) {
logger.info(
`Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume`
);
return;
}
if (reason) {
logger.info(`Marking feature ${featureId} as interrupted: ${reason}`);
} else {
logger.info(`Marking feature ${featureId} as interrupted`);
}
await this.updateFeatureStatus(projectPath, featureId, 'interrupted');
} }
/** /**
@@ -3355,49 +3140,7 @@ Format your response as a structured markdown document.`;
featureId: string, featureId: string,
updates: Partial<PlanSpec> updates: Partial<PlanSpec>
): Promise<void> { ): Promise<void> {
// Use getFeatureDir helper for consistent path resolution await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, updates);
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
try {
// Use recovery-enabled read for corrupted file handling
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
logRecoveryWarning(result, `Feature ${featureId}`, logger);
const feature = result.data;
if (!feature) {
logger.warn(`Feature ${featureId} not found or could not be recovered`);
return;
}
// Initialize planSpec if it doesn't exist
if (!feature.planSpec) {
feature.planSpec = {
status: 'pending',
version: 1,
reviewedByUser: false,
};
}
// Apply updates
Object.assign(feature.planSpec, updates);
// If content is being updated and it's a new version, increment version
if (updates.content && updates.content !== feature.planSpec.content) {
feature.planSpec.version = (feature.planSpec.version || 0) + 1;
}
feature.updatedAt = new Date().toISOString();
// Use atomic write with backup support
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
} catch (error) {
logger.error(`Failed to update planSpec for ${featureId}:`, error);
}
} }
/** /**
@@ -3417,36 +3160,7 @@ Format your response as a structured markdown document.`;
featureId: string, featureId: string,
summary: string summary: string
): Promise<void> { ): Promise<void> {
const featureDir = getFeatureDir(projectPath, featureId); await this.featureStateManager.saveFeatureSummary(projectPath, featureId, summary);
const featurePath = path.join(featureDir, 'feature.json');
try {
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
logRecoveryWarning(result, `Feature ${featureId}`, logger);
const feature = result.data;
if (!feature) {
logger.warn(`Feature ${featureId} not found or could not be recovered`);
return;
}
feature.summary = summary;
feature.updatedAt = new Date().toISOString();
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
this.eventBus.emitAutoModeEvent('auto_mode_summary', {
featureId,
projectPath,
summary,
});
} catch (error) {
logger.error(`Failed to save summary for ${featureId}:`, error);
}
} }
/** /**
@@ -3458,46 +3172,7 @@ Format your response as a structured markdown document.`;
taskId: string, taskId: string,
status: ParsedTask['status'] status: ParsedTask['status']
): Promise<void> { ): Promise<void> {
// Use getFeatureDir helper for consistent path resolution await this.featureStateManager.updateTaskStatus(projectPath, featureId, taskId, status);
const featureDir = getFeatureDir(projectPath, featureId);
const featurePath = path.join(featureDir, 'feature.json');
try {
// Use recovery-enabled read for corrupted file handling
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
maxBackups: DEFAULT_BACKUP_COUNT,
autoRestore: true,
});
logRecoveryWarning(result, `Feature ${featureId}`, logger);
const feature = result.data;
if (!feature || !feature.planSpec?.tasks) {
logger.warn(`Feature ${featureId} not found or has no tasks`);
return;
}
// Find and update the task
const task = feature.planSpec.tasks.find((t) => t.id === taskId);
if (task) {
task.status = status;
feature.updatedAt = new Date().toISOString();
// Use atomic write with backup support
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
// Emit event for UI update
this.eventBus.emitAutoModeEvent('auto_mode_task_status', {
featureId,
projectPath,
taskId,
status,
tasks: feature.planSpec.tasks,
});
}
} catch (error) {
logger.error(`Failed to update task ${taskId} status for ${featureId}:`, error);
}
} }
/** /**
@@ -3570,7 +3245,7 @@ Format your response as a structured markdown document.`;
// Get the actual primary branch name for the project (e.g., "main", "master", "develop") // Get the actual primary branch name for the project (e.g., "main", "master", "develop")
// This is needed to correctly match features when branchName is null (main worktree) // This is needed to correctly match features when branchName is null (main worktree)
const primaryBranch = await getCurrentBranch(projectPath); const primaryBranch = await this.worktreeResolver.getCurrentBranch(projectPath);
try { try {
const entries = await secureFs.readdir(featuresDir, { const entries = await secureFs.readdir(featuresDir, {
@@ -5665,7 +5340,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
const existingBranches = await this.getExistingBranches(projectPath); const existingBranches = await this.getExistingBranches(projectPath);
// Get current/primary branch (features with null branchName are implicitly on this) // Get current/primary branch (features with null branchName are implicitly on this)
const primaryBranch = await getCurrentBranch(projectPath); const primaryBranch = await this.worktreeResolver.getCurrentBranch(projectPath);
// Check each feature with a branchName // Check each feature with a branchName
for (const feature of featuresWithBranches) { for (const feature of featuresWithBranches) {

View File

@@ -474,106 +474,40 @@ describe('auto-mode-service.ts', () => {
}); });
describe('markFeatureInterrupted', () => { describe('markFeatureInterrupted', () => {
// Helper to mock updateFeatureStatus // Helper to mock featureStateManager.markFeatureInterrupted
const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => { const mockFeatureStateManagerMarkInterrupted = (
(svc as any).updateFeatureStatus = mockFn; svc: AutoModeService,
mockFn: ReturnType<typeof vi.fn>
) => {
(svc as any).featureStateManager.markFeatureInterrupted = mockFn;
}; };
// Helper to mock loadFeature it('should delegate to featureStateManager.markFeatureInterrupted', async () => {
const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => { const markMock = vi.fn().mockResolvedValue(undefined);
(svc as any).loadFeature = mockFn; mockFeatureStateManagerMarkInterrupted(service, markMock);
};
it('should call updateFeatureStatus with interrupted status for non-pipeline features', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123'); await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted'); expect(markMock).toHaveBeenCalledWith('/test/project', 'feature-123', undefined);
}); });
it('should call updateFeatureStatus with reason when provided', async () => { it('should pass reason to featureStateManager.markFeatureInterrupted', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' }); const markMock = vi.fn().mockResolvedValue(undefined);
const updateMock = vi.fn().mockResolvedValue(undefined); mockFeatureStateManagerMarkInterrupted(service, markMock);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown'); await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown');
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted'); expect(markMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'server shutdown');
}); });
it('should propagate errors from updateFeatureStatus', async () => { it('should propagate errors from featureStateManager', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' }); const markMock = vi.fn().mockRejectedValue(new Error('Update failed'));
const updateMock = vi.fn().mockRejectedValue(new Error('Update failed')); mockFeatureStateManagerMarkInterrupted(service, markMock);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await expect(service.markFeatureInterrupted('/test/project', 'feature-123')).rejects.toThrow( await expect(service.markFeatureInterrupted('/test/project', 'feature-123')).rejects.toThrow(
'Update failed' 'Update failed'
); );
}); });
it('should preserve pipeline_implementation status instead of marking as interrupted', async () => {
const loadMock = vi
.fn()
.mockResolvedValue({ id: 'feature-123', status: 'pipeline_implementation' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown');
// updateFeatureStatus should NOT be called for pipeline statuses
expect(updateMock).not.toHaveBeenCalled();
});
it('should preserve pipeline_testing status instead of marking as interrupted', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pipeline_testing' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).not.toHaveBeenCalled();
});
it('should preserve pipeline_review status instead of marking as interrupted', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pipeline_review' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).not.toHaveBeenCalled();
});
it('should mark feature as interrupted when loadFeature returns null', async () => {
const loadMock = vi.fn().mockResolvedValue(null);
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
});
it('should mark feature as interrupted for pending status', async () => {
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pending' });
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markFeatureInterrupted('/test/project', 'feature-123');
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
});
}); });
describe('markAllRunningFeaturesInterrupted', () => { describe('markAllRunningFeaturesInterrupted', () => {
@@ -588,23 +522,21 @@ describe('auto-mode-service.ts', () => {
getConcurrencyManager(svc).acquire(feature); getConcurrencyManager(svc).acquire(feature);
}; };
// Helper to mock updateFeatureStatus // Helper to mock featureStateManager.markFeatureInterrupted
const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => { const mockFeatureStateManagerMarkInterrupted = (
(svc as any).updateFeatureStatus = mockFn; svc: AutoModeService,
}; mockFn: ReturnType<typeof vi.fn>
) => {
// Helper to mock loadFeature (svc as any).featureStateManager.markFeatureInterrupted = mockFn;
const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
(svc as any).loadFeature = mockFn;
}; };
it('should do nothing when no features are running', async () => { it('should do nothing when no features are running', async () => {
const updateMock = vi.fn().mockResolvedValue(undefined); const markMock = vi.fn().mockResolvedValue(undefined);
mockUpdateFeatureStatus(service, updateMock); mockFeatureStateManagerMarkInterrupted(service, markMock);
await service.markAllRunningFeaturesInterrupted(); await service.markAllRunningFeaturesInterrupted();
expect(updateMock).not.toHaveBeenCalled(); expect(markMock).not.toHaveBeenCalled();
}); });
it('should mark a single running feature as interrupted', async () => { it('should mark a single running feature as interrupted', async () => {
@@ -614,14 +546,12 @@ describe('auto-mode-service.ts', () => {
isAutoMode: true, isAutoMode: true,
}); });
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' }); const markMock = vi.fn().mockResolvedValue(undefined);
const updateMock = vi.fn().mockResolvedValue(undefined); mockFeatureStateManagerMarkInterrupted(service, markMock);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted(); await service.markAllRunningFeaturesInterrupted();
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted'); expect(markMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'server shutdown');
}); });
it('should mark multiple running features as interrupted', async () => { it('should mark multiple running features as interrupted', async () => {
@@ -641,17 +571,15 @@ describe('auto-mode-service.ts', () => {
isAutoMode: true, isAutoMode: true,
}); });
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' }); const markMock = vi.fn().mockResolvedValue(undefined);
const updateMock = vi.fn().mockResolvedValue(undefined); mockFeatureStateManagerMarkInterrupted(service, markMock);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted(); await service.markAllRunningFeaturesInterrupted();
expect(updateMock).toHaveBeenCalledTimes(3); expect(markMock).toHaveBeenCalledTimes(3);
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'interrupted'); expect(markMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'server shutdown');
expect(updateMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'interrupted'); expect(markMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'server shutdown');
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-3', 'interrupted'); expect(markMock).toHaveBeenCalledWith('/project-a', 'feature-3', 'server shutdown');
}); });
it('should mark features in parallel', async () => { it('should mark features in parallel', async () => {
@@ -663,20 +591,20 @@ describe('auto-mode-service.ts', () => {
}); });
} }
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' });
const callOrder: string[] = []; const callOrder: string[] = [];
const updateMock = vi.fn().mockImplementation(async (_path: string, featureId: string) => { const markMock = vi
callOrder.push(featureId); .fn()
await new Promise((resolve) => setTimeout(resolve, 10)); .mockImplementation(async (_path: string, featureId: string, _reason?: string) => {
}); callOrder.push(featureId);
mockLoadFeature(service, loadMock); await new Promise((resolve) => setTimeout(resolve, 10));
mockUpdateFeatureStatus(service, updateMock); });
mockFeatureStateManagerMarkInterrupted(service, markMock);
const startTime = Date.now(); const startTime = Date.now();
await service.markAllRunningFeaturesInterrupted(); await service.markAllRunningFeaturesInterrupted();
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
expect(updateMock).toHaveBeenCalledTimes(5); expect(markMock).toHaveBeenCalledTimes(5);
// If executed in parallel, total time should be ~10ms // If executed in parallel, total time should be ~10ms
// If sequential, it would be ~50ms (5 * 10ms) // If sequential, it would be ~50ms (5 * 10ms)
expect(duration).toBeLessThan(40); expect(duration).toBeLessThan(40);
@@ -694,35 +622,31 @@ describe('auto-mode-service.ts', () => {
isAutoMode: false, isAutoMode: false,
}); });
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' }); const markMock = vi
const updateMock = vi
.fn() .fn()
.mockResolvedValueOnce(undefined) .mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('Failed to update')); .mockRejectedValueOnce(new Error('Failed to update'));
mockLoadFeature(service, loadMock); mockFeatureStateManagerMarkInterrupted(service, markMock);
mockUpdateFeatureStatus(service, updateMock);
// Should not throw even though one feature failed // Should not throw even though one feature failed
await expect(service.markAllRunningFeaturesInterrupted()).resolves.not.toThrow(); await expect(service.markAllRunningFeaturesInterrupted()).resolves.not.toThrow();
expect(updateMock).toHaveBeenCalledTimes(2); expect(markMock).toHaveBeenCalledTimes(2);
}); });
it('should use provided reason in logging', async () => { it('should use provided reason', async () => {
addRunningFeatureForInterrupt(service, { addRunningFeatureForInterrupt(service, {
featureId: 'feature-1', featureId: 'feature-1',
projectPath: '/project/path', projectPath: '/project/path',
isAutoMode: true, isAutoMode: true,
}); });
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' }); const markMock = vi.fn().mockResolvedValue(undefined);
const updateMock = vi.fn().mockResolvedValue(undefined); mockFeatureStateManagerMarkInterrupted(service, markMock);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted('manual stop'); await service.markAllRunningFeaturesInterrupted('manual stop');
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted'); expect(markMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'manual stop');
}); });
it('should use default reason when none provided', async () => { it('should use default reason when none provided', async () => {
@@ -732,17 +656,15 @@ describe('auto-mode-service.ts', () => {
isAutoMode: true, isAutoMode: true,
}); });
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' }); const markMock = vi.fn().mockResolvedValue(undefined);
const updateMock = vi.fn().mockResolvedValue(undefined); mockFeatureStateManagerMarkInterrupted(service, markMock);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted(); await service.markAllRunningFeaturesInterrupted();
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted'); expect(markMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'server shutdown');
}); });
it('should preserve pipeline statuses for running features', async () => { it('should call markFeatureInterrupted for all running features (pipeline status handling delegated to FeatureStateManager)', async () => {
addRunningFeatureForInterrupt(service, { addRunningFeatureForInterrupt(service, {
featureId: 'feature-1', featureId: 'feature-1',
projectPath: '/project-a', projectPath: '/project-a',
@@ -759,27 +681,18 @@ describe('auto-mode-service.ts', () => {
isAutoMode: true, isAutoMode: true,
}); });
// feature-1 has in_progress (should be interrupted) // FeatureStateManager handles pipeline status preservation internally
// feature-2 has pipeline_testing (should be preserved) const markMock = vi.fn().mockResolvedValue(undefined);
// feature-3 has pipeline_implementation (should be preserved) mockFeatureStateManagerMarkInterrupted(service, markMock);
const loadMock = vi
.fn()
.mockImplementation(async (_projectPath: string, featureId: string) => {
if (featureId === 'feature-1') return { id: 'feature-1', status: 'in_progress' };
if (featureId === 'feature-2') return { id: 'feature-2', status: 'pipeline_testing' };
if (featureId === 'feature-3')
return { id: 'feature-3', status: 'pipeline_implementation' };
return null;
});
const updateMock = vi.fn().mockResolvedValue(undefined);
mockLoadFeature(service, loadMock);
mockUpdateFeatureStatus(service, updateMock);
await service.markAllRunningFeaturesInterrupted(); await service.markAllRunningFeaturesInterrupted();
// Only feature-1 should be marked as interrupted // All running features should have markFeatureInterrupted called
expect(updateMock).toHaveBeenCalledTimes(1); // (FeatureStateManager internally preserves pipeline statuses)
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'interrupted'); expect(markMock).toHaveBeenCalledTimes(3);
expect(markMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'server shutdown');
expect(markMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'server shutdown');
expect(markMock).toHaveBeenCalledWith('/project-c', 'feature-3', 'server shutdown');
}); });
}); });