Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
}
}

await parseAndCreateFeatures(projectPath, contentForParsing, events);
await parseAndCreateFeatures(projectPath, contentForParsing, events, settingsService);

logger.debug('========== generateFeaturesFromSpec() completed ==========');
}
50 changes: 46 additions & 4 deletions apps/server/src/routes/app-spec/parse-and-create-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,54 @@ import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/
import { getFeaturesDir } from '@automaker/platform';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getNotificationService } from '../../services/notification-service.js';
import type { SettingsService } from '../../services/settings-service.js';
import { resolvePhaseModel } from '@automaker/model-resolver';

const logger = createLogger('SpecRegeneration');

export async function parseAndCreateFeatures(
projectPath: string,
content: string,
events: EventEmitter
events: EventEmitter,
settingsService?: SettingsService
): Promise<void> {
logger.info('========== parseAndCreateFeatures() started ==========');
logger.info(`Content length: ${content.length} chars`);
logger.info('========== CONTENT RECEIVED FOR PARSING ==========');
logger.info(content);
logger.info('========== END CONTENT ==========');

// Load default model and planning settings from settingsService
let defaultModel: string | undefined;
let defaultPlanningMode: string = 'skip';
let defaultRequirePlanApproval = false;

if (settingsService) {
try {
const globalSettings = await settingsService.getGlobalSettings();
const projectSettings = await settingsService.getProjectSettings(projectPath);

const defaultModelEntry =
projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel;
if (defaultModelEntry) {
const resolved = resolvePhaseModel(defaultModelEntry);
defaultModel = resolved.model;
}

defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;

logger.info(
`[parseAndCreateFeatures] Using defaults: model=${defaultModel ?? 'none'}, planningMode=${defaultPlanningMode}, requirePlanApproval=${defaultRequirePlanApproval}`
);
} catch (settingsError) {
logger.warn(
'[parseAndCreateFeatures] Failed to load settings, using defaults:',
settingsError
);
}
}

try {
// Extract JSON from response using shared utility
logger.info('Extracting JSON from response using extractJsonWithArray...');
Expand Down Expand Up @@ -61,7 +95,7 @@ export async function parseAndCreateFeatures(
const featureDir = path.join(featuresDir, feature.id);
await secureFs.mkdir(featureDir, { recursive: true });

const featureData = {
const featureData: Record<string, unknown> = {
id: feature.id,
category: feature.category || 'Uncategorized',
title: feature.title,
Expand All @@ -70,12 +104,20 @@ export async function parseAndCreateFeatures(
priority: feature.priority || 2,
complexity: feature.complexity || 'moderate',
dependencies: feature.dependencies || [],
planningMode: 'skip',
requirePlanApproval: false,
planningMode: defaultPlanningMode,
requirePlanApproval:
defaultPlanningMode === 'skip' || defaultPlanningMode === 'lite'
? false
: defaultRequirePlanApproval,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

// Apply default model if available from settings
if (defaultModel) {
featureData.model = defaultModel;
}

// Use atomic write with backup support for crash protection
await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, {
backupCount: DEFAULT_BACKUP_COUNT,
Expand Down
78 changes: 78 additions & 0 deletions apps/server/src/routes/worktree/routes/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ interface WorktreeInfo {
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
/** List of files with conflicts */
conflictFiles?: string[];
/** Source branch involved in merge/rebase/cherry-pick, when resolvable */
conflictSourceBranch?: string;
}

/**
Expand All @@ -98,6 +100,7 @@ async function detectConflictState(worktreePath: string): Promise<{
hasConflicts: boolean;
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
conflictFiles?: string[];
conflictSourceBranch?: string;
}> {
try {
// Find the canonical .git directory for this worktree (execGitCommand avoids /bin/sh in CI)
Expand Down Expand Up @@ -153,10 +156,84 @@ async function detectConflictState(worktreePath: string): Promise<{
// Fall back to empty list if diff fails
}

// Detect the source branch involved in the conflict
let conflictSourceBranch: string | undefined;
try {
if (conflictType === 'merge' && mergeHeadExists) {
// For merges, resolve MERGE_HEAD to a branch name
const mergeHead = (
(await secureFs.readFile(path.join(gitDir, 'MERGE_HEAD'), 'utf-8')) as string
).trim();
try {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', mergeHead],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
} catch {
// Could not resolve to branch name
}
} else if (conflictType === 'rebase') {
// For rebases, read the onto branch from rebase-merge/head-name or rebase-apply/head-name
const headNamePath = rebaseMergeExists
? path.join(gitDir, 'rebase-merge', 'onto-name')
: path.join(gitDir, 'rebase-apply', 'onto-name');
try {
const ontoName = ((await secureFs.readFile(headNamePath, 'utf-8')) as string).trim();
if (ontoName) {
conflictSourceBranch = ontoName.replace(/^refs\/heads\//, '');
}
} catch {
// onto-name may not exist; try to resolve the onto commit
try {
const ontoPath = rebaseMergeExists
? path.join(gitDir, 'rebase-merge', 'onto')
: path.join(gitDir, 'rebase-apply', 'onto');
const ontoCommit = ((await secureFs.readFile(ontoPath, 'utf-8')) as string).trim();
if (ontoCommit) {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
}
} catch {
// Could not resolve onto commit
}
}
} else if (conflictType === 'cherry-pick' && cherryPickHeadExists) {
// For cherry-picks, try to resolve CHERRY_PICK_HEAD to a branch name
const cherryPickHead = (
(await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8')) as string
).trim();
try {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', cherryPickHead],
worktreePath
);
const cleaned = branchName.trim().replace(/~\d+$/, '');
if (cleaned && cleaned !== 'undefined') {
conflictSourceBranch = cleaned;
}
} catch {
// Could not resolve to branch name
}
}
} catch {
// Ignore source branch detection errors
}

return {
hasConflicts: conflictFiles.length > 0,
conflictType,
conflictFiles,
conflictSourceBranch,
};
} catch {
// If anything fails, assume no conflicts
Expand Down Expand Up @@ -594,6 +671,7 @@ export function createListHandler() {
// hasConflicts is true only when there are actual unresolved files
worktree.hasConflicts = conflictState.hasConflicts;
worktree.conflictFiles = conflictState.conflictFiles;
worktree.conflictSourceBranch = conflictState.conflictSourceBranch;
} catch {
// Ignore conflict detection errors
}
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/services/codex-model-cache-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,11 @@ export class CodexModelCacheService {
* Infer tier from model ID
*/
private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) {
if (
modelId.includes('max') ||
modelId.includes('gpt-5.2-codex') ||
modelId.includes('gpt-5.3-codex')
) {
return 'premium';
}
if (modelId.includes('mini')) {
Expand Down
32 changes: 23 additions & 9 deletions apps/ui/src/components/views/board-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -715,15 +715,27 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';

// Aggregate running auto tasks across all worktrees for this project
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
const runningAutoTasksAllWorktrees = useMemo(() => {
if (!currentProject?.id) return [];
const prefix = `${currentProject.id}::`;
return Object.entries(autoModeByWorktree)
.filter(([key]) => key.startsWith(prefix))
.flatMap(([, state]) => state.runningTasks ?? []);
}, [autoModeByWorktree, currentProject?.id]);
// Aggregate running auto tasks across all worktrees for this project.
// IMPORTANT: Use a derived selector with shallow equality instead of subscribing
// to the raw autoModeByWorktree object. The raw subscription caused the entire
// BoardView to re-render on EVERY auto-mode state change (any worktree), which
// during worktree switches cascaded through DndContext/KanbanBoard and triggered
// React error #185 (maximum update depth exceeded), crashing the board view.
const runningAutoTasksAllWorktrees = useAppStore(
useShallow((state) => {
if (!currentProject?.id) return [] as string[];
const prefix = `${currentProject.id}::`;
const tasks: string[] = [];
for (const [key, worktreeState] of Object.entries(state.autoModeByWorktree)) {
if (key.startsWith(prefix) && worktreeState.runningTasks) {
for (const task of worktreeState.runningTasks) {
tasks.push(task);
}
}
}
return tasks;
})
);

// Get in-progress features for keyboard shortcuts (needed before actions hook)
// Must be after runningAutoTasks is defined
Expand Down Expand Up @@ -831,6 +843,7 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
onWorktreeAutoSelect: addAndSelectWorktree,
currentWorktreeBranch,
stopFeature: autoMode.stopFeature,
});

// Handler for bulk updating multiple features
Expand Down Expand Up @@ -1506,6 +1519,7 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
runningAutoTasks: runningAutoTasksAllWorktrees,
persistFeatureUpdate,
handleStartImplementation,
stopFeature: autoMode.stopFeature,
});

// Handle dependency link creation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,11 +179,18 @@ export function AgentOutputModal({
enabled: open && !!resolvedProjectPath,
});

// Fetch feature data to access the server-side accumulated summary
// Fetch feature data to access the server-side accumulated summary.
// Also used to show fresh description/status instead of potentially stale props
// (e.g. when opening via deep link from a notification click).
const { data: feature, refetch: refetchFeature } = useFeature(resolvedProjectPath, featureId, {
enabled: open && !!resolvedProjectPath && !isBacklogPlan,
});

// Prefer fresh data from server over potentially stale props passed at open time.
const resolvedDescription = feature?.description ?? featureDescription;
const resolvedStatus = feature?.status ?? featureStatus;
const resolvedBranchName = feature?.branchName ?? branchName;

// Reset streamed content when modal opens or featureId changes
useEffect(() => {
if (open) {
Expand Down Expand Up @@ -519,7 +526,7 @@ export function AgentOutputModal({
<DialogHeader className="shrink-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-10">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
{resolvedStatus !== 'verified' && resolvedStatus !== 'waiting_approval' && (
<Spinner size="md" />
)}
Agent Output
Expand Down Expand Up @@ -581,7 +588,7 @@ export function AgentOutputModal({
className="mt-1 max-h-24 overflow-y-auto wrap-break-word"
data-testid="agent-output-description"
>
{featureDescription}
{resolvedDescription}
</DialogDescription>
</DialogHeader>

Expand All @@ -601,7 +608,7 @@ export function AgentOutputModal({
{resolvedProjectPath ? (
<GitDiffPanel
projectPath={resolvedProjectPath}
featureId={branchName || featureId}
featureId={resolvedBranchName || featureId}
compact={false}
useWorktrees={useWorktrees}
className="border-0 rounded-lg"
Expand Down
Loading
Loading