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';
/** List of files with conflicts */
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) {
// For merges, resolve MERGE_HEAD to a branch name
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();
try {
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-apply', 'onto-name');
try {
const ontoName = (await secureFs.readFile(headNamePath, 'utf-8')).trim();
const ontoName = ((await secureFs.readFile(headNamePath, 'utf-8')) as string).trim();
if (ontoName) {
conflictSourceBranch = ontoName.replace(/^refs\/heads\//, '');
}
@@ -190,7 +192,7 @@ async function detectConflictState(worktreePath: string): Promise<{
const ontoPath = rebaseMergeExists
? path.join(gitDir, 'rebase-merge', '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) {
const branchName = await execGitCommand(
['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit],
@@ -208,7 +210,7 @@ async function detectConflictState(worktreePath: string): Promise<{
} 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')
(await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8')) as string
).trim();
try {
const branchName = await execGitCommand(
@@ -669,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
}

View File

@@ -843,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

View File

@@ -14,7 +14,6 @@ import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/descri
import { getElectronAPI } from '@/lib/electron';
import { isConnectionError, handleServerOffline, getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
@@ -86,6 +85,7 @@ interface UseBoardActionsProps {
onWorktreeCreated?: () => void;
onWorktreeAutoSelect?: (worktree: { path: string; branch: string }) => void;
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({
@@ -114,6 +114,7 @@ export function useBoardActions({
onWorktreeCreated,
onWorktreeAutoSelect,
currentWorktreeBranch,
stopFeature,
}: UseBoardActionsProps) {
const queryClient = useQueryClient();
@@ -130,7 +131,6 @@ export function useBoardActions({
const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode);
const isPrimaryWorktreeBranch = useAppStore((s) => s.isPrimaryWorktreeBranch);
const getPrimaryWorktreeBranch = useAppStore((s) => s.getPrimaryWorktreeBranch);
const autoMode = useAutoMode();
// React Query mutations for feature operations
const verifyFeatureMutation = useVerifyFeature(currentProject?.path ?? '');
@@ -538,7 +538,7 @@ export function useBoardActions({
if (isRunning) {
try {
await autoMode.stopFeature(featureId);
await stopFeature(featureId);
// Remove from all worktrees
if (currentProject) {
removeRunningTaskFromAllWorktrees(currentProject.id, featureId);
@@ -573,7 +573,7 @@ export function useBoardActions({
removeFeature(featureId);
await persistFeatureDelete(featureId);
},
[features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete, currentProject]
[features, runningAutoTasks, stopFeature, removeFeature, persistFeatureDelete, currentProject]
);
const handleRunFeature = useCallback(
@@ -1032,7 +1032,7 @@ export function useBoardActions({
const handleForceStopFeature = useCallback(
async (feature: Feature) => {
try {
await autoMode.stopFeature(feature.id);
await stopFeature(feature.id);
const targetStatus =
feature.skipTests && feature.status === 'waiting_approval'
@@ -1040,7 +1040,7 @@ export function useBoardActions({
: 'backlog';
// 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.
// Without this, runningAutoTasksAllWorktrees still contains the feature
// 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 () => {
@@ -1197,7 +1197,7 @@ export function useBoardActions({
if (runningVerified.length > 0) {
await Promise.allSettled(
runningVerified.map((feature) =>
autoMode.stopFeature(feature.id).catch((error) => {
stopFeature(feature.id).catch((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
loadFeatures();
}
}, [features, runningAutoTasks, autoMode, updateFeature, currentProject, loadFeatures]);
}, [features, runningAutoTasks, stopFeature, updateFeature, currentProject, loadFeatures]);
const handleDuplicateFeature = useCallback(
async (feature: Feature, asChild: boolean = false) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -959,10 +959,10 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Stop a specific feature
const stopFeature = useCallback(
async (featureId: string) => {
async (featureId: string): Promise<boolean> => {
if (!currentProject) {
logger.error('No project selected');
return;
return false;
}
try {
@@ -983,6 +983,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
message: 'Feature stopped by user',
passes: false,
});
return true;
} else {
logger.error('Failed to stop feature:', result.error);
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)
const serverEndpoints = (finalSettings as GlobalSettings).ntfyEndpoints ?? [];
const currentEndpoints = useAppStore.getState().ntfyEndpoints;
if (
JSON.stringify(serverEndpoints) !== JSON.stringify(currentEndpoints) &&
serverEndpoints.length > 0
) {
if (JSON.stringify(serverEndpoints) !== JSON.stringify(currentEndpoints)) {
logger.info(
`[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 projectName: string;
test.beforeEach(async ({}, testInfo) => {
test.beforeEach(async (_, testInfo) => {
projectName = `test-project-${testInfo.workerIndex}-${Date.now()}`;
projectPath = path.join(TEST_TEMP_DIR, projectName);
fs.mkdirSync(projectPath, { recursive: true });