feat: Add conflict source branch tracking and fix auto-mode subscription cascade

This commit is contained in:
gsxdsm
2026-03-02 07:43:00 -08:00
parent c11f390764
commit 59b100b5cc
11 changed files with 35 additions and 24 deletions

View File

@@ -87,6 +87,8 @@ interface WorktreeInfo {
conflictType?: 'merge' | 'rebase' | 'cherry-pick'; conflictType?: 'merge' | 'rebase' | 'cherry-pick';
/** List of files with conflicts */ /** List of files with conflicts */
conflictFiles?: string[]; conflictFiles?: string[];
/** Source branch involved in merge/rebase/cherry-pick, when resolvable */
conflictSourceBranch?: string;
} }
/** /**
@@ -160,7 +162,7 @@ async function detectConflictState(worktreePath: string): Promise<{
if (conflictType === 'merge' && mergeHeadExists) { if (conflictType === 'merge' && mergeHeadExists) {
// For merges, resolve MERGE_HEAD to a branch name // For merges, resolve MERGE_HEAD to a branch name
const mergeHead = ( const mergeHead = (
await secureFs.readFile(path.join(gitDir, 'MERGE_HEAD'), 'utf-8') (await secureFs.readFile(path.join(gitDir, 'MERGE_HEAD'), 'utf-8')) as string
).trim(); ).trim();
try { try {
const branchName = await execGitCommand( const branchName = await execGitCommand(
@@ -180,7 +182,7 @@ async function detectConflictState(worktreePath: string): Promise<{
? path.join(gitDir, 'rebase-merge', 'onto-name') ? path.join(gitDir, 'rebase-merge', 'onto-name')
: path.join(gitDir, 'rebase-apply', 'onto-name'); : path.join(gitDir, 'rebase-apply', 'onto-name');
try { try {
const ontoName = (await secureFs.readFile(headNamePath, 'utf-8')).trim(); const ontoName = ((await secureFs.readFile(headNamePath, 'utf-8')) as string).trim();
if (ontoName) { if (ontoName) {
conflictSourceBranch = ontoName.replace(/^refs\/heads\//, ''); conflictSourceBranch = ontoName.replace(/^refs\/heads\//, '');
} }
@@ -190,7 +192,7 @@ async function detectConflictState(worktreePath: string): Promise<{
const ontoPath = rebaseMergeExists const ontoPath = rebaseMergeExists
? path.join(gitDir, 'rebase-merge', 'onto') ? path.join(gitDir, 'rebase-merge', 'onto')
: path.join(gitDir, 'rebase-apply', 'onto'); : path.join(gitDir, 'rebase-apply', 'onto');
const ontoCommit = (await secureFs.readFile(ontoPath, 'utf-8')).trim(); const ontoCommit = ((await secureFs.readFile(ontoPath, 'utf-8')) as string).trim();
if (ontoCommit) { if (ontoCommit) {
const branchName = await execGitCommand( const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit], ['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit],
@@ -208,7 +210,7 @@ async function detectConflictState(worktreePath: string): Promise<{
} else if (conflictType === 'cherry-pick' && cherryPickHeadExists) { } else if (conflictType === 'cherry-pick' && cherryPickHeadExists) {
// For cherry-picks, try to resolve CHERRY_PICK_HEAD to a branch name // For cherry-picks, try to resolve CHERRY_PICK_HEAD to a branch name
const cherryPickHead = ( const cherryPickHead = (
await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8') (await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8')) as string
).trim(); ).trim();
try { try {
const branchName = await execGitCommand( const branchName = await execGitCommand(
@@ -669,6 +671,7 @@ export function createListHandler() {
// hasConflicts is true only when there are actual unresolved files // hasConflicts is true only when there are actual unresolved files
worktree.hasConflicts = conflictState.hasConflicts; worktree.hasConflicts = conflictState.hasConflicts;
worktree.conflictFiles = conflictState.conflictFiles; worktree.conflictFiles = conflictState.conflictFiles;
worktree.conflictSourceBranch = conflictState.conflictSourceBranch;
} catch { } catch {
// Ignore conflict detection errors // Ignore conflict detection errors
} }

View File

@@ -843,6 +843,7 @@ export function BoardView({ initialFeatureId }: BoardViewProps) {
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1), onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
onWorktreeAutoSelect: addAndSelectWorktree, onWorktreeAutoSelect: addAndSelectWorktree,
currentWorktreeBranch, currentWorktreeBranch,
stopFeature: autoMode.stopFeature,
}); });
// Handler for bulk updating multiple features // Handler for bulk updating multiple features

View File

@@ -14,7 +14,6 @@ import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/descri
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { isConnectionError, handleServerOffline, getHttpApiClient } from '@/lib/http-api-client'; import { isConnectionError, handleServerOffline, getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations'; import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
import { truncateDescription } from '@/lib/utils'; import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver'; import { getBlockingDependencies } from '@automaker/dependency-resolver';
@@ -86,6 +85,7 @@ interface UseBoardActionsProps {
onWorktreeCreated?: () => void; onWorktreeCreated?: () => void;
onWorktreeAutoSelect?: (worktree: { path: string; branch: string }) => void; onWorktreeAutoSelect?: (worktree: { path: string; branch: string }) => void;
currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering
stopFeature: (featureId: string) => Promise<boolean>; // Passed from parent's useAutoMode to avoid duplicate subscription
} }
export function useBoardActions({ export function useBoardActions({
@@ -114,6 +114,7 @@ export function useBoardActions({
onWorktreeCreated, onWorktreeCreated,
onWorktreeAutoSelect, onWorktreeAutoSelect,
currentWorktreeBranch, currentWorktreeBranch,
stopFeature,
}: UseBoardActionsProps) { }: UseBoardActionsProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -130,7 +131,6 @@ export function useBoardActions({
const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode); const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode);
const isPrimaryWorktreeBranch = useAppStore((s) => s.isPrimaryWorktreeBranch); const isPrimaryWorktreeBranch = useAppStore((s) => s.isPrimaryWorktreeBranch);
const getPrimaryWorktreeBranch = useAppStore((s) => s.getPrimaryWorktreeBranch); const getPrimaryWorktreeBranch = useAppStore((s) => s.getPrimaryWorktreeBranch);
const autoMode = useAutoMode();
// React Query mutations for feature operations // React Query mutations for feature operations
const verifyFeatureMutation = useVerifyFeature(currentProject?.path ?? ''); const verifyFeatureMutation = useVerifyFeature(currentProject?.path ?? '');
@@ -538,7 +538,7 @@ export function useBoardActions({
if (isRunning) { if (isRunning) {
try { try {
await autoMode.stopFeature(featureId); await stopFeature(featureId);
// Remove from all worktrees // Remove from all worktrees
if (currentProject) { if (currentProject) {
removeRunningTaskFromAllWorktrees(currentProject.id, featureId); removeRunningTaskFromAllWorktrees(currentProject.id, featureId);
@@ -573,7 +573,7 @@ export function useBoardActions({
removeFeature(featureId); removeFeature(featureId);
await persistFeatureDelete(featureId); await persistFeatureDelete(featureId);
}, },
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete, currentProject] [features, runningAutoTasks, stopFeature, removeFeature, persistFeatureDelete, currentProject]
); );
const handleRunFeature = useCallback( const handleRunFeature = useCallback(
@@ -1032,7 +1032,7 @@ export function useBoardActions({
const handleForceStopFeature = useCallback( const handleForceStopFeature = useCallback(
async (feature: Feature) => { async (feature: Feature) => {
try { try {
await autoMode.stopFeature(feature.id); await stopFeature(feature.id);
const targetStatus = const targetStatus =
feature.skipTests && feature.status === 'waiting_approval' feature.skipTests && feature.status === 'waiting_approval'
@@ -1040,7 +1040,7 @@ export function useBoardActions({
: 'backlog'; : 'backlog';
// Remove the running task from ALL worktrees for this project. // Remove the running task from ALL worktrees for this project.
// autoMode.stopFeature only removes from its scoped worktree (branchName), // stopFeature only removes from its scoped worktree (branchName),
// but the feature may be tracked under a different worktree branch. // but the feature may be tracked under a different worktree branch.
// Without this, runningAutoTasksAllWorktrees still contains the feature // Without this, runningAutoTasksAllWorktrees still contains the feature
// and the board column logic forces it into in_progress. // and the board column logic forces it into in_progress.
@@ -1085,7 +1085,7 @@ export function useBoardActions({
}); });
} }
}, },
[autoMode, moveFeature, persistFeatureUpdate, currentProject, queryClient] [stopFeature, moveFeature, persistFeatureUpdate, currentProject, queryClient]
); );
const handleStartNextFeatures = useCallback(async () => { const handleStartNextFeatures = useCallback(async () => {
@@ -1197,7 +1197,7 @@ export function useBoardActions({
if (runningVerified.length > 0) { if (runningVerified.length > 0) {
await Promise.allSettled( await Promise.allSettled(
runningVerified.map((feature) => runningVerified.map((feature) =>
autoMode.stopFeature(feature.id).catch((error) => { stopFeature(feature.id).catch((error) => {
logger.error('Error stopping feature before archive:', error); logger.error('Error stopping feature before archive:', error);
}) })
) )
@@ -1236,7 +1236,7 @@ export function useBoardActions({
// Reload features to sync state with server on error // Reload features to sync state with server on error
loadFeatures(); loadFeatures();
} }
}, [features, runningAutoTasks, autoMode, updateFeature, currentProject, loadFeatures]); }, [features, runningAutoTasks, stopFeature, updateFeature, currentProject, loadFeatures]);
const handleDuplicateFeature = useCallback( const handleDuplicateFeature = useCallback(
async (feature: Feature, asChild: boolean = false) => { async (feature: Feature, asChild: boolean = false) => {

View File

@@ -19,7 +19,7 @@ interface UseBoardDragDropProps {
runningAutoTasks: string[]; runningAutoTasks: string[];
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>; persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
handleStartImplementation: (feature: Feature) => Promise<boolean>; handleStartImplementation: (feature: Feature) => Promise<boolean>;
stopFeature: (featureId: string) => Promise<void>; stopFeature: (featureId: string) => Promise<boolean>;
} }
export function useBoardDragDrop({ export function useBoardDragDrop({
@@ -256,8 +256,12 @@ export function useBoardDragDrop({
// If the feature is currently running, stop it first // If the feature is currently running, stop it first
if (isRunningTask) { if (isRunningTask) {
try { try {
await stopFeature(featureId); const stopped = await stopFeature(featureId);
logger.info('Stopped running feature via drag to backlog:', featureId); if (stopped) {
logger.info('Stopped running feature via drag to backlog:', featureId);
} else {
logger.warn('Feature was not running by the time stop was requested:', featureId);
}
} catch (error) { } catch (error) {
logger.error('Error stopping feature during drag to backlog:', error); logger.error('Error stopping feature during drag to backlog:', error);
toast.error('Failed to stop agent', { toast.error('Failed to stop agent', {

View File

@@ -482,7 +482,7 @@ export function WorktreeActionsDropdown({
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
onCreateConflictResolutionFeature({ onCreateConflictResolutionFeature({
sourceBranch: worktree.branch, sourceBranch: worktree.conflictSourceBranch ?? worktree.branch,
targetBranch: worktree.branch, targetBranch: worktree.branch,
targetWorktreePath: worktree.path, targetWorktreePath: worktree.path,
conflictFiles: worktree.conflictFiles, conflictFiles: worktree.conflictFiles,

View File

@@ -17,6 +17,8 @@ export interface WorktreeInfo {
conflictType?: 'merge' | 'rebase' | 'cherry-pick'; conflictType?: 'merge' | 'rebase' | 'cherry-pick';
/** List of files with conflicts */ /** List of files with conflicts */
conflictFiles?: string[]; conflictFiles?: string[];
/** The branch that is the source of the conflict (e.g. the branch being merged in) */
conflictSourceBranch?: string;
} }
export interface BranchInfo { export interface BranchInfo {

View File

@@ -475,6 +475,7 @@ export function GraphViewPage() {
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch); setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
}, },
currentWorktreeBranch, currentWorktreeBranch,
stopFeature: autoMode.stopFeature,
}); });
// Handle add and start feature // Handle add and start feature

View File

@@ -28,6 +28,8 @@ interface WorktreeInfo {
conflictType?: 'merge' | 'rebase' | 'cherry-pick'; conflictType?: 'merge' | 'rebase' | 'cherry-pick';
/** List of files with conflicts */ /** List of files with conflicts */
conflictFiles?: string[]; conflictFiles?: string[];
/** The branch that is the source of the conflict (e.g. the branch being merged in) */
conflictSourceBranch?: string;
} }
interface RemovedWorktree { interface RemovedWorktree {

View File

@@ -959,10 +959,10 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Stop a specific feature // Stop a specific feature
const stopFeature = useCallback( const stopFeature = useCallback(
async (featureId: string) => { async (featureId: string): Promise<boolean> => {
if (!currentProject) { if (!currentProject) {
logger.error('No project selected'); logger.error('No project selected');
return; return false;
} }
try { try {
@@ -983,6 +983,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
message: 'Feature stopped by user', message: 'Feature stopped by user',
passes: false, passes: false,
}); });
return true;
} else { } else {
logger.error('Failed to stop feature:', result.error); logger.error('Failed to stop feature:', result.error);
throw new Error(result.error || 'Failed to stop feature'); throw new Error(result.error || 'Failed to stop feature');

View File

@@ -613,10 +613,7 @@ function RootLayoutContent() {
// Reconcile ntfy endpoints from server (same rationale as eventHooks) // Reconcile ntfy endpoints from server (same rationale as eventHooks)
const serverEndpoints = (finalSettings as GlobalSettings).ntfyEndpoints ?? []; const serverEndpoints = (finalSettings as GlobalSettings).ntfyEndpoints ?? [];
const currentEndpoints = useAppStore.getState().ntfyEndpoints; const currentEndpoints = useAppStore.getState().ntfyEndpoints;
if ( if (JSON.stringify(serverEndpoints) !== JSON.stringify(currentEndpoints)) {
JSON.stringify(serverEndpoints) !== JSON.stringify(currentEndpoints) &&
serverEndpoints.length > 0
) {
logger.info( logger.info(
`[FAST_HYDRATE] Reconciling ntfyEndpoints from server (server=${serverEndpoints.length}, store=${currentEndpoints.length})` `[FAST_HYDRATE] Reconciling ntfyEndpoints from server (server=${serverEndpoints.length}, store=${currentEndpoints.length})`
); );

View File

@@ -28,7 +28,7 @@ test.describe('Feature Deep Link', () => {
let projectPath: string; let projectPath: string;
let projectName: string; let projectName: string;
test.beforeEach(async ({}, testInfo) => { test.beforeEach(async (_, testInfo) => {
projectName = `test-project-${testInfo.workerIndex}-${Date.now()}`; projectName = `test-project-${testInfo.workerIndex}-${Date.now()}`;
projectPath = path.join(TEST_TEMP_DIR, projectName); projectPath = path.join(TEST_TEMP_DIR, projectName);
fs.mkdirSync(projectPath, { recursive: true }); fs.mkdirSync(projectPath, { recursive: true });