diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 54f2f8f16..05050d832 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -15,6 +15,7 @@ import { buildPromptWithImages, isAbortError, classifyError, + isAmbiguousCLIExit, loadContextFiles, } from '@automaker/utils'; import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; @@ -33,6 +34,7 @@ import { import { FeatureLoader } from './feature-loader.js'; import type { SettingsService } from './settings-service.js'; import { pipelineService, PipelineService } from './pipeline-service.js'; +import { ClaudeUsageService } from './claude-usage-service.js'; import { getAutoLoadClaudeMdSetting, getEnableSandboxModeSetting, @@ -238,6 +240,11 @@ export class AutoModeService { return true; } + // Immediately pause for ambiguous CLI exits (often quota issues in disguise) + if (isAmbiguousCLIExit(new Error(errorInfo.message))) { + return true; + } + return false; } @@ -245,7 +252,7 @@ export class AutoModeService { * Signal that we should pause due to repeated failures or quota exhaustion. * This will pause the auto loop to prevent repeated failures. */ - private signalShouldPause(errorInfo: { type: string; message: string }): void { + private async signalShouldPause(errorInfo: { type: string; message: string }): Promise { if (this.pausedDueToFailures) { return; // Already paused } @@ -256,6 +263,59 @@ export class AutoModeService { `[AutoMode] Pausing auto loop after ${failureCount} consecutive failures. Last error: ${errorInfo.type}` ); + // Fetch current usage data to provide suggested resume time + let suggestedResumeAt: string | undefined; + let lastKnownUsage: Record | undefined; + + try { + const usageService = new ClaudeUsageService(); + if (await usageService.isAvailable()) { + const usage = await usageService.fetchUsageData(); + if (usage && !('error' in usage)) { + lastKnownUsage = usage as Record; + // Calculate suggested resume time based on reset times + const sessionReset = (usage as { sessionResetTime?: string }).sessionResetTime; + const sessionResetText = (usage as { sessionResetText?: string }).sessionResetText; + const weeklyReset = (usage as { weeklyResetTime?: string }).weeklyResetTime; + const weeklyResetText = (usage as { weeklyResetText?: string }).weeklyResetText; + + if (sessionReset) { + // Check if it's already an ISO date string + const resetDate = new Date(sessionReset); + if (!isNaN(resetDate.getTime())) { + suggestedResumeAt = sessionReset; + } else if (sessionResetText) { + // Parse "Resets in Xh Ym" format from text + const match = sessionResetText.match(/(\d+)h\s*(\d+)?m?/); + if (match) { + const hours = parseInt(match[1], 10); + const minutes = parseInt(match[2] || '0', 10); + const resetMs = (hours * 60 + minutes) * 60 * 1000; + suggestedResumeAt = new Date(Date.now() + resetMs).toISOString(); + } + } + } else if (weeklyReset) { + // Check if it's already an ISO date string + const resetDate = new Date(weeklyReset); + if (!isNaN(resetDate.getTime())) { + suggestedResumeAt = weeklyReset; + } else if (weeklyResetText) { + // Parse text format + const match = weeklyResetText.match(/(\d+)h\s*(\d+)?m?/); + if (match) { + const hours = parseInt(match[1], 10); + const minutes = parseInt(match[2] || '0', 10); + const resetMs = (hours * 60 + minutes) * 60 * 1000; + suggestedResumeAt = new Date(Date.now() + resetMs).toISOString(); + } + } + } + } + } + } catch (e) { + console.warn('[AutoMode] Failed to fetch usage data for pause info:', e); + } + // Emit event to notify UI this.emitAutoModeEvent('auto_mode_paused_failures', { message: @@ -266,6 +326,8 @@ export class AutoModeService { originalError: errorInfo.message, failureCount, projectPath: this.config?.projectPath, + suggestedResumeAt, + lastKnownUsage, }); // Stop the auto loop @@ -298,6 +360,73 @@ export class AutoModeService { // Reset failure tracking when user manually starts auto mode this.resetFailureTracking(); + // Proactive usage limit check before starting the loop + try { + const usageService = new ClaudeUsageService(); + if (await usageService.isAvailable()) { + const usage = await usageService.fetchUsageData(); + if (usage && !('error' in usage)) { + const isAtSessionLimit = usage.sessionPercentage >= 100; + const isAtWeeklyLimit = usage.weeklyPercentage >= 100; + + if (isAtSessionLimit || isAtWeeklyLimit) { + console.log('[AutoMode] Usage limit detected at startup, not starting loop'); + + // Use the reset time directly if it's an ISO string, otherwise parse the text + let suggestedResumeAt: string | undefined; + if (isAtSessionLimit && usage.sessionResetTime) { + // Check if it's already an ISO date string + const resetDate = new Date(usage.sessionResetTime); + if (!isNaN(resetDate.getTime())) { + suggestedResumeAt = usage.sessionResetTime; + } else { + // Try to parse "Resets in Xh Ym" format from sessionResetText + const match = (usage.sessionResetText || '').match(/(\d+)h\s*(\d+)?m?/); + if (match) { + const hours = parseInt(match[1], 10); + const minutes = parseInt(match[2] || '0', 10); + suggestedResumeAt = new Date( + Date.now() + (hours * 60 + minutes) * 60 * 1000 + ).toISOString(); + } + } + } else if (isAtWeeklyLimit && usage.weeklyResetTime) { + // Check if it's already an ISO date string + const resetDate = new Date(usage.weeklyResetTime); + if (!isNaN(resetDate.getTime())) { + suggestedResumeAt = usage.weeklyResetTime; + } else { + // Try to parse text format + const match = (usage.weeklyResetText || '').match(/(\d+)h\s*(\d+)?m?/); + if (match) { + const hours = parseInt(match[1], 10); + const minutes = parseInt(match[2] || '0', 10); + suggestedResumeAt = new Date( + Date.now() + (hours * 60 + minutes) * 60 * 1000 + ).toISOString(); + } + } + } + + // Emit pause event instead of starting + this.emitAutoModeEvent('auto_mode_paused_failures', { + message: + 'Auto Mode cannot start: Usage limit reached. Please wait for your quota to reset.', + errorType: 'quota_exhausted', + projectPath, + suggestedResumeAt, + lastKnownUsage: usage as Record, + }); + + return; // Don't start the loop + } + } + } + } catch (error) { + console.warn('[AutoMode] Failed to check usage limits at startup:', error); + // Continue starting the loop anyway + } + this.autoLoopRunning = true; this.autoLoopAbortController = new AbortController(); this.config = { diff --git a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts index e0ab4c4db..c91188d7d 100644 --- a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts +++ b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AutoModeService } from '@/services/auto-mode-service.js'; import { ProviderFactory } from '@/providers/provider-factory.js'; import { FeatureLoader } from '@/services/feature-loader.js'; +import { ClaudeUsageService } from '@/services/claude-usage-service.js'; import { createTestGitRepo, createTestFeature, @@ -29,11 +30,32 @@ describe('auto-mode-service.ts (integration)', () => { emit: vi.fn(), }; + // Mock usage data that indicates normal operation (not at limit) + const mockUsageOK = { + sessionPercentage: 50, + weeklyPercentage: 60, + sessionResetTime: null, + weeklyResetTime: null, + sonnetWeeklyPercentage: 0, + sessionTokensUsed: 0, + sessionLimit: 0, + costUsed: null, + costLimit: null, + costCurrency: null, + sessionResetText: '', + weeklyResetText: '', + userTimezone: 'UTC', + }; + beforeEach(async () => { vi.clearAllMocks(); service = new AutoModeService(mockEvents as any); featureLoader = new FeatureLoader(); testRepo = await createTestGitRepo(); + + // Mock ClaudeUsageService to prevent usage checks from blocking tests + vi.spyOn(ClaudeUsageService.prototype, 'isAvailable').mockResolvedValue(true); + vi.spyOn(ClaudeUsageService.prototype, 'fetchUsageData').mockResolvedValue(mockUsageOK); }); afterEach(async () => { diff --git a/apps/server/tests/unit/services/auto-mode-service-usage-limit.test.ts b/apps/server/tests/unit/services/auto-mode-service-usage-limit.test.ts new file mode 100644 index 000000000..a00c7a313 --- /dev/null +++ b/apps/server/tests/unit/services/auto-mode-service-usage-limit.test.ts @@ -0,0 +1,432 @@ +/** + * Tests for Auto Mode Service Usage Limit Feature + * + * Tests the behavior when usage limits are reached: + * - Immediate check before starting auto mode + * - Pause dialog emission when limits are hit + * - Suggested resume time calculation + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AutoModeService } from '@/services/auto-mode-service.js'; +import { ClaudeUsageService } from '@/services/claude-usage-service.js'; + +describe('AutoModeService - Usage Limit Feature', () => { + let service: AutoModeService; + const mockEvents = { + subscribe: vi.fn(), + emit: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + service = new AutoModeService(mockEvents as any); + + // Mock ClaudeUsageService methods + vi.spyOn(ClaudeUsageService.prototype, 'isAvailable').mockResolvedValue(true); + }); + + afterEach(async () => { + // Cleanup: stop any running loops + try { + await service.stopAutoLoop(); + } catch { + // Ignore errors during cleanup + } + }); + + describe('startAutoLoop - Usage Limit Check', () => { + it('should check usage limits before starting the loop', async () => { + const mockUsage = { + sessionPercentage: 50, + weeklyPercentage: 60, + sessionResetTime: null, + weeklyResetTime: null, + sonnetWeeklyPercentage: 0, + sessionTokensUsed: 0, + sessionLimit: 0, + costUsed: null, + costLimit: null, + costCurrency: null, + sessionResetText: '', + weeklyResetText: '', + userTimezone: 'UTC', + }; + + vi.spyOn(ClaudeUsageService.prototype, 'fetchUsageData').mockResolvedValue(mockUsage); + + const promise = service.startAutoLoop('/test/project', 3); + + // Give it time to check usage + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(ClaudeUsageService.prototype.isAvailable).toHaveBeenCalled(); + expect(ClaudeUsageService.prototype.fetchUsageData).toHaveBeenCalled(); + + // Cleanup + await service.stopAutoLoop(); + await promise.catch(() => {}); + }); + + it('should emit pause event and NOT start loop when session limit is reached', async () => { + const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000); // 2 hours from now + const mockUsage = { + sessionPercentage: 100, + weeklyPercentage: 50, + sessionResetTime: futureDate.toISOString(), + weeklyResetTime: null, + sonnetWeeklyPercentage: 0, + sessionTokensUsed: 0, + sessionLimit: 0, + costUsed: null, + costLimit: null, + costCurrency: null, + sessionResetText: 'Resets in 2h', + weeklyResetText: '', + userTimezone: 'UTC', + }; + + vi.spyOn(ClaudeUsageService.prototype, 'fetchUsageData').mockResolvedValue(mockUsage); + + await service.startAutoLoop('/test/project', 3); + + // Should emit pause event + const emitCalls = mockEvents.emit.mock.calls; + expect(emitCalls.length).toBeGreaterThan(0); + const pauseEventCall = emitCalls.find( + (call) => call[1]?.type === 'auto_mode_paused_failures' + ); + expect(pauseEventCall).toBeDefined(); + expect(pauseEventCall![0]).toBe('auto-mode:event'); + expect(pauseEventCall![1].errorType).toBe('quota_exhausted'); + expect(pauseEventCall![1].message.toLowerCase()).toContain('usage limit'); + expect(pauseEventCall![1].suggestedResumeAt).toBe(futureDate.toISOString()); + + // Should NOT emit start event + const startEventCalls = mockEvents.emit.mock.calls.filter((call: any[]) => + call[1]?.message?.includes('Auto mode started') + ); + expect(startEventCalls.length).toBe(0); + + // Loop should not be running + const runningCount = await service.stopAutoLoop(); + expect(runningCount).toBe(0); + }); + + it('should emit pause event and NOT start loop when weekly limit is reached', async () => { + const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours from now + const mockUsage = { + sessionPercentage: 50, + weeklyPercentage: 100, + sessionResetTime: null, + weeklyResetTime: futureDate.toISOString(), + sonnetWeeklyPercentage: 0, + sessionTokensUsed: 0, + sessionLimit: 0, + costUsed: null, + costLimit: null, + costCurrency: null, + sessionResetText: '', + weeklyResetText: 'Resets tomorrow', + userTimezone: 'UTC', + }; + + vi.spyOn(ClaudeUsageService.prototype, 'fetchUsageData').mockResolvedValue(mockUsage); + + await service.startAutoLoop('/test/project', 3); + + // Should emit pause event with weekly reset time + expect(mockEvents.emit).toHaveBeenCalledWith( + 'auto-mode:event', + expect.objectContaining({ + type: 'auto_mode_paused_failures', + errorType: 'quota_exhausted', + suggestedResumeAt: futureDate.toISOString(), + }) + ); + + // Loop should not be running + const runningCount = await service.stopAutoLoop(); + expect(runningCount).toBe(0); + }); + + it('should prioritize session reset time when both limits are reached', async () => { + const sessionReset = new Date(Date.now() + 1 * 60 * 60 * 1000); // 1 hour + const weeklyReset = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + const mockUsage = { + sessionPercentage: 100, + weeklyPercentage: 100, + sessionResetTime: sessionReset.toISOString(), + weeklyResetTime: weeklyReset.toISOString(), + sonnetWeeklyPercentage: 0, + sessionTokensUsed: 0, + sessionLimit: 0, + costUsed: null, + costLimit: null, + costCurrency: null, + sessionResetText: 'Resets in 1h', + weeklyResetText: 'Resets tomorrow', + userTimezone: 'UTC', + }; + + vi.spyOn(ClaudeUsageService.prototype, 'fetchUsageData').mockResolvedValue(mockUsage); + + await service.startAutoLoop('/test/project', 3); + + // Should use session reset time (earlier) + expect(mockEvents.emit).toHaveBeenCalledWith( + 'auto-mode:event', + expect.objectContaining({ + suggestedResumeAt: sessionReset.toISOString(), + }) + ); + }); + + it('should start loop normally when usage is below limits', async () => { + const mockUsage = { + sessionPercentage: 50, + weeklyPercentage: 60, + sessionResetTime: null, + weeklyResetTime: null, + sonnetWeeklyPercentage: 0, + sessionTokensUsed: 0, + sessionLimit: 0, + costUsed: null, + costLimit: null, + costCurrency: null, + sessionResetText: '', + weeklyResetText: '', + userTimezone: 'UTC', + }; + + vi.spyOn(ClaudeUsageService.prototype, 'fetchUsageData').mockResolvedValue(mockUsage); + + const promise = service.startAutoLoop('/test/project', 3); + + // Give it time to start + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should emit start event + expect(mockEvents.emit).toHaveBeenCalledWith( + 'auto-mode:event', + expect.objectContaining({ + message: expect.stringContaining('Auto mode started'), + }) + ); + + // Cleanup + await service.stopAutoLoop(); + await promise.catch(() => {}); + }); + + it('should continue starting loop if usage check fails', async () => { + vi.spyOn(ClaudeUsageService.prototype, 'fetchUsageData').mockRejectedValue( + new Error('CLI not available') + ); + + const promise = service.startAutoLoop('/test/project', 3); + + // Give it time to start + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should still emit start event (graceful degradation) + expect(mockEvents.emit).toHaveBeenCalledWith( + 'auto-mode:event', + expect.objectContaining({ + message: expect.stringContaining('Auto mode started'), + }) + ); + + // Cleanup + await service.stopAutoLoop(); + await promise.catch(() => {}); + }); + + it('should continue starting loop if CLI is not available', async () => { + const fetchUsageSpy = vi.spyOn(ClaudeUsageService.prototype, 'fetchUsageData'); + vi.spyOn(ClaudeUsageService.prototype, 'isAvailable').mockResolvedValue(false); + + const promise = service.startAutoLoop('/test/project', 3); + + // Give it time to start + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should not call fetchUsageData when CLI is not available + expect(fetchUsageSpy).not.toHaveBeenCalled(); + + // Should still emit start event + expect(mockEvents.emit).toHaveBeenCalledWith( + 'auto-mode:event', + expect.objectContaining({ + message: expect.stringContaining('Auto mode started'), + }) + ); + + // Cleanup + await service.stopAutoLoop(); + await promise.catch(() => {}); + }); + + it('should include lastKnownUsage in pause event', async () => { + const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000); + const mockUsage = { + sessionPercentage: 100, + weeklyPercentage: 50, + sessionResetTime: futureDate.toISOString(), + weeklyResetTime: null, + sonnetWeeklyPercentage: 0, + sessionTokensUsed: 1000, + sessionLimit: 2000, + costUsed: null, + costLimit: null, + costCurrency: null, + sessionResetText: 'Resets in 2h', + weeklyResetText: '', + userTimezone: 'UTC', + }; + + vi.spyOn(ClaudeUsageService.prototype, 'fetchUsageData').mockResolvedValue(mockUsage); + + await service.startAutoLoop('/test/project', 3); + + // Should include usage data in event + expect(mockEvents.emit).toHaveBeenCalledWith( + 'auto-mode:event', + expect.objectContaining({ + lastKnownUsage: expect.objectContaining({ + sessionPercentage: 100, + sessionTokensUsed: 1000, + sessionLimit: 2000, + }), + }) + ); + }); + }); + + describe('trackFailureAndCheckPause - Immediate Pause on Quota Errors', () => { + it('should immediately pause on quota_exhausted error without waiting for threshold', () => { + // Access private method for testing + const trackFailure = (service as any).trackFailureAndCheckPause.bind(service); + + const shouldPause = trackFailure({ + type: 'quota_exhausted', + message: 'Quota exhausted', + originalError: new Error('Quota exhausted'), + }); + + // Should pause immediately, not wait for 3 failures + expect(shouldPause).toBe(true); + }); + + it('should immediately pause on rate_limit error without waiting for threshold', () => { + const trackFailure = (service as any).trackFailureAndCheckPause.bind(service); + + const shouldPause = trackFailure({ + type: 'rate_limit', + message: 'Rate limit exceeded', + originalError: new Error('Rate limit exceeded'), + }); + + expect(shouldPause).toBe(true); + }); + + it('should immediately pause on ambiguous CLI exit error', () => { + const trackFailure = (service as any).trackFailureAndCheckPause.bind(service); + + const shouldPause = trackFailure({ + type: 'unknown', + message: 'Claude Code process exited with code 1', + originalError: new Error('Claude Code process exited with code 1'), + }); + + // Should pause immediately on first occurrence (no longer wait for 2) + expect(shouldPause).toBe(true); + }); + + it('should not immediately pause on regular errors', () => { + const trackFailure = (service as any).trackFailureAndCheckPause.bind(service); + + const shouldPause = trackFailure({ + type: 'execution', + message: 'Some generic error', + originalError: new Error('Some generic error'), + }); + + // Should not pause on single regular error + expect(shouldPause).toBe(false); + }); + + it('should pause after CONSECUTIVE_FAILURE_THRESHOLD for regular errors', () => { + const trackFailure = (service as any).trackFailureAndCheckPause.bind(service); + + // First 2 failures should not pause + expect( + trackFailure({ + type: 'execution', + message: 'Error 1', + originalError: new Error('Error 1'), + }) + ).toBe(false); + + expect( + trackFailure({ + type: 'execution', + message: 'Error 2', + originalError: new Error('Error 2'), + }) + ).toBe(false); + + // Third failure should pause (threshold is 3) + expect( + trackFailure({ + type: 'execution', + message: 'Error 3', + originalError: new Error('Error 3'), + }) + ).toBe(true); + }); + }); + + describe('signalShouldPause - Usage Limit Detection', () => { + it('should fetch usage data when pausing due to quota exhaustion', async () => { + const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000); + const mockUsage = { + sessionPercentage: 100, + weeklyPercentage: 50, + sessionResetTime: futureDate.toISOString(), + weeklyResetTime: null, + sonnetWeeklyPercentage: 0, + sessionTokensUsed: 0, + sessionLimit: 0, + costUsed: null, + costLimit: null, + costCurrency: null, + sessionResetText: 'Resets in 2h', + weeklyResetText: '', + userTimezone: 'UTC', + }; + + vi.spyOn(ClaudeUsageService.prototype, 'fetchUsageData').mockResolvedValue(mockUsage); + + // Access private method for testing + const errorInfo = { + type: 'quota_exhausted', + message: 'Quota exhausted', + originalError: new Error('Quota exhausted'), + }; + + // Set project path so usage check can run + (service as any).config = { projectPath: '/test/project' }; + + // Call the private method via a public method that triggers it + // We'll need to trigger a failure that causes pause + await service.startAutoLoop('/test/project', 3); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // The usage check should have been called + expect(ClaudeUsageService.prototype.fetchUsageData).toHaveBeenCalled(); + + await service.stopAutoLoop(); + }); + }); +}); 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 3dda13e21..f390ece21 100644 --- a/apps/server/tests/unit/services/auto-mode-service.test.ts +++ b/apps/server/tests/unit/services/auto-mode-service.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AutoModeService } from '@/services/auto-mode-service.js'; +import { ClaudeUsageService } from '@/services/claude-usage-service.js'; import type { Feature } from '@automaker/types'; describe('auto-mode-service.ts', () => { @@ -9,9 +10,30 @@ describe('auto-mode-service.ts', () => { emit: vi.fn(), }; + // Mock usage data that indicates normal operation (not at limit) + const mockUsageOK = { + sessionPercentage: 50, + weeklyPercentage: 60, + sessionResetTime: null, + weeklyResetTime: null, + sonnetWeeklyPercentage: 0, + sessionTokensUsed: 0, + sessionLimit: 0, + costUsed: null, + costLimit: null, + costCurrency: null, + sessionResetText: '', + weeklyResetText: '', + userTimezone: 'UTC', + }; + beforeEach(() => { vi.clearAllMocks(); service = new AutoModeService(mockEvents as any); + + // Mock ClaudeUsageService to prevent usage checks from blocking tests + vi.spyOn(ClaudeUsageService.prototype, 'isAvailable').mockResolvedValue(true); + vi.spyOn(ClaudeUsageService.prototype, 'fetchUsageData').mockResolvedValue(mockUsageOK); }); describe('constructor', () => { @@ -25,7 +47,10 @@ describe('auto-mode-service.ts', () => { // Start first loop const promise1 = service.startAutoLoop('/test/project', 3); - // Try to start second loop + // Wait for the first loop to actually start (usage check + set running flag) + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Try to start second loop - should throw await expect(service.startAutoLoop('/test/project', 3)).rejects.toThrow('already running'); // Cleanup diff --git a/apps/ui/src/components/dialogs/auto-mode-resume-dialog.tsx b/apps/ui/src/components/dialogs/auto-mode-resume-dialog.tsx new file mode 100644 index 000000000..e0adee8f5 --- /dev/null +++ b/apps/ui/src/components/dialogs/auto-mode-resume-dialog.tsx @@ -0,0 +1,312 @@ +/** + * Auto Mode Resume Dialog + * + * Shows when auto mode is paused due to usage limits or consecutive failures. + * Allows users to schedule when auto mode should automatically resume. + */ + +import { useState, useMemo, useEffect } from 'react'; +import { Clock, AlertTriangle, Timer, Calendar, X } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAppStore, type ClaudeUsage, type AutoModeResumeSchedule } from '@/store/app-store'; +import { cn } from '@/lib/utils'; +import { getElectronAPI } from '@/lib/electron'; + +// Quick duration options (in minutes) +const DURATION_OPTIONS = [ + { label: '15m', minutes: 15 }, + { label: '30m', minutes: 30 }, + { label: '1h', minutes: 60 }, + { label: '1h 30m', minutes: 90 }, + { label: '2h', minutes: 120 }, +]; + +function formatTimeUntil(targetDate: Date): string { + const now = new Date(); + const diffMs = targetDate.getTime() - now.getTime(); + + if (diffMs <= 0) return 'now'; + + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + + if (hours === 0) { + return `${minutes}m`; + } + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +} + +function formatTime(date: Date): string { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +export function AutoModeResumeDialog() { + const { + autoModePauseDialogOpen, + autoModePauseReason, + closeAutoModePauseDialog, + setAutoModeResumeSchedule, + claudeUsage, + setClaudeUsage, + currentProject, + projects, + } = useAppStore(); + + const [selectedDuration, setSelectedDuration] = useState(null); + const [customMinutes, setCustomMinutes] = useState(''); + const [useResetTime, setUseResetTime] = useState(false); + const [fetchingUsage, setFetchingUsage] = useState(false); + + // Get the project ID from the pause reason path or current project + const projectId = useMemo(() => { + if (autoModePauseReason?.projectPath) { + const project = projects.find((p) => p.path === autoModePauseReason.projectPath); + return project?.id; + } + return currentProject?.id; + }, [autoModePauseReason?.projectPath, projects, currentProject?.id]); + + // Calculate the earliest reset time from usage data + const resetTime = useMemo((): Date | null => { + if (!claudeUsage) return null; + + const candidates: Date[] = []; + + // Session reset time (if at limit) + if (claudeUsage.sessionPercentage >= 100 && claudeUsage.sessionResetTime) { + const sessionReset = new Date(claudeUsage.sessionResetTime); + if (!isNaN(sessionReset.getTime()) && sessionReset > new Date()) { + candidates.push(sessionReset); + } + } + + // Weekly reset time (if at limit) + if (claudeUsage.weeklyPercentage >= 100 && claudeUsage.weeklyResetTime) { + const weeklyReset = new Date(claudeUsage.weeklyResetTime); + if (!isNaN(weeklyReset.getTime()) && weeklyReset > new Date()) { + candidates.push(weeklyReset); + } + } + + // Return the earliest reset time + if (candidates.length === 0) return null; + return new Date(Math.min(...candidates.map((d) => d.getTime()))); + }, [claudeUsage]); + + // Fetch latest usage data when dialog opens + useEffect(() => { + if (autoModePauseDialogOpen) { + const fetchUsage = async () => { + setFetchingUsage(true); + try { + const api = getElectronAPI(); + if (api.claude) { + const data = await api.claude.getUsage(); + if (!('error' in data)) { + setClaudeUsage(data); + } + } + } catch (error) { + console.error('Failed to fetch Claude usage:', error); + } finally { + setFetchingUsage(false); + } + }; + fetchUsage(); + } + }, [autoModePauseDialogOpen, setClaudeUsage]); + + // Reset state when dialog opens + useEffect(() => { + if (autoModePauseDialogOpen) { + setSelectedDuration(null); + setCustomMinutes(''); + setUseResetTime(false); + } + }, [autoModePauseDialogOpen]); + + // Calculate the resume time based on selection + const resumeAt = useMemo((): Date | null => { + if (useResetTime && resetTime) { + return resetTime; + } + + const minutes = selectedDuration || (customMinutes ? parseInt(customMinutes, 10) : 0); + if (minutes > 0) { + return new Date(Date.now() + minutes * 60 * 1000); + } + + return null; + }, [selectedDuration, customMinutes, useResetTime, resetTime]); + + const handleScheduleResume = () => { + if (!resumeAt || !projectId) return; + + const schedule: AutoModeResumeSchedule = { + resumeAt: resumeAt.toISOString(), + reason: useResetTime ? 'usage_reset' : 'manual_schedule', + scheduledAt: new Date().toISOString(), + lastKnownUsage: claudeUsage || undefined, + }; + + setAutoModeResumeSchedule(projectId, schedule); + closeAutoModePauseDialog(); + }; + + const handleKeepPaused = () => { + closeAutoModePauseDialog(); + }; + + const handleDurationSelect = (minutes: number) => { + setSelectedDuration(minutes); + setCustomMinutes(''); + setUseResetTime(false); + }; + + const handleCustomMinutesChange = (value: string) => { + // Only allow numbers + if (value === '' || /^\d+$/.test(value)) { + setCustomMinutes(value); + setSelectedDuration(null); + setUseResetTime(false); + } + }; + + const handleUseResetTime = () => { + setUseResetTime(true); + setSelectedDuration(null); + setCustomMinutes(''); + }; + + // Determine error type for display + const isUsageLimit = + autoModePauseReason?.errorType === 'quota_exhausted' || + autoModePauseReason?.errorType === 'rate_limit'; + + return ( + {}}> + e.preventDefault()} + showCloseButton={false} + > + + + + {isUsageLimit ? 'Usage Limit Reached' : 'Auto Mode Paused'} + + +
+

+ {isUsageLimit + ? 'You have reached your Claude Code usage limit. Auto Mode has been paused to prevent repeated failures.' + : autoModePauseReason?.message || + 'Auto Mode has been paused due to repeated failures.'} +

+ + {resetTime && ( +
+ + + Usage resets at {formatTime(resetTime)} ({formatTimeUntil(resetTime)}) + +
+ )} + +
+ + + {/* Quick duration buttons */} +
+ {DURATION_OPTIONS.map((option) => ( + + ))} +
+ + {/* Custom duration input */} +
+ or +
+ handleCustomMinutesChange(e.target.value)} + className="w-20 h-8 text-sm" + /> + minutes +
+
+ + {/* Resume at reset button */} + {resetTime && ( + + )} + + {/* Preview of selected time */} + {resumeAt && ( +
+ Auto Mode will resume at + {formatTime(resumeAt)} + + {' '} + ({formatTimeUntil(resumeAt)} from now) + +
+ )} +
+
+
+
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts index dd2597f57..ae45f7c04 100644 --- a/apps/ui/src/components/dialogs/index.ts +++ b/apps/ui/src/components/dialogs/index.ts @@ -1,3 +1,4 @@ +export { AutoModeResumeDialog } from './auto-mode-resume-dialog'; export { BoardBackgroundModal } from './board-background-modal'; export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-dialog'; export { DeleteSessionDialog } from './delete-session-dialog'; diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 655643048..5b6963413 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -6,7 +6,7 @@ import { rectIntersection, pointerWithin, } from '@dnd-kit/core'; -import { useAppStore, Feature } from '@/store/app-store'; +import { useAppStore, Feature, isClaudeUsageAtLimit } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import { getHttpApiClient } from '@/lib/http-api-client'; import type { AutoModeEvent } from '@/types/electron'; @@ -17,6 +17,7 @@ import { getBlockingDependencies } from '@automaker/dependency-resolver'; import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal'; import { RefreshCw } from 'lucide-react'; import { useAutoMode } from '@/hooks/use-auto-mode'; +import { useAutoModeScheduler } from '@/hooks/use-auto-mode-scheduler'; import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { useWindowState } from '@/hooks/use-window-state'; // Board-view specific imports @@ -88,6 +89,12 @@ export function BoardView() { isPrimaryWorktreeBranch, getPrimaryWorktreeBranch, setPipelineConfig, + claudeUsage, + isAutoModePaused, + getAutoModePaused, + setAutoModePaused, + openAutoModePauseDialog, + setAutoModeRunning, } = useAppStore(); // Subscribe to pipelineConfigByProject to trigger re-renders when it changes const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject); @@ -234,6 +241,9 @@ export function BoardView() { // Get runningTasks from the hook (scoped to current project) const runningAutoTasks = autoMode.runningTasks; + // Hook to manage scheduled auto mode resumes + useAutoModeScheduler(); + // Window state hook for compact dialog mode const { isMaximized } = useWindowState(); @@ -669,6 +679,29 @@ export function BoardView() { return; } + // Check if auto mode is paused due to usage limits + if (isAutoModePaused(currentProject.id)) { + // Auto mode is paused, don't try to pick up new features + return; + } + + // Proactive usage limit check when auto mode starts + if (isClaudeUsageAtLimit(claudeUsage)) { + console.log('[BoardView] Usage limit detected, pausing auto mode'); + setAutoModeRunning(currentProject.id, false); + setAutoModePaused(currentProject.id, { + pausedAt: new Date().toISOString(), + reason: 'usage_limit', + lastKnownUsage: claudeUsage ?? undefined, + }); + openAutoModePauseDialog({ + message: 'Usage limit reached. Auto Mode has been paused.', + errorType: 'quota_exhausted', + projectPath: currentProject.path, + }); + return; + } + let isChecking = false; let isActive = true; // Track if this effect is still active @@ -679,6 +712,11 @@ export function BoardView() { return; } + // Check if auto mode is paused due to usage limits + if (isAutoModePaused(currentProject.id)) { + return; + } + // Prevent concurrent executions if (isChecking) { return; @@ -812,6 +850,12 @@ export function BoardView() { isPrimaryWorktreeBranch, enableDependencyBlocking, persistFeatureUpdate, + // Usage limit checks + claudeUsage, + isAutoModePaused, + setAutoModeRunning, + setAutoModePaused, + openAutoModePauseDialog, ]); // Use keyboard shortcuts hook (after actions hook) @@ -1028,6 +1072,23 @@ export function BoardView() { description: 'Add new feature', }} isMounted={isMounted} + isAutoModePaused={currentProject ? isAutoModePaused(currentProject.id) : false} + autoModePausedState={currentProject ? getAutoModePaused(currentProject.id) : null} + autoModeResumeSchedule={useAppStore + .getState() + .getAutoModeResumeSchedule(currentProject?.id || '')} + onOpenPauseDialog={() => { + const pausedState = currentProject ? getAutoModePaused(currentProject.id) : null; + openAutoModePauseDialog({ + message: + pausedState?.reason === 'usage_limit' + ? 'Usage limit reached. Auto Mode is paused.' + : 'Auto Mode is paused due to repeated failures.', + errorType: pausedState?.reason === 'usage_limit' ? 'quota_exhausted' : 'execution', + projectPath: currentProject?.path, + suggestedResumeAt: pausedState?.suggestedResumeAt, + }); + }} /> {/* Worktree Panel */} diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index bc20f37a4..d947e64b8 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -3,11 +3,12 @@ import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -import { Plus, Bot, Wand2 } from 'lucide-react'; +import { Plus, Bot, Wand2, AlertCircle, Clock } from 'lucide-react'; import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; import { ClaudeUsagePopover } from '@/components/claude-usage-popover'; -import { useAppStore } from '@/store/app-store'; +import { useAppStore, AutoModePausedState, AutoModeResumeSchedule } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; interface BoardHeaderProps { projectName: string; @@ -20,6 +21,10 @@ interface BoardHeaderProps { onOpenPlanDialog: () => void; addFeatureShortcut: KeyboardShortcut; isMounted: boolean; + isAutoModePaused?: boolean; + autoModePausedState?: AutoModePausedState | null; + autoModeResumeSchedule?: AutoModeResumeSchedule | null; + onOpenPauseDialog?: () => void; } export function BoardHeader({ @@ -33,10 +38,26 @@ export function BoardHeader({ onOpenPlanDialog, addFeatureShortcut, isMounted, + isAutoModePaused, + autoModePausedState, + autoModeResumeSchedule, + onOpenPauseDialog, }: BoardHeaderProps) { const apiKeys = useAppStore((state) => state.apiKeys); const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); + // Format remaining time for scheduled resume + const formatTimeRemaining = (resumeAt: string) => { + const now = new Date(); + const resumeDate = new Date(resumeAt); + const diffMs = resumeDate.getTime() - now.getTime(); + if (diffMs <= 0) return 'resuming soon...'; + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; + }; + // Hide usage tracking when using API key (only show for Claude Code CLI users) // Check both user-entered API key and environment variable ANTHROPIC_API_KEY // Also hide on Windows for now (CLI usage command not supported) @@ -99,6 +120,45 @@ export function BoardHeader({ )} + {/* Paused/Scheduled Resume Indicator */} + {isMounted && isAutoModePaused && !isAutoModeRunning && ( + + {autoModeResumeSchedule ? ( + + + + + +

Click to view or change scheduled resume

+
+
+ ) : ( + + + + + +

Click to schedule auto mode resume

+
+
+ )} +
+ )} +