Skip to content

feat: Workspace filesystem cleanup#391

Merged
cameroncooke merged 17 commits intomainfrom
cam/weather-example-workspace-cleanup
May 5, 2026
Merged

feat: Workspace filesystem cleanup#391
cameroncooke merged 17 commits intomainfrom
cam/weather-example-workspace-cleanup

Conversation

@cameroncooke
Copy link
Copy Markdown
Collaborator

@cameroncooke cameroncooke commented May 4, 2026

Centralize workspace-scoped filesystem lifecycle management for XcodeBuildMCP-owned runtime artifacts.

XcodeBuildMCP can have multiple MCP servers, daemons, CLI invocations, tests, and simulator helper processes active for the same or different workspaces. This change moves log retention, daemon file cleanup, and simulator OSLog helper tracking behind workspace-keyed paths, shared locks, and ownership checks so one process does not delete another live process's artifacts.

Cleanup now protects active daemon and simulator OSLog outputs, reconciles stale workspace-owned helpers during startup and shutdown, and prunes old XcodeBuildMCP-managed logs through a consistent retention path. The branch also updates the rendered snapshot fixtures to reflect the new workspace-scoped paths in CLI, MCP, and JSON output.

Reviewers should pay particular attention to the multi-process cleanup boundaries, ownership checks, and snapshot fixture churn because these are the areas most likely to hide regressions in concurrent server/daemon usage.

Fixes #385

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 4, 2026

Open in StackBlitz

npm i https://pkg.pr.new/xcodebuildmcp@391

commit: 9631f13

Comment thread src/daemon/socket-path.ts
Comment thread src/daemon/daemon-registry.ts
Comment thread src/utils/process-liveness.ts
Comment thread src/daemon.ts Outdated
Comment thread src/snapshot-tests/__fixtures__/json/simulator/test--failure.json Outdated
Comment thread src/daemon.ts
@cameroncooke cameroncooke marked this pull request as ready for review May 4, 2026 15:20
Centralize workspace-scoped filesystem cleanup so log retention, daemon files, and simulator OSLog helpers are managed through multi-process-safe paths and locks. This keeps active workspace artifacts protected while pruning stale
XcodeBuildMCP-owned files consistently.
@cameroncooke cameroncooke force-pushed the cam/weather-example-workspace-cleanup branch from 18fc675 to 87c486d Compare May 4, 2026 15:22
Comment thread src/utils/process-liveness.ts
@cameroncooke cameroncooke changed the title feat: Add Weather example and workspace cleanup feat: Workspace filesystem cleanup May 4, 2026
Comment thread src/cli/daemon-control.ts Outdated
Comment thread src/server/mcp-shutdown.ts Outdated
The fallback path in forceStopDaemon was broken after socket paths moved to tmpdir. The old logic tried to derive workspace key from basename(dirname(socketPath)), which now returns the socket directory name (e.g. xcodebuildmcp-0dcf2d98505d) instead of the actual workspace key (e.g. XcodeBuildMCP-0dcf2d98505d).

When findDaemonRegistryEntryBySocketPath returns null, we can no longer reconstruct the workspace key from the socket path alone. Instead, when the registry entry is missing, directly clean up the socket file to ensure force-stop doesn't silently fail.
Comment thread src/utils/workspace-filesystem-lifecycle.ts
Comment thread src/daemon/daemon-registry.ts Outdated
Comment thread src/daemon/daemon-registry.ts
- Remove unused exported function removeDaemonRegistryEntry which had no callers
- Fix canRemoveRegistryEntry to require pid when allowLiveOwner is true
- Prevents bypass of ownership check when allowLiveOwner: true without pid
- Ensures multi-process safety invariant for concurrent daemon operations
Comment thread src/cli/daemon-control.ts Outdated
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Force-stop skips cleanup when daemon is slow to die
    • Added allowLiveOwner flag to forceStopDaemon cleanup call to ensure socket removal even if daemon process is still alive after SIGTERM.
  • ✅ Fixed: Unused exported function after import removal
    • Removed unused getWorkspaceKey export from socket-path.ts as it is no longer imported anywhere in the codebase.
Preview (bfda741235)
diff --git a/AGENTS.md b/AGENTS.md
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,6 +80,15 @@
 - When working on skill sources in `skills/`, use the `skill-creator` skill workflow.
 - After modifying any skill source, run `npx skill-check <skill-directory>` and address all errors/warnings before handoff.
 -
+## Multi-process filesystem state
+- XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces.
+- Shared filesystem state under `~/Library/Developer/XcodeBuildMCP` must be multi-process safe.
+- Use workspace-key scoped directories for workspace-owned state.
+- Do not store runtime state under `~/.xcodebuildmcp`; `.xcodebuildmcp/config.yaml` is only project configuration.
+- Use shared lock and atomic-write helpers for mutable shared files.
+- Prefer one-record-per-file registries over shared aggregate files.
+- Cleanup must verify ownership before deleting shared artifacts.
+
 ## Style
 - Keep answers short and concise
 - No emojis in commits, issues, PR comments, or code

diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,10 @@
 - Fixed simulator OSLog helper cleanup so server and daemon startup reconcile same-workspace orphaned log streams without stopping helpers owned by live sessions in other workspaces ([#382](https://github.com/getsentry/XcodeBuildMCP/issues/382)).
 - Fixed Weather example test discovery and made CLI test progress visible while tests are running instead of leaving the last build phase displayed.
 
+### Changed
+
+- Centralized workspace log retention and startup/shutdown filesystem cleanup so XcodeBuildMCP-owned logs are pruned consistently while preserving active daemon and simulator OSLog outputs.
+
 ## [2.5.0-beta.1]
 
 ### Breaking

diff --git a/CLAUDE.md b/CLAUDE.md
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,6 +21,15 @@
 - When working on skill sources in `skills/`, use the `skill-creator` skill workflow.
 - After modifying any skill source, run `npx skill-check <skill-directory>` and address all errors/warnings before handoff.
 -
+## Multi-process filesystem state
+- XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces.
+- Shared filesystem state under `~/Library/Developer/XcodeBuildMCP` must be multi-process safe.
+- Use workspace-key scoped directories for workspace-owned state.
+- Do not store runtime state under `~/.xcodebuildmcp`; `.xcodebuildmcp/config.yaml` is only project configuration.
+- Use shared lock and atomic-write helpers for mutable shared files.
+- Prefer one-record-per-file registries over shared aggregate files.
+- Cleanup must verify ownership before deleting shared artifacts.
+
 ## Style
 - Keep answers short and concise
 - No emojis in commits, issues, PR comments, or code

diff --git a/src/cli.ts b/src/cli.ts
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -2,13 +2,12 @@
 import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts';
 import { buildCliToolCatalog } from './cli/cli-tool-catalog.ts';
 import { buildYargsApp } from './cli/yargs-app.ts';
-import { getSocketPath, getWorkspaceKey, resolveWorkspaceRoot } from './daemon/socket-path.ts';
+import { getSocketPath } from './daemon/socket-path.ts';
 import { startMcpServer } from './server/start-mcp-server.ts';
 import { listCliWorkflowIdsFromManifest } from './runtime/tool-catalog.ts';
 import { flushAndCloseSentry, initSentry, recordBootstrapDurationMetric } from './utils/sentry.ts';
 import { coerceLogLevel, setLogLevel, type LogLevel } from './utils/logger.ts';
 import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts';
-import { configureRuntimeWorkspaceKey } from './utils/runtime-instance.ts';
 
 function findTopLevelCommand(argv: string[]): string | undefined {
   const flagsWithValue = new Set(['--socket', '--log-level', '--style']);
@@ -119,23 +118,13 @@
     },
   });
 
-  // Compute workspace context for daemon routing
-  const workspaceRoot = resolveWorkspaceRoot({
-    cwd: result.runtime.cwd,
-    projectConfigPath: result.configPath,
-  });
+  const { workspaceRoot, workspaceKey } = result;
 
   const defaultSocketPath = getSocketPath({
     cwd: result.runtime.cwd,
     projectConfigPath: result.configPath,
   });
 
-  const workspaceKey = getWorkspaceKey({
-    cwd: result.runtime.cwd,
-    projectConfigPath: result.configPath,
-  });
-  configureRuntimeWorkspaceKey(workspaceKey);
-
   const cliExposedWorkflowIds = await listCliWorkflowIdsFromManifest({
     excludeWorkflows: ['session-management', 'workflow-discovery'],
   });

diff --git a/src/cli/daemon-control.ts b/src/cli/daemon-control.ts
--- a/src/cli/daemon-control.ts
+++ b/src/cli/daemon-control.ts
@@ -1,10 +1,12 @@
 import { spawn } from 'node:child_process';
 import { fileURLToPath } from 'node:url';
-import { dirname, resolve, basename } from 'node:path';
-import { existsSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { existsSync, unlinkSync } from 'node:fs';
 import { DaemonClient, DaemonVersionMismatchError } from './daemon-client.ts';
-import { readDaemonRegistryEntry } from '../daemon/daemon-registry.ts';
-import { removeStaleSocket } from '../daemon/socket-path.ts';
+import {
+  cleanupWorkspaceDaemonFiles,
+  findDaemonRegistryEntryBySocketPath,
+} from '../daemon/daemon-registry.ts';
 
 /**
  * Default timeout for daemon startup in milliseconds.
@@ -38,8 +40,7 @@
  * sends SIGTERM, and removes the stale socket.
  */
 export async function forceStopDaemon(socketPath: string): Promise<void> {
-  const workspaceKey = basename(dirname(socketPath));
-  const entry = readDaemonRegistryEntry(workspaceKey);
+  const entry = findDaemonRegistryEntryBySocketPath(socketPath);
   if (entry?.pid) {
     try {
       process.kill(entry.pid, 'SIGTERM');
@@ -49,7 +50,21 @@
     // Brief wait for the process to exit.
     await new Promise((resolve) => setTimeout(resolve, 500));
   }
-  removeStaleSocket(socketPath);
+  if (entry) {
+    cleanupWorkspaceDaemonFiles(entry.workspaceKey, {
+      pid: entry.pid,
+      socketPath,
+      allowLiveOwner: true,
+    });
+  } else {
+    // Registry entry missing; cannot derive workspace key from socket path alone.
+    // Clean up the socket file directly.
+    try {
+      unlinkSync(socketPath);
+    } catch {
+      // Socket may already be gone.
+    }
+  }
 }
 
 export interface StartDaemonBackgroundOptions {

diff --git a/src/daemon.ts b/src/daemon.ts
--- a/src/daemon.ts
+++ b/src/daemon.ts
@@ -9,15 +9,13 @@
   ensureSocketDir,
   removeStaleSocket,
   getSocketPath,
-  getWorkspaceKey,
-  resolveWorkspaceRoot,
   logPathForWorkspaceKey,
 } from './daemon/socket-path.ts';
 import { startDaemonServer } from './daemon/daemon-server.ts';
 import {
+  acquireDaemonRegistryMutationLock,
   writeDaemonRegistryEntry,
-  removeDaemonRegistryEntry,
-  cleanupWorkspaceDaemonFiles,
+  type DaemonRegistryMutationLock,
 } from './daemon/daemon-registry.ts';
 import { log, normalizeLogLevel, setLogFile, setLogLevel } from './utils/logger.ts';
 import { version } from './version.ts';
@@ -42,11 +40,11 @@
 } from './utils/sentry.ts';
 import { isXcodemakeBinaryAvailable, isXcodemakeEnabled } from './utils/xcodemake/index.ts';
 import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts';
-import { configureRuntimeWorkspaceKey } from './utils/runtime-instance.ts';
 import {
-  reconcileSimulatorLaunchOsLogOrphansForWorkspace,
-  terminateLiveSimulatorLaunchOsLogSessionsSync,
-} from './utils/log-capture/index.ts';
+  cleanupOwnedWorkspaceFilesystemArtifacts,
+  runWorkspaceFilesystemLifecycleSweep,
+  terminateOwnedWorkspaceFilesystemArtifactsSync,
+} from './utils/workspace-filesystem-lifecycle.ts';
 
 async function checkExistingDaemon(socketPath: string): Promise<boolean> {
   return new Promise<boolean>((resolve) => {
@@ -124,17 +122,8 @@
     },
   });
 
-  const workspaceRoot = resolveWorkspaceRoot({
-    cwd: result.runtime.cwd,
-    projectConfigPath: result.configPath,
-  });
+  const { workspaceRoot, workspaceKey } = result;
 
-  const workspaceKey = getWorkspaceKey({
-    cwd: result.runtime.cwd,
-    projectConfigPath: result.configPath,
-  });
-  configureRuntimeWorkspaceKey(workspaceKey);
-
   const logPath = resolveDaemonLogPath(workspaceKey);
   if (logPath) {
     ensureLogDir(logPath);
@@ -159,20 +148,27 @@
 
   log('info', `[Daemon] Workspace: ${workspaceRoot}`);
   log('info', `[Daemon] Socket: ${socketPath}`);
-  try {
-    const reconciliation = await reconcileSimulatorLaunchOsLogOrphansForWorkspace(workspaceKey);
-    if (reconciliation.stoppedSessionCount > 0 || reconciliation.errorCount > 0) {
+
+  const runStartupLifecycleSweep = async (): Promise<void> => {
+    try {
+      const lifecycle = await runWorkspaceFilesystemLifecycleSweep({
+        workspaceKey,
+        trigger: 'startup',
+      });
+      if (lifecycle.stopped > 0 || lifecycle.deleted > 0 || lifecycle.errors.length > 0) {
+        log(
+          lifecycle.errors.length > 0 ? 'warn' : 'info',
+          `[Daemon] Filesystem lifecycle: ${JSON.stringify(lifecycle)}`,
+        );
+      }
+    } catch (error) {
       log(
-        reconciliation.errorCount > 0 ? 'warn' : 'info',
-        `[Daemon] Simulator OSLog reconciliation: ${JSON.stringify(reconciliation)}`,
+        'warn',
+        `[Daemon] Filesystem lifecycle failed: ${error instanceof Error ? error.message : String(error)}`,
       );
     }
-  } catch (error) {
-    log(
-      'warn',
-      `[Daemon] Simulator OSLog reconciliation failed: ${error instanceof Error ? error.message : String(error)}`,
-    );
-  }
+  };
+
   if (logPath) {
     log('info', `[Daemon] Logs: ${logPath}`);
   }
@@ -187,6 +183,28 @@
     process.exit(1);
   }
 
+  const startupRegistryLock = acquireDaemonRegistryMutationLock(workspaceKey);
+  if (!startupRegistryLock) {
+    log('error', '[Daemon] Unable to acquire daemon registry lock');
+    console.error('Error: Unable to acquire daemon registry lock');
+    await flushAndCloseSentry(1000);
+    process.exit(1);
+  }
+  let pendingStartupRegistryLock: DaemonRegistryMutationLock | null = startupRegistryLock;
+  const releaseStartupRegistryLock = (): void => {
+    pendingStartupRegistryLock?.release();
+    pendingStartupRegistryLock = null;
+  };
+
+  const isRunningAfterLock = await checkExistingDaemon(socketPath);
+  if (isRunningAfterLock) {
+    releaseStartupRegistryLock();
+    log('error', '[Daemon] Another daemon is already running for this workspace');
+    console.error('Error: Daemon is already running for this workspace');
+    await flushAndCloseSentry(1000);
+    process.exit(1);
+  }
+
   removeStaleSocket(socketPath);
 
   const excludedWorkflows = ['session-management', 'workflow-discovery'];
@@ -302,26 +320,33 @@
     recordDaemonLifecycleMetric('shutdown');
     log('info', '[Daemon] Shutting down...');
 
-    // Close the server
+    const cleanupArtifacts = (): Promise<unknown> =>
+      cleanupOwnedWorkspaceFilesystemArtifacts({
+        workspaceKey,
+        trigger: 'shutdown',
+        daemonCleanup: {
+          pid: process.pid,
+          socketPath,
+          allowLiveOwner: true,
+        },
+      });
+
     server.close(() => {
       log('info', '[Daemon] Server closed');
-
-      // Remove registry entry and socket
-      removeDaemonRegistryEntry(workspaceKey);
-      removeStaleSocket(socketPath);
-
-      log('info', '[Daemon] Cleanup complete');
-      void flushAndCloseSentry(2000).finally(() => {
-        process.exit(exitCode);
+      void cleanupArtifacts().finally(() => {
+        log('info', '[Daemon] Cleanup complete');
+        void flushAndCloseSentry(2000).finally(() => {
+          process.exit(exitCode);
+        });
       });
     });
 
-    // Force exit if server doesn't close in time
     setTimeout(() => {
       log('warn', '[Daemon] Forced shutdown after timeout');
-      cleanupWorkspaceDaemonFiles(workspaceKey);
-      void flushAndCloseSentry(1000).finally(() => {
-        process.exit(1);
+      void cleanupArtifacts().finally(() => {
+        void flushAndCloseSentry(1000).finally(() => {
+          process.exit(1);
+        });
       });
     }, 5000);
   };
@@ -384,20 +409,29 @@
     idleCheckTimer.unref?.();
   }
 
+  server.on('error', releaseStartupRegistryLock);
+
   server.listen(socketPath, () => {
     log('info', `[Daemon] Listening on ${socketPath}`);
 
     // Write registry entry after successful listen
-    writeDaemonRegistryEntry({
-      workspaceKey,
-      workspaceRoot,
-      socketPath,
-      logPath: logPath ?? undefined,
-      pid: process.pid,
-      startedAt,
-      enabledWorkflows: daemonWorkflows,
-      version: String(version),
-    });
+    try {
+      writeDaemonRegistryEntry(
+        {
+          workspaceKey,
+          workspaceRoot,
+          socketPath,
+          logPath: logPath ?? undefined,
+          pid: process.pid,
+          startedAt,
+          enabledWorkflows: daemonWorkflows,
+          version: String(version),
+        },
+        { lock: startupRegistryLock },
+      );
+    } finally {
+      releaseStartupRegistryLock();
+    }
 
     writeLine(`Daemon started (PID: ${process.pid})`);
     writeLine(`Workspace: ${workspaceRoot}`);
@@ -405,11 +439,15 @@
     writeLine(`Tools: ${catalog.tools.length}`);
     recordBootstrapDurationMetric('cli-daemon', Date.now() - daemonBootstrapStart);
 
+    // Filesystem orphan reconciliation and log retention run fire-and-forget after listen so
+    // a slow sweep cannot delay request serving. Request handlers must not assume orphans
+    // have been cleaned at startup.
     setImmediate(() => {
       void enrichSentryMetadata().catch((error) => {
         const message = error instanceof Error ? error.message : String(error);
         log('warn', `[Daemon] Failed to enrich Sentry metadata: ${message}`);
       });
+      void runStartupLifecycleSweep();
     });
   });
 
@@ -421,7 +459,7 @@
   };
 
   process.on('exit', () => {
-    terminateLiveSimulatorLaunchOsLogSessionsSync();
+    terminateOwnedWorkspaceFilesystemArtifactsSync();
   });
   process.on('SIGTERM', () => shutdown(0));
   process.on('SIGINT', () => shutdown(0));

diff --git a/src/daemon/__tests__/daemon-registry.test.ts b/src/daemon/__tests__/daemon-registry.test.ts
new file mode 100644
--- /dev/null
+++ b/src/daemon/__tests__/daemon-registry.test.ts
@@ -1,0 +1,248 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { existsSync, mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import * as path from 'node:path';
+import {
+  acquireDaemonRegistryMutationLock,
+  cleanupWorkspaceDaemonFiles,
+  findDaemonRegistryEntryBySocketPath,
+  listDaemonRegistryEntries,
+  readDaemonRegistryEntry,
+  type DaemonRegistryEntry,
+  writeDaemonRegistryEntry,
+} from '../daemon-registry.ts';
+import {
+  daemonDirForWorkspaceKey,
+  logPathForWorkspaceKey,
+  registryPathForWorkspaceKey,
+  setDaemonRunDirOverrideForTests,
+} from '../socket-path.ts';
+import { setXcodeBuildMCPAppDirOverrideForTests } from '../../utils/log-paths.ts';
+
+const stalePid = 999_999_999;
+
+function createEntry(overrides: Partial<DaemonRegistryEntry> = {}): DaemonRegistryEntry {
+  const workspaceKey = overrides.workspaceKey ?? 'workspace-a';
+  return {
+    workspaceKey,
+    workspaceRoot: `/workspaces/${workspaceKey}`,
+    socketPath: path.join(daemonDirForWorkspaceKey(workspaceKey), 'd.sock'),
+    pid: stalePid,
+    startedAt: '2026-05-02T00:00:00.000Z',
+    enabledWorkflows: ['build'],
+    version: '1.0.0',
+    ...overrides,
+  };
+}
+
+describe('daemon registry', () => {
+  let appDir: string;
+  let daemonRunDir: string;
+
+  beforeEach(() => {
+    appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-daemon-registry-app-'));
+    daemonRunDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-daemon-registry-run-'));
+    setXcodeBuildMCPAppDirOverrideForTests(appDir);
+    setDaemonRunDirOverrideForTests(daemonRunDir);
+  });
+
+  afterEach(() => {
+    setXcodeBuildMCPAppDirOverrideForTests(null);
+    setDaemonRunDirOverrideForTests(null);
+    rmSync(appDir, { recursive: true, force: true });
+    rmSync(daemonRunDir, { recursive: true, force: true });
+  });
+
+  it('writes daemon metadata under workspace state and places the socket in temp runtime storage', () => {
+    const entry = createEntry();
+
+    writeDaemonRegistryEntry(entry);
+
+    const expectedRegistryPath = path.join(
+      appDir,
+      'workspaces',
+      'workspace-a',
+      'state',
+      'daemon',
+      'daemon.json',
+    );
+    expect(registryPathForWorkspaceKey('workspace-a')).toBe(expectedRegistryPath);
+    expect(readDaemonRegistryEntry('workspace-a')).toEqual(entry);
+    expect(existsSync(expectedRegistryPath)).toBe(true);
+    expect(entry.socketPath).toBe(path.join(daemonRunDir, 'xcodebuildmcp-0dcf2d98505d', 'd.sock'));
+    expect(logPathForWorkspaceKey('workspace-a')).toBe(
+      path.join(appDir, 'workspaces', 'workspace-a', 'logs', 'daemon.log'),
+    );
+
+    const raw = readFileSync(expectedRegistryPath, 'utf8');
+    expect(JSON.parse(raw)).toEqual(entry);
+  });
+
+  it('returns null when workspace metadata is invalid', () => {
+    const registryPath = registryPathForWorkspaceKey('workspace-a');
+    mkdirSync(path.dirname(registryPath), { recursive: true, mode: 0o700 });
+    writeFileSync(registryPath, '{invalid json');
+
+    expect(readDaemonRegistryEntry('workspace-a')).toBeNull();
+  });
+
+  it('rejects workspace metadata stored under the wrong workspace key', () => {
+    const mismatchedEntry = createEntry({ workspaceKey: 'workspace-b', version: 'wrong' });
+
+    const registryPath = registryPathForWorkspaceKey('workspace-a');
+    mkdirSync(path.dirname(registryPath), { recursive: true, mode: 0o700 });
+    writeFileSync(registryPath, `${JSON.stringify(mismatchedEntry, null, 2)}\n`, { mode: 0o600 });
+
+    expect(readDaemonRegistryEntry('workspace-a')).toBeNull();
+  });
+
+  it('lists workspace metadata', () => {
+    const workspaceEntry = createEntry({ workspaceKey: 'workspace-a', version: 'workspace' });
+
+    writeDaemonRegistryEntry(workspaceEntry);
+
+    expect(listDaemonRegistryEntries()).toEqual(expect.arrayContaining([workspaceEntry]));
+  });
+
+  it('finds registry metadata by custom socket path', () => {
+    const entry = createEntry({ socketPath: path.join(daemonRunDir, 'custom.sock') });
+
+    writeDaemonRegistryEntry(entry);
+
+    expect(findDaemonRegistryEntryBySocketPath(entry.socketPath)).toEqual(entry);
+  });
+
+  it('does not clean up live mismatched metadata or sockets', () => {
+    const entry = createEntry({ pid: process.pid });
+    writeDaemonRegistryEntry(entry);
+    mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 });
+    writeFileSync(entry.socketPath, 'socket placeholder');
+
+    cleanupWorkspaceDaemonFiles(entry.workspaceKey, {
+      pid: process.pid + 1,
+      socketPath: entry.socketPath,
+      allowLiveOwner: true,
+    });
+
+    expect(readDaemonRegistryEntry(entry.workspaceKey)).toEqual(entry);
+    expect(existsSync(entry.socketPath)).toBe(true);
+  });
+
+  it('cleans up current-owned workspace metadata and socket', () => {
+    const entry = createEntry({ pid: process.pid });
+    writeDaemonRegistryEntry(entry);
+    mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 });
+    writeFileSync(entry.socketPath, 'socket placeholder');
+
+    cleanupWorkspaceDaemonFiles(entry.workspaceKey, {
+      pid: process.pid,
+      socketPath: entry.socketPath,
+      allowLiveOwner: true,
+    });
+
+    expect(readDaemonRegistryEntry(entry.workspaceKey)).toBeNull();
+    expect(existsSync(entry.socketPath)).toBe(false);
+  });
+
+  it('does not clean up while another daemon registry mutation holds the workspace lock', () => {
+    const entry = createEntry();
+    writeDaemonRegistryEntry(entry);
+    mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 });
+    writeFileSync(entry.socketPath, 'socket placeholder');
+    const lock = acquireDaemonRegistryMutationLock(entry.workspaceKey);
+    expect(lock).not.toBeNull();
+
+    try {
+      cleanupWorkspaceDaemonFiles(entry.workspaceKey, {
+        socketPath: entry.socketPath,
+      });
+
+      expect(readDaemonRegistryEntry(entry.workspaceKey)).toEqual(entry);
+      expect(existsSync(entry.socketPath)).toBe(true);
+    } finally {
+      lock?.release();
+    }
+  });
+
+  it('recovers an expired ownerless daemon registry lock', () => {
+    const lockDir = path.join(appDir, 'workspaces', 'workspace-a', 'locks', 'daemon-registry.lock');
+    mkdirSync(lockDir, { recursive: true });
+    const staleDate = new Date(Date.now() - 60_000);
+    utimesSync(lockDir, staleDate, staleDate);
+
+    const lock = acquireDaemonRegistryMutationLock('workspace-a');
+
+    expect(lock).not.toBeNull();
+    lock?.release();
+  });
+
+  it('recovers an expired malformed daemon registry lock', () => {
+    const lockDir = path.join(appDir, 'workspaces', 'workspace-a', 'locks', 'daemon-registry.lock');
+    mkdirSync(lockDir, { recursive: true });
+    writeFileSync(path.join(lockDir, 'owner.json'), '{not-json');
+    const staleDate = new Date(Date.now() - 60_000);
+    utimesSync(lockDir, staleDate, staleDate);
+
+    const lock = acquireDaemonRegistryMutationLock('workspace-a');
+
+    expect(lock).not.toBeNull();
+    lock?.release();
+  });
+
+  it('recovers an expired daemon registry lock owned by a dead pid', () => {
+    const lockDir = path.join(appDir, 'workspaces', 'workspace-a', 'locks', 'daemon-registry.lock');
+    mkdirSync(lockDir, { recursive: true });
+    const now = Date.now();
+    writeFileSync(
+      path.join(lockDir, 'owner.json'),
+      `${JSON.stringify({
+        token: 'stale-token',
+        pid: stalePid,
+        purpose: 'daemon-registry',
+        acquiredAtMs: now - 60_000,
+        expiresAtMs: now - 30_000,
+      })}\n`,
+    );
+
+    const lock = acquireDaemonRegistryMutationLock('workspace-a');
+
+    expect(lock).not.toBeNull();
+    lock?.release();
+  });
+
+  it('does not unlink replacement daemon metadata or socket during old-owner cleanup', () => {
+    const oldEntry = createEntry({ pid: stalePid, startedAt: '2026-05-02T00:00:00.000Z' });
+    const replacementEntry = createEntry({
+      pid: process.pid,
+      startedAt: '2026-05-02T00:01:00.000Z',
+    });
+    writeDaemonRegistryEntry(oldEntry);
+    writeDaemonRegistryEntry(replacementEntry);
+    mkdirSync(path.dirname(replacementEntry.socketPath), { recursive: true, mode: 0o700 });
+    writeFileSync(replacementEntry.socketPath, 'replacement socket placeholder');
+
+    cleanupWorkspaceDaemonFiles(oldEntry.workspaceKey, {
+      pid: oldEntry.pid,
+      socketPath: oldEntry.socketPath,
+      allowLiveOwner: true,
+    });
+
+    expect(readDaemonRegistryEntry(replacementEntry.workspaceKey)).toEqual(replacementEntry);
+    expect(existsSync(replacementEntry.socketPath)).toBe(true);
+  });
+
+  it('cleans up stale matching workspace metadata and socket', () => {
+    const entry = createEntry();
+    writeDaemonRegistryEntry(entry);
+    mkdirSync(path.dirname(entry.socketPath), { recursive: true, mode: 0o700 });
+    writeFileSync(entry.socketPath, 'socket placeholder');
+
+    cleanupWorkspaceDaemonFiles(entry.workspaceKey, {
+      socketPath: entry.socketPath,
+    });
+
+    expect(existsSync(registryPathForWorkspaceKey(entry.workspaceKey))).toBe(false);
+    expect(existsSync(entry.socketPath)).toBe(false);
+  });
+});

diff --git a/src/daemon/daemon-registry.ts b/src/daemon/daemon-registry.ts
--- a/src/daemon/daemon-registry.ts
+++ b/src/daemon/daemon-registry.ts
@@ -1,19 +1,65 @@
+import { randomUUID } from 'node:crypto';
 import {
-  existsSync,
   mkdirSync,
   readdirSync,
   readFileSync,
+  renameSync,
+  rmSync,
   unlinkSync,
   writeFileSync,
 } from 'node:fs';
-import { join, dirname } from 'node:path';
-import {
-  daemonsDir,
-  daemonDirForWorkspaceKey,
-  registryPathForWorkspaceKey,
-} from './socket-path.ts';
+import { dirname, join } from 'node:path';
+import { daemonDirForWorkspaceKey, registryPathForWorkspaceKey } from './socket-path.ts';
+import { getWorkspacesDir, getWorkspaceFilesystemLayout } from '../utils/log-paths.ts';
+import { tryAcquireFsLockSync } from '../utils/fs-lock-sync.ts';
+import { isPidAlive } from '../utils/process-liveness.ts';
 
+export interface DaemonRegistryMutationLock {
+  readonly workspaceKey: string;
+  release(): void;
+}
+
+const DAEMON_REGISTRY_LOCK_LEASE_MS = 30_000;
+const DAEMON_REGISTRY_LOCK_WAIT_MS = 1_000;
+const DAEMON_REGISTRY_LOCK_POLL_MS = 10;
+const DAEMON_REGISTRY_LOCK_PURPOSE = 'daemon-registry';
+
+const SLEEP_SYNC_WAIT_TARGET = new Int32Array(new SharedArrayBuffer(4));
+
+function sleepSync(ms: number): void {
+  Atomics.wait(SLEEP_SYNC_WAIT_TARGET, 0, 0, ms);
+}
+
 /**
+ * Synchronous lock acquisition with bounded busy-wait. Blocks the event loop for up to
+ * DAEMON_REGISTRY_LOCK_WAIT_MS on contention. Only safe to call from startup or shutdown
+ * paths (writeDaemonRegistryEntry, cleanupWorkspaceDaemonFiles)
+ * — never from request handlers.
+ */
+export function acquireDaemonRegistryMutationLock(
+  workspaceKey: string,
+): DaemonRegistryMutationLock | null {
+  const lockDir = join(getWorkspaceFilesystemLayout(workspaceKey).locks, 'daemon-registry.lock');
+  const deadline = Date.now() + DAEMON_REGISTRY_LOCK_WAIT_MS;
+  do {
+    const lock = tryAcquireFsLockSync({
+      lockDir,
+      purpose: DAEMON_REGISTRY_LOCK_PURPOSE,
+      leaseMs: DAEMON_REGISTRY_LOCK_LEASE_MS,
+    });
+    if (lock) {
+      return {
+        workspaceKey,
+        release: () => lock.release(),
+      };
+    }
+    sleepSync(DAEMON_REGISTRY_LOCK_POLL_MS);
+  } while (Date.now() < deadline);
+
+  return null;
+}
+
+/**
  * Metadata stored for each running daemon.
  */
 export interface DaemonRegistryEntry {
@@ -27,111 +73,264 @@
   version: string;
 }
 
-/**
- * Write a daemon registry entry.
- * Creates the daemon directory if it doesn't exist.
- */
-export function writeDaemonRegistryEntry(entry: DaemonRegistryEntry): void {
-  const registryPath = registryPathForWorkspaceKey(entry.workspaceKey);
-  const dir = dirname(registryPath);
+export interface DaemonFileCleanupOptions {
+  socketPath?: string;
+  pid?: number;
+  allowLiveOwner?: boolean;
+}
 
-  if (!existsSync(dir)) {
-    mkdirSync(dir, { recursive: true, mode: 0o700 });
+interface WriteDaemonRegistryEntryOptions {
+  lock?: DaemonRegistryMutationLock;
+}
+
+function isDaemonRegistryEntry(value: unknown): value is DaemonRegistryEntry {
+  if (typeof value !== 'object' || value === null) {
+    return false;
   }
 
-  writeFileSync(registryPath, JSON.stringify(entry, null, 2), {
-    mode: 0o600,
-  });
+  const entry = value as Partial<DaemonRegistryEntry>;
+  return (
+    typeof entry.workspaceKey === 'string' &&
+    entry.workspaceKey.length > 0 &&
+    typeof entry.workspaceRoot === 'string' &&
+    typeof entry.socketPath === 'string' &&
+    (entry.logPath === undefined || typeof entry.logPath === 'string') &&
+    typeof entry.pid === 'number' &&
+    Number.isInteger(entry.pid) &&
+    entry.pid > 0 &&
+    typeof entry.startedAt === 'string' &&
+    Array.isArray(entry.enabledWorkflows) &&
+    entry.enabledWorkflows.every((workflow) => typeof workflow === 'string') &&
+    typeof entry.version === 'string'
+  );
 }
 
-/**
- * Remove a daemon registry entry.
- */
-export function removeDaemonRegistryEntry(workspaceKey: string): void {
-  const registryPath = registryPathForWorkspaceKey(workspaceKey);
+type RegistryReadResult =
+  | { status: 'missing' }
+  | { status: 'invalid' }
+  | { status: 'valid'; entry: DaemonRegistryEntry };
 
-  if (existsSync(registryPath)) {
-    unlinkSync(registryPath);
+function readRegistryEntryAtPath(
+  registryPath: string,
+  expectedWorkspaceKey?: string,
+): RegistryReadResult {
+  let content: string;
+  try {
+    content = readFileSync(registryPath, 'utf8');
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+      return { status: 'missing' };
+    }
+    return { status: 'invalid' };
   }
+
+  try {
+    const parsed = JSON.parse(content) as unknown;
+    if (!isDaemonRegistryEntry(parsed)) {
+      return { status: 'invalid' };
+    }
+    if (expectedWorkspaceKey !== undefined && parsed.workspaceKey !== expectedWorkspaceKey) {
... diff truncated: showing 800 of 7071 lines

You can send follow-ups to the cloud agent here.

Comment thread src/cli/daemon-control.ts Outdated
Comment thread src/daemon/socket-path.ts
- Add allowLiveOwner flag to forceStopDaemon cleanup to ensure socket removal even if process is slow to die
- Remove unused getWorkspaceKey export from socket-path.ts
Comment thread src/snapshot-tests/__fixtures__/cli/macos/test--success.txt
Comment thread src/utils/workspace-filesystem-lifecycle.ts
Comment thread src/utils/workspace-filesystem-lifecycle.ts
Harden daemon socket directory validation, make shutdown cleanup use the
expanded timeout budget, and remove stale registry-owned sockets by their
recorded path. Keep workspace-scoped log paths in snapshot normalization
and preserve Swift Testing display names that end with a period.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Comment thread src/daemon/daemon-registry.ts
Make daemon file cleanup fail explicitly when it cannot acquire the
workspace registry lock. This prevents force-stop and lifecycle cleanup
paths from silently leaving stale daemon artifacts behind.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
@cameroncooke
Copy link
Copy Markdown
Collaborator Author

Addressed the remaining registry cleanup lock concern in 2ea5733. The force-stop allowLiveOwner path and unused getWorkspaceKey removal were already present in the branch.

Comment thread src/server/mcp-shutdown.ts Outdated
Pass STEP_TIMEOUT_MS (1000ms) as per-session timeout to cleanupOwnedWorkspaceFilesystemArtifacts instead of the step wrapper timeout. The step wrapper timeout is bulkStepTimeoutMs(count) = count * 1000 + 100, which gives enough room for count sequential operations at 1000ms each. Passing the step wrapper timeout as the per-session timeout causes total time to be count * bulkStepTimeoutMs(count), exceeding the step budget.
Comment thread src/snapshot-tests/__fixtures__/json/device/test--failure.json
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Cleanup timeout passes fixed value instead of scaled budget
    • Changed cleanupOwnedWorkspaceFilesystemArtifacts call to pass workspaceFilesystemCleanupTimeoutMs instead of STEP_TIMEOUT_MS so multi-session cleanup receives the scaled timeout budget.
Preview (6af258eb1b)
diff --git a/AGENTS.md b/AGENTS.md
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,6 +80,15 @@
 - When working on skill sources in `skills/`, use the `skill-creator` skill workflow.
 - After modifying any skill source, run `npx skill-check <skill-directory>` and address all errors/warnings before handoff.
 -
+## Multi-process filesystem state
+- XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces.
+- Shared filesystem state under `~/Library/Developer/XcodeBuildMCP` must be multi-process safe.
+- Use workspace-key scoped directories for workspace-owned state.
+- Do not store runtime state under `~/.xcodebuildmcp`; `.xcodebuildmcp/config.yaml` is only project configuration.
+- Use shared lock and atomic-write helpers for mutable shared files.
+- Prefer one-record-per-file registries over shared aggregate files.
+- Cleanup must verify ownership before deleting shared artifacts.
+
 ## Style
 - Keep answers short and concise
 - No emojis in commits, issues, PR comments, or code

diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,10 @@
 - Fixed simulator OSLog helper cleanup so server and daemon startup reconcile same-workspace orphaned log streams without stopping helpers owned by live sessions in other workspaces ([#382](https://github.com/getsentry/XcodeBuildMCP/issues/382)).
 - Fixed Weather example test discovery and made CLI test progress visible while tests are running instead of leaving the last build phase displayed.
 
+### Changed
+
+- Centralized workspace log retention and startup/shutdown filesystem cleanup so XcodeBuildMCP-owned logs are pruned consistently while preserving active daemon and simulator OSLog outputs.
+
 ## [2.5.0-beta.1]
 
 ### Breaking

diff --git a/CLAUDE.md b/CLAUDE.md
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,6 +21,15 @@
 - When working on skill sources in `skills/`, use the `skill-creator` skill workflow.
 - After modifying any skill source, run `npx skill-check <skill-directory>` and address all errors/warnings before handoff.
 -
+## Multi-process filesystem state
+- XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces.
+- Shared filesystem state under `~/Library/Developer/XcodeBuildMCP` must be multi-process safe.
+- Use workspace-key scoped directories for workspace-owned state.
+- Do not store runtime state under `~/.xcodebuildmcp`; `.xcodebuildmcp/config.yaml` is only project configuration.
+- Use shared lock and atomic-write helpers for mutable shared files.
+- Prefer one-record-per-file registries over shared aggregate files.
+- Cleanup must verify ownership before deleting shared artifacts.
+
 ## Style
 - Keep answers short and concise
 - No emojis in commits, issues, PR comments, or code

diff --git a/src/cli.ts b/src/cli.ts
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -2,13 +2,12 @@
 import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts';
 import { buildCliToolCatalog } from './cli/cli-tool-catalog.ts';
 import { buildYargsApp } from './cli/yargs-app.ts';
-import { getSocketPath, getWorkspaceKey, resolveWorkspaceRoot } from './daemon/socket-path.ts';
+import { getSocketPath } from './daemon/socket-path.ts';
 import { startMcpServer } from './server/start-mcp-server.ts';
 import { listCliWorkflowIdsFromManifest } from './runtime/tool-catalog.ts';
 import { flushAndCloseSentry, initSentry, recordBootstrapDurationMetric } from './utils/sentry.ts';
 import { coerceLogLevel, setLogLevel, type LogLevel } from './utils/logger.ts';
 import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts';
-import { configureRuntimeWorkspaceKey } from './utils/runtime-instance.ts';
 
 function findTopLevelCommand(argv: string[]): string | undefined {
   const flagsWithValue = new Set(['--socket', '--log-level', '--style']);
@@ -119,23 +118,13 @@
     },
   });
 
-  // Compute workspace context for daemon routing
-  const workspaceRoot = resolveWorkspaceRoot({
-    cwd: result.runtime.cwd,
-    projectConfigPath: result.configPath,
-  });
+  const { workspaceRoot, workspaceKey } = result;
 
   const defaultSocketPath = getSocketPath({
     cwd: result.runtime.cwd,
     projectConfigPath: result.configPath,
   });
 
-  const workspaceKey = getWorkspaceKey({
-    cwd: result.runtime.cwd,
-    projectConfigPath: result.configPath,
-  });
-  configureRuntimeWorkspaceKey(workspaceKey);
-
   const cliExposedWorkflowIds = await listCliWorkflowIdsFromManifest({
     excludeWorkflows: ['session-management', 'workflow-discovery'],
   });

diff --git a/src/cli/daemon-control.ts b/src/cli/daemon-control.ts
--- a/src/cli/daemon-control.ts
+++ b/src/cli/daemon-control.ts
@@ -1,10 +1,12 @@
 import { spawn } from 'node:child_process';
 import { fileURLToPath } from 'node:url';
-import { dirname, resolve, basename } from 'node:path';
-import { existsSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { existsSync, unlinkSync } from 'node:fs';
 import { DaemonClient, DaemonVersionMismatchError } from './daemon-client.ts';
-import { readDaemonRegistryEntry } from '../daemon/daemon-registry.ts';
-import { removeStaleSocket } from '../daemon/socket-path.ts';
+import {
+  cleanupWorkspaceDaemonFiles,
+  findDaemonRegistryEntryBySocketPath,
+} from '../daemon/daemon-registry.ts';
 
 /**
  * Default timeout for daemon startup in milliseconds.
@@ -38,8 +40,7 @@
  * sends SIGTERM, and removes the stale socket.
  */
 export async function forceStopDaemon(socketPath: string): Promise<void> {
-  const workspaceKey = basename(dirname(socketPath));
-  const entry = readDaemonRegistryEntry(workspaceKey);
+  const entry = findDaemonRegistryEntryBySocketPath(socketPath);
   if (entry?.pid) {
     try {
       process.kill(entry.pid, 'SIGTERM');
@@ -49,7 +50,21 @@
     // Brief wait for the process to exit.
     await new Promise((resolve) => setTimeout(resolve, 500));
   }
-  removeStaleSocket(socketPath);
+  if (entry) {
+    cleanupWorkspaceDaemonFiles(entry.workspaceKey, {
+      pid: entry.pid,
+      socketPath,
+      allowLiveOwner: true,
+    });
+  } else {
+    // Registry entry missing; cannot derive workspace key from socket path alone.
+    // Clean up the socket file directly.
+    try {
+      unlinkSync(socketPath);
+    } catch {
+      // Socket may already be gone.
+    }
+  }
 }
 
 export interface StartDaemonBackgroundOptions {

diff --git a/src/daemon.ts b/src/daemon.ts
--- a/src/daemon.ts
+++ b/src/daemon.ts
@@ -9,15 +9,13 @@
   ensureSocketDir,
   removeStaleSocket,
   getSocketPath,
-  getWorkspaceKey,
-  resolveWorkspaceRoot,
   logPathForWorkspaceKey,
 } from './daemon/socket-path.ts';
 import { startDaemonServer } from './daemon/daemon-server.ts';
 import {
+  acquireDaemonRegistryMutationLock,
   writeDaemonRegistryEntry,
-  removeDaemonRegistryEntry,
-  cleanupWorkspaceDaemonFiles,
+  type DaemonRegistryMutationLock,
 } from './daemon/daemon-registry.ts';
 import { log, normalizeLogLevel, setLogFile, setLogLevel } from './utils/logger.ts';
 import { version } from './version.ts';
@@ -42,11 +40,11 @@
 } from './utils/sentry.ts';
 import { isXcodemakeBinaryAvailable, isXcodemakeEnabled } from './utils/xcodemake/index.ts';
 import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts';
-import { configureRuntimeWorkspaceKey } from './utils/runtime-instance.ts';
 import {
-  reconcileSimulatorLaunchOsLogOrphansForWorkspace,
-  terminateLiveSimulatorLaunchOsLogSessionsSync,
-} from './utils/log-capture/index.ts';
+  cleanupOwnedWorkspaceFilesystemArtifacts,
+  runWorkspaceFilesystemLifecycleSweep,
+  terminateOwnedWorkspaceFilesystemArtifactsSync,
+} from './utils/workspace-filesystem-lifecycle.ts';
 
 async function checkExistingDaemon(socketPath: string): Promise<boolean> {
   return new Promise<boolean>((resolve) => {
@@ -124,17 +122,8 @@
     },
   });
 
-  const workspaceRoot = resolveWorkspaceRoot({
-    cwd: result.runtime.cwd,
-    projectConfigPath: result.configPath,
-  });
+  const { workspaceRoot, workspaceKey } = result;
 
-  const workspaceKey = getWorkspaceKey({
-    cwd: result.runtime.cwd,
-    projectConfigPath: result.configPath,
-  });
-  configureRuntimeWorkspaceKey(workspaceKey);
-
   const logPath = resolveDaemonLogPath(workspaceKey);
   if (logPath) {
     ensureLogDir(logPath);
@@ -159,20 +148,27 @@
 
   log('info', `[Daemon] Workspace: ${workspaceRoot}`);
   log('info', `[Daemon] Socket: ${socketPath}`);
-  try {
-    const reconciliation = await reconcileSimulatorLaunchOsLogOrphansForWorkspace(workspaceKey);
-    if (reconciliation.stoppedSessionCount > 0 || reconciliation.errorCount > 0) {
+
+  const runStartupLifecycleSweep = async (): Promise<void> => {
+    try {
+      const lifecycle = await runWorkspaceFilesystemLifecycleSweep({
+        workspaceKey,
+        trigger: 'startup',
+      });
+      if (lifecycle.stopped > 0 || lifecycle.deleted > 0 || lifecycle.errors.length > 0) {
+        log(
+          lifecycle.errors.length > 0 ? 'warn' : 'info',
+          `[Daemon] Filesystem lifecycle: ${JSON.stringify(lifecycle)}`,
+        );
+      }
+    } catch (error) {
       log(
-        reconciliation.errorCount > 0 ? 'warn' : 'info',
-        `[Daemon] Simulator OSLog reconciliation: ${JSON.stringify(reconciliation)}`,
+        'warn',
+        `[Daemon] Filesystem lifecycle failed: ${error instanceof Error ? error.message : String(error)}`,
       );
     }
-  } catch (error) {
-    log(
-      'warn',
-      `[Daemon] Simulator OSLog reconciliation failed: ${error instanceof Error ? error.message : String(error)}`,
-    );
-  }
+  };
+
   if (logPath) {
     log('info', `[Daemon] Logs: ${logPath}`);
   }
@@ -187,246 +183,300 @@
     process.exit(1);
   }
 
-  removeStaleSocket(socketPath);
-
-  const excludedWorkflows = ['session-management', 'workflow-discovery'];
-
-  // Daemon runtime serves CLI routing and should not be filtered by enabledWorkflows.
-  // CLI exposure is controlled at CLI catalog/command registration time.
-  // Get all workflows from manifest (for reporting purposes and filtering).
-  const manifest = loadManifest();
-  const allWorkflowIds = Array.from(manifest.workflows.keys());
-  const daemonWorkflows = allWorkflowIds.filter(
-    (workflowId) => !excludedWorkflows.includes(workflowId),
-  );
-  const xcodeIdeWorkflowEnabled = daemonWorkflows.includes('xcode-ide');
-  const axeBinary = resolveAxeBinary();
-  const axeAvailable = axeBinary !== null;
-  const axeSource: 'env' | 'bundled' | 'path' | 'unavailable' = axeBinary?.source ?? 'unavailable';
-  const xcodemakeAvailable = isXcodemakeBinaryAvailable();
-  const xcodemakeEnabled = isXcodemakeEnabled();
-  const baseSentryRuntimeContext = {
-    mode: 'cli-daemon' as const,
-    enabledWorkflows: daemonWorkflows,
-    disableSessionDefaults: result.runtime.config.disableSessionDefaults,
-    disableXcodeAutoSync: result.runtime.config.disableXcodeAutoSync,
-    incrementalBuildsEnabled: result.runtime.config.incrementalBuildsEnabled,
-    debugEnabled: result.runtime.config.debug,
-    uiDebuggerGuardMode: result.runtime.config.uiDebuggerGuardMode,
-    xcodeIdeWorkflowEnabled,
-    axeAvailable,
-    axeSource,
-    xcodemakeAvailable,
-    xcodemakeEnabled,
+  const startupRegistryLock = acquireDaemonRegistryMutationLock(workspaceKey);
+  if (!startupRegistryLock) {
+    log('error', '[Daemon] Unable to acquire daemon registry lock');
+    console.error('Error: Unable to acquire daemon registry lock');
+    await flushAndCloseSentry(1000);
+    process.exit(1);
+  }
+  let pendingStartupRegistryLock: DaemonRegistryMutationLock | null = startupRegistryLock;
+  const releaseStartupRegistryLock = (): void => {
+    pendingStartupRegistryLock?.release();
+    pendingStartupRegistryLock = null;
   };
-  setSentryRuntimeContext(baseSentryRuntimeContext);
 
-  const enrichSentryMetadata = async (): Promise<void> => {
-    const commandExecutor = getDefaultCommandExecutor();
-    const xcodeVersion = await getXcodeVersionMetadata(async (command) => {
-      const result = await commandExecutor(command, 'Get Xcode Version');
-      return { success: result.success, output: result.output };
-    });
-    const xcodeAvailable = Boolean(
-      xcodeVersion.version ??
-        xcodeVersion.buildVersion ??
-        xcodeVersion.developerDir ??
-        xcodeVersion.xcodebuildPath,
-    );
-    const axeVersion = await getAxeVersionMetadata(async (command) => {
-      const result = await commandExecutor(command, 'Get AXe Version');
-      return { success: result.success, output: result.output };
-    }, axeBinary?.path);
+  const isRunningAfterLock = await checkExistingDaemon(socketPath);
+  if (isRunningAfterLock) {
+    releaseStartupRegistryLock();
+    log('error', '[Daemon] Another daemon is already running for this workspace');
+    console.error('Error: Daemon is already running for this workspace');
+    await flushAndCloseSentry(1000);
+    process.exit(1);
+  }
 
-    setSentryRuntimeContext({
-      ...baseSentryRuntimeContext,
-      xcodeAvailable,
-      axeVersion,
-      xcodeDeveloperDir: xcodeVersion.developerDir,
-      xcodebuildPath: xcodeVersion.xcodebuildPath,
-      xcodeVersion: xcodeVersion.version,
-      xcodeBuildVersion: xcodeVersion.buildVersion,
-    });
-  };
+  try {
+    removeStaleSocket(socketPath);
 
-  const catalog = await buildDaemonToolCatalogFromManifest({
-    excludeWorkflows: excludedWorkflows,
-  });
+    const excludedWorkflows = ['session-management', 'workflow-discovery'];
 
-  log('info', `[Daemon] Loaded ${catalog.tools.length} tools`);
+    // Daemon runtime serves CLI routing and should not be filtered by enabledWorkflows.
+    // CLI exposure is controlled at CLI catalog/command registration time.
+    // Get all workflows from manifest (for reporting purposes and filtering).
+    const manifest = loadManifest();
+    const allWorkflowIds = Array.from(manifest.workflows.keys());
+    const daemonWorkflows = allWorkflowIds.filter(
+      (workflowId) => !excludedWorkflows.includes(workflowId),
+    );
+    const xcodeIdeWorkflowEnabled = daemonWorkflows.includes('xcode-ide');
+    const axeBinary = resolveAxeBinary();
+    const axeAvailable = axeBinary !== null;
+    const axeSource: 'env' | 'bundled' | 'path' | 'unavailable' =
+      axeBinary?.source ?? 'unavailable';
+    const xcodemakeAvailable = isXcodemakeBinaryAvailable();
+    const xcodemakeEnabled = isXcodemakeEnabled();
+    const baseSentryRuntimeContext = {
+      mode: 'cli-daemon' as const,
+      enabledWorkflows: daemonWorkflows,
+      disableSessionDefaults: result.runtime.config.disableSessionDefaults,
+      disableXcodeAutoSync: result.runtime.config.disableXcodeAutoSync,
+      incrementalBuildsEnabled: result.runtime.config.incrementalBuildsEnabled,
+      debugEnabled: result.runtime.config.debug,
+      uiDebuggerGuardMode: result.runtime.config.uiDebuggerGuardMode,
+      xcodeIdeWorkflowEnabled,
+      axeAvailable,
+      axeSource,
+      xcodemakeAvailable,
+      xcodemakeEnabled,
+    };
+    setSentryRuntimeContext(baseSentryRuntimeContext);
 
-  const startedAt = new Date().toISOString();
-  const idleTimeoutMs = resolveDaemonIdleTimeoutMs();
-  const configuredIdleTimeout = process.env[DAEMON_IDLE_TIMEOUT_ENV_KEY]?.trim();
-  if (configuredIdleTimeout) {
-    const parsedIdleTimeout = Number(configuredIdleTimeout);
-    if (!Number.isFinite(parsedIdleTimeout) || parsedIdleTimeout < 0) {
-      log(
-        'warn',
-        `[Daemon] Invalid ${DAEMON_IDLE_TIMEOUT_ENV_KEY}=${configuredIdleTimeout}; using default ${idleTimeoutMs}ms`,
+    const enrichSentryMetadata = async (): Promise<void> => {
+      const commandExecutor = getDefaultCommandExecutor();
+      const xcodeVersion = await getXcodeVersionMetadata(async (command) => {
+        const result = await commandExecutor(command, 'Get Xcode Version');
+        return { success: result.success, output: result.output };
+      });
+      const xcodeAvailable = Boolean(
+        xcodeVersion.version ??
+          xcodeVersion.buildVersion ??
+          xcodeVersion.developerDir ??
+          xcodeVersion.xcodebuildPath,
       );
-    }
-  }
+      const axeVersion = await getAxeVersionMetadata(async (command) => {
+        const result = await commandExecutor(command, 'Get AXe Version');
+        return { success: result.success, output: result.output };
+      }, axeBinary?.path);
 
-  if (idleTimeoutMs === 0) {
-    log('info', '[Daemon] Idle shutdown disabled');
-  } else {
-    log(
-      'info',
-      `[Daemon] Idle shutdown enabled: timeout=${idleTimeoutMs}ms interval=${DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS}ms`,
-    );
-  }
-  recordDaemonGaugeMetric('idle_timeout_ms', idleTimeoutMs);
+      setSentryRuntimeContext({
+        ...baseSentryRuntimeContext,
+        xcodeAvailable,
+        axeVersion,
+        xcodeDeveloperDir: xcodeVersion.developerDir,
+        xcodebuildPath: xcodeVersion.xcodebuildPath,
+        xcodeVersion: xcodeVersion.version,
+        xcodeBuildVersion: xcodeVersion.buildVersion,
+      });
+    };
 
-  let isShuttingDown = false;
-  let inFlightRequests = 0;
-  let lastActivityAt = Date.now();
-  let idleCheckTimer: NodeJS.Timeout | null = null;
+    const catalog = await buildDaemonToolCatalogFromManifest({
+      excludeWorkflows: excludedWorkflows,
+    });
 
-  const markActivity = (): void => {
-    lastActivityAt = Date.now();
-  };
+    log('info', `[Daemon] Loaded ${catalog.tools.length} tools`);
 
-  // Unified shutdown handler
-  const shutdown = (exitCode = 0): void => {
-    if (isShuttingDown) {
-      return;
+    const startedAt = new Date().toISOString();
+    const idleTimeoutMs = resolveDaemonIdleTimeoutMs();
+    const configuredIdleTimeout = process.env[DAEMON_IDLE_TIMEOUT_ENV_KEY]?.trim();
+    if (configuredIdleTimeout) {
+      const parsedIdleTimeout = Number(configuredIdleTimeout);
+      if (!Number.isFinite(parsedIdleTimeout) || parsedIdleTimeout < 0) {
+        log(
+          'warn',
+          `[Daemon] Invalid ${DAEMON_IDLE_TIMEOUT_ENV_KEY}=${configuredIdleTimeout}; using default ${idleTimeoutMs}ms`,
+        );
+      }
     }
-    isShuttingDown = true;
 
-    if (idleCheckTimer) {
-      clearInterval(idleCheckTimer);
-      idleCheckTimer = null;
+    if (idleTimeoutMs === 0) {
+      log('info', '[Daemon] Idle shutdown disabled');
+    } else {
+      log(
+        'info',
+        `[Daemon] Idle shutdown enabled: timeout=${idleTimeoutMs}ms interval=${DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS}ms`,
+      );
     }
+    recordDaemonGaugeMetric('idle_timeout_ms', idleTimeoutMs);
 
-    recordDaemonLifecycleMetric('shutdown');
-    log('info', '[Daemon] Shutting down...');
+    let isShuttingDown = false;
+    let inFlightRequests = 0;
+    let lastActivityAt = Date.now();
+    let idleCheckTimer: NodeJS.Timeout | null = null;
 
-    // Close the server
-    server.close(() => {
-      log('info', '[Daemon] Server closed');
+    const markActivity = (): void => {
+      lastActivityAt = Date.now();
+    };
 
-      // Remove registry entry and socket
-      removeDaemonRegistryEntry(workspaceKey);
-      removeStaleSocket(socketPath);
-
-      log('info', '[Daemon] Cleanup complete');
-      void flushAndCloseSentry(2000).finally(() => {
-        process.exit(exitCode);
-      });
-    });
-
-    // Force exit if server doesn't close in time
-    setTimeout(() => {
-      log('warn', '[Daemon] Forced shutdown after timeout');
-      cleanupWorkspaceDaemonFiles(workspaceKey);
-      void flushAndCloseSentry(1000).finally(() => {
-        process.exit(1);
-      });
-    }, 5000);
-  };
-
-  const emitRequestGauges = (): void => {
-    recordDaemonGaugeMetric('inflight_requests', inFlightRequests);
-    recordDaemonGaugeMetric('active_sessions', getDaemonActivitySnapshot().activeOperationCount);
-  };
-
-  const server = startDaemonServer({
-    socketPath,
-    logPath: logPath ?? undefined,
-    startedAt,
-    enabledWorkflows: daemonWorkflows,
-    catalog,
-    workspaceRoot,
-    workspaceKey,
-    xcodeIdeWorkflowEnabled,
-    requestShutdown: shutdown,
-    onRequestStarted: () => {
-      inFlightRequests += 1;
-      markActivity();
-      emitRequestGauges();
-    },
-    onRequestFinished: () => {
-      inFlightRequests = Math.max(0, inFlightRequests - 1);
-      markActivity();
-      emitRequestGauges();
-    },
-  });
-  emitRequestGauges();
-
-  if (idleTimeoutMs > 0) {
-    idleCheckTimer = setInterval(() => {
+    // Unified shutdown handler
+    const shutdown = (exitCode = 0): void => {
       if (isShuttingDown) {
         return;
       }
+      isShuttingDown = true;
 
-      emitRequestGauges();
-
-      const idleForMs = Date.now() - lastActivityAt;
-      if (idleForMs < idleTimeoutMs) {
-        return;
+      if (idleCheckTimer) {
+        clearInterval(idleCheckTimer);
+        idleCheckTimer = null;
       }
 
-      if (inFlightRequests > 0) {
-        return;
-      }
+      recordDaemonLifecycleMetric('shutdown');
+      log('info', '[Daemon] Shutting down...');
 
-      if (hasActiveRuntimeSessions(getDaemonActivitySnapshot())) {
-        return;
-      }
+      const cleanupArtifacts = (): Promise<unknown> =>
+        cleanupOwnedWorkspaceFilesystemArtifacts({
+          workspaceKey,
+          trigger: 'shutdown',
+          daemonCleanup: {
+            pid: process.pid,
+            socketPath,
+            allowLiveOwner: true,
+          },
+        });
 
-      log(
-        'info',
-        `[Daemon] Idle timeout reached (${idleForMs}ms >= ${idleTimeoutMs}ms); shutting down`,
-      );
-      shutdown();
-    }, DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS);
-    idleCheckTimer.unref?.();
-  }
+      let forcedShutdownTimer: NodeJS.Timeout | null = setTimeout(() => {
+        forcedShutdownTimer = null;
+        log('warn', '[Daemon] Forced shutdown after timeout');
+        void cleanupArtifacts().finally(() => {
+          void flushAndCloseSentry(1000).finally(() => {
+            process.exit(1);
+          });
+        });
+      }, 5000);
+      forcedShutdownTimer.unref?.();
 
-  server.listen(socketPath, () => {
-    log('info', `[Daemon] Listening on ${socketPath}`);
+      server.close(() => {
+        if (forcedShutdownTimer) {
+          clearTimeout(forcedShutdownTimer);
+          forcedShutdownTimer = null;
+        }
+        log('info', '[Daemon] Server closed');
+        void cleanupArtifacts().finally(() => {
+          log('info', '[Daemon] Cleanup complete');
+          void flushAndCloseSentry(2000).finally(() => {
+            process.exit(exitCode);
+          });
+        });
+      });
+    };
 
-    // Write registry entry after successful listen
-    writeDaemonRegistryEntry({
-      workspaceKey,
-      workspaceRoot,
+    const emitRequestGauges = (): void => {
+      recordDaemonGaugeMetric('inflight_requests', inFlightRequests);
+      recordDaemonGaugeMetric('active_sessions', getDaemonActivitySnapshot().activeOperationCount);
+    };
+
+    const server = startDaemonServer({
       socketPath,
       logPath: logPath ?? undefined,
-      pid: process.pid,
       startedAt,
       enabledWorkflows: daemonWorkflows,
-      version: String(version),
+      catalog,
+      workspaceRoot,
+      workspaceKey,
+      xcodeIdeWorkflowEnabled,
+      requestShutdown: shutdown,
+      onRequestStarted: () => {
+        inFlightRequests += 1;
+        markActivity();
+        emitRequestGauges();
+      },
+      onRequestFinished: () => {
+        inFlightRequests = Math.max(0, inFlightRequests - 1);
+        markActivity();
+        emitRequestGauges();
+      },
     });
+    emitRequestGauges();
 
-    writeLine(`Daemon started (PID: ${process.pid})`);
-    writeLine(`Workspace: ${workspaceRoot}`);
-    writeLine(`Socket: ${socketPath}`);
-    writeLine(`Tools: ${catalog.tools.length}`);
-    recordBootstrapDurationMetric('cli-daemon', Date.now() - daemonBootstrapStart);
+    if (idleTimeoutMs > 0) {
+      idleCheckTimer = setInterval(() => {
+        if (isShuttingDown) {
+          return;
+        }
 
-    setImmediate(() => {
-      void enrichSentryMetadata().catch((error) => {
-        const message = error instanceof Error ? error.message : String(error);
-        log('warn', `[Daemon] Failed to enrich Sentry metadata: ${message}`);
+        emitRequestGauges();
+
+        const idleForMs = Date.now() - lastActivityAt;
+        if (idleForMs < idleTimeoutMs) {
+          return;
+        }
+
+        if (inFlightRequests > 0) {
+          return;
+        }
+
+        if (hasActiveRuntimeSessions(getDaemonActivitySnapshot())) {
+          return;
+        }
+
+        log(
+          'info',
+          `[Daemon] Idle timeout reached (${idleForMs}ms >= ${idleTimeoutMs}ms); shutting down`,
+        );
+        shutdown();
+      }, DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS);
+      idleCheckTimer.unref?.();
+    }
+
+    server.on('error', releaseStartupRegistryLock);
+
+    server.listen(socketPath, () => {
+      log('info', `[Daemon] Listening on ${socketPath}`);
+
+      // Write registry entry after successful listen
+      try {
+        writeDaemonRegistryEntry(
+          {
+            workspaceKey,
+            workspaceRoot,
+            socketPath,
+            logPath: logPath ?? undefined,
+            pid: process.pid,
+            startedAt,
+            enabledWorkflows: daemonWorkflows,
+            version: String(version),
+          },
+          { lock: startupRegistryLock },
+        );
+      } finally {
+        releaseStartupRegistryLock();
+      }
+
+      writeLine(`Daemon started (PID: ${process.pid})`);
+      writeLine(`Workspace: ${workspaceRoot}`);
+      writeLine(`Socket: ${socketPath}`);
+      writeLine(`Tools: ${catalog.tools.length}`);
+      recordBootstrapDurationMetric('cli-daemon', Date.now() - daemonBootstrapStart);
+
+      // Filesystem orphan reconciliation and log retention run fire-and-forget after listen so
+      // a slow sweep cannot delay request serving. Request handlers must not assume orphans
+      // have been cleaned at startup.
+      setImmediate(() => {
+        void enrichSentryMetadata().catch((error) => {
+          const message = error instanceof Error ? error.message : String(error);
+          log('warn', `[Daemon] Failed to enrich Sentry metadata: ${message}`);
+        });
+        void runStartupLifecycleSweep();
       });
     });
-  });
 
-  const handleCrash = (reason: unknown): void => {
-    recordDaemonLifecycleMetric('crash');
-    const message = reason instanceof Error ? reason.message : String(reason);
-    log('error', `[Daemon] Crash: ${message}`, { sentry: true });
-    shutdown(1);
-  };
+    const handleCrash = (reason: unknown): void => {
+      recordDaemonLifecycleMetric('crash');
+      const message = reason instanceof Error ? reason.message : String(reason);
+      log('error', `[Daemon] Crash: ${message}`, { sentry: true });
+      shutdown(1);
+    };
 
-  process.on('exit', () => {
-    terminateLiveSimulatorLaunchOsLogSessionsSync();
-  });
-  process.on('SIGTERM', () => shutdown(0));
-  process.on('SIGINT', () => shutdown(0));
-  process.on('uncaughtException', handleCrash);
-  process.on('unhandledRejection', handleCrash);
+    process.on('exit', () => {
+      terminateOwnedWorkspaceFilesystemArtifactsSync();
+    });
+    process.on('SIGTERM', () => shutdown(0));
+    process.on('SIGINT', () => shutdown(0));
+    process.on('uncaughtException', handleCrash);
+    process.on('unhandledRejection', handleCrash);
+  } catch (error) {
+    releaseStartupRegistryLock();
+    throw error;
+  }
 }
 
 main().catch(async (err) => {

diff --git a/src/daemon/__tests__/daemon-registry.test.ts b/src/daemon/__tests__/daemon-registry.test.ts
new file mode 100644
--- /dev/null
+++ b/src/daemon/__tests__/daemon-registry.test.ts
@@ -1,0 +1,262 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { existsSync, mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import * as path from 'node:path';
+import {
+  acquireDaemonRegistryMutationLock,
+  cleanupWorkspaceDaemonFiles,
+  findDaemonRegistryEntryBySocketPath,
+  listDaemonRegistryEntries,
+  readDaemonRegistryEntry,
+  type DaemonRegistryEntry,
+  writeDaemonRegistryEntry,
+} from '../daemon-registry.ts';
+import {
+  daemonDirForWorkspaceKey,
+  logPathForWorkspaceKey,
+  registryPathForWorkspaceKey,
+  setDaemonRunDirOverrideForTests,
+} from '../socket-path.ts';
+import { setXcodeBuildMCPAppDirOverrideForTests } from '../../utils/log-paths.ts';
+
+const stalePid = 999_999_999;
+
+function createEntry(overrides: Partial<DaemonRegistryEntry> = {}): DaemonRegistryEntry {
+  const workspaceKey = overrides.workspaceKey ?? 'workspace-a';
+  return {
+    workspaceKey,
+    workspaceRoot: `/workspaces/${workspaceKey}`,
+    socketPath: path.join(daemonDirForWorkspaceKey(workspaceKey), 'd.sock'),
+    pid: stalePid,
+    startedAt: '2026-05-02T00:00:00.000Z',
+    enabledWorkflows: ['build'],
+    version: '1.0.0',
+    ...overrides,
+  };
+}
+
+describe('daemon registry', () => {
+  let appDir: string;
+  let daemonRunDir: string;
+
+  beforeEach(() => {
... diff truncated: showing 800 of 8387 lines

You can send follow-ups to the cloud agent here.

Comment thread src/server/mcp-shutdown.ts Outdated
cursoragent and others added 2 commits May 4, 2026 18:12
Keep the workspace filesystem cleanup shutdown step on a count-scaled
outer timeout while using the normal per-session timeout for the inner
owned OSLog cleanup work.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Force-stop daemon fails to clean socket on lock contention
    • Wrapped cleanupWorkspaceDaemonFiles call in try-catch with fallback to direct socket removal when lock acquisition fails.
Preview (53f3dfac2d)
diff --git a/AGENTS.md b/AGENTS.md
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,6 +80,15 @@
 - When working on skill sources in `skills/`, use the `skill-creator` skill workflow.
 - After modifying any skill source, run `npx skill-check <skill-directory>` and address all errors/warnings before handoff.
 -
+## Multi-process filesystem state
+- XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces.
+- Shared filesystem state under `~/Library/Developer/XcodeBuildMCP` must be multi-process safe.
+- Use workspace-key scoped directories for workspace-owned state.
+- Do not store runtime state under `~/.xcodebuildmcp`; `.xcodebuildmcp/config.yaml` is only project configuration.
+- Use shared lock and atomic-write helpers for mutable shared files.
+- Prefer one-record-per-file registries over shared aggregate files.
+- Cleanup must verify ownership before deleting shared artifacts.
+
 ## Style
 - Keep answers short and concise
 - No emojis in commits, issues, PR comments, or code

diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,10 @@
 - Fixed simulator OSLog helper cleanup so server and daemon startup reconcile same-workspace orphaned log streams without stopping helpers owned by live sessions in other workspaces ([#382](https://github.com/getsentry/XcodeBuildMCP/issues/382)).
 - Fixed Weather example test discovery and made CLI test progress visible while tests are running instead of leaving the last build phase displayed.
 
+### Changed
+
+- Centralized workspace log retention and startup/shutdown filesystem cleanup so XcodeBuildMCP-owned logs are pruned consistently while preserving active daemon and simulator OSLog outputs.
+
 ## [2.5.0-beta.1]
 
 ### Breaking

diff --git a/CLAUDE.md b/CLAUDE.md
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,6 +21,15 @@
 - When working on skill sources in `skills/`, use the `skill-creator` skill workflow.
 - After modifying any skill source, run `npx skill-check <skill-directory>` and address all errors/warnings before handoff.
 -
+## Multi-process filesystem state
+- XcodeBuildMCP explicitly supports multiple concurrent MCP server, daemon, CLI, test, and helper processes for the same or different workspaces.
+- Shared filesystem state under `~/Library/Developer/XcodeBuildMCP` must be multi-process safe.
+- Use workspace-key scoped directories for workspace-owned state.
+- Do not store runtime state under `~/.xcodebuildmcp`; `.xcodebuildmcp/config.yaml` is only project configuration.
+- Use shared lock and atomic-write helpers for mutable shared files.
+- Prefer one-record-per-file registries over shared aggregate files.
+- Cleanup must verify ownership before deleting shared artifacts.
+
 ## Style
 - Keep answers short and concise
 - No emojis in commits, issues, PR comments, or code

diff --git a/src/cli.ts b/src/cli.ts
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -2,13 +2,12 @@
 import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts';
 import { buildCliToolCatalog } from './cli/cli-tool-catalog.ts';
 import { buildYargsApp } from './cli/yargs-app.ts';
-import { getSocketPath, getWorkspaceKey, resolveWorkspaceRoot } from './daemon/socket-path.ts';
+import { getSocketPath } from './daemon/socket-path.ts';
 import { startMcpServer } from './server/start-mcp-server.ts';
 import { listCliWorkflowIdsFromManifest } from './runtime/tool-catalog.ts';
 import { flushAndCloseSentry, initSentry, recordBootstrapDurationMetric } from './utils/sentry.ts';
 import { coerceLogLevel, setLogLevel, type LogLevel } from './utils/logger.ts';
 import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts';
-import { configureRuntimeWorkspaceKey } from './utils/runtime-instance.ts';
 
 function findTopLevelCommand(argv: string[]): string | undefined {
   const flagsWithValue = new Set(['--socket', '--log-level', '--style']);
@@ -119,23 +118,13 @@
     },
   });
 
-  // Compute workspace context for daemon routing
-  const workspaceRoot = resolveWorkspaceRoot({
-    cwd: result.runtime.cwd,
-    projectConfigPath: result.configPath,
-  });
+  const { workspaceRoot, workspaceKey } = result;
 
   const defaultSocketPath = getSocketPath({
     cwd: result.runtime.cwd,
     projectConfigPath: result.configPath,
   });
 
-  const workspaceKey = getWorkspaceKey({
-    cwd: result.runtime.cwd,
-    projectConfigPath: result.configPath,
-  });
-  configureRuntimeWorkspaceKey(workspaceKey);
-
   const cliExposedWorkflowIds = await listCliWorkflowIdsFromManifest({
     excludeWorkflows: ['session-management', 'workflow-discovery'],
   });

diff --git a/src/cli/daemon-control.ts b/src/cli/daemon-control.ts
--- a/src/cli/daemon-control.ts
+++ b/src/cli/daemon-control.ts
@@ -1,10 +1,12 @@
 import { spawn } from 'node:child_process';
 import { fileURLToPath } from 'node:url';
-import { dirname, resolve, basename } from 'node:path';
-import { existsSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { existsSync, unlinkSync } from 'node:fs';
 import { DaemonClient, DaemonVersionMismatchError } from './daemon-client.ts';
-import { readDaemonRegistryEntry } from '../daemon/daemon-registry.ts';
-import { removeStaleSocket } from '../daemon/socket-path.ts';
+import {
+  cleanupWorkspaceDaemonFiles,
+  findDaemonRegistryEntryBySocketPath,
+} from '../daemon/daemon-registry.ts';
 
 /**
  * Default timeout for daemon startup in milliseconds.
@@ -38,8 +40,7 @@
  * sends SIGTERM, and removes the stale socket.
  */
 export async function forceStopDaemon(socketPath: string): Promise<void> {
-  const workspaceKey = basename(dirname(socketPath));
-  const entry = readDaemonRegistryEntry(workspaceKey);
+  const entry = findDaemonRegistryEntryBySocketPath(socketPath);
   if (entry?.pid) {
     try {
       process.kill(entry.pid, 'SIGTERM');
@@ -49,7 +50,30 @@
     // Brief wait for the process to exit.
     await new Promise((resolve) => setTimeout(resolve, 500));
   }
-  removeStaleSocket(socketPath);
+  if (entry) {
+    try {
+      cleanupWorkspaceDaemonFiles(entry.workspaceKey, {
+        pid: entry.pid,
+        socketPath,
+        allowLiveOwner: true,
+      });
+    } catch {
+      // Lock contention: fall back to direct socket removal.
+      try {
+        unlinkSync(socketPath);
+      } catch {
+        // Socket may already be gone.
+      }
+    }
+  } else {
+    // Registry entry missing; cannot derive workspace key from socket path alone.
+    // Clean up the socket file directly.
+    try {
+      unlinkSync(socketPath);
+    } catch {
+      // Socket may already be gone.
+    }
+  }
 }
 
 export interface StartDaemonBackgroundOptions {

diff --git a/src/daemon.ts b/src/daemon.ts
--- a/src/daemon.ts
+++ b/src/daemon.ts
@@ -9,15 +9,13 @@
   ensureSocketDir,
   removeStaleSocket,
   getSocketPath,
-  getWorkspaceKey,
-  resolveWorkspaceRoot,
   logPathForWorkspaceKey,
 } from './daemon/socket-path.ts';
 import { startDaemonServer } from './daemon/daemon-server.ts';
 import {
+  acquireDaemonRegistryMutationLock,
   writeDaemonRegistryEntry,
-  removeDaemonRegistryEntry,
-  cleanupWorkspaceDaemonFiles,
+  type DaemonRegistryMutationLock,
 } from './daemon/daemon-registry.ts';
 import { log, normalizeLogLevel, setLogFile, setLogLevel } from './utils/logger.ts';
 import { version } from './version.ts';
@@ -42,11 +40,11 @@
 } from './utils/sentry.ts';
 import { isXcodemakeBinaryAvailable, isXcodemakeEnabled } from './utils/xcodemake/index.ts';
 import { hydrateSentryDisabledEnvFromProjectConfig } from './utils/sentry-config.ts';
-import { configureRuntimeWorkspaceKey } from './utils/runtime-instance.ts';
 import {
-  reconcileSimulatorLaunchOsLogOrphansForWorkspace,
-  terminateLiveSimulatorLaunchOsLogSessionsSync,
-} from './utils/log-capture/index.ts';
+  cleanupOwnedWorkspaceFilesystemArtifacts,
+  runWorkspaceFilesystemLifecycleSweep,
+  terminateOwnedWorkspaceFilesystemArtifactsSync,
+} from './utils/workspace-filesystem-lifecycle.ts';
 
 async function checkExistingDaemon(socketPath: string): Promise<boolean> {
   return new Promise<boolean>((resolve) => {
@@ -124,17 +122,8 @@
     },
   });
 
-  const workspaceRoot = resolveWorkspaceRoot({
-    cwd: result.runtime.cwd,
-    projectConfigPath: result.configPath,
-  });
+  const { workspaceRoot, workspaceKey } = result;
 
-  const workspaceKey = getWorkspaceKey({
-    cwd: result.runtime.cwd,
-    projectConfigPath: result.configPath,
-  });
-  configureRuntimeWorkspaceKey(workspaceKey);
-
   const logPath = resolveDaemonLogPath(workspaceKey);
   if (logPath) {
     ensureLogDir(logPath);
@@ -159,20 +148,27 @@
 
   log('info', `[Daemon] Workspace: ${workspaceRoot}`);
   log('info', `[Daemon] Socket: ${socketPath}`);
-  try {
-    const reconciliation = await reconcileSimulatorLaunchOsLogOrphansForWorkspace(workspaceKey);
-    if (reconciliation.stoppedSessionCount > 0 || reconciliation.errorCount > 0) {
+
+  const runStartupLifecycleSweep = async (): Promise<void> => {
+    try {
+      const lifecycle = await runWorkspaceFilesystemLifecycleSweep({
+        workspaceKey,
+        trigger: 'startup',
+      });
+      if (lifecycle.stopped > 0 || lifecycle.deleted > 0 || lifecycle.errors.length > 0) {
+        log(
+          lifecycle.errors.length > 0 ? 'warn' : 'info',
+          `[Daemon] Filesystem lifecycle: ${JSON.stringify(lifecycle)}`,
+        );
+      }
+    } catch (error) {
       log(
-        reconciliation.errorCount > 0 ? 'warn' : 'info',
-        `[Daemon] Simulator OSLog reconciliation: ${JSON.stringify(reconciliation)}`,
+        'warn',
+        `[Daemon] Filesystem lifecycle failed: ${error instanceof Error ? error.message : String(error)}`,
       );
     }
-  } catch (error) {
-    log(
-      'warn',
-      `[Daemon] Simulator OSLog reconciliation failed: ${error instanceof Error ? error.message : String(error)}`,
-    );
-  }
+  };
+
   if (logPath) {
     log('info', `[Daemon] Logs: ${logPath}`);
   }
@@ -187,246 +183,300 @@
     process.exit(1);
   }
 
-  removeStaleSocket(socketPath);
-
-  const excludedWorkflows = ['session-management', 'workflow-discovery'];
-
-  // Daemon runtime serves CLI routing and should not be filtered by enabledWorkflows.
-  // CLI exposure is controlled at CLI catalog/command registration time.
-  // Get all workflows from manifest (for reporting purposes and filtering).
-  const manifest = loadManifest();
-  const allWorkflowIds = Array.from(manifest.workflows.keys());
-  const daemonWorkflows = allWorkflowIds.filter(
-    (workflowId) => !excludedWorkflows.includes(workflowId),
-  );
-  const xcodeIdeWorkflowEnabled = daemonWorkflows.includes('xcode-ide');
-  const axeBinary = resolveAxeBinary();
-  const axeAvailable = axeBinary !== null;
-  const axeSource: 'env' | 'bundled' | 'path' | 'unavailable' = axeBinary?.source ?? 'unavailable';
-  const xcodemakeAvailable = isXcodemakeBinaryAvailable();
-  const xcodemakeEnabled = isXcodemakeEnabled();
-  const baseSentryRuntimeContext = {
-    mode: 'cli-daemon' as const,
-    enabledWorkflows: daemonWorkflows,
-    disableSessionDefaults: result.runtime.config.disableSessionDefaults,
-    disableXcodeAutoSync: result.runtime.config.disableXcodeAutoSync,
-    incrementalBuildsEnabled: result.runtime.config.incrementalBuildsEnabled,
-    debugEnabled: result.runtime.config.debug,
-    uiDebuggerGuardMode: result.runtime.config.uiDebuggerGuardMode,
-    xcodeIdeWorkflowEnabled,
-    axeAvailable,
-    axeSource,
-    xcodemakeAvailable,
-    xcodemakeEnabled,
+  const startupRegistryLock = acquireDaemonRegistryMutationLock(workspaceKey);
+  if (!startupRegistryLock) {
+    log('error', '[Daemon] Unable to acquire daemon registry lock');
+    console.error('Error: Unable to acquire daemon registry lock');
+    await flushAndCloseSentry(1000);
+    process.exit(1);
+  }
+  let pendingStartupRegistryLock: DaemonRegistryMutationLock | null = startupRegistryLock;
+  const releaseStartupRegistryLock = (): void => {
+    pendingStartupRegistryLock?.release();
+    pendingStartupRegistryLock = null;
   };
-  setSentryRuntimeContext(baseSentryRuntimeContext);
 
-  const enrichSentryMetadata = async (): Promise<void> => {
-    const commandExecutor = getDefaultCommandExecutor();
-    const xcodeVersion = await getXcodeVersionMetadata(async (command) => {
-      const result = await commandExecutor(command, 'Get Xcode Version');
-      return { success: result.success, output: result.output };
-    });
-    const xcodeAvailable = Boolean(
-      xcodeVersion.version ??
-        xcodeVersion.buildVersion ??
-        xcodeVersion.developerDir ??
-        xcodeVersion.xcodebuildPath,
-    );
-    const axeVersion = await getAxeVersionMetadata(async (command) => {
-      const result = await commandExecutor(command, 'Get AXe Version');
-      return { success: result.success, output: result.output };
-    }, axeBinary?.path);
+  const isRunningAfterLock = await checkExistingDaemon(socketPath);
+  if (isRunningAfterLock) {
+    releaseStartupRegistryLock();
+    log('error', '[Daemon] Another daemon is already running for this workspace');
+    console.error('Error: Daemon is already running for this workspace');
+    await flushAndCloseSentry(1000);
+    process.exit(1);
+  }
 
-    setSentryRuntimeContext({
-      ...baseSentryRuntimeContext,
-      xcodeAvailable,
-      axeVersion,
-      xcodeDeveloperDir: xcodeVersion.developerDir,
-      xcodebuildPath: xcodeVersion.xcodebuildPath,
-      xcodeVersion: xcodeVersion.version,
-      xcodeBuildVersion: xcodeVersion.buildVersion,
-    });
-  };
+  try {
+    removeStaleSocket(socketPath);
 
-  const catalog = await buildDaemonToolCatalogFromManifest({
-    excludeWorkflows: excludedWorkflows,
-  });
+    const excludedWorkflows = ['session-management', 'workflow-discovery'];
 
-  log('info', `[Daemon] Loaded ${catalog.tools.length} tools`);
+    // Daemon runtime serves CLI routing and should not be filtered by enabledWorkflows.
+    // CLI exposure is controlled at CLI catalog/command registration time.
+    // Get all workflows from manifest (for reporting purposes and filtering).
+    const manifest = loadManifest();
+    const allWorkflowIds = Array.from(manifest.workflows.keys());
+    const daemonWorkflows = allWorkflowIds.filter(
+      (workflowId) => !excludedWorkflows.includes(workflowId),
+    );
+    const xcodeIdeWorkflowEnabled = daemonWorkflows.includes('xcode-ide');
+    const axeBinary = resolveAxeBinary();
+    const axeAvailable = axeBinary !== null;
+    const axeSource: 'env' | 'bundled' | 'path' | 'unavailable' =
+      axeBinary?.source ?? 'unavailable';
+    const xcodemakeAvailable = isXcodemakeBinaryAvailable();
+    const xcodemakeEnabled = isXcodemakeEnabled();
+    const baseSentryRuntimeContext = {
+      mode: 'cli-daemon' as const,
+      enabledWorkflows: daemonWorkflows,
+      disableSessionDefaults: result.runtime.config.disableSessionDefaults,
+      disableXcodeAutoSync: result.runtime.config.disableXcodeAutoSync,
+      incrementalBuildsEnabled: result.runtime.config.incrementalBuildsEnabled,
+      debugEnabled: result.runtime.config.debug,
+      uiDebuggerGuardMode: result.runtime.config.uiDebuggerGuardMode,
+      xcodeIdeWorkflowEnabled,
+      axeAvailable,
+      axeSource,
+      xcodemakeAvailable,
+      xcodemakeEnabled,
+    };
+    setSentryRuntimeContext(baseSentryRuntimeContext);
 
-  const startedAt = new Date().toISOString();
-  const idleTimeoutMs = resolveDaemonIdleTimeoutMs();
-  const configuredIdleTimeout = process.env[DAEMON_IDLE_TIMEOUT_ENV_KEY]?.trim();
-  if (configuredIdleTimeout) {
-    const parsedIdleTimeout = Number(configuredIdleTimeout);
-    if (!Number.isFinite(parsedIdleTimeout) || parsedIdleTimeout < 0) {
-      log(
-        'warn',
-        `[Daemon] Invalid ${DAEMON_IDLE_TIMEOUT_ENV_KEY}=${configuredIdleTimeout}; using default ${idleTimeoutMs}ms`,
+    const enrichSentryMetadata = async (): Promise<void> => {
+      const commandExecutor = getDefaultCommandExecutor();
+      const xcodeVersion = await getXcodeVersionMetadata(async (command) => {
+        const result = await commandExecutor(command, 'Get Xcode Version');
+        return { success: result.success, output: result.output };
+      });
+      const xcodeAvailable = Boolean(
+        xcodeVersion.version ??
+          xcodeVersion.buildVersion ??
+          xcodeVersion.developerDir ??
+          xcodeVersion.xcodebuildPath,
       );
-    }
-  }
+      const axeVersion = await getAxeVersionMetadata(async (command) => {
+        const result = await commandExecutor(command, 'Get AXe Version');
+        return { success: result.success, output: result.output };
+      }, axeBinary?.path);
 
-  if (idleTimeoutMs === 0) {
-    log('info', '[Daemon] Idle shutdown disabled');
-  } else {
-    log(
-      'info',
-      `[Daemon] Idle shutdown enabled: timeout=${idleTimeoutMs}ms interval=${DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS}ms`,
-    );
-  }
-  recordDaemonGaugeMetric('idle_timeout_ms', idleTimeoutMs);
+      setSentryRuntimeContext({
+        ...baseSentryRuntimeContext,
+        xcodeAvailable,
+        axeVersion,
+        xcodeDeveloperDir: xcodeVersion.developerDir,
+        xcodebuildPath: xcodeVersion.xcodebuildPath,
+        xcodeVersion: xcodeVersion.version,
+        xcodeBuildVersion: xcodeVersion.buildVersion,
+      });
+    };
 
-  let isShuttingDown = false;
-  let inFlightRequests = 0;
-  let lastActivityAt = Date.now();
-  let idleCheckTimer: NodeJS.Timeout | null = null;
+    const catalog = await buildDaemonToolCatalogFromManifest({
+      excludeWorkflows: excludedWorkflows,
+    });
 
-  const markActivity = (): void => {
-    lastActivityAt = Date.now();
-  };
+    log('info', `[Daemon] Loaded ${catalog.tools.length} tools`);
 
-  // Unified shutdown handler
-  const shutdown = (exitCode = 0): void => {
-    if (isShuttingDown) {
-      return;
+    const startedAt = new Date().toISOString();
+    const idleTimeoutMs = resolveDaemonIdleTimeoutMs();
+    const configuredIdleTimeout = process.env[DAEMON_IDLE_TIMEOUT_ENV_KEY]?.trim();
+    if (configuredIdleTimeout) {
+      const parsedIdleTimeout = Number(configuredIdleTimeout);
+      if (!Number.isFinite(parsedIdleTimeout) || parsedIdleTimeout < 0) {
+        log(
+          'warn',
+          `[Daemon] Invalid ${DAEMON_IDLE_TIMEOUT_ENV_KEY}=${configuredIdleTimeout}; using default ${idleTimeoutMs}ms`,
+        );
+      }
     }
-    isShuttingDown = true;
 
-    if (idleCheckTimer) {
-      clearInterval(idleCheckTimer);
-      idleCheckTimer = null;
+    if (idleTimeoutMs === 0) {
+      log('info', '[Daemon] Idle shutdown disabled');
+    } else {
+      log(
+        'info',
+        `[Daemon] Idle shutdown enabled: timeout=${idleTimeoutMs}ms interval=${DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS}ms`,
+      );
     }
+    recordDaemonGaugeMetric('idle_timeout_ms', idleTimeoutMs);
 
-    recordDaemonLifecycleMetric('shutdown');
-    log('info', '[Daemon] Shutting down...');
+    let isShuttingDown = false;
+    let inFlightRequests = 0;
+    let lastActivityAt = Date.now();
+    let idleCheckTimer: NodeJS.Timeout | null = null;
 
-    // Close the server
-    server.close(() => {
-      log('info', '[Daemon] Server closed');
+    const markActivity = (): void => {
+      lastActivityAt = Date.now();
+    };
 
-      // Remove registry entry and socket
-      removeDaemonRegistryEntry(workspaceKey);
-      removeStaleSocket(socketPath);
-
-      log('info', '[Daemon] Cleanup complete');
-      void flushAndCloseSentry(2000).finally(() => {
-        process.exit(exitCode);
-      });
-    });
-
-    // Force exit if server doesn't close in time
-    setTimeout(() => {
-      log('warn', '[Daemon] Forced shutdown after timeout');
-      cleanupWorkspaceDaemonFiles(workspaceKey);
-      void flushAndCloseSentry(1000).finally(() => {
-        process.exit(1);
-      });
-    }, 5000);
-  };
-
-  const emitRequestGauges = (): void => {
-    recordDaemonGaugeMetric('inflight_requests', inFlightRequests);
-    recordDaemonGaugeMetric('active_sessions', getDaemonActivitySnapshot().activeOperationCount);
-  };
-
-  const server = startDaemonServer({
-    socketPath,
-    logPath: logPath ?? undefined,
-    startedAt,
-    enabledWorkflows: daemonWorkflows,
-    catalog,
-    workspaceRoot,
-    workspaceKey,
-    xcodeIdeWorkflowEnabled,
-    requestShutdown: shutdown,
-    onRequestStarted: () => {
-      inFlightRequests += 1;
-      markActivity();
-      emitRequestGauges();
-    },
-    onRequestFinished: () => {
-      inFlightRequests = Math.max(0, inFlightRequests - 1);
-      markActivity();
-      emitRequestGauges();
-    },
-  });
-  emitRequestGauges();
-
-  if (idleTimeoutMs > 0) {
-    idleCheckTimer = setInterval(() => {
+    // Unified shutdown handler
+    const shutdown = (exitCode = 0): void => {
       if (isShuttingDown) {
         return;
       }
+      isShuttingDown = true;
 
-      emitRequestGauges();
-
-      const idleForMs = Date.now() - lastActivityAt;
-      if (idleForMs < idleTimeoutMs) {
-        return;
+      if (idleCheckTimer) {
+        clearInterval(idleCheckTimer);
+        idleCheckTimer = null;
       }
 
-      if (inFlightRequests > 0) {
-        return;
-      }
+      recordDaemonLifecycleMetric('shutdown');
+      log('info', '[Daemon] Shutting down...');
 
-      if (hasActiveRuntimeSessions(getDaemonActivitySnapshot())) {
-        return;
-      }
+      const cleanupArtifacts = (): Promise<unknown> =>
+        cleanupOwnedWorkspaceFilesystemArtifacts({
+          workspaceKey,
+          trigger: 'shutdown',
+          daemonCleanup: {
+            pid: process.pid,
+            socketPath,
+            allowLiveOwner: true,
+          },
+        });
 
-      log(
-        'info',
-        `[Daemon] Idle timeout reached (${idleForMs}ms >= ${idleTimeoutMs}ms); shutting down`,
-      );
-      shutdown();
-    }, DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS);
-    idleCheckTimer.unref?.();
-  }
+      let forcedShutdownTimer: NodeJS.Timeout | null = setTimeout(() => {
+        forcedShutdownTimer = null;
+        log('warn', '[Daemon] Forced shutdown after timeout');
+        void cleanupArtifacts().finally(() => {
+          void flushAndCloseSentry(1000).finally(() => {
+            process.exit(1);
+          });
+        });
+      }, 5000);
+      forcedShutdownTimer.unref?.();
 
-  server.listen(socketPath, () => {
-    log('info', `[Daemon] Listening on ${socketPath}`);
+      server.close(() => {
+        if (forcedShutdownTimer) {
+          clearTimeout(forcedShutdownTimer);
+          forcedShutdownTimer = null;
+        }
+        log('info', '[Daemon] Server closed');
+        void cleanupArtifacts().finally(() => {
+          log('info', '[Daemon] Cleanup complete');
+          void flushAndCloseSentry(2000).finally(() => {
+            process.exit(exitCode);
+          });
+        });
+      });
+    };
 
-    // Write registry entry after successful listen
-    writeDaemonRegistryEntry({
-      workspaceKey,
-      workspaceRoot,
+    const emitRequestGauges = (): void => {
+      recordDaemonGaugeMetric('inflight_requests', inFlightRequests);
+      recordDaemonGaugeMetric('active_sessions', getDaemonActivitySnapshot().activeOperationCount);
+    };
+
+    const server = startDaemonServer({
       socketPath,
       logPath: logPath ?? undefined,
-      pid: process.pid,
       startedAt,
       enabledWorkflows: daemonWorkflows,
-      version: String(version),
+      catalog,
+      workspaceRoot,
+      workspaceKey,
+      xcodeIdeWorkflowEnabled,
+      requestShutdown: shutdown,
+      onRequestStarted: () => {
+        inFlightRequests += 1;
+        markActivity();
+        emitRequestGauges();
+      },
+      onRequestFinished: () => {
+        inFlightRequests = Math.max(0, inFlightRequests - 1);
+        markActivity();
+        emitRequestGauges();
+      },
     });
+    emitRequestGauges();
 
-    writeLine(`Daemon started (PID: ${process.pid})`);
-    writeLine(`Workspace: ${workspaceRoot}`);
-    writeLine(`Socket: ${socketPath}`);
-    writeLine(`Tools: ${catalog.tools.length}`);
-    recordBootstrapDurationMetric('cli-daemon', Date.now() - daemonBootstrapStart);
+    if (idleTimeoutMs > 0) {
+      idleCheckTimer = setInterval(() => {
+        if (isShuttingDown) {
+          return;
+        }
 
-    setImmediate(() => {
-      void enrichSentryMetadata().catch((error) => {
-        const message = error instanceof Error ? error.message : String(error);
-        log('warn', `[Daemon] Failed to enrich Sentry metadata: ${message}`);
+        emitRequestGauges();
+
+        const idleForMs = Date.now() - lastActivityAt;
+        if (idleForMs < idleTimeoutMs) {
+          return;
+        }
+
+        if (inFlightRequests > 0) {
+          return;
+        }
+
+        if (hasActiveRuntimeSessions(getDaemonActivitySnapshot())) {
+          return;
+        }
+
+        log(
+          'info',
+          `[Daemon] Idle timeout reached (${idleForMs}ms >= ${idleTimeoutMs}ms); shutting down`,
+        );
+        shutdown();
+      }, DEFAULT_DAEMON_IDLE_CHECK_INTERVAL_MS);
+      idleCheckTimer.unref?.();
+    }
+
+    server.on('error', releaseStartupRegistryLock);
+
+    server.listen(socketPath, () => {
+      log('info', `[Daemon] Listening on ${socketPath}`);
+
+      // Write registry entry after successful listen
+      try {
+        writeDaemonRegistryEntry(
+          {
+            workspaceKey,
+            workspaceRoot,
+            socketPath,
+            logPath: logPath ?? undefined,
+            pid: process.pid,
+            startedAt,
+            enabledWorkflows: daemonWorkflows,
+            version: String(version),
+          },
+          { lock: startupRegistryLock },
+        );
+      } finally {
+        releaseStartupRegistryLock();
+      }
+
+      writeLine(`Daemon started (PID: ${process.pid})`);
+      writeLine(`Workspace: ${workspaceRoot}`);
+      writeLine(`Socket: ${socketPath}`);
+      writeLine(`Tools: ${catalog.tools.length}`);
+      recordBootstrapDurationMetric('cli-daemon', Date.now() - daemonBootstrapStart);
+
+      // Filesystem orphan reconciliation and log retention run fire-and-forget after listen so
+      // a slow sweep cannot delay request serving. Request handlers must not assume orphans
+      // have been cleaned at startup.
+      setImmediate(() => {
+        void enrichSentryMetadata().catch((error) => {
+          const message = error instanceof Error ? error.message : String(error);
+          log('warn', `[Daemon] Failed to enrich Sentry metadata: ${message}`);
+        });
+        void runStartupLifecycleSweep();
       });
     });
-  });
 
-  const handleCrash = (reason: unknown): void => {
-    recordDaemonLifecycleMetric('crash');
-    const message = reason instanceof Error ? reason.message : String(reason);
-    log('error', `[Daemon] Crash: ${message}`, { sentry: true });
-    shutdown(1);
-  };
+    const handleCrash = (reason: unknown): void => {
+      recordDaemonLifecycleMetric('crash');
+      const message = reason instanceof Error ? reason.message : String(reason);
+      log('error', `[Daemon] Crash: ${message}`, { sentry: true });
+      shutdown(1);
+    };
 
-  process.on('exit', () => {
-    terminateLiveSimulatorLaunchOsLogSessionsSync();
-  });
-  process.on('SIGTERM', () => shutdown(0));
-  process.on('SIGINT', () => shutdown(0));
-  process.on('uncaughtException', handleCrash);
-  process.on('unhandledRejection', handleCrash);
+    process.on('exit', () => {
+      terminateOwnedWorkspaceFilesystemArtifactsSync();
+    });
+    process.on('SIGTERM', () => shutdown(0));
+    process.on('SIGINT', () => shutdown(0));
+    process.on('uncaughtException', handleCrash);
+    process.on('unhandledRejection', handleCrash);
+  } catch (error) {
+    releaseStartupRegistryLock();
+    throw error;
+  }
 }
 
 main().catch(async (err) => {

diff --git a/src/daemon/__tests__/daemon-registry.test.ts b/src/daemon/__tests__/daemon-registry.test.ts
new file mode 100644
--- /dev/null
+++ b/src/daemon/__tests__/daemon-registry.test.ts
@@ -1,0 +1,262 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { existsSync, mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import * as path from 'node:path';
+import {
+  acquireDaemonRegistryMutationLock,
+  cleanupWorkspaceDaemonFiles,
+  findDaemonRegistryEntryBySocketPath,
+  listDaemonRegistryEntries,
+  readDaemonRegistryEntry,
+  type DaemonRegistryEntry,
+  writeDaemonRegistryEntry,
+} from '../daemon-registry.ts';
+import {
+  daemonDirForWorkspaceKey,
+  logPathForWorkspaceKey,
+  registryPathForWorkspaceKey,
+  setDaemonRunDirOverrideForTests,
+} from '../socket-path.ts';
+import { setXcodeBuildMCPAppDirOverrideForTests } from '../../utils/log-paths.ts';
+
+const stalePid = 999_999_999;
+
+function createEntry(overrides: Partial<DaemonRegistryEntry> = {}): DaemonRegistryEntry {
+  const workspaceKey = overrides.workspaceKey ?? 'workspace-a';
+  return {
+    workspaceKey,
+    workspaceRoot: `/workspaces/${workspaceKey}`,
+    socketPath: path.join(daemonDirForWorkspaceKey(workspaceKey), 'd.sock'),
+    pid: stalePid,
+    startedAt: '2026-05-02T00:00:00.000Z',
+    enabledWorkflows: ['build'],
+    version: '1.0.0',
... diff truncated: showing 800 of 8396 lines

You can send follow-ups to the cloud agent here.

Comment thread src/cli/daemon-control.ts Outdated
cursoragent and others added 2 commits May 4, 2026 18:20
When cleanupWorkspaceDaemonFiles fails to acquire the daemon registry
lock, forceStopDaemon now catches the error and falls back to directly
removing the socket file. This preserves the force-stop contract that
cleanup must succeed even under contention, which is critical since
force-stop is the fallback path when graceful shutdown fails.
Comment thread src/utils/fs-lock-sync.ts
Comment thread src/utils/log-paths.ts
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Server error handler swallows listen failures silently
    • Server error handler now calls handleCrash(err) to properly log, report to Sentry, and shutdown with exit code 1 on listen failures.
  • ✅ Fixed: Forced shutdown and server close race double cleanup
    • Forced shutdown timer now sets null after logging to prevent race, and server close callback only runs cleanup if timer still exists.

Create PR

Or push these changes by commenting:

@cursor push 194bc2c908
Preview (194bc2c908)
diff --git a/src/daemon.ts b/src/daemon.ts
--- a/src/daemon.ts
+++ b/src/daemon.ts
@@ -334,8 +334,8 @@
         });
 
       let forcedShutdownTimer: NodeJS.Timeout | null = setTimeout(() => {
+        log('warn', '[Daemon] Forced shutdown after timeout');
         forcedShutdownTimer = null;
-        log('warn', '[Daemon] Forced shutdown after timeout');
         void cleanupArtifacts().finally(() => {
           void flushAndCloseSentry(1000).finally(() => {
             process.exit(1);
@@ -348,14 +348,14 @@
         if (forcedShutdownTimer) {
           clearTimeout(forcedShutdownTimer);
           forcedShutdownTimer = null;
+          log('info', '[Daemon] Server closed');
+          void cleanupArtifacts().finally(() => {
+            log('info', '[Daemon] Cleanup complete');
+            void flushAndCloseSentry(2000).finally(() => {
+              process.exit(exitCode);
+            });
+          });
         }
-        log('info', '[Daemon] Server closed');
-        void cleanupArtifacts().finally(() => {
-          log('info', '[Daemon] Cleanup complete');
-          void flushAndCloseSentry(2000).finally(() => {
-            process.exit(exitCode);
-          });
-        });
       });
     };
 
@@ -417,7 +417,10 @@
       idleCheckTimer.unref?.();
     }
 
-    server.on('error', releaseStartupRegistryLock);
+    server.on('error', (err) => {
+      releaseStartupRegistryLock();
+      handleCrash(err);
+    });
 
     server.listen(socketPath, () => {
       log('info', `[Daemon] Listening on ${socketPath}`);

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 0047a48. Configure here.

Comment thread src/daemon.ts Outdated
Comment thread src/daemon.ts
Comment thread src/utils/workspace-filesystem-lifecycle.ts
cameroncooke and others added 2 commits May 4, 2026 20:03
Release the startup registry lock and exit non-zero when the daemon server fails to listen.
Guard shutdown cleanup so forced timeout and server close cannot run cleanup twice.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Treat inaccessible PIDs as live, require daemon instance identity before
live-owner cleanup, and make force-stop verify the current registry entry
before signaling or unregistering daemon files.

Move MCP startup cleanup off the stdio critical path and report embedded
shutdown cleanup errors as failures. Normalize unstable Swift Testing
snapshot progress while preserving stable failure summaries.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Comment thread src/snapshot-tests/json-normalize.ts Outdated
Comment thread src/snapshot-tests/normalize.ts Outdated
cameroncooke and others added 2 commits May 5, 2026 02:00
Keep suite-less simulator test cases in structured snapshot fixtures so
normalization does not hide output contract changes. Replace the previous
fixture-specific progress collapse with shape-based normalization that
preserves final counts and rejects malformed progress sequences.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Record embedded housekeeping cleanup errors as shutdown diagnostics instead
of failed shutdown steps. Keep diagnostic-only cleanup telemetry at info
severity so best-effort cleanup does not look like an operational failure.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Comment thread src/cli/daemon-control.ts
Allow force-stop cleanup to remove the matched daemon registry and socket
when the stopped process PID has already been reused. The cleanup remains
scoped to the recorded daemon instance ID so a newer daemon entry is not
removed accidentally.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Comment thread src/utils/simulator-steps.ts
When helper log path protection fails after spawning a detached xcrun helper,
release the child from the Node event loop after sending SIGTERM. This keeps
best-effort helper cleanup from keeping the parent process alive.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Comment thread src/utils/workspace-filesystem-lifecycle.ts
@cameroncooke cameroncooke merged commit 38b57a7 into main May 5, 2026
47 checks passed
@cameroncooke cameroncooke deleted the cam/weather-example-workspace-cleanup branch May 5, 2026 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Log directory grows unbounded; cleanup is gated on a tool that's rarely invoked

2 participants