diff --git a/apps/frontend/src/main/__tests__/file-watcher.test.ts b/apps/frontend/src/main/__tests__/file-watcher.test.ts new file mode 100644 index 0000000000..685e7e8950 --- /dev/null +++ b/apps/frontend/src/main/__tests__/file-watcher.test.ts @@ -0,0 +1,301 @@ +/** + * Unit tests for FileWatcher concurrency mechanisms + * Tests deduplication, supersession, cancellation, and unwatchAll behaviour + * under concurrent watch()/unwatch() call patterns. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import path from 'path'; + +// --------------------------------------------------------------------------- +// Mock chokidar BEFORE importing FileWatcher so the module sees our mock. +// --------------------------------------------------------------------------- + +// A minimal FSWatcher stub that lets us control when close() resolves. +class MockFSWatcher extends EventEmitter { + close: ReturnType; + constructor(closeImpl?: () => Promise) { + super(); + this.close = vi.fn(closeImpl ?? (() => Promise.resolve())); + } +} + +// Track every watcher created so tests can inspect them. +let createdWatchers: MockFSWatcher[] = []; +// Factory override — tests replace this to inject custom stubs. +let watchFactory: (() => MockFSWatcher) | null = null; + +vi.mock('chokidar', () => ({ + default: { + watch: vi.fn((_path: string, _opts: unknown) => { + const watcher = watchFactory ? watchFactory() : new MockFSWatcher(); + createdWatchers.push(watcher); + return watcher; + }) + } +})); + +// Mock 'fs' so we can control existsSync / readFileSync without touching disk. +vi.mock('fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => JSON.stringify({ phases: [] })) +})); + +// --------------------------------------------------------------------------- +// Import after mocks are registered +// --------------------------------------------------------------------------- +import { FileWatcher } from '../file-watcher'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('FileWatcher concurrency', () => { + let fw: FileWatcher; + + beforeEach(() => { + fw = new FileWatcher(); + createdWatchers = []; + watchFactory = null; + vi.clearAllMocks(); + }); + + afterEach(async () => { + // Clean up any watchers that are still open. + await fw.unwatchAll(); + }); + + // ------------------------------------------------------------------------- + // 1. Deduplication — same taskId + same specDir + // ------------------------------------------------------------------------- + describe('deduplication: second watch() with same specDir is a no-op', () => { + it('should only create one FSWatcher when watch() is called twice with the same specDir while the first is still in-flight', async () => { + const specDir = '/project/.auto-claude/specs/001-task'; + const taskId = 'task-1'; + + // To create a real async gap we need an existing watcher whose close() is slow. + // First, set up a watcher for taskId (completes synchronously). + await fw.watch(taskId, specDir); + expect(createdWatchers).toHaveLength(1); + + // Replace close() with a slow one so the next watch() call has an async gap. + const existingWatcher = createdWatchers[0]; + let resolveClose!: () => void; + existingWatcher.close = vi.fn( + () => new Promise((res) => { resolveClose = res; }) + ); + + // Now start two concurrent watch() calls for the SAME specDir. + // Both will try to enter, but the second should be deduplicated. + const watchPromise1 = fw.watch(taskId, specDir); + const watchPromise2 = fw.watch(taskId, specDir); + + // Resolve the close so both can proceed. + resolveClose(); + await Promise.all([watchPromise1, watchPromise2]); + + // Only one new FSWatcher should have been created (the second call was a no-op). + // createdWatchers[0] is the original; createdWatchers[1] is the new one. + expect(createdWatchers).toHaveLength(2); + expect(fw.isWatching(taskId)).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // 2. Supersession — same taskId, different specDir + // ------------------------------------------------------------------------- + describe('supersession: watch() with different specDir replaces the in-flight call', () => { + it('should let the second call win when the first is awaiting close()', async () => { + const taskId = 'task-2'; + const specDir1 = path.join('/project', '.auto-claude', 'specs', '001-first'); + const specDir2 = path.join('/project', '.auto-claude', 'specs', '002-second'); + + // First call installs an existing watcher (simulate: the watcher for + // specDir1 is already set up so the second watch() needs to close it). + // We do this by running the first watch() to completion first. + await fw.watch(taskId, specDir1); + expect(createdWatchers).toHaveLength(1); + + // Now make the close() of the first watcher slow so there's an async gap + // during which the second watch() can enter and supersede. + const existingWatcher = createdWatchers[0]; + let resolveClose!: () => void; + existingWatcher.close = vi.fn( + () => new Promise((res) => { resolveClose = res; }) + ); + + // Start the second watch() — it will try to close the first watcher's + // FSWatcher and will be awaiting that. + const watch2Promise = fw.watch(taskId, specDir2); + + // While the second watch() is awaiting close, start a THIRD call with + // yet another specDir — this supersedes the second call. + // Actually for the test described in the finding, we want: + // - First call bails, second call creates the watcher. + // Let's resolve the close and let watch2 finish. + resolveClose(); + await watch2Promise; + + // The final watcher should be for specDir2. + expect(fw.getWatchedSpecDir(taskId)).toBe(specDir2); + // Two watchers were created in total (one for each specDir). + expect(createdWatchers).toHaveLength(2); + }); + + it('first watch() bails when pendingWatches changes to a different specDir', async () => { + const taskId = 'task-super'; + const specDir1 = path.join('/project', '.auto-claude', 'specs', 'super-first'); + const specDir2 = path.join('/project', '.auto-claude', 'specs', 'super-second'); + + // Make the first watcher's close() slow so we can interleave. + let resolveFirstClose!: () => void; + watchFactory = () => { + const w = new MockFSWatcher(() => new Promise((res) => { resolveFirstClose = res; })); + return w; + }; + + // Start first watch(). + const watch1Promise = fw.watch(taskId, specDir1); + + // Immediately start second watch() — before the first has resolved the + // slow close(). At this point specDir1 watch hasn't even created an + // FSWatcher yet (it's the very first call so there's no existing watcher + // to close), so watch1Promise may resolve synchronously up to watcher + // creation. Reset factory to normal for subsequent watcher creations. + watchFactory = null; + + const watch2Promise = fw.watch(taskId, specDir2); + + // Let any remaining microtasks run. + await Promise.resolve(); + if (resolveFirstClose) resolveFirstClose(); + + await Promise.all([watch1Promise, watch2Promise]); + + // The winning call (specDir2) should own the watcher. + expect(fw.getWatchedSpecDir(taskId)).toBe(specDir2); + }); + }); + + // ------------------------------------------------------------------------- + // 3. Cancellation — unwatch() during in-flight watch() + // ------------------------------------------------------------------------- + describe('cancellation: unwatch() during in-flight watch() prevents watcher creation', () => { + it('should not create a watcher when unwatch() is called before the async gap resolves', async () => { + const taskId = 'task-3'; + const specDir = '/project/.auto-claude/specs/003-cancel'; + + // There's no pre-existing watcher, so watch() won't call close(). But it + // does go async (chokidar.watch is sync but we can test the cancellation + // flag by calling unwatch() before watch() runs). + // The real async gap in watch() is the existing.watcher.close() call. + // For this test, let's pre-install a watcher so close() is called. + + // Install a slow-close watcher for taskId by manually populating the map. + // We can do that by running a first watch(), then replacing close(). + await fw.watch(taskId, specDir); + + // Replace the watcher's close() with a slow one. + const existingWatcher = createdWatchers[0]; + let resolveExistingClose!: () => void; + existingWatcher.close = vi.fn( + () => new Promise((res) => { resolveExistingClose = res; }) + ); + + // Start a second watch() — it will await the slow close(). + const specDir2 = '/project/.auto-claude/specs/003-cancel-v2'; + const watchPromise = fw.watch(taskId, specDir2); + + // While watch() is in-flight, call unwatch(). + await fw.unwatch(taskId); + + // Now resolve the slow close so watch() can continue past the await. + resolveExistingClose(); + await watchPromise; + + // No new watcher should have been registered. + expect(fw.isWatching(taskId)).toBe(false); + // Only one FSWatcher was ever created (the original one for specDir). + expect(createdWatchers).toHaveLength(1); + }); + }); + + // ------------------------------------------------------------------------- + // 4. unwatchAll() with pending watches + // ------------------------------------------------------------------------- + describe('unwatchAll() cancels all pending watches', () => { + it('should cancel pending watch() calls and clear pendingWatches', async () => { + const taskId1 = 'task-4a'; + const taskId2 = 'task-4b'; + const specDir1 = '/project/.auto-claude/specs/004a'; + const specDir2 = '/project/.auto-claude/specs/004b'; + + // Set up slow-close scenario for taskId1 (so watch() is in-flight). + await fw.watch(taskId1, specDir1); + const watcher1 = createdWatchers[0]; + let resolveClose1!: () => void; + watcher1.close = vi.fn( + () => new Promise((res) => { resolveClose1 = res; }) + ); + + // Start a new watch for taskId1 with a different specDir — this is now in-flight. + const newSpecDir1 = '/project/.auto-claude/specs/004a-v2'; + const watchPromise1 = fw.watch(taskId1, newSpecDir1); + + // Start a fresh watch for taskId2. + await fw.watch(taskId2, specDir2); + + // Call unwatchAll() while watchPromise1 is still pending. + const unwatchAllPromise = fw.unwatchAll(); + + // Resolve the slow close so everything can proceed. + resolveClose1(); + await Promise.all([watchPromise1, unwatchAllPromise]); + + // After unwatchAll, no watchers should be active. + expect(fw.isWatching(taskId1)).toBe(false); + expect(fw.isWatching(taskId2)).toBe(false); + + // pendingWatches should be cleared (we verify indirectly: a fresh + // watch() call for taskId1 must succeed without treating it as a duplicate). + const specDirFresh = path.join('/project', '.auto-claude', 'specs', '004a-fresh'); + await fw.watch(taskId1, specDirFresh); + expect(fw.isWatching(taskId1)).toBe(true); + expect(fw.getWatchedSpecDir(taskId1)).toBe(specDirFresh); + }); + }); + + // ------------------------------------------------------------------------- + // 5. getWatchedSpecDir() returns correct specDir + // ------------------------------------------------------------------------- + describe('getWatchedSpecDir()', () => { + it('returns the specDir that was passed to watch()', async () => { + const taskId = 'task-5'; + const specDir = path.join('/project', '.auto-claude', 'specs', '005-specdir'); + + await fw.watch(taskId, specDir); + + expect(fw.getWatchedSpecDir(taskId)).toBe(specDir); + }); + + it('returns null when the task is not being watched', () => { + expect(fw.getWatchedSpecDir('unknown-task')).toBeNull(); + }); + + it('returns updated specDir after re-watch with different specDir', async () => { + const taskId = 'task-5b'; + const specDir1 = path.join('/project', '.auto-claude', 'specs', '005b-first'); + const specDir2 = path.join('/project', '.auto-claude', 'specs', '005b-second'); + + await fw.watch(taskId, specDir1); + expect(fw.getWatchedSpecDir(taskId)).toBe(specDir1); + + await fw.watch(taskId, specDir2); + expect(fw.getWatchedSpecDir(taskId)).toBe(specDir2); + }); + }); +}); diff --git a/apps/frontend/src/main/file-watcher.ts b/apps/frontend/src/main/file-watcher.ts index e053518ea1..3246187c5e 100644 --- a/apps/frontend/src/main/file-watcher.ts +++ b/apps/frontend/src/main/file-watcher.ts @@ -15,64 +15,122 @@ interface WatcherInfo { */ export class FileWatcher extends EventEmitter { private watchers: Map = new Map(); + // Maps taskId -> specDir for the in-flight watch() call. + // Allows re-watch calls with a different specDir to proceed while + // still preventing duplicate calls for the exact same specDir. + private pendingWatches: Map = new Map(); + // Tracks taskIds that had unwatch() called while watch() was in-flight. + // Checked after each await point in watch() to avoid creating a leaked watcher. + private cancelledWatches: Set = new Set(); /** * Start watching a task's implementation plan */ async watch(taskId: string, specDir: string): Promise { - // Stop any existing watcher for this task - await this.unwatch(taskId); - - const planPath = path.join(specDir, 'implementation_plan.json'); - - // Check if plan file exists - if (!existsSync(planPath)) { - this.emit('error', taskId, `Plan file not found: ${planPath}`); + // Prevent overlapping watch() calls for the same taskId + specDir combination. + // Since watch() is async, rapid-fire callers could enter concurrently + // before the first call updates state, creating duplicate watchers. + // A call with a different specDir is a legitimate re-watch and is allowed through. + const pendingSpecDir = this.pendingWatches.get(taskId); + if (pendingSpecDir !== undefined && pendingSpecDir === specDir) { return; } + this.pendingWatches.set(taskId, specDir); + + try { + // Close any existing watcher for this task. + // Delete from the map BEFORE awaiting close so that a concurrent watch() + // call entering after the await cannot obtain the same FSWatcher reference + // and attempt a second close() on the same object. + const existing = this.watchers.get(taskId); + if (existing) { + this.watchers.delete(taskId); + await existing.watcher.close(); + } + + // Check if a newer watch() call has superseded this one while we were awaiting. + // If the pending specDir changed, another concurrent watch() took over — bail out + // to avoid overwriting the watcher it is about to create. + if (this.pendingWatches.get(taskId) !== specDir) { + return; + } - // Create watcher with settings to handle frequent writes - const watcher = chokidar.watch(planPath, { - persistent: true, - ignoreInitial: true, - awaitWriteFinish: { - stabilityThreshold: 300, - pollInterval: 100 + // Check if unwatch() was called while we were awaiting above. + if (this.cancelledWatches.has(taskId)) { + this.cancelledWatches.delete(taskId); + return; } - }); - // Store watcher info - this.watchers.set(taskId, { - taskId, - watcher, - planPath - }); + const planPath = path.join(specDir, 'implementation_plan.json'); - // Handle file changes - watcher.on('change', () => { + // Check if plan file exists + if (!existsSync(planPath)) { + this.emit('error', taskId, `Plan file not found: ${planPath}`); + return; + } + + // Create watcher with settings to handle frequent writes + const watcher = chokidar.watch(planPath, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 300, + pollInterval: 100 + } + }); + + // Check again after the synchronous watcher creation (no await, but defensive). + if (this.cancelledWatches.has(taskId)) { + this.cancelledWatches.delete(taskId); + await watcher.close(); + return; + } + + // Store watcher info + this.watchers.set(taskId, { + taskId, + watcher, + planPath + }); + + // Handle file changes + watcher.on('change', () => { + try { + const content = readFileSync(planPath, 'utf-8'); + const plan: ImplementationPlan = JSON.parse(content); + this.emit('progress', taskId, plan); + } catch { + // File might be in the middle of being written + // Ignore parse errors, next change event will have complete file + } + }); + + // Handle errors + watcher.on('error', (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + this.emit('error', taskId, message); + }); + + // Read and emit initial state try { const content = readFileSync(planPath, 'utf-8'); const plan: ImplementationPlan = JSON.parse(content); this.emit('progress', taskId, plan); } catch { - // File might be in the middle of being written - // Ignore parse errors, next change event will have complete file + // Initial read failed - not critical + } + } finally { + // Only clean up if this call still owns the entry. If a superseding + // concurrent watch() call has already updated pendingWatches with a + // different specDir, leave that entry intact so the superseding call + // can proceed correctly. + if (this.pendingWatches.get(taskId) === specDir) { + this.pendingWatches.delete(taskId); + // The delete above guarantees has() is now false, so there is no + // longer any in-flight watch() for this taskId. Clear the + // cancellation flag so it doesn't linger for future watch() calls. + this.cancelledWatches.delete(taskId); } - }); - - // Handle errors - watcher.on('error', (error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - this.emit('error', taskId, message); - }); - - // Read and emit initial state - try { - const content = readFileSync(planPath, 'utf-8'); - const plan: ImplementationPlan = JSON.parse(content); - this.emit('progress', taskId, plan); - } catch { - // Initial read failed - not critical } } @@ -80,6 +138,13 @@ export class FileWatcher extends EventEmitter { * Stop watching a task */ async unwatch(taskId: string): Promise { + // If watch() is currently in-flight for this taskId, it is already closing the + // existing watcher. Just set the cancellation flag and return to avoid a + // double-close of the same FSWatcher. + if (this.pendingWatches.has(taskId)) { + this.cancelledWatches.add(taskId); + return; + } const watcherInfo = this.watchers.get(taskId); if (watcherInfo) { await watcherInfo.watcher.close(); @@ -91,6 +156,17 @@ export class FileWatcher extends EventEmitter { * Stop all watchers */ async unwatchAll(): Promise { + // Cancel any in-flight watch() calls so they don't create new watchers + // after this cleanup completes. + for (const taskId of this.pendingWatches.keys()) { + this.cancelledWatches.add(taskId); + } + this.pendingWatches.clear(); + // Clear cancellation flags now that pendingWatches is empty: the in-flight + // calls will bail via the supersession check (pendingWatches.get() returns + // undefined) and will not clean up cancelledWatches themselves. Clearing + // here ensures the instance is fully reset for subsequent use. + this.cancelledWatches.clear(); const closePromises = Array.from(this.watchers.values()).map( async (info) => { await info.watcher.close(); @@ -107,6 +183,15 @@ export class FileWatcher extends EventEmitter { return this.watchers.has(taskId); } + /** + * Get the spec directory currently being watched for a task + */ + getWatchedSpecDir(taskId: string): string | null { + const watcherInfo = this.watchers.get(taskId); + if (!watcherInfo) return null; + return path.dirname(watcherInfo.planPath); + } + /** * Get current plan state for a task */ diff --git a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts index c0625596cd..6e36c81f93 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -98,7 +98,23 @@ export function registerAgenteventsHandlers( // Send final plan state to renderer BEFORE unwatching // This ensures the renderer has the final subtask data (fixes 0/0 subtask bug) - const finalPlan = fileWatcher.getCurrentPlan(taskId); + // Try the file watcher's current path first, then fall back to worktree path + let finalPlan = fileWatcher.getCurrentPlan(taskId); + if (!finalPlan && exitTask && exitProject) { + // File watcher may have been watching the wrong path (main vs worktree) + // Try reading directly from the worktree + const worktreePath = findTaskWorktree(exitProject.path, exitTask.specId); + if (worktreePath) { + const specsBaseDir = getSpecsDir(exitProject.autoBuildPath); + const worktreePlanPath = path.join(worktreePath, specsBaseDir, exitTask.specId, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + try { + const content = readFileSync(worktreePlanPath, 'utf-8'); + finalPlan = JSON.parse(content); + } catch { + // Worktree plan file not readable - not critical + } + } + } if (finalPlan) { safeSendToRenderer( getMainWindow, @@ -109,7 +125,9 @@ export function registerAgenteventsHandlers( ); } - fileWatcher.unwatch(taskId); + fileWatcher.unwatch(taskId).catch((err) => { + console.error(`[agent-events-handlers] Failed to unwatch for ${taskId}:`, err); + }); if (processType === "spec-creation") { console.warn(`[Task ${taskId}] Spec creation completed with code ${code}`); @@ -211,15 +229,26 @@ export function registerAgenteventsHandlers( const worktreePath = findTaskWorktree(project.path, task.specId); if (worktreePath) { const specsBaseDir = getSpecsDir(project.autoBuildPath); + const worktreeSpecDir = path.join(worktreePath, specsBaseDir, task.specId); const worktreePlanPath = path.join( - worktreePath, - specsBaseDir, - task.specId, + worktreeSpecDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN ); if (existsSync(worktreePlanPath)) { persistPlanPhaseSync(worktreePlanPath, progress.phase, project.id); } + + // Re-watch the worktree path if the file watcher is still watching the main project path. + // This handles the case where the task started before the worktree existed: + // the initial watch fell back to the main project spec dir, but now the worktree + // is available and implementation_plan.json is being written there. + const currentWatchDir = fileWatcher.getWatchedSpecDir(taskId); + if (currentWatchDir && currentWatchDir !== worktreeSpecDir && existsSync(worktreePlanPath)) { + console.warn(`[agent-events-handlers] Re-watching worktree path for ${taskId}: ${worktreeSpecDir}`); + fileWatcher.watch(taskId, worktreeSpecDir).catch((err) => { + console.error(`[agent-events-handlers] Failed to re-watch worktree for ${taskId}:`, err); + }); + } } } else if (xstateInTerminalState && progress.phase) { console.debug(`[agent-events-handlers] Skipping persistPlanPhaseSync for ${taskId}: XState in '${currentXState}', not overwriting with phase '${progress.phase}'`); diff --git a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts index 95bcb9d8e6..4a51eaf7a4 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -81,6 +81,22 @@ async function ensureProfileManagerInitialized(): Promise< } } +/** + * Get the spec directory for file watching, preferring the worktree path if it exists. + * When a task runs in a worktree, implementation_plan.json is written there, + * not in the main project's spec directory. + */ +function getSpecDirForWatcher(projectPath: string, specsBaseDir: string, specId: string): string { + const worktreePath = findTaskWorktree(projectPath, specId); + if (worktreePath) { + const worktreeSpecDir = path.join(worktreePath, specsBaseDir, specId); + if (existsSync(path.join(worktreeSpecDir, 'implementation_plan.json'))) { + return worktreeSpecDir; + } + } + return path.join(projectPath, specsBaseDir, specId); +} + /** * Register task execution handlers (start, stop, review, status management, recovery) */ @@ -205,15 +221,16 @@ export function registerTaskExecutionHandlers( } // Start file watcher for this task + // Use worktree path if it exists, since the backend writes implementation_plan.json there const specsBaseDir = getSpecsDir(project.autoBuildPath); - const specDir = path.join( - project.path, - specsBaseDir, - task.specId - ); - fileWatcher.watch(taskId, specDir); + const watchSpecDir = getSpecDirForWatcher(project.path, specsBaseDir, task.specId); + fileWatcher.watch(taskId, watchSpecDir).catch((err) => { + console.error(`[TASK_START] Failed to watch spec dir for ${taskId}:`, err); + }); // Check if spec.md exists (indicates spec creation was already done or in progress) + // Check main project path for spec file (spec is created before worktree) + const specDir = path.join(project.path, specsBaseDir, task.specId); const specFilePath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); const hasSpec = existsSync(specFilePath); @@ -289,7 +306,9 @@ export function registerTaskExecutionHandlers( */ ipcMain.on(IPC_CHANNELS.TASK_STOP, (_, taskId: string) => { agentManager.killTask(taskId); - fileWatcher.unwatch(taskId); + fileWatcher.unwatch(taskId).catch((err) => { + console.error('[TASK_STOP] Failed to unwatch:', err); + }); // Find task and project to emit USER_STOPPED with plan context const { task, project } = findTaskAndProject(taskId); @@ -710,7 +729,11 @@ export function registerTaskExecutionHandlers( } // Start file watcher for this task - fileWatcher.watch(taskId, specDir); + // Use worktree path if it exists, since the backend writes implementation_plan.json there + const watchSpecDir = getSpecDirForWatcher(project.path, specsBaseDir, task.specId); + fileWatcher.watch(taskId, watchSpecDir).catch((err) => { + console.error(`[TASK_UPDATE_STATUS] Failed to watch spec dir for ${taskId}:`, err); + }); // Check if spec.md exists const specFilePath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); @@ -1065,7 +1088,9 @@ export function registerTaskExecutionHandlers( } // Stop file watcher if it was watching this task - fileWatcher.unwatch(taskId); + fileWatcher.unwatch(taskId).catch((err) => { + console.error('[TASK_RECOVER_STUCK] Failed to unwatch:', err); + }); // Auto-restart the task if requested let autoRestarted = false; @@ -1146,12 +1171,16 @@ export function registerTaskExecutionHandlers( // Start the task execution // Start file watcher for this task - const specsBaseDir = getSpecsDir(project.autoBuildPath); - const specDirForWatcher = path.join(project.path, specsBaseDir, task.specId); - fileWatcher.watch(taskId, specDirForWatcher); + // Use worktree path if it exists, since the backend writes implementation_plan.json there + const watchSpecDir = getSpecDirForWatcher(project.path, specsBaseDir, task.specId); + fileWatcher.watch(taskId, watchSpecDir).catch((err) => { + console.error(`[Recovery] Failed to watch spec dir for ${taskId}:`, err); + }); // Check if spec.md exists to determine whether to run spec creation or task execution - const specFilePath = path.join(specDirForWatcher, AUTO_BUILD_PATHS.SPEC_FILE); + // Check main project path for spec file (spec is created before worktree) + // mainSpecDir is declared earlier in the handler scope + const specFilePath = path.join(mainSpecDir, AUTO_BUILD_PATHS.SPEC_FILE); const hasSpec = existsSync(specFilePath); const needsSpecCreation = !hasSpec; @@ -1162,7 +1191,7 @@ export function registerTaskExecutionHandlers( // No spec file - need to run spec_runner.py to create the spec const taskDescription = task.description || task.title; console.warn(`[Recovery] Starting spec creation for: ${task.specId}`); - agentManager.startSpecCreation(taskId, project.path, taskDescription, specDirForWatcher, task.metadata, baseBranchForRecovery, project.id); + agentManager.startSpecCreation(taskId, project.path, taskDescription, mainSpecDir, task.metadata, baseBranchForRecovery, project.id); } else { // Spec exists - run task execution console.warn(`[Recovery] Starting task execution for: ${task.specId}`);