fix(workspace): whitelist watched roots to stop EMFILE crash

The workspace watcher watched all of ~/.rowboat recursively. Chokidar v4
(no fsevents) holds one OS watch handle per file, and internal state dirs
(runs-archive, storage/turns, engines, ...) grow unboundedly with usage —
~20k open fds on a 1.1G workdir, crashing the app with EMFILE once the
fd limit is hit.

Only watch the roots whose change events actually have consumers:
knowledge, bases, inbox_lists, gmail_sync, calendar_sync, bg-tasks and
config/agent-schedule.json — with .git ignored (knowledge/ is a git repo).
This commit is contained in:
Gagan 2026-07-13 17:41:01 +05:30
parent 4182794b76
commit 4db72e1fe4
2 changed files with 29 additions and 12 deletions

View file

@ -583,11 +583,12 @@ function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceCh
/**
* Start workspace watcher
* Watches the configured workspace root recursively and emits change events to renderer
* Watches the user-facing workspace roots recursively and emits change events to renderer
*
* This should be called once when the app starts (from main.ts).
* The watcher runs as a main-process service and catches ALL filesystem changes
* (both from IPC handlers and external changes like terminal/git).
* The watcher runs as a main-process service and catches all filesystem changes
* under the watched roots (both from IPC handlers and external changes like
* terminal/git).
*
* Safe to call multiple times - guards against duplicate watchers.
*/

View file

@ -9,10 +9,28 @@ import { Stats } from 'node:fs';
export type WorkspaceChangeCallback = (event: z.infer<typeof WorkspaceChangeEvent>) => void;
// The only WorkDir paths whose change events have a consumer (knowledge index
// invalidation in main, and the tree/editor, live-notes, email, sidebar and
// meetings views in the renderer). Everything else under WorkDir — runs-archive,
// storage/turns, engines, logs, code-mode, ... — is internal state that grows
// unboundedly with usage; chokidar v4 holds one OS watch handle per file, so
// watching all of WorkDir exhausts the process fd limit (EMFILE crash) once
// the workdir gets big enough.
const WATCHED_ROOTS = [
'knowledge',
'bases',
'inbox_lists',
'gmail_sync',
'calendar_sync',
'bg-tasks',
'config/agent-schedule.json',
];
/**
* Create a workspace watcher
* Watches the configured workspace root recursively and emits change events via callback
*
* Watches the user-facing workspace roots (WATCHED_ROOTS) recursively and
* emits WorkDir-relative change events via callback.
*
* Returns a watcher instance that can be closed.
* The watcher emits events immediately without debouncing.
* Debouncing and lifecycle management should be handled by the caller.
@ -22,15 +40,13 @@ export async function createWorkspaceWatcher(
): Promise<FSWatcher> {
await ensureWorkspaceRoot();
// Code-section session worktrees are full repo checkouts (thousands of files,
// possibly node_modules) living under WorkDir — watching them would flood the
// event stream and burn file handles, and nothing in the app renders them
// from workspace events.
const codeModeDir = path.join(WorkDir, 'code-mode');
const watcher = chokidar.watch(WorkDir, {
const roots = WATCHED_ROOTS.map((rel) => path.join(WorkDir, rel));
const watcher = chokidar.watch(roots, {
ignoreInitial: true,
// knowledge/ is a git repo (version history) — its .git object store is
// thousands of files nothing renders, so keep it out of the watch set.
ignored: (watchedPath: string) =>
watchedPath === codeModeDir || watchedPath.startsWith(codeModeDir + path.sep),
path.relative(WorkDir, watchedPath).split(path.sep).includes('.git'),
awaitWriteFinish: {
stabilityThreshold: 150,
pollInterval: 50,