diff --git a/src/server/runtime/ServerService.ts b/src/server/runtime/ServerService.ts index a81b24888..300d330cd 100644 --- a/src/server/runtime/ServerService.ts +++ b/src/server/runtime/ServerService.ts @@ -858,10 +858,17 @@ function getServerPort(): number { function spawnServerDaemon(port: number): number | undefined { const scriptPath = typeof __filename !== 'undefined' ? __filename : fileURLToPath(import.meta.url); + // A cwd that does not exist makes spawn fail with ENOENT, and paths.ts resolves + // DATA_DIR without creating it — so create it rather than depend on some earlier + // caller having done so. Idempotent. + mkdirSync(paths.dataDir(), { recursive: true }); const child = spawn(process.execPath, [scriptPath, '--daemon'], { detached: true, stdio: 'ignore', windowsHide: true, + // Never the caller's directory: a daemon holds its cwd open for its whole life, and + // on Windows that locks the folder against rename or move (#3706). + cwd: paths.dataDir(), // Strip host CLI bleed-through (CLAUDE_CODE_*, including EFFORT_LEVEL) and // Anthropic credentials before handing env to the detached daemon. The // daemon re-reads credentials from ~/.claude-mem/.env at SDK spawn time. diff --git a/src/services/infrastructure/ProcessManager.ts b/src/services/infrastructure/ProcessManager.ts index 8d2d20192..ebf75e4fb 100644 --- a/src/services/infrastructure/ProcessManager.ts +++ b/src/services/infrastructure/ProcessManager.ts @@ -357,7 +357,33 @@ function executeCwdRemap(dbPath: string, effectiveDataDir: string, markerPath: s } } -export function buildWindowsDaemonStartCommand(runtimePath: string, scriptPath: string): string { +/** + * Where a detached daemon should stand, which is anywhere but the user's project. + * + * A process holds an open handle on its working directory. On Windows that makes the + * directory unrenamable and unmovable for the daemon's whole lifetime, and the daemon + * outlives the session that spawned it -- so a project folder became permanently locked + * with "The process cannot access the file because it is being used by another process" + * until the user found and killed bun.exe (#3706). POSIX allows the rename but still + * pins the directory against unmount. claude-mem's own data directory always exists by + * the time a daemon starts and is never a directory the user is reorganising. + */ +export function daemonWorkingDirectory(): string { + const dir = paths.dataDir(); + // Created here rather than assumed: a cwd that does not exist makes spawn fail with + // ENOENT and Start-Process fail outright, so passing one turns a first run on a fresh + // install into a launch failure. paths.ts resolves DATA_DIR but does not create it -- + // today something else happens to create it first, which is a coupling this must not + // depend on. mkdir -p is idempotent, so the usual case costs one stat. + mkdirSync(dir, { recursive: true }); + return dir; +} + +export function buildWindowsDaemonStartCommand( + runtimePath: string, + scriptPath: string, + workingDirectory: string = daemonWorkingDirectory() +): string { const psSingleQuote = (value: string) => value.replace(/'/g, "''"); // Windows PowerShell 5.1 joins -ArgumentList elements with spaces WITHOUT // quoting them when it builds the child's native command line, so a script @@ -366,7 +392,7 @@ export function buildWindowsDaemonStartCommand(runtimePath: string, scriptPath: // double quotes inside the single-quoted PS string keeps the path a single // argument. -FilePath is safe as-is: it is a single-string parameter and // never goes through that join. - return `Start-Process -FilePath '${psSingleQuote(runtimePath)}' -ArgumentList @('"${psSingleQuote(scriptPath)}"','--daemon') -WindowStyle Hidden`; + return `Start-Process -FilePath '${psSingleQuote(runtimePath)}' -ArgumentList @('"${psSingleQuote(scriptPath)}"','--daemon') -WorkingDirectory '${psSingleQuote(workingDirectory)}' -WindowStyle Hidden`; } export const WORKER_BOOT_PROBE_TIMEOUT_MS = 5000; @@ -520,6 +546,7 @@ export function spawnDaemon( const child = spawnHidden(execPath, args, { detached: true, stdio: 'ignore', + cwd: daemonWorkingDirectory(), env }); diff --git a/src/shared/worker-utils.ts b/src/shared/worker-utils.ts index 28e3d52a0..414e5892e 100644 --- a/src/shared/worker-utils.ts +++ b/src/shared/worker-utils.ts @@ -687,9 +687,16 @@ export async function ensureWorkerRunning(): Promise { logger.info('SYSTEM', 'Worker not running — lazy-spawning', { runtimePath, scriptPath }); try { + // A cwd that does not exist makes spawn fail with ENOENT, and paths.ts resolves + // DATA_DIR without creating it. Idempotent, so the usual case costs one stat. + mkdirSync(DATA_DIR, { recursive: true }); const proc = spawnHidden(runtimePath, [scriptPath, '--daemon'], { detached: true, stdio: ['ignore', 'ignore', 'ignore'], + // This spawn runs from a hook, so the inherited cwd is the user's project. A + // daemon holds its cwd open for its whole life, and on Windows that locks the + // folder against rename or move long after the session ends (#3706). + cwd: DATA_DIR, }); proc.unref(); } catch (error: unknown) { diff --git a/tests/infrastructure/process-manager.test.ts b/tests/infrastructure/process-manager.test.ts index fedc8ac8b..e368cbba8 100644 --- a/tests/infrastructure/process-manager.test.ts +++ b/tests/infrastructure/process-manager.test.ts @@ -29,6 +29,7 @@ const { probeWorkerBootFailure, shouldRetryWorkerBootProbe, buildWindowsDaemonStartCommand, + daemonWorkingDirectory, resolveWorkerRuntimePath, captureProcessStartToken, verifyPidFileOwnership, @@ -688,10 +689,10 @@ describe('ProcessManager', () => { const runtimePath = String.raw`C:\Users\Test User\.bun\bin\bun.exe`; const scriptPath = String.raw`C:\Users\Test User\.claude\plugins\marketplaces\thedotmack\plugin\scripts\worker-service.cjs`; - const command = buildWindowsDaemonStartCommand(runtimePath, scriptPath); + const command = buildWindowsDaemonStartCommand(runtimePath, scriptPath, String.raw`C:\daemon-home`); expect(command).toBe( - `Start-Process -FilePath '${runtimePath}' -ArgumentList @('"${scriptPath}"','--daemon') -WindowStyle Hidden` + `Start-Process -FilePath '${runtimePath}' -ArgumentList @('"${scriptPath}"','--daemon') -WorkingDirectory 'C:\\daemon-home' -WindowStyle Hidden` ); }); @@ -711,7 +712,7 @@ describe('ProcessManager', () => { ); expect(command).toBe( - `Start-Process -FilePath 'C:\\Users\\O''Brien\\.bun\\bin\\bun.exe' -ArgumentList @('"C:\\Users\\O''Brien\\plugin\\scripts\\worker-service.cjs"','--daemon') -WindowStyle Hidden` + `Start-Process -FilePath 'C:\\Users\\O''Brien\\.bun\\bin\\bun.exe' -ArgumentList @('"C:\\Users\\O''Brien\\plugin\\scripts\\worker-service.cjs"','--daemon') -WorkingDirectory '${DATA_DIR.replace(/'/g, "''")}' -WindowStyle Hidden` ); }); }); @@ -803,6 +804,51 @@ describe('ProcessManager', () => { }); }); + // A process holds an open handle on its working directory. On Windows that locks the + // directory against rename and move for as long as the process lives, and a daemon + // outlives the session that spawned it -- so a hook-spawned daemon inheriting the + // project folder left it permanently locked (#3706). + describe('daemon working directory (#3706)', () => { + it('pins the daemon to a directory the user is not working in', () => { + const command = buildWindowsDaemonStartCommand( + String.raw`C:\bun\bun.exe`, + String.raw`C:\plugin\worker-service.cjs` + ); + + expect(command).toContain('-WorkingDirectory'); + expect(command).toContain(`-WorkingDirectory '${DATA_DIR.replace(/'/g, "''")}'`); + }); + + it('escapes a single quote in the working directory', () => { + const command = buildWindowsDaemonStartCommand( + String.raw`C:\bun\bun.exe`, + String.raw`C:\plugin\worker-service.cjs`, + String.raw`C:\Users\O'Brien\.claude-mem` + ); + + expect(command).toContain(String.raw`-WorkingDirectory 'C:\Users\O''Brien\.claude-mem'`); + }); + + it('defaults to the claude-mem data directory, never the caller cwd', () => { + expect(daemonWorkingDirectory()).toBe(DATA_DIR); + expect(daemonWorkingDirectory()).not.toBe(process.cwd()); + }); + + // Passing a cwd that does not exist is worse than passing none: spawn fails with + // ENOENT and Start-Process refuses outright, so this fix would turn a first run on + // a fresh install into a launch failure. paths.ts resolves DATA_DIR but never + // creates it — today some earlier caller happens to, which is not a guarantee. + it('creates the directory it hands out, so a fresh install can spawn', () => { + rmSync(DATA_DIR, { recursive: true, force: true }); + expect(existsSync(DATA_DIR)).toBe(false); + + const dir = daemonWorkingDirectory(); + + expect(existsSync(dir)).toBe(true); + expect(statSync(dir).isDirectory()).toBe(true); + }); + }); + describe('SIGHUP handling', () => { it('should have SIGHUP listeners registered (integration check)', () => { if (process.platform === 'win32') return;