Fix Title Generation Production Build & Add Sentry Observability (#1781)
* auto-claude: subtask-1-1 - Add Sentry instrumentation to TitleGenerator Add Sentry breadcrumbs and captureException calls to TitleGenerator.generateTitle() at key decision points: source path resolution, Python path resolution, process spawn, process exit (success/failure/timeout), rate limit detection, and process errors. All Sentry calls wrapped in try/catch to prevent cascading failures. Extended sentry-electron type stubs with addBreadcrumb and captureContext support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * auto-claude: subtask-2-1 - Replace spawn env with pythonEnvManager.getPythonEnv() Replace process.env spread with pythonEnvManager.getPythonEnv() as the base environment for the title generator subprocess. Add getSentryEnvForSubprocess() overlay and a guard for pythonEnvManager.isEnvReady() that falls back gracefully. Remove manual PYTHONUNBUFFERED/PYTHONIOENCODING/PYTHONUTF8 vars since pythonEnvManager.getPythonEnv() already sets them. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * auto-claude: subtask-3-1 - Add Sentry breadcrumbs to TASK_CREATE and TASK_UPDATE handlers Add breadcrumbs for title generation lifecycle: invocation, success, fallback to description truncation, and error cases. All Sentry calls wrapped in try/catch for safety. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review findings for Sentry instrumentation - Extract safeBreadcrumb() and safeCaptureException() helpers to sentry.ts, replacing repetitive try/catch boilerplate across title-generator and crud-handlers - Extract generateTitleWithFallback() shared helper in crud-handlers.ts, eliminating ~100 lines of duplicated title generation logic between TASK_CREATE and TASK_UPDATE - Add missing PYTHONUNBUFFERED=1 to title-generator subprocess env to match all other subprocess spawners in the codebase - Move isEnvReady() guard before 'Spawning process' breadcrumb and reuse the cached venvReady variable instead of calling isEnvReady() twice Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string> {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+17
-2
@@ -17,16 +17,31 @@ interface SentryInitOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface SentryBreadcrumb {
|
||||
category?: string;
|
||||
message?: string;
|
||||
level?: 'fatal' | 'error' | 'warning' | 'info' | 'debug';
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SentryCaptureContext {
|
||||
contexts?: Record<string, Record<string, unknown>>;
|
||||
tags?: Record<string, string>;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user