Independent SessionStart events from multiple Claude Code sessions
each fire `episodic-memory sync --background`, which forks a detached
worker. Without coordination, N parents trigger N concurrent worker
processes racing the same archive and SQLite database.
The reentrancy guard (#87) covers same-process recursion, but it
can't stop SessionStart events from independent sessions whose envs
don't carry EPISODIC_MEMORY_SUMMARIZER_GUARD.
Reproduced locally on macOS: 3 parallel `node dist/sync-cli.js`
processes against the same TEST_ARCHIVE_DIR — worker 1 completes;
workers 2 and 3 crash with `SqliteError: database is locked`
(SQLITE_BUSY) trying to init the DB. On Windows, the reporter's
setup with ~67 worktrees instead piles up enough claude.exe children
to exhaust the desktop heap and crash with STATUS_DLL_INIT_FAILED
(0xC0000142). Same root cause; different blast radius depending on
how far the workers get before stepping on each other.
Fix: a single-instance lock around the sync worker, implemented as a
thin wrapper in src/file-lock.ts over the `proper-lockfile` package.
A first attempt at a hand-rolled openSync('wx') + PID-file protocol
had a residual race under concurrent stale-stealers that pure file
primitives cannot fully close without advisory locking.
proper-lockfile uses an atomic-mkdir + mtime-heartbeat protocol that
is race-free under that contention shape — the same approach npm
itself uses.
sync-cli.ts acquires <log-dir>/episodic-memory-sync.lock after the
source-dir check and before initDatabase(); if another live process
holds it, the worker prints "sync already running (pid X); skipping"
to stderr and exits 0. The lock releases on normal exit and on the
common signals (SIGINT/SIGTERM/SIGHUP).
Embedding migration's own lock now delegates to the generic helper;
its old export names (acquireMigrationLock, releaseMigrationLock,
MigrationLockHandle) stay for back-compat.
proper-lockfile is excluded from the esbuild bundle (runtime dep on
the same level as better-sqlite3/transformers/etc.) so the MCP
server bundle size is unchanged. The wrapper's install-health probe
(#95 Bug 1) gains proper-lockfile as a required package so a partial
extraction surfaces a useful diagnostic.
Tests:
- test/file-lock.test.ts: acquire/release, contention,
parent-dir creation, garbage diagnostic content, I/O error
propagation, N-concurrent-acquirers stress test, mtime-based
stale recovery, fresh-lock-not-reclaimable. Subprocess imports
use pathToFileURL for Windows compatibility.
- test/sync-cli-single-instance.test.ts: integration via real
child-process spawn — two concurrent workers (one completes,
one skips), single sequential run still works, lock released
on normal exit, stale lock from a dead PID is reclaimable.
- Existing test/embedding-migration.test.ts continues to pass.
Closes#97.