Skip to content
Open
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
8 changes: 8 additions & 0 deletions apps/backend/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
find_spec,
get_project_dir,
print_banner,
resolve_project_dir_for_spec,
setup_environment,
)
from .workspace_commands import (
Expand Down Expand Up @@ -321,6 +322,13 @@ def _run_cli() -> None:

# Determine project directory
project_dir = get_project_dir(args.project_dir)
if args.spec:
resolved_project_dir = resolve_project_dir_for_spec(project_dir, args.spec)
if resolved_project_dir != project_dir:
debug(
"run.py", f"Adjusted project directory for spec: {resolved_project_dir}"
)
project_dir = resolved_project_dir
debug("run.py", f"Using project directory: {project_dir}")

# Get model from CLI arg or env var (None if not explicitly set)
Expand Down
23 changes: 23 additions & 0 deletions apps/backend/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,29 @@ def get_project_dir(provided_dir: Path | None) -> Path:
return project_dir


def resolve_project_dir_for_spec(project_dir: Path, spec_identifier: str) -> Path:
"""
Resolve project dir if the spec exists under a child project folder.

This handles cases where --project-dir is a parent (e.g., E:\\projects)
but the spec lives under a child repo (e.g., E:\\projects\\auto-claude).
"""
if find_spec(project_dir, spec_identifier):
return project_dir

try:
for child in project_dir.iterdir():
if not child.is_dir():
continue
if find_spec(child, spec_identifier):
return child
except OSError:
# Non-fatal: fall back to original project_dir
pass

return project_dir


def find_specs_dir(project_dir: Path) -> Path:
"""
Find the specs directory for a project.
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/runners/spec_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,11 @@ def main():
if not review_state.is_approved():
debug_error("spec_runner", "Spec not approved - cannot start build")
print()
if not args.interactive:
print_status(
"Spec requires human review. Build not started.", "warning"
)
sys.exit(0)
print_status("Build cannot start: spec not approved.", "error")
print()
print(f" {muted('To approve the spec, run:')}")
Expand Down
39 changes: 28 additions & 11 deletions apps/backend/spec/pipeline/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import json
import os
from collections.abc import Callable
from pathlib import Path

Expand Down Expand Up @@ -283,17 +284,28 @@ def run_phase(name: str, phase_fn: Callable) -> phases.PhaseResult:
)
return phase_fn()

skip_discovery = os.getenv("SKIP_DISCOVERY", "").lower() == "true"

# === PHASE 1: DISCOVERY ===
result = await run_phase("discovery", phase_executor.phase_discovery)
results.append(result)
if not result.success:
print_status("Discovery failed", "error")
task_logger.end_phase(
LogPhase.PLANNING, success=False, message="Discovery failed"
if skip_discovery:
task_logger.log(
"Skipping discovery phase (flag enabled)",
LogEntryType.INFO,
LogPhase.PLANNING,
)
return False
# Store summary for subsequent phases (compaction)
await self._store_phase_summary("discovery")
print_status("Skipping discovery phase (flag enabled)", "info")
results.append(phases.PhaseResult("discovery", True, [], [], 0))
else:
result = await run_phase("discovery", phase_executor.phase_discovery)
results.append(result)
if not result.success:
print_status("Discovery failed", "error")
task_logger.end_phase(
LogPhase.PLANNING, success=False, message="Discovery failed"
)
return False
# Store summary for subsequent phases (compaction)
await self._store_phase_summary("discovery")

# === PHASE 2: REQUIREMENTS GATHERING ===
result = await run_phase(
Expand Down Expand Up @@ -409,7 +421,7 @@ def run_phase(name: str, phase_fn: Callable) -> phases.PhaseResult:
)

# === HUMAN REVIEW CHECKPOINT ===
return self._run_review_checkpoint(auto_approve)
return self._run_review_checkpoint(auto_approve, interactive)

async def _create_linear_task_if_enabled(self) -> None:
"""Create a Linear task if Linear integration is enabled."""
Expand Down Expand Up @@ -613,18 +625,23 @@ def _print_completion_summary(
)
)

def _run_review_checkpoint(self, auto_approve: bool) -> bool:
def _run_review_checkpoint(self, auto_approve: bool, interactive: bool) -> bool:
"""Run the human review checkpoint.

Args:
auto_approve: Whether to auto-approve without human review
interactive: Whether to run in interactive mode

Returns:
True if approved, False otherwise
"""
print()
print_section("HUMAN REVIEW CHECKPOINT", Icons.SEARCH)

if not auto_approve and not interactive:
print_status("Skipping interactive review (non-interactive mode)", "info")
return True

try:
review_state = run_review_checkpoint(
spec_dir=self.spec_dir,
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"semver": "^7.7.3",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0",
"xstate": "^5.17.0",
"zod": "^4.2.1",
"zustand": "^5.0.9"
},
Expand Down
5 changes: 3 additions & 2 deletions apps/frontend/src/__tests__/e2e/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ describe('E2E Smoke Tests', () => {
statusHandler({}, 'task-001', 'in_progress');
}

expect(statusCallback).toHaveBeenCalledWith('task-001', 'in_progress', undefined);
expect(statusCallback).toHaveBeenCalledWith('task-001', 'in_progress', undefined, undefined);

// Cleanup listeners
cleanupProgress();
Expand Down Expand Up @@ -656,7 +656,8 @@ describe('E2E Smoke Tests', () => {
index + 1,
'task-001',
status,
undefined
undefined,
undefined // reviewReason
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,9 @@ describe('Task Lifecycle Integration', () => {
eventHandler({}, 'task-001', 'spec_complete');
}

// Verify callback was invoked with correct parameters (taskId, status, projectId)
// Note: projectId is optional and undefined when not provided
expect(callback).toHaveBeenCalledWith('task-001', 'spec_complete', undefined);
// Verify callback was invoked with correct parameters (taskId, status, projectId, reviewReason)
// Note: projectId and reviewReason are optional and undefined when not provided
expect(callback).toHaveBeenCalledWith('task-001', 'spec_complete', undefined, undefined);
});

it('should emit task:progress event with updated plan during spec creation', async () => {
Expand Down
17 changes: 16 additions & 1 deletion apps/frontend/src/main/__tests__/ipc-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,20 @@ vi.mock("../cli-tool-manager", () => ({
getToolPathAsync: vi.fn((tool: string) => Promise.resolve(tool)),
}));

// Mock task-state-utils to disable XState for these tests
// This ensures we test the fallback path behavior consistently
vi.mock("../task-state-utils", () => ({
isXstateEnabled: vi.fn(() => false),
isDebugEnabled: vi.fn(() => false),
invalidateXstateCache: vi.fn(),
emitTaskStatusChange: vi.fn((getMainWindow, taskId, status, projectId, reviewReason) => {
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send("task:statusChange", taskId, status, projectId, reviewReason);
}
}),
}));

// Mock modules before importing
vi.mock("electron", () => {
const mockIpcMain = new (class extends EventEmitter {
Expand Down Expand Up @@ -689,7 +703,8 @@ describe("IPC Handlers", { timeout: 30000 }, () => {
"task:statusChange",
"task-1",
"human_review",
expect.any(String) // projectId for multi-project filtering
expect.any(String), // projectId for multi-project filtering
expect.anything() // reviewReason (optional)
);
});
});
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/main/claude-profile-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
} from './claude-profile/profile-scorer';
import { getCredentialsFromKeychain } from './claude-profile/credential-utils';
import {
DEFAULT_CLAUDE_CONFIG_DIR,
CLAUDE_PROFILES_DIR,
generateProfileId as generateProfileIdImpl,
createProfileDirectory as createProfileDirectoryImpl,
Expand Down
77 changes: 44 additions & 33 deletions apps/frontend/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { initSentryMain } from './sentry';
import { preWarmToolCache } from './cli-tool-manager';
import { initializeClaudeProfileManager, getClaudeProfileManager } from './claude-profile-manager';
import { isMacOS, isWindows } from './platform';
import { cleanupTaskStateManager } from './task-state-manager';
import type { AppSettings, AuthFailureInfo } from '../shared/types';

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -304,7 +305,8 @@ app.whenReady().then(() => {

// Validate and migrate autoBuildPath - must contain runners/spec_runner.py
// Uses EAFP pattern (try/catch with accessSync) instead of existsSync to avoid TOCTOU race conditions
let validAutoBuildPath = settings.autoBuildPath;
const envAutoBuildPath = (process.env.AUTO_CLAUDE_BACKEND_PATH || '').trim() || undefined;
let validAutoBuildPath = envAutoBuildPath || settings.autoBuildPath;
if (validAutoBuildPath) {
const specRunnerPath = join(validAutoBuildPath, 'runners', 'spec_runner.py');
let specRunnerExists = false;
Expand All @@ -316,42 +318,47 @@ app.whenReady().then(() => {
}

if (!specRunnerExists) {
// Migration: Try to fix stale paths from old project structure
// Old structure: /path/to/project/auto-claude
// New structure: /path/to/project/apps/backend
let migrated = false;
if (validAutoBuildPath.endsWith('/auto-claude') || validAutoBuildPath.endsWith('\\auto-claude')) {
const basePath = validAutoBuildPath.replace(/[/\\]auto-claude$/, '');
const correctedPath = join(basePath, 'apps', 'backend');
const correctedSpecRunnerPath = join(correctedPath, 'runners', 'spec_runner.py');

let correctedPathExists = false;
try {
accessSync(correctedSpecRunnerPath);
correctedPathExists = true;
} catch {
// Corrected path doesn't exist
}

if (correctedPathExists) {
console.log('[main] Migrating autoBuildPath from old structure:', validAutoBuildPath, '->', correctedPath);
settings.autoBuildPath = correctedPath;
validAutoBuildPath = correctedPath;
migrated = true;

// Save the corrected setting - we're the only process modifying settings at startup
if (envAutoBuildPath) {
console.warn('[main] AUTO_CLAUDE_BACKEND_PATH is invalid (missing runners/spec_runner.py), will use auto-detection:', validAutoBuildPath);
validAutoBuildPath = undefined; // Let auto-detection find the correct path
} else {
// Migration: Try to fix stale paths from old project structure
// Old structure: /path/to/project/auto-claude
// New structure: /path/to/project/apps/backend
let migrated = false;
if (validAutoBuildPath.endsWith('/auto-claude') || validAutoBuildPath.endsWith('\\auto-claude')) {
const basePath = validAutoBuildPath.replace(/[/\\]auto-claude$/, '');
const correctedPath = join(basePath, 'apps', 'backend');
const correctedSpecRunnerPath = join(correctedPath, 'runners', 'spec_runner.py');

let correctedPathExists = false;
try {
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
console.log('[main] Successfully saved migrated autoBuildPath to settings');
} catch (writeError) {
console.warn('[main] Failed to save migrated autoBuildPath:', writeError);
accessSync(correctedSpecRunnerPath);
correctedPathExists = true;
} catch {
// Corrected path doesn't exist
}

if (correctedPathExists) {
console.log('[main] Migrating autoBuildPath from old structure:', validAutoBuildPath, '->', correctedPath);
settings.autoBuildPath = correctedPath;
validAutoBuildPath = correctedPath;
migrated = true;

// Save the corrected setting - we're the only process modifying settings at startup
try {
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
console.log('[main] Successfully saved migrated autoBuildPath to settings');
} catch (writeError) {
console.warn('[main] Failed to save migrated autoBuildPath:', writeError);
}
}
}
}

if (!migrated) {
console.warn('[main] Configured autoBuildPath is invalid (missing runners/spec_runner.py), will use auto-detection:', validAutoBuildPath);
validAutoBuildPath = undefined; // Let auto-detection find the correct path
if (!migrated) {
console.warn('[main] Configured autoBuildPath is invalid (missing runners/spec_runner.py), will use auto-detection:', validAutoBuildPath);
validAutoBuildPath = undefined; // Let auto-detection find the correct path
}
}
}
}
Expand Down Expand Up @@ -504,6 +511,10 @@ app.on('before-quit', async () => {
usageMonitor.stop();
console.warn('[main] Usage monitor stopped');

// Cleanup XState task state manager to stop all actors and prevent memory leaks
cleanupTaskStateManager();
console.warn('[main] Task state manager cleaned up');

// Kill all running agent processes
if (agentManager) {
await agentManager.killAll();
Expand Down
Loading
Loading