diff --git a/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts index 74a2543c..653c41de 100644 --- a/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/crud-handlers.ts @@ -14,6 +14,7 @@ import { cleanupWorktree } from '../../utils/worktree-cleanup'; import { getToolPath } from '../../cli-tool-manager'; import { getIsolatedGitEnv } from '../../utils/git-isolation'; import { taskStateManager } from '../../task-state-manager'; +import { safeBreadcrumb } from '../../sentry'; /** * Sanitize thinking levels in task metadata in-place. @@ -39,6 +40,69 @@ function sanitizeThinkingLevels(metadata: TaskMetadata): void { } } +/** + * Generate a title from a description using AI, with Sentry breadcrumbs and fallback. + * Shared between TASK_CREATE and TASK_UPDATE handlers. + */ +async function generateTitleWithFallback( + description: string, + handler: string, + taskId?: string, +): Promise { + const breadcrumbData = taskId ? { handler, taskId } : { handler }; + + safeBreadcrumb({ + category: 'task-crud', + message: 'Title generation invoked (empty title detected)', + level: 'info', + data: { ...breadcrumbData, descriptionLength: description.length }, + }); + + try { + const generatedTitle = await titleGenerator.generateTitle(description); + if (generatedTitle) { + console.warn(`[${handler}] Generated title:`, generatedTitle); + safeBreadcrumb({ + category: 'task-crud', + message: 'Title generation succeeded', + level: 'info', + data: { ...breadcrumbData, generatedTitleLength: generatedTitle.length }, + }); + return generatedTitle; + } + + // Fallback: create title from first line of description + const fallback = truncateToTitle(description); + console.warn(`[${handler}] AI generation failed, using fallback:`, fallback); + safeBreadcrumb({ + category: 'task-crud', + message: 'Title generation returned null, using description truncation fallback', + level: 'warning', + data: { ...breadcrumbData, fallbackTitle: fallback }, + }); + return fallback; + } catch (err) { + console.error(`[${handler}] Title generation error:`, err); + const fallback = truncateToTitle(description); + safeBreadcrumb({ + category: 'task-crud', + message: 'Title generation error, using description truncation fallback', + level: 'error', + data: { ...breadcrumbData, error: err instanceof Error ? err.message : String(err) }, + }); + return fallback; + } +} + +/** + * Truncate a description to a short title (first line, max 60 chars). + */ +function truncateToTitle(description: string): string { + let title = description.split('\n')[0].substring(0, 60); + if (title.length === 60) title += '...'; + return title; +} + /** * Register task CRUD (Create, Read, Update, Delete) handlers */ @@ -90,23 +154,7 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void { let finalTitle = title; if (!title || !title.trim()) { console.warn('[TASK_CREATE] Title is empty, generating with Claude AI...'); - try { - const generatedTitle = await titleGenerator.generateTitle(description); - if (generatedTitle) { - finalTitle = generatedTitle; - console.warn('[TASK_CREATE] Generated title:', finalTitle); - } else { - // Fallback: create title from first line of description - finalTitle = description.split('\n')[0].substring(0, 60); - if (finalTitle.length === 60) finalTitle += '...'; - console.warn('[TASK_CREATE] AI generation failed, using fallback:', finalTitle); - } - } catch (err) { - console.error('[TASK_CREATE] Title generation error:', err); - // Fallback: create title from first line of description - finalTitle = description.split('\n')[0].substring(0, 60); - if (finalTitle.length === 60) finalTitle += '...'; - } + finalTitle = await generateTitleWithFallback(description, 'TASK_CREATE'); } // Generate a unique spec ID based on existing specs @@ -395,26 +443,9 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void { // Auto-generate title if empty let finalTitle = updates.title; if (updates.title !== undefined && !updates.title.trim()) { - // Get description to use for title generation const descriptionToUse = updates.description ?? task.description; console.warn('[TASK_UPDATE] Title is empty, generating with Claude AI...'); - try { - const generatedTitle = await titleGenerator.generateTitle(descriptionToUse); - if (generatedTitle) { - finalTitle = generatedTitle; - console.warn('[TASK_UPDATE] Generated title:', finalTitle); - } else { - // Fallback: create title from first line of description - finalTitle = descriptionToUse.split('\n')[0].substring(0, 60); - if (finalTitle.length === 60) finalTitle += '...'; - console.warn('[TASK_UPDATE] AI generation failed, using fallback:', finalTitle); - } - } catch (err) { - console.error('[TASK_UPDATE] Title generation error:', err); - // Fallback: create title from first line of description - finalTitle = descriptionToUse.split('\n')[0].substring(0, 60); - if (finalTitle.length === 60) finalTitle += '...'; - } + finalTitle = await generateTitleWithFallback(descriptionToUse, 'TASK_UPDATE', taskId); } // Update implementation_plan.json diff --git a/apps/frontend/src/main/sentry.ts b/apps/frontend/src/main/sentry.ts index 672203ae..ad068d98 100644 --- a/apps/frontend/src/main/sentry.ts +++ b/apps/frontend/src/main/sentry.ts @@ -180,6 +180,26 @@ export function setSentryEnabled(enabled: boolean): void { console.log(`[Sentry] Error reporting ${enabled ? 'enabled' : 'disabled'} (programmatic)`); } +/** + * Safely add a Sentry breadcrumb, ignoring errors if Sentry is not initialized. + * Use this instead of raw `Sentry.addBreadcrumb()` to avoid try/catch boilerplate. + */ +export function safeBreadcrumb(breadcrumb: SentryBreadcrumb): void { + try { + Sentry.addBreadcrumb(breadcrumb); + } catch { /* Sentry not initialized */ } +} + +/** + * Safely capture a Sentry exception, ignoring errors if Sentry is not initialized. + * Use this instead of raw `Sentry.captureException()` to avoid try/catch boilerplate. + */ +export function safeCaptureException(error: Error, context?: SentryCaptureContext): void { + try { + Sentry.captureException(error, context); + } catch { /* Sentry not initialized */ } +} + /** * Get Sentry environment variables for passing to Python subprocesses * diff --git a/apps/frontend/src/main/title-generator.ts b/apps/frontend/src/main/title-generator.ts index f2225674..1f19a810 100644 --- a/apps/frontend/src/main/title-generator.ts +++ b/apps/frontend/src/main/title-generator.ts @@ -4,10 +4,12 @@ import { spawn } from 'child_process'; import { EventEmitter } from 'events'; import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv } from './rate-limit-detector'; import { parsePythonCommand, getValidatedPythonPath } from './python-detector'; -import { getConfiguredPythonPath } from './python-env-manager'; +import { pythonEnvManager, getConfiguredPythonPath } from './python-env-manager'; import { getAPIProfileEnv } from './services/profile'; import { getOAuthModeClearVars } from './agent/env-utils'; import { getEffectiveSourcePath } from './updater/path-resolver'; +import { getSentryEnvForSubprocess, safeBreadcrumb, safeCaptureException } from './sentry'; +import { maskUserPaths } from '../shared/utils/sentry-privacy'; /** * Debug logging - only logs when DEBUG=true or in development mode @@ -124,9 +126,25 @@ export class TitleGenerator extends EventEmitter { if (!autoBuildSource) { debug('Auto-claude source path not found'); + safeBreadcrumb({ + category: 'title-generator', + message: 'Source path not found', + level: 'warning', + data: { + hasConfiguredPath: !!this.autoBuildSourcePath, + effectivePathExists: existsSync(getEffectiveSourcePath()), + }, + }); return null; } + safeBreadcrumb({ + category: 'title-generator', + message: 'Source path resolved', + level: 'info', + data: { sourcePath: maskUserPaths(autoBuildSource) }, + }); + const prompt = this.createTitlePrompt(description); const script = this.createGenerationScript(prompt); @@ -168,20 +186,54 @@ export class TitleGenerator extends EventEmitter { profileEnvClearsOAuthToken: profileEnv.CLAUDE_CODE_OAUTH_TOKEN === '' }); + // Resolve Python path and check env readiness + const resolvedPythonPath = this.pythonPath; + const venvReady = pythonEnvManager.isEnvReady(); + + safeBreadcrumb({ + category: 'title-generator', + message: 'Python path resolved', + level: 'info', + data: { + pythonPath: maskUserPaths(resolvedPythonPath), + venvReady, + isApiProfileActive, + hasOAuthEnv: !!profileEnv.CLAUDE_CONFIG_DIR, + }, + }); + + // Guard: if Python env isn't ready, log and fall back gracefully + if (!venvReady) { + debug('Python environment not ready, skipping title generation'); + safeBreadcrumb({ + category: 'title-generator', + message: 'Python environment not ready - skipping title generation', + level: 'warning', + }); + return null; + } + return new Promise((resolve) => { // Parse Python command to handle space-separated commands like "py -3" - const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath); + const [pythonCommand, pythonBaseArgs] = parsePythonCommand(resolvedPythonPath); + + safeBreadcrumb({ + category: 'title-generator', + message: 'Spawning process', + level: 'info', + data: { pythonCommand: maskUserPaths(pythonCommand) }, + }); + const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], { cwd: autoBuildSource, env: { - ...process.env, + ...pythonEnvManager.getPythonEnv(), // Python environment including PYTHONPATH (fixes subprocess Python resolution) + ...getSentryEnvForSubprocess(), // Sentry config for subprocess error tracking ...autoBuildEnv, ...profileEnv, // Claude OAuth profile - includes CLAUDE_CONFIG_DIR and clears CLAUDE_CODE_OAUTH_TOKEN ...apiProfileEnv, // API profile (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, etc.) ...oauthModeClearVars, // Clear stale ANTHROPIC_* vars when in OAuth mode - PYTHONUNBUFFERED: '1', - PYTHONIOENCODING: 'utf-8', - PYTHONUTF8: '1' + PYTHONUNBUFFERED: '1', // Ensure stdout isn't buffered (critical for reading output before kill/timeout) } }); @@ -189,6 +241,21 @@ export class TitleGenerator extends EventEmitter { let errorOutput = ''; const timeout = setTimeout(() => { console.warn('[TitleGenerator] Title generation timed out after 60s'); + safeBreadcrumb({ + category: 'title-generator', + message: 'Process timed out after 60s', + level: 'warning', + }); + safeCaptureException(new Error('TitleGenerator: process timed out'), { + contexts: { + titleGenerator: { + pythonPath: maskUserPaths(resolvedPythonPath), + sourcePath: maskUserPaths(autoBuildSource), + venvReady, + stderrSnippet: maskUserPaths(errorOutput.substring(0, 500)), + }, + }, + }); childProcess.kill(); resolve(null); }, 60000); // 60 second timeout for SDK initialization + API call @@ -207,6 +274,11 @@ export class TitleGenerator extends EventEmitter { if (code === 0 && output.trim()) { const title = this.cleanTitle(output.trim()); debug('Generated title:', title); + safeBreadcrumb({ + category: 'title-generator', + message: 'Title generated successfully', + level: 'info', + }); resolve(title); } else { // Check for rate limit @@ -219,6 +291,16 @@ export class TitleGenerator extends EventEmitter { suggestedProfile: rateLimitDetection.suggestedProfile?.name }); + safeBreadcrumb({ + category: 'title-generator', + message: 'Rate limit detected', + level: 'warning', + data: { + limitType: rateLimitDetection.limitType, + resetTime: rateLimitDetection.resetTime, + }, + }); + const rateLimitInfo = createSDKRateLimitInfo('title-generator', rateLimitDetection); this.emit('sdk-rate-limit', rateLimitInfo); } @@ -230,6 +312,24 @@ export class TitleGenerator extends EventEmitter { output: output.substring(0, 200), isRateLimited: rateLimitDetection.isRateLimited }); + + safeCaptureException( + new Error(`TitleGenerator: process exited with code ${code}`), + { + contexts: { + titleGenerator: { + exitCode: code, + pythonPath: maskUserPaths(resolvedPythonPath), + sourcePath: maskUserPaths(autoBuildSource), + venvReady, + isRateLimited: rateLimitDetection.isRateLimited, + isApiProfileActive, + stderrSnippet: maskUserPaths(errorOutput.substring(0, 500)), + }, + }, + } + ); + resolve(null); } }); @@ -237,6 +337,16 @@ export class TitleGenerator extends EventEmitter { childProcess.on('error', (err) => { clearTimeout(timeout); console.warn('[TitleGenerator] Process error:', err.message); + safeCaptureException(err, { + contexts: { + titleGenerator: { + pythonPath: maskUserPaths(resolvedPythonPath), + sourcePath: maskUserPaths(autoBuildSource), + venvReady, + isApiProfileActive, + }, + }, + }); resolve(null); }); }); diff --git a/apps/frontend/src/types/sentry-electron.d.ts b/apps/frontend/src/types/sentry-electron.d.ts index 12d62fa0..a63bb917 100644 --- a/apps/frontend/src/types/sentry-electron.d.ts +++ b/apps/frontend/src/types/sentry-electron.d.ts @@ -17,16 +17,31 @@ interface SentryInitOptions { enabled?: boolean; } +interface SentryBreadcrumb { + category?: string; + message?: string; + level?: 'fatal' | 'error' | 'warning' | 'info' | 'debug'; + data?: Record; +} + +interface SentryCaptureContext { + contexts?: Record>; + tags?: Record; + extra?: Record; +} + declare module '@sentry/electron/main' { export type ErrorEvent = SentryErrorEvent; export function init(options: SentryInitOptions): void; - export function captureException(error: Error): void; + export function captureException(error: Error, context?: SentryCaptureContext): void; export function withScope(callback: (scope: SentryScope) => void): void; + export function addBreadcrumb(breadcrumb: SentryBreadcrumb): void; } declare module '@sentry/electron/renderer' { export type ErrorEvent = SentryErrorEvent; export function init(options: SentryInitOptions): void; - export function captureException(error: Error): void; + export function captureException(error: Error, context?: SentryCaptureContext): void; export function withScope(callback: (scope: SentryScope) => void): void; + export function addBreadcrumb(breadcrumb: SentryBreadcrumb): void; }