5e91c3a75c
* fix(frontend): pass CLAUDE_CLI_PATH to Python backend subprocess
Fixes ACS-230: Claude Code CLI not found despite being installed
When the Electron app is launched from Finder/Dock (not from terminal),
the Python subprocess doesn't inherit the user's shell PATH. This causes
the Claude Agent SDK in the Python backend to fail finding the Claude CLI
when it's installed via Homebrew at /opt/homebrew/bin/claude (macOS) or
other non-standard locations.
This fix:
1. Detects the Claude CLI path using the existing getToolInfo('claude')
2. Passes it to Python backend via CLAUDE_CLI_PATH environment variable
3. Respects existing CLAUDE_CLI_PATH if already set (user override)
4. Follows the same pattern as CLAUDE_CODE_GIT_BASH_PATH (Windows only)
The Python backend (apps/backend/core/client.py) already checks
CLAUDE_CLI_PATH first in find_claude_cli() (line 316), so no backend
changes are needed.
Related: PR #1004 (commit e07a0dbd) which added comprehensive CLI detection
to the backend, but the frontend wasn't passing the detected path.
Refs: ACS-230
* fix: correct typo in CLAUDE_CLI_PATH environment variable check
Addressed review feedback on PR #1081:
- Fixed typo: CLADE_CLI_PATH → CLAUDE_CLI_PATH (line 147)
- This ensures user-provided CLAUDE_CLI_PATH overrides are respected
- Previously the typo caused the check to always fail, ignoring user overrides
The typo was caught by automated review tools (gemini-code-assist, sentry).
Fixes review comments:
- https://github.com/AndyMik90/Auto-Claude/pull/1081#discussion_r2691279285
- https://github.com/AndyMik90/Auto-Claude/pull/1081#discussion_r2691284743
---------
Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
756 lines
27 KiB
TypeScript
756 lines
27 KiB
TypeScript
import { spawn } from 'child_process';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { existsSync, readFileSync } from 'fs';
|
|
import { app } from 'electron';
|
|
|
|
// ESM-compatible __dirname
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
import { EventEmitter } from 'events';
|
|
import { AgentState } from './agent-state';
|
|
import { AgentEvents } from './agent-events';
|
|
import { ProcessType, ExecutionProgressData } from './types';
|
|
import type { CompletablePhase } from '../../shared/constants/phase-protocol';
|
|
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv, detectAuthFailure } from '../rate-limit-detector';
|
|
import { getAPIProfileEnv } from '../services/profile';
|
|
import { projectStore } from '../project-store';
|
|
import { getClaudeProfileManager } from '../claude-profile-manager';
|
|
import { parsePythonCommand, validatePythonPath } from '../python-detector';
|
|
import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager';
|
|
import { buildMemoryEnvVars } from '../memory-env-builder';
|
|
import { readSettingsFile } from '../settings-utils';
|
|
import type { AppSettings } from '../../shared/types/settings';
|
|
import { getOAuthModeClearVars } from './env-utils';
|
|
import { getAugmentedEnv } from '../env-utils';
|
|
import { getToolInfo } from '../cli-tool-manager';
|
|
|
|
|
|
function deriveGitBashPath(gitExePath: string): string | null {
|
|
if (process.platform !== 'win32') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const gitDir = path.dirname(gitExePath); // e.g., D:\...\Git\mingw64\bin
|
|
const gitDirName = path.basename(gitDir).toLowerCase();
|
|
|
|
// Find Git installation root
|
|
let gitRoot: string;
|
|
|
|
if (gitDirName === 'cmd') {
|
|
// .../Git/cmd/git.exe -> .../Git
|
|
gitRoot = path.dirname(gitDir);
|
|
} else if (gitDirName === 'bin') {
|
|
// Could be .../Git/bin/git.exe OR .../Git/mingw64/bin/git.exe
|
|
const parent = path.dirname(gitDir);
|
|
const parentName = path.basename(parent).toLowerCase();
|
|
if (parentName === 'mingw64' || parentName === 'mingw32') {
|
|
// .../Git/mingw64/bin/git.exe -> .../Git
|
|
gitRoot = path.dirname(parent);
|
|
} else {
|
|
// .../Git/bin/git.exe -> .../Git
|
|
gitRoot = parent;
|
|
}
|
|
} else {
|
|
// Unknown structure - try to find 'bin' sibling
|
|
gitRoot = path.dirname(gitDir);
|
|
}
|
|
|
|
// Bash.exe is in Git/bin/bash.exe
|
|
const bashPath = path.join(gitRoot, 'bin', 'bash.exe');
|
|
|
|
if (existsSync(bashPath)) {
|
|
console.log('[AgentProcess] Derived git-bash path:', bashPath);
|
|
return bashPath;
|
|
}
|
|
|
|
// Fallback: check one level up if gitRoot didn't work
|
|
const altBashPath = path.join(path.dirname(gitRoot), 'bin', 'bash.exe');
|
|
if (existsSync(altBashPath)) {
|
|
console.log('[AgentProcess] Found git-bash at alternate path:', altBashPath);
|
|
return altBashPath;
|
|
}
|
|
|
|
console.warn('[AgentProcess] Could not find bash.exe from git path:', gitExePath);
|
|
return null;
|
|
} catch (error) {
|
|
console.error('[AgentProcess] Error deriving git-bash path:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process spawning and lifecycle management
|
|
*/
|
|
export class AgentProcessManager {
|
|
private state: AgentState;
|
|
private events: AgentEvents;
|
|
private emitter: EventEmitter;
|
|
// Python path will be configured by pythonEnvManager after venv is ready
|
|
// Use null to indicate not yet configured - getPythonPath() will use fallback
|
|
private _pythonPath: string | null = null;
|
|
private autoBuildSourcePath: string = '';
|
|
|
|
constructor(state: AgentState, events: AgentEvents, emitter: EventEmitter) {
|
|
this.state = state;
|
|
this.events = events;
|
|
this.emitter = emitter;
|
|
}
|
|
|
|
configure(pythonPath?: string, autoBuildSourcePath?: string): void {
|
|
if (pythonPath) {
|
|
const validation = validatePythonPath(pythonPath);
|
|
if (validation.valid) {
|
|
this._pythonPath = validation.sanitizedPath || pythonPath;
|
|
} else {
|
|
console.error(`[AgentProcess] Invalid Python path rejected: ${validation.reason}`);
|
|
console.error(`[AgentProcess] Falling back to getConfiguredPythonPath()`);
|
|
// Don't set _pythonPath - let getPythonPath() use getConfiguredPythonPath() fallback
|
|
}
|
|
}
|
|
if (autoBuildSourcePath) {
|
|
this.autoBuildSourcePath = autoBuildSourcePath;
|
|
}
|
|
}
|
|
|
|
private setupProcessEnvironment(
|
|
extraEnv: Record<string, string>
|
|
): NodeJS.ProcessEnv {
|
|
const profileEnv = getProfileEnv();
|
|
// Use getAugmentedEnv() to ensure common tool paths (dotnet, homebrew, etc.)
|
|
// are available even when app is launched from Finder/Dock
|
|
const augmentedEnv = getAugmentedEnv();
|
|
|
|
// On Windows, detect and pass git-bash path for Claude Code CLI
|
|
// Electron can detect git via where.exe, but Python subprocess may not have the same PATH
|
|
const gitBashEnv: Record<string, string> = {};
|
|
if (process.platform === 'win32' && !process.env.CLAUDE_CODE_GIT_BASH_PATH) {
|
|
try {
|
|
const gitInfo = getToolInfo('git');
|
|
if (gitInfo.found && gitInfo.path) {
|
|
const bashPath = deriveGitBashPath(gitInfo.path);
|
|
if (bashPath) {
|
|
gitBashEnv['CLAUDE_CODE_GIT_BASH_PATH'] = bashPath;
|
|
console.log('[AgentProcess] Setting CLAUDE_CODE_GIT_BASH_PATH:', bashPath);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('[AgentProcess] Failed to detect git-bash path:', error);
|
|
}
|
|
}
|
|
|
|
// Detect and pass Claude CLI path to Python backend
|
|
// Common issue: Claude CLI installed via Homebrew at /opt/homebrew/bin/claude (macOS)
|
|
// or other non-standard locations not in subprocess PATH when app launches from Finder/Dock
|
|
const claudeCliEnv: Record<string, string> = {};
|
|
if (!process.env.CLAUDE_CLI_PATH) {
|
|
try {
|
|
const claudeInfo = getToolInfo('claude');
|
|
if (claudeInfo.found && claudeInfo.path) {
|
|
claudeCliEnv['CLAUDE_CLI_PATH'] = claudeInfo.path;
|
|
console.log('[AgentProcess] Setting CLAUDE_CLI_PATH:', claudeInfo.path, `(source: ${claudeInfo.source})`);
|
|
}
|
|
} catch (error) {
|
|
console.warn('[AgentProcess] Failed to detect Claude CLI path:', error);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...augmentedEnv,
|
|
...gitBashEnv,
|
|
...claudeCliEnv,
|
|
...extraEnv,
|
|
...profileEnv,
|
|
PYTHONUNBUFFERED: '1',
|
|
PYTHONIOENCODING: 'utf-8',
|
|
PYTHONUTF8: '1'
|
|
} as NodeJS.ProcessEnv;
|
|
}
|
|
|
|
private handleProcessFailure(
|
|
taskId: string,
|
|
allOutput: string,
|
|
processType: ProcessType
|
|
): boolean {
|
|
console.log('[AgentProcess] Checking for rate limit in output (last 500 chars):', allOutput.slice(-500));
|
|
|
|
const rateLimitDetection = detectRateLimit(allOutput);
|
|
console.log('[AgentProcess] Rate limit detection result:', {
|
|
isRateLimited: rateLimitDetection.isRateLimited,
|
|
resetTime: rateLimitDetection.resetTime,
|
|
limitType: rateLimitDetection.limitType,
|
|
profileId: rateLimitDetection.profileId,
|
|
suggestedProfile: rateLimitDetection.suggestedProfile
|
|
});
|
|
|
|
if (rateLimitDetection.isRateLimited) {
|
|
const wasHandled = this.handleRateLimitWithAutoSwap(
|
|
taskId,
|
|
rateLimitDetection,
|
|
processType
|
|
);
|
|
if (wasHandled) return true;
|
|
|
|
const source = processType === 'spec-creation' ? 'roadmap' : 'task';
|
|
const rateLimitInfo = createSDKRateLimitInfo(source, rateLimitDetection, { taskId });
|
|
console.log('[AgentProcess] Emitting sdk-rate-limit event (manual):', rateLimitInfo);
|
|
this.emitter.emit('sdk-rate-limit', rateLimitInfo);
|
|
return true;
|
|
}
|
|
|
|
return this.handleAuthFailure(taskId, allOutput);
|
|
}
|
|
|
|
private handleRateLimitWithAutoSwap(
|
|
taskId: string,
|
|
rateLimitDetection: ReturnType<typeof detectRateLimit>,
|
|
processType: ProcessType
|
|
): boolean {
|
|
const profileManager = getClaudeProfileManager();
|
|
const autoSwitchSettings = profileManager.getAutoSwitchSettings();
|
|
|
|
console.log('[AgentProcess] Auto-switch settings:', {
|
|
enabled: autoSwitchSettings.enabled,
|
|
autoSwitchOnRateLimit: autoSwitchSettings.autoSwitchOnRateLimit,
|
|
proactiveSwapEnabled: autoSwitchSettings.proactiveSwapEnabled
|
|
});
|
|
|
|
if (!autoSwitchSettings.enabled || !autoSwitchSettings.autoSwitchOnRateLimit) {
|
|
console.log('[AgentProcess] Auto-switch disabled - showing manual modal');
|
|
return false;
|
|
}
|
|
|
|
const currentProfileId = rateLimitDetection.profileId;
|
|
const bestProfile = profileManager.getBestAvailableProfile(currentProfileId);
|
|
|
|
console.log('[AgentProcess] Best available profile:', bestProfile ? {
|
|
id: bestProfile.id,
|
|
name: bestProfile.name
|
|
} : 'NONE');
|
|
|
|
if (!bestProfile) {
|
|
console.log('[AgentProcess] No alternative profile available - falling back to manual modal');
|
|
return false;
|
|
}
|
|
|
|
console.log('[AgentProcess] AUTO-SWAP: Switching from', currentProfileId, 'to', bestProfile.id);
|
|
profileManager.setActiveProfile(bestProfile.id);
|
|
|
|
const source = processType === 'spec-creation' ? 'roadmap' : 'task';
|
|
const rateLimitInfo = createSDKRateLimitInfo(source, rateLimitDetection, { taskId });
|
|
rateLimitInfo.wasAutoSwapped = true;
|
|
rateLimitInfo.swappedToProfile = { id: bestProfile.id, name: bestProfile.name };
|
|
rateLimitInfo.swapReason = 'reactive';
|
|
|
|
console.log('[AgentProcess] Emitting sdk-rate-limit event (auto-swapped):', rateLimitInfo);
|
|
this.emitter.emit('sdk-rate-limit', rateLimitInfo);
|
|
|
|
console.log('[AgentProcess] Emitting auto-swap-restart-task event for task:', taskId);
|
|
this.emitter.emit('auto-swap-restart-task', taskId, bestProfile.id);
|
|
return true;
|
|
}
|
|
|
|
private handleAuthFailure(taskId: string, allOutput: string): boolean {
|
|
console.log('[AgentProcess] No rate limit detected - checking for auth failure');
|
|
const authFailureDetection = detectAuthFailure(allOutput);
|
|
|
|
if (authFailureDetection.isAuthFailure) {
|
|
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
|
|
this.emitter.emit('auth-failure', taskId, {
|
|
profileId: authFailureDetection.profileId,
|
|
failureType: authFailureDetection.failureType,
|
|
message: authFailureDetection.message,
|
|
originalError: authFailureDetection.originalError
|
|
});
|
|
return true;
|
|
}
|
|
|
|
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get the configured Python path.
|
|
* Returns explicitly configured path, or falls back to getConfiguredPythonPath()
|
|
* which uses the venv Python if ready.
|
|
*/
|
|
getPythonPath(): string {
|
|
// If explicitly configured (by pythonEnvManager), use that
|
|
if (this._pythonPath) {
|
|
return this._pythonPath;
|
|
}
|
|
// Otherwise use the global configured path (venv if ready, else bundled/system)
|
|
return getConfiguredPythonPath();
|
|
}
|
|
|
|
/**
|
|
* Get the auto-claude source path (detects automatically if not configured)
|
|
*/
|
|
getAutoBuildSourcePath(): string | null {
|
|
// Use runners/spec_runner.py as the validation marker - this is the file actually needed
|
|
const validatePath = (p: string): boolean => {
|
|
return existsSync(p) && existsSync(path.join(p, 'runners', 'spec_runner.py'));
|
|
};
|
|
|
|
// If manually configured AND valid, use that
|
|
if (this.autoBuildSourcePath && validatePath(this.autoBuildSourcePath)) {
|
|
return this.autoBuildSourcePath;
|
|
}
|
|
|
|
// Auto-detect from app location (configured path was invalid or not set)
|
|
const possiblePaths = [
|
|
// Packaged app: backend is in extraResources (process.resourcesPath/backend)
|
|
...(app.isPackaged ? [path.join(process.resourcesPath, 'backend')] : []),
|
|
// Dev mode: from dist/main -> ../../backend (apps/frontend/out/main -> apps/backend)
|
|
path.resolve(__dirname, '..', '..', '..', 'backend'),
|
|
// Alternative: from app root -> apps/backend
|
|
path.resolve(app.getAppPath(), '..', 'backend'),
|
|
// If running from repo root with apps structure
|
|
path.resolve(process.cwd(), 'apps', 'backend')
|
|
];
|
|
|
|
for (const p of possiblePaths) {
|
|
if (validatePath(p)) {
|
|
return p;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Ensure Python environment is ready before spawning processes.
|
|
* This is a shared method used by AgentManager and AgentQueueManager
|
|
* to prevent race conditions where tasks start before venv initialization completes.
|
|
*
|
|
* @param context - Context identifier for logging (e.g., 'AgentManager', 'AgentQueue')
|
|
* @returns Object with ready status and optional error message
|
|
*/
|
|
async ensurePythonEnvReady(context: string): Promise<{ ready: boolean; error?: string }> {
|
|
if (pythonEnvManager.isEnvReady()) {
|
|
return { ready: true };
|
|
}
|
|
|
|
console.log(`[${context}] Python environment not ready, waiting for initialization...`);
|
|
|
|
const autoBuildSource = this.getAutoBuildSourcePath();
|
|
if (!autoBuildSource) {
|
|
const error = 'auto-build source not found';
|
|
console.error(`[${context}] Cannot initialize Python - ${error}`);
|
|
return { ready: false, error };
|
|
}
|
|
|
|
const status = await pythonEnvManager.initialize(autoBuildSource);
|
|
if (!status.ready) {
|
|
console.error(`[${context}] Python environment initialization failed:`, status.error);
|
|
return { ready: false, error: status.error || 'initialization failed' };
|
|
}
|
|
|
|
console.log(`[${context}] Python environment now ready`);
|
|
return { ready: true };
|
|
}
|
|
|
|
/**
|
|
* Get project-specific environment variables based on project settings
|
|
*/
|
|
private getProjectEnvVars(projectPath: string): Record<string, string> {
|
|
const env: Record<string, string> = {};
|
|
|
|
// Find project by path
|
|
const projects = projectStore.getProjects();
|
|
const project = projects.find((p) => p.path === projectPath);
|
|
|
|
if (project?.settings) {
|
|
// Graphiti MCP integration
|
|
if (project.settings.graphitiMcpEnabled) {
|
|
const graphitiUrl = project.settings.graphitiMcpUrl || 'http://localhost:8000/mcp/';
|
|
env['GRAPHITI_MCP_URL'] = graphitiUrl;
|
|
}
|
|
|
|
// CLAUDE.md integration (enabled by default)
|
|
if (project.settings.useClaudeMd !== false) {
|
|
env['USE_CLAUDE_MD'] = 'true';
|
|
}
|
|
}
|
|
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Parse environment variables from a .env file content.
|
|
* Filters out empty values to prevent overriding valid tokens from profiles.
|
|
*/
|
|
private parseEnvFile(envPath: string): Record<string, string> {
|
|
if (!existsSync(envPath)) {
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
const envContent = readFileSync(envPath, 'utf-8');
|
|
const envVars: Record<string, string> = {};
|
|
|
|
// Handle both Unix (\n) and Windows (\r\n) line endings
|
|
for (const line of envContent.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
// Skip comments and empty lines
|
|
if (!trimmed || trimmed.startsWith('#')) {
|
|
continue;
|
|
}
|
|
|
|
const eqIndex = trimmed.indexOf('=');
|
|
if (eqIndex > 0) {
|
|
const key = trimmed.substring(0, eqIndex).trim();
|
|
let value = trimmed.substring(eqIndex + 1).trim();
|
|
|
|
// Remove quotes if present
|
|
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
|
|
// Skip empty values to prevent overriding valid values from other sources
|
|
if (value) {
|
|
envVars[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return envVars;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load environment variables from project's .auto-claude/.env file
|
|
* This contains frontend-configured settings like memory/Graphiti configuration
|
|
*/
|
|
private loadProjectEnv(projectPath: string): Record<string, string> {
|
|
// Find project by path to get autoBuildPath
|
|
const projects = projectStore.getProjects();
|
|
const project = projects.find((p) => p.path === projectPath);
|
|
|
|
if (!project?.autoBuildPath) {
|
|
return {};
|
|
}
|
|
|
|
const envPath = path.join(projectPath, project.autoBuildPath, '.env');
|
|
return this.parseEnvFile(envPath);
|
|
}
|
|
|
|
/**
|
|
* Load environment variables from auto-claude .env file
|
|
*/
|
|
loadAutoBuildEnv(): Record<string, string> {
|
|
const autoBuildSource = this.getAutoBuildSourcePath();
|
|
if (!autoBuildSource) {
|
|
return {};
|
|
}
|
|
|
|
const envPath = path.join(autoBuildSource, '.env');
|
|
return this.parseEnvFile(envPath);
|
|
}
|
|
|
|
/**
|
|
* Spawn a Python process for task execution
|
|
*/
|
|
async spawnProcess(
|
|
taskId: string,
|
|
cwd: string,
|
|
args: string[],
|
|
extraEnv: Record<string, string> = {},
|
|
processType: ProcessType = 'task-execution'
|
|
): Promise<void> {
|
|
const isSpecRunner = processType === 'spec-creation';
|
|
this.killProcess(taskId);
|
|
|
|
const spawnId = this.state.generateSpawnId();
|
|
const env = this.setupProcessEnvironment(extraEnv);
|
|
|
|
// Get Python environment (PYTHONPATH for bundled packages, etc.)
|
|
const pythonEnv = pythonEnvManager.getPythonEnv();
|
|
|
|
// Get active API profile environment variables
|
|
let apiProfileEnv: Record<string, string> = {};
|
|
try {
|
|
apiProfileEnv = await getAPIProfileEnv();
|
|
} catch (error) {
|
|
console.error('[Agent Process] Failed to get API profile env:', error);
|
|
// Continue with empty profile env (falls back to OAuth mode)
|
|
}
|
|
|
|
// Get OAuth mode clearing vars (clears stale ANTHROPIC_* vars when in OAuth mode)
|
|
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
|
|
|
|
// Parse Python commandto handle space-separated commands like "py -3"
|
|
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.getPythonPath());
|
|
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
|
cwd,
|
|
env: {
|
|
...env, // Already includes process.env, extraEnv, profileEnv, PYTHONUNBUFFERED, PYTHONUTF8
|
|
...pythonEnv, // Include Python environment (PYTHONPATH for bundled packages)
|
|
...oauthModeClearVars, // Clear stale ANTHROPIC_* vars when in OAuth mode
|
|
...apiProfileEnv // Include active API profile config (highest priority for ANTHROPIC_* vars)
|
|
}
|
|
});
|
|
|
|
this.state.addProcess(taskId, {
|
|
taskId,
|
|
process: childProcess,
|
|
startedAt: new Date(),
|
|
spawnId
|
|
});
|
|
|
|
let currentPhase: ExecutionProgressData['phase'] = isSpecRunner ? 'planning' : 'planning';
|
|
let phaseProgress = 0;
|
|
let currentSubtask: string | undefined;
|
|
let lastMessage: string | undefined;
|
|
let allOutput = '';
|
|
let stdoutBuffer = '';
|
|
let stderrBuffer = '';
|
|
let sequenceNumber = 0;
|
|
// FIX (ACS-203): Track completed phases to prevent phase overlaps
|
|
// When a phase completes, it's added to this array before transitioning to the next phase
|
|
const completedPhases: CompletablePhase[] = [];
|
|
|
|
this.emitter.emit('execution-progress', taskId, {
|
|
phase: currentPhase,
|
|
phaseProgress: 0,
|
|
overallProgress: this.events.calculateOverallProgress(currentPhase, 0),
|
|
message: isSpecRunner ? 'Starting spec creation...' : 'Starting build process...',
|
|
sequenceNumber: ++sequenceNumber,
|
|
completedPhases: [...completedPhases]
|
|
});
|
|
|
|
const isDebug = ['true', '1', 'yes', 'on'].includes(process.env.DEBUG?.toLowerCase() ?? '');
|
|
|
|
const processLog = (line: string) => {
|
|
allOutput = (allOutput + line).slice(-10000);
|
|
|
|
const hasMarker = line.includes('__EXEC_PHASE__');
|
|
if (isDebug && hasMarker) {
|
|
console.log(`[PhaseDebug:${taskId}] Found marker in line: "${line.substring(0, 200)}"`);
|
|
}
|
|
|
|
const phaseUpdate = this.events.parseExecutionPhase(line, currentPhase, isSpecRunner);
|
|
|
|
if (isDebug && hasMarker) {
|
|
console.log(`[PhaseDebug:${taskId}] Parse result:`, phaseUpdate);
|
|
}
|
|
|
|
if (phaseUpdate) {
|
|
const phaseChanged = phaseUpdate.phase !== currentPhase;
|
|
|
|
if (isDebug) {
|
|
console.log(`[PhaseDebug:${taskId}] Phase update: ${currentPhase} -> ${phaseUpdate.phase} (changed: ${phaseChanged})`);
|
|
}
|
|
|
|
// FIX (ACS-203): Manage completedPhases when phases transition
|
|
// When leaving a non-terminal phase (not complete/failed), add it to completedPhases
|
|
if (phaseChanged && currentPhase !== 'idle' && currentPhase !== phaseUpdate.phase) {
|
|
// Type guard to narrow currentPhase to CompletablePhase
|
|
const isCompletablePhase = (phase: ExecutionProgressData['phase']): phase is CompletablePhase => {
|
|
return ['planning', 'coding', 'qa_review', 'qa_fixing'].includes(phase);
|
|
};
|
|
if (isCompletablePhase(currentPhase) && !completedPhases.includes(currentPhase)) {
|
|
completedPhases.push(currentPhase);
|
|
if (isDebug) {
|
|
console.log(`[PhaseDebug:${taskId}] Marked phase as completed:`, { phase: currentPhase, completedPhases });
|
|
}
|
|
}
|
|
}
|
|
|
|
currentPhase = phaseUpdate.phase;
|
|
|
|
if (phaseUpdate.currentSubtask) {
|
|
currentSubtask = phaseUpdate.currentSubtask;
|
|
}
|
|
if (phaseUpdate.message) {
|
|
lastMessage = phaseUpdate.message;
|
|
}
|
|
|
|
if (phaseChanged) {
|
|
phaseProgress = 10;
|
|
} else {
|
|
phaseProgress = Math.min(90, phaseProgress + 5);
|
|
}
|
|
|
|
const overallProgress = this.events.calculateOverallProgress(currentPhase, phaseProgress);
|
|
|
|
if (isDebug) {
|
|
console.log(`[PhaseDebug:${taskId}] Emitting execution-progress:`, { phase: currentPhase, phaseProgress, overallProgress, completedPhases });
|
|
}
|
|
|
|
this.emitter.emit('execution-progress', taskId, {
|
|
phase: currentPhase,
|
|
phaseProgress,
|
|
overallProgress,
|
|
currentSubtask,
|
|
message: lastMessage,
|
|
sequenceNumber: ++sequenceNumber,
|
|
completedPhases: [...completedPhases]
|
|
});
|
|
}
|
|
};
|
|
|
|
const processBufferedOutput = (buffer: string, newData: string): string => {
|
|
if (isDebug && newData.includes('__EXEC_PHASE__')) {
|
|
console.log(`[PhaseDebug:${taskId}] Raw chunk with marker (${newData.length} bytes): "${newData.substring(0, 300)}"`);
|
|
console.log(`[PhaseDebug:${taskId}] Current buffer before append (${buffer.length} bytes): "${buffer.substring(0, 100)}"`);
|
|
}
|
|
|
|
buffer += newData;
|
|
const lines = buffer.split('\n');
|
|
const remaining = lines.pop() || '';
|
|
|
|
if (isDebug && newData.includes('__EXEC_PHASE__')) {
|
|
console.log(`[PhaseDebug:${taskId}] Split into ${lines.length} complete lines, remaining buffer: "${remaining.substring(0, 100)}"`);
|
|
}
|
|
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
this.emitter.emit('log', taskId, line + '\n');
|
|
processLog(line);
|
|
if (isDebug) {
|
|
console.log(`[Agent:${taskId}] ${line}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return remaining;
|
|
};
|
|
|
|
childProcess.stdout?.on('data', (data: Buffer) => {
|
|
stdoutBuffer = processBufferedOutput(stdoutBuffer, data.toString('utf8'));
|
|
});
|
|
|
|
childProcess.stderr?.on('data', (data: Buffer) => {
|
|
stderrBuffer = processBufferedOutput(stderrBuffer, data.toString('utf8'));
|
|
});
|
|
|
|
childProcess.on('exit', (code: number | null) => {
|
|
if (stdoutBuffer.trim()) {
|
|
this.emitter.emit('log', taskId, stdoutBuffer + '\n');
|
|
processLog(stdoutBuffer);
|
|
}
|
|
if (stderrBuffer.trim()) {
|
|
this.emitter.emit('log', taskId, stderrBuffer + '\n');
|
|
processLog(stderrBuffer);
|
|
}
|
|
|
|
this.state.deleteProcess(taskId);
|
|
|
|
if (this.state.wasSpawnKilled(spawnId)) {
|
|
this.state.clearKilledSpawn(spawnId);
|
|
return;
|
|
}
|
|
|
|
if (code !== 0) {
|
|
console.log('[AgentProcess] Process failed with code:', code, 'for task:', taskId);
|
|
const wasHandled = this.handleProcessFailure(taskId, allOutput, processType);
|
|
if (wasHandled) {
|
|
this.emitter.emit('exit', taskId, code, processType);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (code !== 0 && currentPhase !== 'complete' && currentPhase !== 'failed') {
|
|
this.emitter.emit('execution-progress', taskId, {
|
|
phase: 'failed',
|
|
phaseProgress: 0,
|
|
overallProgress: this.events.calculateOverallProgress(currentPhase, phaseProgress),
|
|
message: `Process exited with code ${code}`,
|
|
sequenceNumber: ++sequenceNumber,
|
|
completedPhases: [...completedPhases]
|
|
});
|
|
}
|
|
|
|
this.emitter.emit('exit', taskId, code, processType);
|
|
});
|
|
|
|
// Handle process error
|
|
childProcess.on('error', (err: Error) => {
|
|
console.error('[AgentProcess] Process error:', err.message);
|
|
this.state.deleteProcess(taskId);
|
|
|
|
this.emitter.emit('execution-progress', taskId, {
|
|
phase: 'failed',
|
|
phaseProgress: 0,
|
|
overallProgress: 0,
|
|
message: `Error: ${err.message}`,
|
|
sequenceNumber: ++sequenceNumber,
|
|
completedPhases: [...completedPhases]
|
|
});
|
|
|
|
this.emitter.emit('error', taskId, err.message);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Kill a specific task's process
|
|
*/
|
|
killProcess(taskId: string): boolean {
|
|
const agentProcess = this.state.getProcess(taskId);
|
|
if (agentProcess) {
|
|
try {
|
|
// Mark this specific spawn as killed so its exit handler knows to ignore
|
|
this.state.markSpawnAsKilled(agentProcess.spawnId);
|
|
|
|
// Send SIGTERM first for graceful shutdown
|
|
agentProcess.process.kill('SIGTERM');
|
|
|
|
// Force kill after timeout
|
|
setTimeout(() => {
|
|
if (!agentProcess.process.killed) {
|
|
agentProcess.process.kill('SIGKILL');
|
|
}
|
|
}, 5000);
|
|
|
|
this.state.deleteProcess(taskId);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Kill all running processes
|
|
*/
|
|
async killAllProcesses(): Promise<void> {
|
|
const killPromises = this.state.getRunningTaskIds().map((taskId) => {
|
|
return new Promise<void>((resolve) => {
|
|
this.killProcess(taskId);
|
|
resolve();
|
|
});
|
|
});
|
|
await Promise.all(killPromises);
|
|
}
|
|
|
|
/**
|
|
* Get combined environment variables for a project
|
|
*
|
|
* Priority (later sources override earlier):
|
|
* 1. App-wide memory settings from settings.json (NEW - enables memory from onboarding)
|
|
* 2. Backend source .env (apps/backend/.env) - CLI defaults
|
|
* 3. Project's .auto-claude/.env - Frontend-configured settings (memory, integrations)
|
|
* 4. Project settings (graphitiMcpUrl, useClaudeMd) - Runtime overrides
|
|
*/
|
|
getCombinedEnv(projectPath: string): Record<string, string> {
|
|
// Load app-wide memory settings from settings.json
|
|
// This bridges onboarding config to backend agents
|
|
const appSettings = (readSettingsFile() || {}) as Partial<AppSettings>;
|
|
const memoryEnv = buildMemoryEnvVars(appSettings as AppSettings);
|
|
|
|
// Existing env sources
|
|
const autoBuildEnv = this.loadAutoBuildEnv();
|
|
const projectFileEnv = this.loadProjectEnv(projectPath);
|
|
const projectSettingsEnv = this.getProjectEnvVars(projectPath);
|
|
|
|
// Priority: app-wide memory -> backend .env -> project .env -> project settings
|
|
// Later sources override earlier ones
|
|
return { ...memoryEnv, ...autoBuildEnv, ...projectFileEnv, ...projectSettingsEnv };
|
|
}
|
|
}
|