Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 130 additions & 1 deletion apps/server/src/services/auto-mode-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
buildPromptWithImages,
isAbortError,
classifyError,
isAmbiguousCLIExit,
loadContextFiles,
} from '@automaker/utils';
import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver';
Expand All @@ -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,
Expand Down Expand Up @@ -238,14 +240,19 @@ 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;
}

/**
* 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<void> {
if (this.pausedDueToFailures) {
return; // Already paused
}
Expand All @@ -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<string, unknown> | undefined;

try {
const usageService = new ClaudeUsageService();
if (await usageService.isAvailable()) {
const usage = await usageService.fetchUsageData();
if (usage && !('error' in usage)) {
lastKnownUsage = usage as Record<string, unknown>;
// 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();
}
}
}
Comment on lines +267 to +312
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Duplicated reset time parsing logic violates DRY principle.

The same logic for parsing session/weekly reset times from usage data appears twice: once in signalShouldPause (lines 282-312) and once in startAutoLoop (lines 384-408). This duplication makes maintenance harder and increases bug risk.

🔎 Proposed fix: Extract helper method
+  /**
+   * Extract suggested resume time from usage data
+   */
+  private getSuggestedResumeTime(usage: {
+    sessionPercentage?: number;
+    sessionResetTime?: string;
+    sessionResetText?: string;
+    weeklyPercentage?: number;
+    weeklyResetTime?: string;
+    weeklyResetText?: string;
+  }): string | undefined {
+    const isAtSessionLimit = (usage.sessionPercentage ?? 0) >= 100;
+    const isAtWeeklyLimit = (usage.weeklyPercentage ?? 0) >= 100;
+    
+    const parseResetText = (text: string | undefined): string | undefined => {
+      if (!text) return undefined;
+      const match = text.match(/(\d+)h\s*(\d+)?m?/);
+      if (match) {
+        const hours = parseInt(match[1], 10);
+        const minutes = parseInt(match[2] || '0', 10);
+        return new Date(Date.now() + (hours * 60 + minutes) * 60 * 1000).toISOString();
+      }
+      return undefined;
+    };
+    
+    const tryParseResetTime = (resetTime?: string, resetText?: string): string | undefined => {
+      if (resetTime) {
+        const resetDate = new Date(resetTime);
+        if (!isNaN(resetDate.getTime())) return resetTime;
+      }
+      return parseResetText(resetText);
+    };
+    
+    if (isAtSessionLimit) {
+      return tryParseResetTime(usage.sessionResetTime, usage.sessionResetText);
+    } else if (isAtWeeklyLimit) {
+      return tryParseResetTime(usage.weeklyResetTime, usage.weeklyResetText);
+    }
+    return undefined;
+  }

Then use this.getSuggestedResumeTime(usage) in both locations.

Also applies to: 376-408

🤖 Prompt for AI Agents
In apps/server/src/services/auto-mode-service.ts around lines 267-312
(duplication also present around 376-408): the reset-time parsing logic is
duplicated; extract a single private helper on the class like
getSuggestedResumeTime(usage: Record<string, unknown> | any): string | undefined
that encapsulates the current logic (check sessionReset/weeklyReset for ISO,
else parse sessionResetText/weeklyResetText with the hours/minutes regex and
convert to an ISO datetime), return the computed ISO string or undefined, and
use that helper from both signalShouldPause and startAutoLoop; keep existing
lastKnownUsage assignment and type checks, ensure the helper accepts the same
usage shape, is properly typed/nullable, and replace the duplicated blocks with
calls to this.getSuggestedResumeTime(usage).

}
Comment on lines +276 to +313
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic for calculating suggestedResumeAt is duplicated in signalShouldPause and startAutoLoop. This increases maintenance overhead and risk of inconsistencies. Consider extracting this into a private helper method. This would also be a good opportunity to make the logic in signalShouldPause more robust by checking usage.sessionPercentage and usage.weeklyPercentage before deciding which reset time to use, similar to the logic in startAutoLoop.

For example, you could create a helper function:

private parseResumeTime(resetTime?: string, resetText?: string): string | undefined {
  // ... parsing logic ...
}

And then use it in both places.

}
} 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:
Expand All @@ -266,6 +326,8 @@ export class AutoModeService {
originalError: errorInfo.message,
failureCount,
projectPath: this.config?.projectPath,
suggestedResumeAt,
lastKnownUsage,
});

// Stop the auto loop
Expand Down Expand Up @@ -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<string, unknown>,
});

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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading