fix: Address code review comments

This commit is contained in:
gsxdsm
2026-02-16 11:53:09 -08:00
parent eed5e20438
commit 462dbf1522
12 changed files with 147 additions and 74 deletions

View File

@@ -17,7 +17,7 @@ export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat)
approved: boolean; approved: boolean;
editedPlan?: string; editedPlan?: string;
feedback?: string; feedback?: string;
projectPath?: string; projectPath: string;
}; };
if (!featureId) { if (!featureId) {
@@ -36,6 +36,14 @@ export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat)
return; return;
} }
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
// Note: We no longer check hasPendingApproval here because resolvePlanApproval // Note: We no longer check hasPendingApproval here because resolvePlanApproval
// can handle recovery when pending approval is not in Map but feature has planSpec.status='generated' // can handle recovery when pending approval is not in Map but feature has planSpec.status='generated'
// This supports cases where the server restarted while waiting for approval // This supports cases where the server restarted while waiting for approval
@@ -48,7 +56,7 @@ export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat)
// Resolve the pending approval (with recovery support) // Resolve the pending approval (with recovery support)
const result = await autoModeService.resolvePlanApproval( const result = await autoModeService.resolvePlanApproval(
projectPath || '', projectPath,
featureId, featureId,
approved, approved,
editedPlan, editedPlan,

View File

@@ -33,18 +33,23 @@ export function createListHandler(
// We don't await this to keep the list response fast // We don't await this to keep the list response fast
// Note: detectOrphanedFeatures handles errors internally and always resolves // Note: detectOrphanedFeatures handles errors internally and always resolves
if (autoModeService) { if (autoModeService) {
autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => { autoModeService
if (orphanedFeatures.length > 0) { .detectOrphanedFeatures(projectPath)
logger.info( .then((orphanedFeatures) => {
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}` if (orphanedFeatures.length > 0) {
);
for (const { feature, missingBranch } of orphanedFeatures) {
logger.info( logger.info(
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists` `[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
); );
for (const { feature, missingBranch } of orphanedFeatures) {
logger.info(
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
);
}
} }
} })
}); .catch((error) => {
logger.warn(`[ProjectLoad] Orphan detection failed for ${projectPath}:`, error);
});
} }
res.json({ success: true, features }); res.json({ success: true, features });

View File

@@ -25,7 +25,7 @@ import {
isOpencodeModel, isOpencodeModel,
supportsStructuredOutput, supportsStructuredOutput,
} from '@automaker/types'; } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver'; import { resolvePhaseModel, resolveModelString } from '@automaker/model-resolver';
import { extractJson } from '../../../lib/json-extractor.js'; import { extractJson } from '../../../lib/json-extractor.js';
import { writeValidation } from '../../../lib/validation-storage.js'; import { writeValidation } from '../../../lib/validation-storage.js';
import { streamingQuery } from '../../../providers/simple-query-service.js'; import { streamingQuery } from '../../../providers/simple-query-service.js';
@@ -190,9 +190,10 @@ ${basePrompt}`;
// CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7") // CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7")
// to the API, NOT the resolved Claude model - otherwise we get "model not found" // to the API, NOT the resolved Claude model - otherwise we get "model not found"
// For standard Claude models, resolve aliases (e.g., 'opus' -> 'claude-opus-4-20250514')
const effectiveModel = claudeCompatibleProvider const effectiveModel = claudeCompatibleProvider
? (model as string) ? (model as string)
: providerResolvedModel || (model as string); : providerResolvedModel || resolveModelString(model as string);
logger.info(`Using model: ${effectiveModel}`); logger.info(`Using model: ${effectiveModel}`);
// Use streamingQuery with event callbacks // Use streamingQuery with event callbacks

View File

@@ -560,6 +560,7 @@ export class AgentExecutor {
revText += b.text || ''; revText += b.text || '';
this.eventBus.emitAutoModeEvent('auto_mode_progress', { this.eventBus.emitAutoModeEvent('auto_mode_progress', {
featureId, featureId,
branchName,
content: b.text, content: b.text,
}); });
} }
@@ -638,6 +639,7 @@ export class AgentExecutor {
cwd: o.workDir, cwd: o.workDir,
allowedTools: o.sdkOptions?.allowedTools as string[] | undefined, allowedTools: o.sdkOptions?.allowedTools as string[] | undefined,
abortController: o.abortController, abortController: o.abortController,
thinkingLevel: o.thinkingLevel,
mcpServers: mcpServers:
o.mcpServers && Object.keys(o.mcpServers).length > 0 o.mcpServers && Object.keys(o.mcpServers).length > 0
? (o.mcpServers as Record<string, { command: string }>) ? (o.mcpServers as Record<string, { command: string }>)

View File

@@ -31,8 +31,16 @@ export interface ProjectAutoLoopState {
branchName: string | null; branchName: string | null;
} }
/**
* Generate a unique key for a worktree auto-loop instance.
*
* When branchName is null, this represents the main worktree (uses '__main__' sentinel).
* Named branches always use their exact name — the caller is responsible for passing
* null for the primary branch (main/master/etc.) so key matching stays consistent
* with ConcurrencyManager's dynamic primary branch resolution.
*/
export function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string { export function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
return `${projectPath}::${(branchName === 'main' ? null : branchName) ?? '__main__'}`; return `${projectPath}::${branchName ?? '__main__'}`;
} }
export type ExecuteFeatureFn = ( export type ExecuteFeatureFn = (
@@ -404,11 +412,15 @@ export class AutoLoopCoordinator {
reject(new Error('Aborted')); reject(new Error('Aborted'));
return; return;
} }
const timeout = setTimeout(resolve, ms); const onAbort = () => {
signal?.addEventListener('abort', () => {
clearTimeout(timeout); clearTimeout(timeout);
reject(new Error('Aborted')); reject(new Error('Aborted'));
}); };
const timeout = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort);
}); });
} }
} }

View File

@@ -17,7 +17,7 @@ import { promisify } from 'util';
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types'; import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types';
import { DEFAULT_MAX_CONCURRENCY, stripProviderPrefix } from '@automaker/types'; import { DEFAULT_MAX_CONCURRENCY, stripProviderPrefix } from '@automaker/types';
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils'; import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform'; import { getFeatureDir, spawnProcess } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js'; import * as secureFs from '../../lib/secure-fs.js';
import { validateWorkingDirectory } from '../../lib/sdk-options.js'; import { validateWorkingDirectory } from '../../lib/sdk-options.js';
import { getPromptCustomization, getProviderByModelId } from '../../lib/settings-helpers.js'; import { getPromptCustomization, getProviderByModelId } from '../../lib/settings-helpers.js';
@@ -48,6 +48,24 @@ import type {
const execAsync = promisify(exec); const execAsync = promisify(exec);
const logger = createLogger('AutoModeServiceFacade'); const logger = createLogger('AutoModeServiceFacade');
/**
* Execute git command with array arguments to prevent command injection.
*/
async function execGitCommand(args: string[], cwd: string): Promise<string> {
const result = await spawnProcess({
command: 'git',
args,
cwd,
});
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
throw new Error(errorMessage);
}
}
/** /**
* AutoModeServiceFacade provides a clean interface for auto-mode functionality. * AutoModeServiceFacade provides a clean interface for auto-mode functionality.
* *
@@ -589,19 +607,8 @@ ${prompt}
Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`; Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`;
try { try {
this.eventBus.emitAutoModeEvent('auto_mode_feature_start', {
featureId,
projectPath: this.projectPath,
branchName: feature?.branchName ?? null,
feature: {
id: featureId,
title: feature?.title || 'Follow-up',
description: feature?.description || 'Following up on feature',
},
});
// NOTE: Facade does not have runAgent - this method requires AutoModeService // NOTE: Facade does not have runAgent - this method requires AutoModeService
// For now, throw to indicate routes should use AutoModeService.followUpFeature // Do NOT emit start events before throwing to prevent false start events
throw new Error( throw new Error(
'followUpFeature not fully implemented in facade - use AutoModeService.followUpFeature instead' 'followUpFeature not fully implemented in facade - use AutoModeService.followUpFeature instead'
); );
@@ -691,18 +698,22 @@ Address the follow-up instructions above. Review the previous work and make the
// Use project path // Use project path
} }
} else { } else {
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); // Use worktreeResolver instead of manual .worktrees lookup
const legacyWorktreePath = path.join(this.projectPath, '.worktrees', sanitizedFeatureId); const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId);
try { const branchName = feature?.branchName;
await secureFs.access(legacyWorktreePath); if (branchName) {
workDir = legacyWorktreePath; const resolved = await this.worktreeResolver.findWorktreeForBranch(
} catch { this.projectPath,
// Use project path branchName
);
if (resolved) {
workDir = resolved;
}
} }
} }
try { try {
const { stdout: status } = await execAsync('git status --porcelain', { cwd: workDir }); const status = await execGitCommand(['status', '--porcelain'], workDir);
if (!status.trim()) { if (!status.trim()) {
return null; return null;
} }
@@ -712,9 +723,9 @@ Address the follow-up instructions above. Review the previous work and make the
feature?.description?.split('\n')[0]?.substring(0, 60) || `Feature ${featureId}`; feature?.description?.split('\n')[0]?.substring(0, 60) || `Feature ${featureId}`;
const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`; const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`;
await execAsync('git add -A', { cwd: workDir }); await execGitCommand(['add', '-A'], workDir);
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { cwd: workDir }); await execGitCommand(['commit', '-m', commitMessage], workDir);
const { stdout: hash } = await execAsync('git rev-parse HEAD', { cwd: workDir }); const hash = await execGitCommand(['rev-parse', 'HEAD'], workDir);
this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', {
featureId, featureId,
@@ -975,10 +986,10 @@ Address the follow-up instructions above. Review the previous work and make the
return orphanedFeatures; return orphanedFeatures;
} }
// Get existing branches // Get existing branches (using safe array-based command)
const { stdout } = await execAsync( const stdout = await execGitCommand(
'git for-each-ref --format="%(refname:short)" refs/heads/', ['for-each-ref', '--format=%(refname:short)', 'refs/heads/'],
{ cwd: this.projectPath } this.projectPath
); );
const existingBranches = new Set( const existingBranches = new Set(
stdout stdout

View File

@@ -190,9 +190,9 @@ ${feature.spec}
} }
} }
let worktreePath: string | null = null; let worktreePath: string | null = providedWorktreePath ?? null;
const branchName = feature.branchName; const branchName = feature.branchName;
if (useWorktrees && branchName) { if (!worktreePath && useWorktrees && branchName) {
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName); worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
} }
@@ -289,6 +289,11 @@ ${feature.spec}
testAttempts: 0, testAttempts: 0,
maxTestAttempts: 5, maxTestAttempts: 5,
}); });
// Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it
const refreshed = await this.loadFeatureFn(projectPath, featureId);
if (refreshed?.status === 'merge_conflict') {
return;
}
} }
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';

View File

@@ -115,6 +115,13 @@ export class FeatureStateManager {
// PERSIST BEFORE EMIT (Pitfall 2) // PERSIST BEFORE EMIT (Pitfall 2)
await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT });
// Emit status change event so UI can react without polling
this.emitAutoModeEvent('feature_status_changed', {
featureId,
projectPath,
status,
});
// Create notifications for important status changes // Create notifications for important status changes
const notificationService = getNotificationService(); const notificationService = getNotificationService();
if (status === 'waiting_approval') { if (status === 'waiting_approval') {

View File

@@ -4,12 +4,8 @@
* Extracted from worktree merge route to allow internal service calls. * Extracted from worktree merge route to allow internal service calls.
*/ */
import { exec } from 'child_process';
import { promisify } from 'util';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { spawnProcess } from '@automaker/platform'; import { spawnProcess } from '@automaker/platform';
const execAsync = promisify(exec);
const logger = createLogger('MergeService'); const logger = createLogger('MergeService');
export interface MergeOptions { export interface MergeOptions {
@@ -80,9 +76,23 @@ export async function performMerge(
const mergeTo = targetBranch || 'main'; const mergeTo = targetBranch || 'main';
// Validate source branch exists // Validate branch names early to reject invalid input before any git operations
if (!isValidBranchName(branchName)) {
return {
success: false,
error: `Invalid source branch name: "${branchName}"`,
};
}
if (!isValidBranchName(mergeTo)) {
return {
success: false,
error: `Invalid target branch name: "${mergeTo}"`,
};
}
// Validate source branch exists (using safe array-based command)
try { try {
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath }); await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
} catch { } catch {
return { return {
success: false, success: false,
@@ -90,9 +100,9 @@ export async function performMerge(
}; };
} }
// Validate target branch exists // Validate target branch exists (using safe array-based command)
try { try {
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath }); await execGitCommand(['rev-parse', '--verify', mergeTo], projectPath);
} catch { } catch {
return { return {
success: false, success: false,
@@ -100,13 +110,14 @@ export async function performMerge(
}; };
} }
// Merge the feature branch into the target branch // Merge the feature branch into the target branch (using safe array-based commands)
const mergeCmd = options?.squash const mergeMessage = options?.message || `Merge ${branchName} into ${mergeTo}`;
? `git merge --squash ${branchName}` const mergeArgs = options?.squash
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`; ? ['merge', '--squash', branchName]
: ['merge', branchName, '-m', mergeMessage];
try { try {
await execAsync(mergeCmd, { cwd: projectPath }); await execGitCommand(mergeArgs, projectPath);
} catch (mergeError: unknown) { } catch (mergeError: unknown) {
// Check if this is a merge conflict // Check if this is a merge conflict
const err = mergeError as { stdout?: string; stderr?: string; message?: string }; const err = mergeError as { stdout?: string; stderr?: string; message?: string };
@@ -125,11 +136,10 @@ export async function performMerge(
throw mergeError; throw mergeError;
} }
// If squash merge, need to commit // If squash merge, need to commit (using safe array-based command)
if (options?.squash) { if (options?.squash) {
await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, { const squashMessage = options?.message || `Merge ${branchName} (squash)`;
cwd: projectPath, await execGitCommand(['commit', '-m', squashMessage], projectPath);
});
} }
// Optionally delete the worktree and branch after merging // Optionally delete the worktree and branch after merging

View File

@@ -460,6 +460,7 @@ export class PipelineOrchestrator {
const session = this.testRunnerService.getSession(sessionId); const session = this.testRunnerService.getSession(sessionId);
if (session && session.status !== 'running' && session.status !== 'pending') { if (session && session.status !== 'running' && session.status !== 'pending') {
clearInterval(checkInterval); clearInterval(checkInterval);
clearTimeout(timeoutId);
resolve({ resolve({
status: session.status, status: session.status,
exitCode: session.exitCode, exitCode: session.exitCode,
@@ -469,7 +470,7 @@ export class PipelineOrchestrator {
}); });
} }
}, 1000); }, 1000);
setTimeout(() => { const timeoutId = setTimeout(() => {
clearInterval(checkInterval); clearInterval(checkInterval);
resolve({ status: 'failed', exitCode: null, duration: 600000 }); resolve({ status: 'failed', exitCode: null, duration: 600000 });
}, 600000); }, 600000);

View File

@@ -83,6 +83,13 @@ export class PlanApprovalService {
); );
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Prevent duplicate registrations for the same key — reject and clean up existing entry
const existing = this.pendingApprovals.get(key);
if (existing) {
existing.reject(new Error('Superseded by a new waitForApproval call'));
this.pendingApprovals.delete(key);
}
// Set up timeout to prevent indefinite waiting and memory leaks // Set up timeout to prevent indefinite waiting and memory leaks
// timeoutId stored in closure, NOT in PendingApproval object // timeoutId stored in closure, NOT in PendingApproval object
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
@@ -226,11 +233,11 @@ export class PlanApprovalService {
status: approved ? 'approved' : 'rejected', status: approved ? 'approved' : 'rejected',
approvedAt: approved ? new Date().toISOString() : undefined, approvedAt: approved ? new Date().toISOString() : undefined,
reviewedByUser: true, reviewedByUser: true,
content: editedPlan, // Update content if user provided an edited version ...(editedPlan !== undefined && { content: editedPlan }), // Only update content if user provided an edited version
}); });
// If rejected with feedback, emit event so client knows the rejection reason // If rejected, emit event so client knows the rejection reason (even without feedback)
if (!approved && feedback) { if (!approved) {
this.eventBus.emitAutoModeEvent('plan_rejected', { this.eventBus.emitAutoModeEvent('plan_rejected', {
featureId, featureId,
projectPath, projectPath,

View File

@@ -313,14 +313,18 @@ export function GraphViewPage() {
// Handle add and start feature // Handle add and start feature
const handleAddAndStartFeature = useCallback( const handleAddAndStartFeature = useCallback(
async (featureData: Parameters<typeof handleAddFeature>[0]) => { async (featureData: Parameters<typeof handleAddFeature>[0]) => {
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id)); try {
await handleAddFeature(featureData); const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
const latestFeatures = useAppStore.getState().features; const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id)); const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) { if (newFeature) {
await handleStartImplementation(newFeature); await handleStartImplementation(newFeature);
}
} catch (error) {
logger.error('Failed to add and start feature:', error);
} }
}, },
[handleAddFeature, handleStartImplementation] [handleAddFeature, handleStartImplementation]