diff --git a/src/__tests__/renderer/hooks/batch/useAutoRunHandlers.worktree.test.ts b/src/__tests__/renderer/hooks/batch/useAutoRunHandlers.worktree.test.ts index 08051cab04..dc26e3dfa3 100644 --- a/src/__tests__/renderer/hooks/batch/useAutoRunHandlers.worktree.test.ts +++ b/src/__tests__/renderer/hooks/batch/useAutoRunHandlers.worktree.test.ts @@ -224,17 +224,18 @@ describe('handleStartBatchRun — worktree dispatch integration', () => { ); }); - it('still uses activeSession.autoRunFolderPath for document source', async () => { + it('copies queued docs to worktree and uses worktree folder path', async () => { const session = createMockSession({ autoRunFolderPath: '/my/autorun/folder', }); const deps = createMockDeps(); - // Populate store with target session + // Populate store with target session (has its own autoRunFolderPath) const worktreeChild = createMockSession({ id: 'worktree-child-99', state: 'idle', parentSessionId: session.id, + autoRunFolderPath: '/worktrees/feature/Auto Run Docs', }); useSessionStore.setState({ sessions: [session, worktreeChild], @@ -258,9 +259,22 @@ describe('handleStartBatchRun — worktree dispatch integration', () => { await result.current.handleStartBatchRun(config); }); - // folderPath (third arg) comes from the parent session, not the worktree + // Docs should be read from parent's folder + expect(window.maestro.autorun.readDoc).toHaveBeenCalledWith( + '/my/autorun/folder', + 'Phase 1.md', + undefined + ); + // Docs should be written to worktree's folder + expect(window.maestro.autorun.writeDoc).toHaveBeenCalledWith( + '/worktrees/feature/Auto Run Docs', + 'Phase 1.md', + '', + undefined + ); + // Batch run uses the worktree's folder path (where docs were copied) const [, , folderPath] = deps.startBatchRun.mock.calls[0]; - expect(folderPath).toBe('/my/autorun/folder'); + expect(folderPath).toBe('/worktrees/feature/Auto Run Docs'); }); it('does not call worktreeSetup for existing-open sessions', async () => { diff --git a/src/__tests__/renderer/utils/worktreeSession.test.ts b/src/__tests__/renderer/utils/worktreeSession.test.ts index 5fd253eb20..fb105130ee 100644 --- a/src/__tests__/renderer/utils/worktreeSession.test.ts +++ b/src/__tests__/renderer/utils/worktreeSession.test.ts @@ -4,7 +4,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { buildWorktreeSession } from '../../../renderer/utils/worktreeSession'; +import { buildWorktreeSession, isPathUnderRoot } from '../../../renderer/utils/worktreeSession'; import type { Session } from '../../../renderer/types'; // Mock generateId for deterministic IDs @@ -139,6 +139,171 @@ describe('buildWorktreeSession', () => { expect(session.sessionSshRemoteConfig).toEqual(sshConfig); }); + describe('autoRunFolderPath resolution', () => { + it('should rebase absolute path under parent cwd onto worktree cwd', () => { + const parent = createMockParentSession({ + cwd: '/projects/main', + autoRunFolderPath: '/projects/main/Auto Run Docs', + }); + const session = buildWorktreeSession({ + parentSession: parent, + path: '/worktrees/feature-x', + name: 'feature-x', + defaultSaveToHistory: true, + defaultShowThinking: 'off', + }); + expect(session.autoRunFolderPath).toBe('/worktrees/feature-x/Auto Run Docs'); + }); + + it('should keep relative path as-is', () => { + const parent = createMockParentSession({ + autoRunFolderPath: 'Auto Run Docs', + }); + const session = buildWorktreeSession({ + parentSession: parent, + path: '/worktrees/feature-x', + name: 'feature-x', + defaultSaveToHistory: true, + defaultShowThinking: 'off', + }); + expect(session.autoRunFolderPath).toBe('Auto Run Docs'); + }); + + it('should keep external absolute path as-is', () => { + const parent = createMockParentSession({ + cwd: '/projects/main', + autoRunFolderPath: '/shared/autorun-docs', + }); + const session = buildWorktreeSession({ + parentSession: parent, + path: '/worktrees/feature-x', + name: 'feature-x', + defaultSaveToHistory: true, + defaultShowThinking: 'off', + }); + expect(session.autoRunFolderPath).toBe('/shared/autorun-docs'); + }); + + it('should handle Windows backslash paths', () => { + const parent = createMockParentSession({ + cwd: 'C:\\Users\\Admin\\Software\\Maestro', + autoRunFolderPath: 'C:\\Users\\Admin\\Software\\Maestro\\Auto Run Docs', + }); + const session = buildWorktreeSession({ + parentSession: parent, + path: 'C:\\Users\\Admin\\Software\\Maestro-worktree', + name: 'worktree', + defaultSaveToHistory: true, + defaultShowThinking: 'off', + }); + expect(session.autoRunFolderPath).toBe( + 'C:\\Users\\Admin\\Software\\Maestro-worktree\\Auto Run Docs' + ); + }); + + it('should return undefined when parent has no autoRunFolderPath', () => { + const parent = createMockParentSession({ + autoRunFolderPath: undefined, + }); + const session = buildWorktreeSession({ + parentSession: parent, + path: '/worktrees/feature-x', + name: 'feature-x', + defaultSaveToHistory: true, + defaultShowThinking: 'off', + }); + expect(session.autoRunFolderPath).toBeUndefined(); + }); + + it('should treat empty string autoRunFolderPath as undefined', () => { + const parent = createMockParentSession({ + autoRunFolderPath: '', + }); + const session = buildWorktreeSession({ + parentSession: parent, + path: '/worktrees/feature-x', + name: 'feature-x', + defaultSaveToHistory: true, + defaultShowThinking: 'off', + }); + expect(session.autoRunFolderPath).toBeUndefined(); + }); + + it('should rebase when autoRunFolderPath equals parent cwd exactly', () => { + const parent = createMockParentSession({ + cwd: '/projects/main', + autoRunFolderPath: '/projects/main', + }); + const session = buildWorktreeSession({ + parentSession: parent, + path: '/worktrees/feature-x', + name: 'feature-x', + defaultSaveToHistory: true, + defaultShowThinking: 'off', + }); + expect(session.autoRunFolderPath).toBe('/worktrees/feature-x'); + }); + + it('should handle case-insensitive matching on Windows-style paths', () => { + const parent = createMockParentSession({ + cwd: 'C:\\Users\\Admin\\Software\\Maestro', + autoRunFolderPath: 'c:\\users\\admin\\software\\Maestro\\Auto Run Docs', + }); + const session = buildWorktreeSession({ + parentSession: parent, + path: 'C:\\Users\\Admin\\Software\\Maestro-worktree', + name: 'worktree', + defaultSaveToHistory: true, + defaultShowThinking: 'off', + }); + expect(session.autoRunFolderPath).toBe( + 'C:\\Users\\Admin\\Software\\Maestro-worktree\\Auto Run Docs' + ); + }); + + it('should use case-sensitive comparison for Unix paths', () => { + const parent = createMockParentSession({ + cwd: '/Projects/Main', + autoRunFolderPath: '/projects/main/Auto Run Docs', + }); + const session = buildWorktreeSession({ + parentSession: parent, + path: '/worktrees/feature-x', + name: 'feature-x', + defaultSaveToHistory: true, + defaultShowThinking: 'off', + }); + // Different case on Unix means different directory - treat as external + expect(session.autoRunFolderPath).toBe('/projects/main/Auto Run Docs'); + }); + }); + + describe('isPathUnderRoot', () => { + it('returns true for path under root (Unix)', () => { + expect(isPathUnderRoot('/projects/main/Auto Run Docs', '/projects/main')).toBe(true); + }); + + it('returns true when path equals root', () => { + expect(isPathUnderRoot('/projects/main', '/projects/main')).toBe(true); + }); + + it('returns false for external path', () => { + expect(isPathUnderRoot('/other/path', '/projects/main')).toBe(false); + }); + + it('returns false for partial prefix match', () => { + expect(isPathUnderRoot('/projects/main-fork/docs', '/projects/main')).toBe(false); + }); + + it('handles Windows case-insensitive', () => { + expect(isPathUnderRoot('C:\\Users\\Admin\\docs', 'c:\\users\\admin')).toBe(true); + }); + + it('handles Unix case-sensitive', () => { + expect(isPathUnderRoot('/Projects/Main/docs', '/projects/main')).toBe(false); + }); + }); + it('should create initial AI tab with correct settings', () => { const parent = createMockParentSession(); const session = buildWorktreeSession({ diff --git a/src/renderer/hooks/batch/useAutoRunHandlers.ts b/src/renderer/hooks/batch/useAutoRunHandlers.ts index 88cae76bac..2c56a291de 100644 --- a/src/renderer/hooks/batch/useAutoRunHandlers.ts +++ b/src/renderer/hooks/batch/useAutoRunHandlers.ts @@ -218,7 +218,11 @@ export function useAutoRunHandlers( startBatchRun, } = deps; - // Handler for auto run folder selection from setup modal + // Handler for auto run folder selection from setup modal. + // Worktree sessions store their own independent Auto Run folder paths - + // the selected path is stored directly on activeSession (whichever session + // is active), with no parent-session resolution. This is correct because + // the user picks the folder relative to the worktree's own working directory. const handleAutoRunFolderSelected = useCallback( async (folderPath: string) => { if (!activeSession) return; @@ -395,14 +399,72 @@ export function useAutoRunHandlers( } } + // Resolve the folder path for the batch run. + // When the user IS on the worktree, activeSession.autoRunFolderPath already + // points to the worktree's own directory (set by buildWorktreeSession). + // When dispatching from parent to worktree, copy queued docs to the + // worktree's Auto Run folder so subsequent runs find them locally. + let folderPath = activeSession.autoRunFolderPath; + if (targetSessionId !== activeSession.id) { + const targetSession = useSessionStore + .getState() + .sessions.find((s) => s.id === targetSessionId); + if (targetSession?.autoRunFolderPath && targetSession.autoRunFolderPath !== folderPath) { + const sshRemoteId = getSshRemoteId(activeSession); + const destFolder = targetSession.autoRunFolderPath; + // Copy only the queued documents from the parent's folder to the worktree + let copyFailed = false; + await Promise.all( + config.documents.map(async (doc) => { + try { + const readResult = await window.maestro.autorun.readDoc( + folderPath, + doc.filename + '.md', + sshRemoteId + ); + if (readResult.success && readResult.content != null) { + await window.maestro.autorun.writeDoc( + destFolder, + doc.filename + '.md', + readResult.content, + sshRemoteId + ); + } else { + copyFailed = true; + window.maestro.logger.log( + 'warn', + `Failed to read doc "${doc.filename}" from source folder`, + 'AutoRunHandlers' + ); + } + } catch (err) { + copyFailed = true; + window.maestro.logger.log( + 'warn', + `Failed to copy doc "${doc.filename}" to worktree: ${err instanceof Error ? err.message : String(err)}`, + 'AutoRunHandlers' + ); + } + }) + ); + if (copyFailed) { + notifyToast({ + type: 'warning', + title: 'Some Documents Failed to Copy', + message: 'Not all documents could be copied to the worktree. Check logs for details.', + }); + } + folderPath = destFolder; + } + } + window.maestro.logger.log('info', 'Starting batch run', 'AutoRunHandlers', { sessionId: targetSessionId, - folderPath: activeSession.autoRunFolderPath, + folderPath, isWorktreeTarget: targetSessionId !== activeSession.id, }); setBatchRunnerModalOpen(false); - // Documents stay with the parent session's autoRunFolderPath; execution targets the worktree agent - startBatchRun(targetSessionId, config, activeSession.autoRunFolderPath); + startBatchRun(targetSessionId, config, folderPath); }, [activeSession, startBatchRun, setBatchRunnerModalOpen] ); diff --git a/src/renderer/hooks/batch/useBatchProcessor.ts b/src/renderer/hooks/batch/useBatchProcessor.ts index 73dc679060..0f5ea40859 100644 --- a/src/renderer/hooks/batch/useBatchProcessor.ts +++ b/src/renderer/hooks/batch/useBatchProcessor.ts @@ -604,8 +604,8 @@ export function useBatchProcessor({ /** * Start a batch processing run for a specific session with multi-document support. * Note: sessionId and folderPath can belong to different sessions when running - * in a worktree — the parent session owns the Auto Run documents (folderPath) - * while the worktree agent (sessionId) executes the tasks. + * in a worktree. Queued docs are copied to the worktree's own Auto Run folder + * before dispatch so subsequent runs find them locally. */ const startBatchRun = useCallback( async (sessionId: string, config: BatchRunConfig, folderPath: string) => { diff --git a/src/renderer/hooks/session/useSessionRestoration.ts b/src/renderer/hooks/session/useSessionRestoration.ts index e6fb4647ac..7e07e4a0bf 100644 --- a/src/renderer/hooks/session/useSessionRestoration.ts +++ b/src/renderer/hooks/session/useSessionRestoration.ts @@ -20,6 +20,7 @@ import { useGroupChatStore } from '../../stores/groupChatStore'; import { gitService } from '../../services/git'; import { generateId } from '../../utils/ids'; import { AUTO_RUN_FOLDER_NAME } from '../../components/Wizard'; +import { isPathUnderRoot } from '../../utils/worktreeSession'; // ============================================================================ // Return type @@ -169,6 +170,22 @@ export function useSessionRestoration(): SessionRestorationReturn { }; } + // Migration: worktree sessions should use their own Auto Run folder path, + // not the parent's. Old sessions inherited the parent's path directly. + // Rebase to projectRoot (which is the worktree's cwd) if the current path + // doesn't already point there. + if (session.parentSessionId && session.autoRunFolderPath && session.projectRoot) { + if (!isPathUnderRoot(session.autoRunFolderPath, session.projectRoot)) { + console.warn( + `[restoreSession] Worktree session autoRunFolderPath was under parent, rebasing to ${session.projectRoot}` + ); + session = { + ...session, + autoRunFolderPath: `${session.projectRoot}/${AUTO_RUN_FOLDER_NAME}`, + }; + } + } + // Migration: ensure fileTreeAutoRefreshInterval is set (default 180s for legacy sessions) if (session.fileTreeAutoRefreshInterval == null) { console.warn( diff --git a/src/renderer/utils/worktreeSession.ts b/src/renderer/utils/worktreeSession.ts index e7e956879a..a4107610ee 100644 --- a/src/renderer/utils/worktreeSession.ts +++ b/src/renderer/utils/worktreeSession.ts @@ -33,6 +33,60 @@ export interface BuildWorktreeSessionParams { worktreeParentPath?: string; } +/** Normalize path separators to forward slashes for comparison. */ +const normSep = (p: string) => p.replace(/\\/g, '/'); + +/** + * Check whether `filePath` is located under (or equal to) `root` (platform-aware). + * Windows paths use case-insensitive comparison; Unix paths are case-sensitive. + */ +export function isPathUnderRoot(filePath: string, root: string): boolean { + const normalizedFile = normSep(filePath); + const normalizedRoot = normSep(root); + const prefix = normalizedRoot.endsWith('/') ? normalizedRoot : normalizedRoot + '/'; + // Either argument having a drive letter means we're on Windows + const isWin = /^[A-Za-z]:/.test(filePath) || /^[A-Za-z]:/.test(root); + if (isWin) { + return ( + normalizedFile.toLowerCase() === normalizedRoot.toLowerCase() || + normalizedFile.toLowerCase().startsWith(prefix.toLowerCase()) + ); + } + return normalizedFile === normalizedRoot || normalizedFile.startsWith(prefix); +} + +/** + * Resolves the Auto Run folder path for a worktree session. + * + * - Relative paths are kept as-is (they resolve from each session's own cwd). + * - Absolute paths under the parent's cwd are rebased onto the worktree's cwd. + * - Absolute paths outside the parent's cwd are kept as-is (intentionally external). + */ +function resolveWorktreeAutoRunPath( + parentAutoRunPath: string | undefined, + parentCwd: string, + worktreeCwd: string +): string | undefined { + if (!parentAutoRunPath) return undefined; + + // Check if the path is absolute (Unix / or Windows drive letter) + const isAbsolute = /^\/|^[A-Za-z]:[/\\]/.test(parentAutoRunPath); + if (!isAbsolute) return parentAutoRunPath; + + if (isPathUnderRoot(parentAutoRunPath, parentCwd)) { + // Strip trailing slash for consistent slicing + const normalizedParentCwd = normSep(parentCwd).replace(/\/$/, ''); + const normalized = normSep(parentAutoRunPath); + const relativePart = normalized.slice(normalizedParentCwd.length); + const result = normSep(worktreeCwd) + relativePart; + // Preserve original separator style + return parentAutoRunPath.includes('\\') ? result.replace(/\//g, '\\') : result; + } + + // External absolute path - keep as-is + return parentAutoRunPath; +} + export function buildWorktreeSession(params: BuildWorktreeSessionParams): Session { const newId = generateId(); const initialTabId = generateId(); @@ -120,6 +174,12 @@ export function buildWorktreeSession(params: BuildWorktreeSessionParams): Sessio // New model inherits these; legacy does not customContextWindow: isLegacy ? undefined : params.parentSession.customContextWindow, nudgeMessage: isLegacy ? undefined : params.parentSession.nudgeMessage, - autoRunFolderPath: isLegacy ? undefined : params.parentSession.autoRunFolderPath, + autoRunFolderPath: isLegacy + ? undefined + : resolveWorktreeAutoRunPath( + params.parentSession.autoRunFolderPath, + params.parentSession.cwd, + params.path + ), } as Session; }