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:
Andy
2026-02-11 16:12:48 +01:00
committed by GitHub
parent f149a7fbd7
commit ded6aad4f7
4 changed files with 219 additions and 43 deletions
@@ -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
+20
View File
@@ -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
*
+116 -6
View File
@@ -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
View File
@@ -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;
}