Skip to content

feat(sqlite): harden multiprocess resiliency and safety#15

Merged
clopca merged 13 commits into
mainfrom
feat/sqlite-multiprocess-resiliency-hardening
Mar 4, 2026
Merged

feat(sqlite): harden multiprocess resiliency and safety#15
clopca merged 13 commits into
mainfrom
feat/sqlite-multiprocess-resiliency-hardening

Conversation

@clopca

@clopca clopca commented Mar 4, 2026

Copy link
Copy Markdown
Owner

Summary

  • Harden SQLite multi-process behavior by replacing destructive startup recovery with fail-safe handling, adding advisory write locking with bounded retries, and enforcing BEGIN IMMEDIATE write transaction coordination.
  • Improve cross-process coordination by wiring process roles, adding daemon-aware platform worker modes with explicit signal/fallback semantics, and introducing worker PID lifecycle + maintenance preflight safety gates.
  • Add SQLite-native maintenance commands (sqlite checkpoint / sqlite integrity), expand operator/docs guidance, and add broad integration and regression test coverage for contention, fallback, and reset workflows.

Validation

  • bun test (936 passed, 0 failed)
  • bun run build

Note

Cursor Bugbot is generating a summary for commit 5c1d426. Configure here.

Summary by CodeRabbit

  • New Features

    • Maintenance CLI: WAL checkpoint, integrity checks, safe reset with --force, structured JSON output; daemon-aware enqueue-only mode with PROCESS_NOW signaling and automatic in‑process fallback.
    • Coordinated advisory write‑locking, retry/backoff for transient DB errors, PID lifecycle and health reporting.
  • Documentation

    • Expanded README, troubleshooting, and platform docs with maintenance playbooks, remediation sequences, daemon/queue semantics, and examples.
  • Improvements

    • Preflight checks to block destructive ops when active processes detected; clearer diagnostics and remediation guidance.
  • Tests

    • New/expanded unit, integration, and e2e suites covering locks, PID handling, maintenance CLI, daemon scenarios, and DB resilience.

@coderabbitai

coderabbitai Bot commented Mar 4, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds cross-process advisory write locks, PID liveness and daemon/status APIs, daemon-aware platform-worker orchestration with enqueue-only ↔ in-process fallback, non-destructive SQLite maintenance commands (checkpoint/integrity) with preflight gating, and broad docs/tests for coordinated multi-process DB access and recovery.

Changes

Cohort / File(s) Summary
Docs & Guides
README.md, docs/platforms.md, docs/troubleshooting.md
Extensive docs on SQLite resiliency, daemon-aware queue modes, maintenance CLI (sqlite checkpoint/integrity, reset-db safe flow and --force), remediation playbooks, and health/reporting semantics.
Daemon & PID Management
src/daemon.ts, src/daemon/..., src/daemon/pid.ts, src/daemon/manager.ts
New PID utilities/types, PID file helpers (read/write/remove/liveness), getDaemonStatus/getMaintenancePreflightStatus APIs, richer signal delivery results, SIGUSR1 handler to trigger PROCESS_NOW, and PID lifecycle management.
Advisory Write Lock
src/db/advisory-lock.ts
New filesystem-backed advisory write-lock with in-process reentrancy, ownership metadata, timeout/retry behavior, helpers (acquireWriteLock, withWriteLock) and AdvisoryLockTimeoutError.
Database Core
src/db/database.ts
Integrates advisory locks and processRole, retry/backoff for transient SQLite errors, staged startup/configure flow, checkpoint/integrity APIs, public getters/methods (writeLockPath, withAdvisoryWriteLock, checkpointWal, integrityCheck) and createDatabase(..., {processRole}).
Maintenance CLI & Preflight
src/maintenance.ts
Maintenance subcommands (sqlite checkpoint/integrity), maintenance preflight gating for reset-db, structured JSON success/failure reporting, folder-context maintenance flow, and --force override behavior.
Platform Worker & Queue Runtime
src/platform-worker.ts, src/runtime/queue-runtime.ts, src/index.ts
Daemon orchestration for workers (PID write/remove, liveness timer, queue mode switching with fallbackToInProcess), daemon signaling on flush, queue-runtime adjustments when switching modes, and processRole-aware DB creation.
Tests & E2E
tests/..., tests/db/advisory-lock.test.ts, tests/db/database.test.ts, tests/integration/*, tests/e2e/build.test.ts
Large test additions covering PID liveness, daemon signaling paths, advisory-lock semantics (reentrancy, timeouts, stale owners), coordinated DB writes, maintenance CLI integration, platform-worker daemon flows, and docs build verification.
Package / Build
package.json
docs:build updated to run bun install --frozen-lockfile then bun run build in docs to ensure deterministic doc builds.

Sequence Diagram(s)

sequenceDiagram
    actor Worker as Platform Worker
    participant Daemon as Daemon Manager
    participant PID as PID Liveness
    participant Queue as Queue Runtime
    participant DB as Database

    rect rgba(76, 175, 80, 0.5)
    Note over Worker,DB: Startup: decide queue mode based on daemon PID
    Worker->>Daemon: getDaemonStatus(dbPath)
    Daemon->>PID: getPidLiveness(daemonPidPath, removeStale=true)
    PID-->>Daemon: {state, pid, stalePid, stalePidRemoved}
    alt daemon running
        Daemon-->>Worker: {running: true, pid}
        Worker->>Queue: setEnqueueOnly()
    else daemon missing/dead
        Daemon-->>Worker: {running: false}
        Worker->>Queue: setInProcess()
    end
    Worker->>DB: createDatabase(dbPath, {processRole: "platform-worker-..."})
    end
Loading
sequenceDiagram
    actor ProcessA as Writer Process A
    participant Lock as Advisory Lock
    participant FS as Filesystem
    actor ProcessB as Writer Process B
    participant DB as Database

    rect rgba(156, 39, 176, 0.5)
    Note over ProcessA,ProcessB: Advisory write lock acquire/retry/release for coordinated writes
    ProcessA->>Lock: acquireWriteLock(lockPath, {role: "daemon"})
    Lock->>FS: open(lockPath, "wx")
    FS-->>Lock: fd or EEXIST
    alt exclusive obtained
        Lock->>FS: write owner metadata
        Lock-->>ProcessA: WriteLockHandle(reentrant=false)
        ProcessA->>DB: perform write (BEGIN IMMEDIATE ... COMMIT)
        ProcessA->>Lock: release()
        Lock->>FS: unlink(lockPath)
    else already held
        Lock->>FS: read owner metadata
        loop retry until timeout
            Lock->>Lock: sleep(retryInterval)
            Lock->>FS: check lock existence
        end
        Lock-->>ProcessB: AdvisoryLockTimeoutError
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I nibble locks and tidy PIDs with care,

Daemons hum softly, workers queue with grace,
Checkpoints and checks keep the DB bright,
Fallback hops in when signals take flight,
A safe reset, a tidy nest—hooray! 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(sqlite): harden multiprocess resiliency and safety' directly aligns with the main objective of improving SQLite multi-process behavior, advisory locking, and cross-process coordination.
Description check ✅ Passed The description covers the main changes (fail-safe handling, advisory locking, process roles, maintenance commands, documentation, and testing), and the author validated with test results and build confirmation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/sqlite-multiprocess-resiliency-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread tests/db/advisory-lock.test.ts Outdated
Comment thread src/db/database.ts
Comment thread src/db/advisory-lock.ts Outdated
@greptile-apps

greptile-apps Bot commented Mar 4, 2026

Copy link
Copy Markdown

Greptile Summary

This PR substantially hardens SQLite multi-process safety by introducing a file-based advisory write lock (O_EXCL), replacing destructive startup recovery with a fail-safe abort, upgrading transactions to BEGIN IMMEDIATE, adding per-operation retry with exponential backoff, and wiring process-role awareness throughout the stack. It also improves cross-process coordination by tracking platform-worker PIDs, adding daemon-aware queue routing with automatic in-process fallback, and introducing sqlite checkpoint / sqlite integrity maintenance commands with preflight process detection.

Verified findings:

  • src/daemon.ts (line 135): Installs a SIGUSR1 handler to forward PROCESS_NOW signals. However, SIGUSR1 is Node.js/Bun's conventional inspector signal; using it here suppresses debugger/profiler attachment. SIGUSR2 would avoid this conflict.

Key strengths:

  • Advisory write lock logic is sound for the standard single-instance case
  • Fail-safe startup change is strictly safer than destructive recovery
  • Cross-process coordination via PID tracking is well-structured
  • Maintenance CLI with preflight process detection is robust
  • Tests are comprehensive (936 passed) and cover cross-process contention, PID lifecycle, daemon scenarios, and resilience workflows

Confidence Score: 4/5

  • Core functionality is safe to merge; one issue with debugger integration should be addressed before shipping.
  • All tests pass (936 passed, 0 failed) and the PR's core safety improvements—advisory write locking, fail-safe startup, BEGIN IMMEDIATE coordination, and cross-process PID tracking—are sound and well-tested. The single verified issue is the use of SIGUSR1, which suppresses Node.js/Bun's built-in inspector signal and will prevent debugger/profiler attachment to the daemon. This is fixable with SIGUSR2 but is a developer experience concern rather than a correctness issue. No data corruption risks or production failures identified.
  • src/daemon.ts (SIGUSR1 signal handling)

Fix All in Codex Fix All in Cursor

Last reviewed commit: d51963b

@greptile-apps

greptile-apps Bot commented Mar 4, 2026

Copy link
Copy Markdown
Additional Comments (5)

tests/db/advisory-lock.test.ts, line 2234
Hardcoded absolute developer path breaks CI

resolve("/Users/clopca/dev/github/open-mem/src/db/advisory-lock.ts") is hardcoded to the developer's local machine path. This test will fail on every other machine (CI, other contributors) with a module-not-found error when the subprocess tries to import the file.

Use import.meta.dir to derive the path relative to the test file instead:

		const advisoryLockModulePath = resolve(import.meta.dir, "../../src/db/advisory-lock.ts");

tests/db/database.test.ts, line 2618
Hardcoded absolute developer path breaks CI

Same problem as in tests/db/advisory-lock.test.ts: resolve("/Users/clopca/dev/github/open-mem/src/db/database.ts") is hardcoded to the developer's local path and will cause the subprocess to fail with a module-not-found error on any other machine.

		const databaseModulePath = resolve(import.meta.dir, "../../src/db/database.ts");

src/db/advisory-lock.ts, line 806
No stale lock detection — crashed processes permanently block writes

The spin loop reads the owner metadata but never checks whether the owner process (identified by owner.pid) is still alive. If any process that holds the write lock crashes (SIGKILL, OOM, unhandled exception), the lock file is left on disk and all subsequent write operations from every process will time out after DEFAULT_TIMEOUT_MS (5 s) and throw AdvisoryLockTimeoutError. This directly undermines the PR's goal of hardening multi-process resiliency.

The readOwnerMetadata result is available inside the loop. Before sleeping, you can cross-check the owner PID:

observedOwner = readOwnerMetadata(lockPath);
// If the owning PID is gone, the lock is stale — remove it and retry immediately
if (observedOwner && !isProcessAlive(observedOwner.pid)) {
    try { unlinkSync(lockPath); } catch { /* TOCTOU-safe */ }
    continue;
}

isProcessAlive is already implemented in src/daemon/pid.ts. Alternatively, consider adding a lockedAt timestamp and a maximum lock age to guard against extremely long-running holders alongside crashed ones.


src/platform-worker.ts, line 1753
Silent daemon-death fallback in liveness timer

fallbackToInProcessLocal() is called without logging any reason when the liveness timer detects a dead daemon. The equivalent logic in src/index.ts always emits a console.warn message ("Daemon died, falling back to in-process processing"), so operators watching logs would have no visibility into this state transition when it happens in the platform worker.

			daemonLivenessTimer = setInterval(() => {
				if (!daemonManager || !daemonManager.getStatus().running) {
					console.warn("[open-mem] Daemon died, falling back to in-process processing");
					fallbackToInProcessLocal();
				}
			}, 30_000);

src/platform-worker.ts, line 1737
DaemonManager created with empty daemonScript

daemonScript: "" is intentional here (the platform worker only observes the daemon, it never starts one), but this creates a footgun: if daemonManager.start() were ever called accidentally on this instance it would try to spawn an empty script path and likely fail silently or with a confusing error.

Consider making the intent explicit with a named factory or a dedicated read-only observer type, or at minimum add an inline comment explaining why daemonScript is intentionally empty:

		daemonManager = new DaemonManager({
			dbPath: config.dbPath,
			projectPath,
			// Platform workers observe daemon status but never start one.
			// daemonScript is intentionally empty — start() must not be called.
			daemonScript: "",
		});

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/maintenance.ts (1)

190-209: ⚠️ Potential issue | 🟠 Major

Ensure folder-context rebuild always closes the database handle.

The current close happens only on the happy path. Any exception before Line 209 leaks the connection.

Proposed fix
 		Database.enableExtensionSupport();
 		const db = createDatabase(config.dbPath, { processRole: "maintenance" });
-		initializeSchema(db, {
-			hasVectorExtension: db.hasVectorExtension,
-			embeddingDimension: config.embeddingDimension,
-		});
-		const sessions = new SessionRepository(db);
-		const observations = new ObservationRepository(db);
-		const result = await rebuildFolderContext(
-			projectPath,
-			sessions,
-			observations,
-			{
-				maxDepth: config.folderContextMaxDepth,
-				mode: config.folderContextMode,
-				filename: config.folderContextFilename,
-			},
-			dryRun,
-		);
-		db.close();
+		try {
+			initializeSchema(db, {
+				hasVectorExtension: db.hasVectorExtension,
+				embeddingDimension: config.embeddingDimension,
+			});
+			const sessions = new SessionRepository(db);
+			const observations = new ObservationRepository(db);
+			const result = await rebuildFolderContext(
+				projectPath,
+				sessions,
+				observations,
+				{
+					maxDepth: config.folderContextMaxDepth,
+					mode: config.folderContextMode,
+					filename: config.folderContextFilename,
+				},
+				dryRun,
+			);
+			console.log(
+				`${dryRun ? "[dry-run] " : ""}Rebuilt context from ${result.observations} observations, touched ${result.filesTouched} files.`,
+			);
+		} finally {
+			db.close();
+		}
-		console.log(
-			`${dryRun ? "[dry-run] " : ""}Rebuilt context from ${result.observations} observations, touched ${result.filesTouched} files.`,
-		);
 		return;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/maintenance.ts` around lines 190 - 209, The database handle created by
createDatabase(...) is only closed on the happy path; wrap the call to
rebuildFolderContext(...) (and any initialization that follows
Database.enableExtensionSupport(), createDatabase(...), initializeSchema(...),
SessionRepository, ObservationRepository) in a try/finally so db.close() is
always executed in the finally block; ensure you still return/propagate the
rebuildFolderContext result or rethrow errors after closing; reference
createDatabase, initializeSchema, SessionRepository, ObservationRepository,
rebuildFolderContext, and db.close when locating where to add the try/finally.
🧹 Nitpick comments (3)
tests/queue/processor-mode.test.ts (1)

143-151: LGTM!

The test correctly validates the onEnqueue callback invocation in enqueue-only mode. The setup follows the established pattern and the assertion properly verifies single invocation per enqueue call.

Optional enhancement: Consider adding a complementary test verifying that onEnqueue is not invoked when mode is "in-process" to ensure the guard condition works both ways.

💡 Optional: Test for in-process mode non-invocation
test("in-process mode does not invoke onEnqueue callback", () => {
	const processor = buildProcessor();
	const onEnqueue = mock(() => {});
	sessionRepo.create("sess-1", "/tmp/proj");
	processor.setOnEnqueue(onEnqueue);
	processor.enqueue("sess-1", "Read", "output", "call-1");
	expect(onEnqueue).toHaveBeenCalledTimes(0);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/queue/processor-mode.test.ts` around lines 143 - 151, Add a
complementary test to assert onEnqueue is not called when the processor is in
"in-process" mode: create a processor via buildProcessor(), register a mock with
processor.setOnEnqueue(...), create the session with
sessionRepo.create("sess-1", "/tmp/proj"), ensure the processor is in
"in-process" mode (either by not calling setMode or explicitly calling
processor.setMode("in-process")), call processor.enqueue("sess-1", "Read",
"output", "call-1"), and assert the mock was called 0 times to validate the
guard in enqueue.
src/daemon/pid.ts (1)

94-100: Minor: redundant ternary expression.

Since kind is already typed as "claude" | "cursor", the ternary on line 98 is unnecessary — kind can be used directly.

♻️ Simplify suffix derivation
 export function getPlatformWorkerPidPath(dbPath: string, kind: PlatformWorkerProcessKind): string {
 	const lastSlash = dbPath.lastIndexOf("/");
 	const pidDir = lastSlash >= 0 ? dbPath.substring(0, lastSlash) : ".";
-	const suffix = kind === "claude" ? "claude" : "cursor";
-	return `${pidDir}/platform-worker-${suffix}.pid`;
+	return `${pidDir}/platform-worker-${kind}.pid`;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/daemon/pid.ts` around lines 94 - 100, In getPlatformWorkerPidPath
simplify the suffix derivation by removing the redundant ternary and use the
already-typed kind value directly (i.e., replace the suffix assignment that
checks kind === "claude" ? "claude" : "cursor" with using kind itself); keep the
rest of the logic (lastSlash, pidDir, return template) unchanged to preserve
behavior.
tests/e2e/build.test.ts (1)

64-78: Case-sensitive error matching in test assertion — minor improvement suggested.

The docs:build script exists in package.json and VitePress is properly configured, so the primary CI failure concerns can be disregarded. However, the error check on line 77 using .not.toContain("error") is case-sensitive and won't catch variations like Error or ERROR in output. Consider using .toLowerCase() for more robust matching:

-		expect(`${stdout}\n${stderr}`).not.toContain("error");
+		expect(`${stdout}\n${stderr}`.toLowerCase()).not.toContain("error");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/build.test.ts` around lines 64 - 78, The test's case-sensitive
assertion uses expect(`${stdout}\n${stderr}`).not.toContain("error") which
misses other casings like "Error" or "ERROR"; update the assertion in the "docs
build command succeeds after documentation updates" test to perform a
case-insensitive check by converting the combined output (the variables stdout
and stderr gathered from proc) to lowercase (or use a case-insensitive regex)
before asserting .not.toContain("error"), referencing the proc, stdout, stderr
and exitCode variables in the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/db/database.ts`:
- Around line 397-417: The current code passes the user callback fn into
withRetry, which can re-execute fn on retryable DB errors and duplicate external
side effects; change the flow so withRetry does NOT wrap the execution of fn.
Specifically, keep the outer withAdvisoryWriteLock(this.processRole, ...) but
remove the wrapping of the transaction body in withRetry; instead call
this.db.transaction(fn) and inspect wrapped.immediate (as in the existing const
wrapped = this.db.transaction(fn) & { immediate?: () => T }) and, if present,
call wrapped.immediate(); otherwise start the manual "BEGIN IMMEDIATE" / commit
/ rollback sequence and execute fn exactly once inside that sequence; any
retries should only cover low-level DB operations (e.g., exec/driver calls), not
the invocation of fn itself.
- Around line 215-230: The configure-failure path in throwConfigureFailure
currently leaves the opened DB handle open; update throwConfigureFailure to
first close the opened DB handle (check the instance field that stores the
connection, e.g., this.db or this.handle created when the constructor opens the
connection) before throwing: call and await the connection's close method if it
returns a promise (or call the sync close otherwise), wrap that close in its own
try/catch to avoid masking the original configure error, then proceed to log and
rethrow the startupError as before.

In `@src/maintenance.ts`:
- Around line 90-95: The createDatabase call (and
Database.enableExtensionSupport/resolveConfig sequence) is currently outside the
structured failure-reporting try/catch so any exception from createDatabase
escapes the JSON error contract; move or wrap Database.enableExtensionSupport(),
resolveConfig(projectPath) and createDatabase(config.dbPath, { processRole:
"maintenance" }) inside the same try block that catches errors and emits the
structured JSON failure response (same pattern used for the checkpoint/integrity
flow), and apply the same change to the other maintenance branch using
createDatabase (the block referenced around the later occurrence) so both paths
report failures via the existing JSON error handler instead of throwing uncaught
exceptions.
- Around line 83-86: The reset routine currently removes database, -wal and -shm
files but misses the advisory write-lock file; update the cleanup to also
rmSync(`${config.dbPath}.write.lock`, { force: true }) (use the same rmSync and
config.dbPath references as the existing deletions) and include it in the
console log so the reset removes the advisory lock file and reports it.

In `@tests/db/advisory-lock.test.ts`:
- Around line 36-40: The test embeds a machine-specific absolute path via
advisoryLockModulePath/resolve in tests/db/advisory-lock.test.ts which breaks
CI; change it to compute a portable path (e.g., use
path.resolve(path.join(__dirname, '..', '..', 'src', 'db', 'advisory-lock.ts'))
or require.resolve with a relative module specifier) and inject that into the
spawned script string that imports acquireWriteLock so the child process can
locate the module on any machine/CI runner.

In `@tests/db/database.test.ts`:
- Around line 371-374: Replace the hardcoded absolute path in the
databaseModulePath variable with a repository-relative path so the spawned
script can import database.ts across environments (CI/local). Update the code
that sets databaseModulePath (the variable named databaseModulePath used in the
template string assigned to script where createDatabase is imported) to compute
the path relative to the repo root (e.g., using process.cwd() or __dirname to
build "src/db/database.ts") and keep using JSON.stringify(databaseModulePath) in
the script template so the child process receives a portable module path.

---

Outside diff comments:
In `@src/maintenance.ts`:
- Around line 190-209: The database handle created by createDatabase(...) is
only closed on the happy path; wrap the call to rebuildFolderContext(...) (and
any initialization that follows Database.enableExtensionSupport(),
createDatabase(...), initializeSchema(...), SessionRepository,
ObservationRepository) in a try/finally so db.close() is always executed in the
finally block; ensure you still return/propagate the rebuildFolderContext result
or rethrow errors after closing; reference createDatabase, initializeSchema,
SessionRepository, ObservationRepository, rebuildFolderContext, and db.close
when locating where to add the try/finally.

---

Nitpick comments:
In `@src/daemon/pid.ts`:
- Around line 94-100: In getPlatformWorkerPidPath simplify the suffix derivation
by removing the redundant ternary and use the already-typed kind value directly
(i.e., replace the suffix assignment that checks kind === "claude" ? "claude" :
"cursor" with using kind itself); keep the rest of the logic (lastSlash, pidDir,
return template) unchanged to preserve behavior.

In `@tests/e2e/build.test.ts`:
- Around line 64-78: The test's case-sensitive assertion uses
expect(`${stdout}\n${stderr}`).not.toContain("error") which misses other casings
like "Error" or "ERROR"; update the assertion in the "docs build command
succeeds after documentation updates" test to perform a case-insensitive check
by converting the combined output (the variables stdout and stderr gathered from
proc) to lowercase (or use a case-insensitive regex) before asserting
.not.toContain("error"), referencing the proc, stdout, stderr and exitCode
variables in the test.

In `@tests/queue/processor-mode.test.ts`:
- Around line 143-151: Add a complementary test to assert onEnqueue is not
called when the processor is in "in-process" mode: create a processor via
buildProcessor(), register a mock with processor.setOnEnqueue(...), create the
session with sessionRepo.create("sess-1", "/tmp/proj"), ensure the processor is
in "in-process" mode (either by not calling setMode or explicitly calling
processor.setMode("in-process")), call processor.enqueue("sess-1", "Read",
"output", "call-1"), and assert the mock was called 0 times to validate the
guard in enqueue.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a2d63c08-b4f1-498c-ac42-802047c1b6e9

📥 Commits

Reviewing files that changed from the base of the PR and between 633b650 and 5c1d426.

📒 Files selected for processing (20)
  • README.md
  • docs/platforms.md
  • docs/troubleshooting.md
  • src/daemon.ts
  • src/daemon/manager.ts
  • src/daemon/pid.ts
  • src/db/advisory-lock.ts
  • src/db/database.ts
  • src/index.ts
  • src/maintenance.ts
  • src/platform-worker.ts
  • src/runtime/queue-runtime.ts
  • tests/daemon/manager.test.ts
  • tests/daemon/pid.test.ts
  • tests/db/advisory-lock.test.ts
  • tests/db/database.test.ts
  • tests/e2e/build.test.ts
  • tests/integration/maintenance-cli.test.ts
  • tests/integration/platform-worker.test.ts
  • tests/queue/processor-mode.test.ts

Comment thread src/db/database.ts
Comment thread src/db/database.ts
Comment thread src/maintenance.ts Outdated
Comment thread src/maintenance.ts
Comment thread tests/db/advisory-lock.test.ts Outdated
Comment thread tests/db/database.test.ts Outdated
@greptile-apps

greptile-apps Bot commented Mar 4, 2026

Copy link
Copy Markdown
Additional Comments (2)

src/db/advisory-lock.ts, line 212
Empty lock file becomes un-reapable permanent stale lock

openSync(lockPath, "wx") atomically creates the lock file (POSIX O_EXCL), but the owner metadata is then written via writeFileSync(lockPath, ...) through the path. There is a window between these two calls where the file exists but is empty. If the acquiring process is killed (e.g., OOM, SIGKILL) inside this window, the file is left with empty content.

tryReapStaleLock (line 126) immediately returns false when owner is null, so no other process can ever reap this empty lock file — all subsequent acquireWriteLock calls will busy-poll until AdvisoryLockTimeoutError is thrown, permanently blocking writes until the file is manually deleted.

The fix is to write the owner JSON through the already-open file descriptor with writeSync(fd, Buffer.from(...)) instead of re-opening by path with writeFileSync. This guarantees the metadata is always associated with the same exclusive fd:

const fd = openSync(lockPath, "wx");
try {
    const ownerJson = JSON.stringify(owner);
    const buf = Buffer.from(ownerJson, "utf8");
    let written = 0;
    while (written < buf.length) {
        written += writeSync(fd, buf, written);
    }
} catch (writeError) {
    releaseLockPath(lockPath, fd);
    throw writeError;
}

src/platform-worker.ts, line 309
PID file written after queue is already started

initialize() calls queueRuntime.setInProcess() or queueRuntime.setEnqueueOnly() (lines 210, 226, 229), both of which immediately call queue.start() internally. The queue is actively processing by the time line 308 executes writePid(state.workerPidPath).

Between initialize() returning and writePid() completing, the maintenance preflight check (getMaintenancePreflightStatus) will find no PID file and report the worker as missing — allowing a reset-db (or even --force reset) to proceed against a live, write-active worker. This directly undermines the safety gate this PR introduces.

The PID file should be written before the queue is started:

const state = initialize(platform, args.projectDir);
writePid(state.workerPidPath);   // ← write PID first
state.queueRuntime.start();       // ← then start the queue

This requires refactoring initialize() to separate queue startup from initialization, closing the TOCTOU window between queue start and PID registration.

Comment thread src/db/database.ts
Comment thread src/platform-worker.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/db/advisory-lock.ts`:
- Around line 205-209: After opening the lock file with openSync(lockPath, "wx")
and obtaining fd, do not call writeFileSync(lockPath, ...); instead write the
JSON metadata through the acquired file descriptor (fd) to avoid reopening the
path and the race window—use fs.writeFileSync(fd, JSON.stringify(owner), {
encoding: "utf8" }) or equivalent fs.writeSync with a Buffer; keep the existing
catch block that calls releaseLockPath(lockPath, fd) but ensure releaseLockPath
closes the fd, and propagate or log the write error as before.

In `@src/platform-worker.ts`:
- Around line 308-313: The PID file is being removed unconditionally which can
delete another live worker's PID; update cleanup and any other removal points
(e.g., the other removal around removePid usage) to first read and parse the PID
file written by writePid, compare it to the current process PID (process.pid),
and only call removePid if they match; if the file is unreadable or the PID
differs, do not delete and log a warning/error instead to avoid removing another
worker's PID.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9b78d8da-547b-4875-9f68-fe6020320fe8

📥 Commits

Reviewing files that changed from the base of the PR and between 5c1d426 and 04ec4ed.

📒 Files selected for processing (9)
  • src/daemon/pid.ts
  • src/db/advisory-lock.ts
  • src/db/database.ts
  • src/maintenance.ts
  • src/platform-worker.ts
  • tests/db/advisory-lock.test.ts
  • tests/db/database.test.ts
  • tests/e2e/build.test.ts
  • tests/queue/processor-mode.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/queue/processor-mode.test.ts
  • tests/e2e/build.test.ts
  • src/daemon/pid.ts

Comment thread src/db/advisory-lock.ts
Comment thread src/platform-worker.ts
@greptile-apps

greptile-apps Bot commented Mar 4, 2026

Copy link
Copy Markdown
Additional Comments (4)

src/db/database.ts, line 427
ROLLBACK may swallow the original error

In the transaction fallback path, if fn() throws and the subsequent this.db.exec("ROLLBACK") also throws (e.g., the connection is broken), the new error from ROLLBACK propagates out of the catch block and the original exception from fn() is silently discarded. The same applies if fn() succeeds but COMMIT throws: the catch block will attempt ROLLBACK, and any error from that call overwrites the COMMIT error.

While Bun's db.transaction() should always provide .immediate() — making this fallback unreachable in practice — the dead-code branch still contains incorrect error handling that could complicate future maintenance or portability. Consider wrapping the ROLLBACK in a nested try/catch to preserve the primary error:

} catch (error) {
    try {
        this.db.exec("ROLLBACK");
    } catch {
        // ignore rollback failure to preserve the primary error
    }
    throw error;
}

src/daemon/pid.ts, line 72
TOCTOU race can delete a live PID file

There is a time-of-check/time-of-use (TOCTOU) window between the isProcessAlive(pid) check (line 62) and the removePid(pidPath) call here. The sequence:

  1. readPid returns a dead PID (say, PID X).
  2. isProcessAlive(X)false.
  3. A new daemon or platform worker starts and overwrites the PID file with its live PID Y.
  4. existsSync(pidPath)true (the new file).
  5. removePid(pidPath) deletes the new process's PID file.

After step 5, the live process Y is now invisible to all preflight checks — getMaintenancePreflightStatus will report it as "missing", potentially allowing destructive maintenance operations to proceed unguarded.

A safer approach is to re-read and compare the PID before unlinking (matching the sameOwner double-check used in tryReapStaleLock), or use an atomic rename/compare-and-delete strategy. At minimum, re-read currentPid after the liveness check and only remove if currentPid === pid.


src/db/advisory-lock.ts, line 434
Atomics.wait blocks the entire event loop

sleepSync uses Atomics.wait on the main thread, which is a true blocking syscall. Every call to acquireWriteLock under contention will stall the Bun event loop — including I/O callbacks, timers, and IPC messages — for up to retryIntervalMs (default 50 ms) per loop iteration. With the default 5 s timeout and 50 ms retries, a contested lock can block the event loop for up to ~5 seconds.

The same pattern appears in withRetry in database.ts, where exponential backoff is also implemented with Atomics.wait.

The inline comment acknowledges the constraint ("Bun SQLite is synchronous"), but the implications for the host plugin's main thread responsiveness are significant if lock contention is sustained. Consider documenting the worst-case latency impact in the advisory-lock API docs, and exposing the timeout as a configuration option in contexts like withAdvisoryWriteLock so callers can set a lower bound for interactive paths.


src/platform-worker.ts, line 113
DaemonManager with empty daemonScript is an unguarded footgun

DaemonManager is constructed with daemonScript: "" so that platform workers can observe daemon status without starting one. However, DaemonManager.start() contains no guard against an empty daemonScript — it would invoke bun run "" --project <path>, which silently fails after the full POLL_TIMEOUT_MS (2 s) busy-wait. A future caller that reaches state.daemonManager.start() in this code path will encounter a silent 2-second hang with no diagnostic.

Consider either:

  • Adding a readOnly flag or a DaemonObserver sub-type that makes start() throw immediately with a clear error message.
  • Asserting daemonScript !== "" at the top of start() with a descriptive error.

Comment thread src/daemon/manager.ts Outdated
Comment thread src/runtime/queue-runtime.ts Outdated
Comment thread src/db/database.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/daemon/pid.ts (1)

30-39: ⚠️ Potential issue | 🟠 Major

Handle PID read races without throwing.

Line 31 + Line 34 is a TOCTOU pattern; another process can remove/change the file between checks, and readFileSync will throw. In liveness polling, that can crash status flows.

🔧 Proposed fix
 export function readPid(pidPath: string): number | null {
-	if (!existsSync(pidPath)) {
-		return null;
-	}
-	const content = readFileSync(pidPath, "utf-8").trim();
-	const pid = Number.parseInt(content, 10);
-	if (Number.isNaN(pid)) {
-		return null;
-	}
-	return pid;
+	try {
+		const content = readFileSync(pidPath, "utf-8").trim();
+		if (!/^\d+$/.test(content)) {
+			return null;
+		}
+		const pid = Number.parseInt(content, 10);
+		return pid > 0 ? pid : null;
+	} catch {
+		return null;
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/daemon/pid.ts` around lines 30 - 39, The readPid function has a TOCTOU
race between existsSync(pidPath) and readFileSync(pidPath) causing readFileSync
to throw if the file is removed/changed; wrap the file read/parse in a try/catch
inside readPid (avoid relying on existsSync), so any FS error (e.g., ENOENT) or
parse errors are caught and the function returns null instead of throwing;
reference the readPid function and the uses of existsSync/readFileSync when
making this change.
♻️ Duplicate comments (1)
src/platform-worker.ts (1)

301-301: ⚠️ Potential issue | 🟠 Major

Guard against overwriting a live worker PID file at startup.

Line 301 writes the PID file unconditionally. If a same-kind worker is already alive, this can clobber its ownership metadata and break liveness/cleanup semantics.

🔧 Proposed fix
-import { getPlatformWorkerPidPath, removePidIfMatches, writePid } from "./daemon/pid";
+import {
+	getPlatformWorkerPidPath,
+	isProcessAlive,
+	readPid,
+	removePidIfMatches,
+	writePid,
+} from "./daemon/pid";
@@
-	writePid(state.workerPidPath);
+	const existingPid = readPid(state.workerPidPath);
+	if (existingPid !== null && existingPid !== process.pid && isProcessAlive(existingPid)) {
+		throw new Error(
+			`[open-mem] Refusing to overwrite live worker PID file (${state.workerPidPath}, pid=${existingPid})`,
+		);
+	}
+	writePid(state.workerPidPath);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/platform-worker.ts` at line 301, The PID file is being written
unconditionally via writePid(state.workerPidPath), which can clobber a live
worker's PID; before calling writePid, check whether state.workerPidPath exists,
read its PID, and verify the process is still alive (e.g., via a helper like
isProcessAlive(pid) or sending signal 0) and whether it represents the same kind
of worker; if a live same-kind process is found, abort startup or log and exit
gracefully, otherwise remove/stale the file and then call
writePid(state.workerPidPath) to create the new PID file.
🧹 Nitpick comments (2)
src/db/database.ts (1)

331-336: Reuse the sleep wait buffer inside retry loop.

Line 335 allocates a new buffer every retry attempt. Consider reusing a module-level wait array to reduce pressure under lock contention.

♻️ Proposed refactor
+const RETRY_WAIT_ARRAY = new Int32Array(new SharedArrayBuffer(4));
@@
-				Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
+				Atomics.wait(RETRY_WAIT_ARRAY, 0, 0, delayMs);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/database.ts` around lines 331 - 336, The retry loop currently
allocates a new SharedArrayBuffer for each Atomics.wait call (new Int32Array(new
SharedArrayBuffer(4))) which increases allocations under contention; introduce a
module-level or function-scoped reusable Int32Array (e.g., waitArray) created
once (using new SharedArrayBuffer(4)) and replace the per-attempt allocation in
the retry loop where Atomics.wait is called (inside the code path that computes
delayMs using BASE_DELAY_MS and then calls Atomics.wait) to use that reusable
waitArray; keep all other logic (delayMs calculation and subsequent call to
getSqliteErrorDetails) unchanged.
src/db/advisory-lock.ts (1)

70-73: Avoid per-call buffer allocation in sleepSync.

Line 72 creates a new SharedArrayBuffer/Int32Array every retry sleep. Reuse a module-level wait buffer to reduce allocation churn in contention paths.

♻️ Proposed refactor
+const SLEEP_WAIT_ARRAY = new Int32Array(new SharedArrayBuffer(4));
+
 function sleepSync(ms: number): void {
 	if (ms <= 0) return;
-	Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
+	Atomics.wait(SLEEP_WAIT_ARRAY, 0, 0, ms);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/advisory-lock.ts` around lines 70 - 73, The sleepSync function
currently allocates a new SharedArrayBuffer and Int32Array on every call which
causes allocation churn; instead create a module-level SharedArrayBuffer and
Int32Array (e.g., const waitBuf = new SharedArrayBuffer(4) and const waitView =
new Int32Array(waitBuf)) and have sleepSync(ms: number) use
Atomics.wait(waitView, 0, 0, ms) (keeping the early return for ms <= 0) so
repeated sleeps reuse the same buffer/view rather than allocating per-call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/daemon/pid.ts`:
- Around line 68-73: The stale-PID cleanup currently calls removePid(pidPath)
unconditionally and may delete a newly written PID file; instead, before
removing check that the current file still contains the stale PID we read
earlier: read the file contents (e.g., fs.readFileSync(pidPath, "utf8") or
equivalent), compare it to the stale pid value you stored (stalePid or pid), and
only call removePid(pidPath) if they match; set stalePidRemoved based on that
conditional removal and handle/read errors safely (try/catch) so you don't
accidentally remove an active process's PID metadata.

In `@src/db/database.ts`:
- Around line 463-467: The checkpointWal method currently interpolates the mode
directly into the PRAGMA SQL; add a runtime whitelist check inside checkpointWal
(before calling db.query) to ensure mode is one of the allowed values (e.g.
normalize to uppercase and allow "PASSIVE","FULL","RESTART"), throw a clear
error if not, and only then use the validated/sanitized mode value when
constructing the PRAGMA string; reference the checkpointWal method and the mode
parameter and keep the rest of the withAdvisoryWriteLock/withRetry flow
unchanged.

In `@tests/db/database.test.ts`:
- Around line 392-413: The race occurs because Bun.spawn is started before the
parent holds the transaction lock; move the child process creation so it happens
after acquiring the lock (i.e., inside the db.transaction block, before the
Atomics.wait) or add an explicit synchronization file/flag: once db.transaction
starts and before Atomics.wait, write the marker (markerPath) or signal the
child, then spawn the child (Bun.spawn) so the child will contend while the
parent holds the lock; reference db.transaction, Atomics.wait, Bun.spawn, and
markerPath when making the change.

In `@tests/integration/platform-worker.test.ts`:
- Around line 486-503: The test currently assumes getMaintenancePreflightStatus
mutates state by removing a stale PID file; change the test to treat preflight
as read-only: after writing the stale PID via getPlatformWorkerPidPath and
calling getMaintenancePreflightStatus, assert workerCheck is defined,
workerCheck.state === "dead", workerCheck.stalePid === 999999, but assert
workerCheck.stalePidRemoved === false and that existsSync(stalePidPath) remains
true (i.e., the file is not deleted). Keep references to
getMaintenancePreflightStatus, getPlatformWorkerPidPath, preflight, workerCheck
and stalePidPath so the assertions are updated in place.

---

Outside diff comments:
In `@src/daemon/pid.ts`:
- Around line 30-39: The readPid function has a TOCTOU race between
existsSync(pidPath) and readFileSync(pidPath) causing readFileSync to throw if
the file is removed/changed; wrap the file read/parse in a try/catch inside
readPid (avoid relying on existsSync), so any FS error (e.g., ENOENT) or parse
errors are caught and the function returns null instead of throwing; reference
the readPid function and the uses of existsSync/readFileSync when making this
change.

---

Duplicate comments:
In `@src/platform-worker.ts`:
- Line 301: The PID file is being written unconditionally via
writePid(state.workerPidPath), which can clobber a live worker's PID; before
calling writePid, check whether state.workerPidPath exists, read its PID, and
verify the process is still alive (e.g., via a helper like isProcessAlive(pid)
or sending signal 0) and whether it represents the same kind of worker; if a
live same-kind process is found, abort startup or log and exit gracefully,
otherwise remove/stale the file and then call writePid(state.workerPidPath) to
create the new PID file.

---

Nitpick comments:
In `@src/db/advisory-lock.ts`:
- Around line 70-73: The sleepSync function currently allocates a new
SharedArrayBuffer and Int32Array on every call which causes allocation churn;
instead create a module-level SharedArrayBuffer and Int32Array (e.g., const
waitBuf = new SharedArrayBuffer(4) and const waitView = new Int32Array(waitBuf))
and have sleepSync(ms: number) use Atomics.wait(waitView, 0, 0, ms) (keeping the
early return for ms <= 0) so repeated sleeps reuse the same buffer/view rather
than allocating per-call.

In `@src/db/database.ts`:
- Around line 331-336: The retry loop currently allocates a new
SharedArrayBuffer for each Atomics.wait call (new Int32Array(new
SharedArrayBuffer(4))) which increases allocations under contention; introduce a
module-level or function-scoped reusable Int32Array (e.g., waitArray) created
once (using new SharedArrayBuffer(4)) and replace the per-attempt allocation in
the retry loop where Atomics.wait is called (inside the code path that computes
delayMs using BASE_DELAY_MS and then calls Atomics.wait) to use that reusable
waitArray; keep all other logic (delayMs calculation and subsequent call to
getSqliteErrorDetails) unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 06b48340-9204-4996-b726-121cdaf32040

📥 Commits

Reviewing files that changed from the base of the PR and between 04ec4ed and 9eb43ec.

📒 Files selected for processing (7)
  • src/daemon/pid.ts
  • src/db/advisory-lock.ts
  • src/db/database.ts
  • src/platform-worker.ts
  • tests/daemon/pid.test.ts
  • tests/db/database.test.ts
  • tests/integration/platform-worker.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/daemon/pid.test.ts

Comment thread src/daemon/pid.ts Outdated
Comment thread src/db/database.ts
Comment thread tests/db/database.test.ts
Comment thread tests/integration/platform-worker.test.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (6)
tests/integration/platform-worker.test.ts (2)

84-98: Silent JSON parse failures may hide test issues.

The flatMap with empty array return on parse failure silently drops malformed lines. If a worker emits partial JSON due to a bug, the test would pass with fewer responses than expected rather than failing explicitly.

💡 Consider logging dropped lines for debugging
 		.flatMap((line) => {
 			try {
 				return [
 					JSON.parse(line) as {
 						ok: boolean;
 						code: string;
 						message?: string;
 						status?: Record<string, unknown>;
 						processed?: number;
 					},
 				];
 			} catch {
+				console.warn(`[test] Dropped non-JSON line: ${line.slice(0, 100)}`);
 				return [];
 			}
 		});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/platform-worker.test.ts` around lines 84 - 98, The current
flatMap that calls JSON.parse silently swallows parse errors and drops malformed
lines; update the catch block in the flatMap (the JSON.parse call within
tests/integration/platform-worker.test.ts) to surface failures instead of
returning an empty array — e.g., log the raw `line` and rethrow a descriptive
Error (or return a single-item object that fails the subsequent assertions) so
the test fails loudly when a line cannot be parsed; reference the
flatMap/JSON.parse location and ensure the thrown error includes the offending
`line` for debugging.

505-576: Ensure complete mock restoration on test failure.

The test patches multiple prototype methods but has all restoration in a single finally block. If an earlier restoration throws, subsequent restorations won't execute. Consider wrapping each restoration individually.

🛡️ More robust restoration pattern
 		} finally {
-			if (previousDaemon === undefined) {
-				delete process.env.OPEN_MEM_DAEMON;
-			} else {
-				process.env.OPEN_MEM_DAEMON = previousDaemon;
-			}
-			DaemonManager.prototype.start = originalStart;
-			DaemonManager.prototype.stop = originalStop;
-			DaemonManager.prototype.isRunning = originalIsRunning;
-			DaemonManager.prototype.getStatus = originalGetStatus;
-			DaemonManager.prototype.signal = originalSignal;
-			QueueProcessor.prototype.setMode = originalSetMode;
+			const restorations = [
+				() => { process.env.OPEN_MEM_DAEMON = previousDaemon ?? ""; if (previousDaemon === undefined) delete process.env.OPEN_MEM_DAEMON; },
+				() => { DaemonManager.prototype.start = originalStart; },
+				() => { DaemonManager.prototype.stop = originalStop; },
+				() => { DaemonManager.prototype.isRunning = originalIsRunning; },
+				() => { DaemonManager.prototype.getStatus = originalGetStatus; },
+				() => { DaemonManager.prototype.signal = originalSignal; },
+				() => { QueueProcessor.prototype.setMode = originalSetMode; },
+			];
+			for (const restore of restorations) {
+				try { restore(); } catch { /* best-effort */ }
+			}
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/platform-worker.test.ts` around lines 505 - 576, The test
mutates DaemonManager.prototype.start/stop/isRunning/getStatus/signal and
QueueProcessor.prototype.setMode but restores them all inside one finally block,
so if one restoration throws the rest won't run; change the finally to restore
each prototype method in its own safe try/catch (or conditional assignment) so
every original
(originalStart/originalStop/originalIsRunning/originalGetStatus/originalSignal/originalSetMode)
is attempted regardless of errors, e.g., restore DaemonManager.prototype.start
in its own try/catch, then stop, isRunning, getStatus, signal, and finally
QueueProcessor.prototype.setMode, ensuring seenModes logic remains unchanged.
tests/db/database.test.ts (1)

247-296: Consider cleanup robustness for the manually created lock file.

The test creates a lock file at db.writeLockPath to simulate a stale lock. While there's a try/catch for cleanup in the finally block, if the test fails before reaching finally, the lock file could persist. The cleanupPaths array isn't used here.

🛡️ Suggested improvement
 	test("test_lock_timeout_diagnostics_include_process_role", () => {
 		const dbPath = `/tmp/open-mem-test-${randomUUID()}.db`;
 		cleanupPaths.push(dbPath);
 		const db = createDatabase(dbPath, { processRole: "maintenance" });
 
 		const lockPath = db.writeLockPath;
+		cleanupPaths.push(lockPath);
 		writeFileSync(
 			lockPath,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/db/database.test.ts` around lines 247 - 296, The test
test_lock_timeout_diagnostics_include_process_role may leave the manually
created lock file behind if the test fails before finally runs; after creating
the lock file at db.writeLockPath (variable lockPath) push lockPath into the
existing cleanupPaths array so global cleanup will remove it, i.e., add
cleanupPaths.push(lockPath) immediately after writing the file (or before) and
keep the existing finally unlinkSync block for best-effort immediate cleanup.
src/daemon/manager.ts (1)

158-227: Consider potential TOCTOU race in the OS signal path.

Between calling getStatus() at Line 180 and sending process.kill() at Line 200, the daemon process could exit. While this is caught by the outer try/catch and returns delivery-failed, the error message might be confusing since it would contain the OS-level error rather than a clear "daemon exited" message.

This is a minor edge case since the delivery-failed state correctly indicates failure, but you could consider checking liveness again or providing more specific error context.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/daemon/manager.ts` around lines 158 - 227, In signal(), avoid the TOCTOU
confusion between getStatus() and process.kill(): before sending SIGUSR1 (in the
branch where status.pid !== null) re-check liveness (e.g., call this.getStatus()
again or use process.kill(pid, 0)) and if the process is gone return a clearer
failure (map ESRCH or a missing PID to a specific state like "daemon-exited" or
"no-daemon" with appropriate pid/stalePid), otherwise proceed to call
process.kill(pid, "SIGUSR1"); also wrap the actual process.kill call in its own
try/catch to translate OS errors (ESRCH) into that clearer state instead of a
generic delivery-failed with raw OS error.
src/db/database.ts (2)

54-65: Potential infinite loop if regex replacement produces same string but with different internal state.

The stripLeadingSqlNoise function loops until the string stops changing. While the regex replacement should always make progress or return the same string, consider adding a safety limit.

🛡️ Optional safety limit
 function stripLeadingSqlNoise(sql: string): string {
 	let normalized = sql;
+	let iterations = 0;
+	const maxIterations = 100;
 	for (;;) {
+		if (++iterations > maxIterations) {
+			console.warn("[open-mem] stripLeadingSqlNoise exceeded max iterations");
+			return normalized.trimStart();
+		}
 		const next = normalized.replace(LEADING_SQL_NOISE, "");
 		if (next === normalized) {
 			return normalized.trimStart();
 		}
 		normalized = next;
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/database.ts` around lines 54 - 65, stripLeadingSqlNoise can
theoretically loop indefinitely if replace continues to return a string that
appears different to the loop condition; add a safety iteration cap inside the
function to prevent an infinite loop: in function stripLeadingSqlNoise(sql:
string) use a maxIterations (e.g. 1000) and decrement/count each loop iteration,
and if the cap is reached break out and return normalized.trimStart(); keep
using LEADING_SQL_NOISE for the replace but ensure the loop exits when
iterations exceed the cap (optionally include a debug/warning path if desired).

496-524: Consider validating maxErrors upper bound.

While maxErrors is floored to at least 1, SQLite's PRAGMA integrity_check(N) accepts very large values. An extremely large maxErrors (e.g., Number.MAX_SAFE_INTEGER) would be valid but potentially expensive. Consider adding an upper bound.

💡 Optional upper bound
 public integrityCheck(maxErrors = 1): IntegrityCheckResult {
-	const normalizedMaxErrors = Number.isFinite(maxErrors) ? Math.max(1, Math.floor(maxErrors)) : 1;
+	const MAX_INTEGRITY_ERRORS = 1000;
+	const normalizedMaxErrors = Number.isFinite(maxErrors) 
+		? Math.min(MAX_INTEGRITY_ERRORS, Math.max(1, Math.floor(maxErrors))) 
+		: 1;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/db/database.ts` around lines 496 - 524, The integrityCheck method
currently only floors maxErrors to >=1 but doesn't cap extremely large values;
add an upper bound constant (e.g., MAX_INTEGRITY_ERRORS) and clamp
normalizedMaxErrors to Math.min(Math.max(1, Math.floor(maxErrors)),
MAX_INTEGRITY_ERRORS) before calling PRAGMA integrity_check, or throw a
RangeError if maxErrors exceeds the allowed maximum; update the variable used in
the PRAGMA call (normalizedMaxErrors) and consider emitting a warning or clear
error when clipping occurs so callers know their value was reduced.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/e2e/build.test.ts`:
- Around line 64-82: The test fails in CI because runner detection is fragile,
the "error" string check is brittle, and docs deps may not be installed; update
the test named "docs build command succeeds after documentation updates" to (1)
use process.execPath directly for the runner (replace the
basename/process.execPath conditional and Bun.which logic with
process.execPath), (2) remove the combined stdout/stderr
`.toLowerCase().not.toContain("error")` assertion and rely only on
`expect(exitCode).toBe(0)` for success, and (3) guard the test when docs
dependencies are missing by skipping in that case (use a preflight like
test.skipIf(!existsSync(resolve(ROOT, "docs/node_modules"))) or ensure docs
dependencies are installed before running the test by running a package manager
install in the docs directory); refer to the test name, the runner variable,
process.execPath, exitCode assertion, and test.skipIf/existsSync/resolve to find
and change the code.

---

Nitpick comments:
In `@src/daemon/manager.ts`:
- Around line 158-227: In signal(), avoid the TOCTOU confusion between
getStatus() and process.kill(): before sending SIGUSR1 (in the branch where
status.pid !== null) re-check liveness (e.g., call this.getStatus() again or use
process.kill(pid, 0)) and if the process is gone return a clearer failure (map
ESRCH or a missing PID to a specific state like "daemon-exited" or "no-daemon"
with appropriate pid/stalePid), otherwise proceed to call process.kill(pid,
"SIGUSR1"); also wrap the actual process.kill call in its own try/catch to
translate OS errors (ESRCH) into that clearer state instead of a generic
delivery-failed with raw OS error.

In `@src/db/database.ts`:
- Around line 54-65: stripLeadingSqlNoise can theoretically loop indefinitely if
replace continues to return a string that appears different to the loop
condition; add a safety iteration cap inside the function to prevent an infinite
loop: in function stripLeadingSqlNoise(sql: string) use a maxIterations (e.g.
1000) and decrement/count each loop iteration, and if the cap is reached break
out and return normalized.trimStart(); keep using LEADING_SQL_NOISE for the
replace but ensure the loop exits when iterations exceed the cap (optionally
include a debug/warning path if desired).
- Around line 496-524: The integrityCheck method currently only floors maxErrors
to >=1 but doesn't cap extremely large values; add an upper bound constant
(e.g., MAX_INTEGRITY_ERRORS) and clamp normalizedMaxErrors to
Math.min(Math.max(1, Math.floor(maxErrors)), MAX_INTEGRITY_ERRORS) before
calling PRAGMA integrity_check, or throw a RangeError if maxErrors exceeds the
allowed maximum; update the variable used in the PRAGMA call
(normalizedMaxErrors) and consider emitting a warning or clear error when
clipping occurs so callers know their value was reduced.

In `@tests/db/database.test.ts`:
- Around line 247-296: The test
test_lock_timeout_diagnostics_include_process_role may leave the manually
created lock file behind if the test fails before finally runs; after creating
the lock file at db.writeLockPath (variable lockPath) push lockPath into the
existing cleanupPaths array so global cleanup will remove it, i.e., add
cleanupPaths.push(lockPath) immediately after writing the file (or before) and
keep the existing finally unlinkSync block for best-effort immediate cleanup.

In `@tests/integration/platform-worker.test.ts`:
- Around line 84-98: The current flatMap that calls JSON.parse silently swallows
parse errors and drops malformed lines; update the catch block in the flatMap
(the JSON.parse call within tests/integration/platform-worker.test.ts) to
surface failures instead of returning an empty array — e.g., log the raw `line`
and rethrow a descriptive Error (or return a single-item object that fails the
subsequent assertions) so the test fails loudly when a line cannot be parsed;
reference the flatMap/JSON.parse location and ensure the thrown error includes
the offending `line` for debugging.
- Around line 505-576: The test mutates
DaemonManager.prototype.start/stop/isRunning/getStatus/signal and
QueueProcessor.prototype.setMode but restores them all inside one finally block,
so if one restoration throws the rest won't run; change the finally to restore
each prototype method in its own safe try/catch (or conditional assignment) so
every original
(originalStart/originalStop/originalIsRunning/originalGetStatus/originalSignal/originalSetMode)
is attempted regardless of errors, e.g., restore DaemonManager.prototype.start
in its own try/catch, then stop, isRunning, getStatus, signal, and finally
QueueProcessor.prototype.setMode, ensuring seenModes logic remains unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e887d561-ad54-4b7e-acd5-cba7b693d330

📥 Commits

Reviewing files that changed from the base of the PR and between 9eb43ec and 65dca80.

📒 Files selected for processing (8)
  • src/daemon/manager.ts
  • src/daemon/pid.ts
  • src/db/database.ts
  • src/runtime/queue-runtime.ts
  • tests/daemon/manager.test.ts
  • tests/db/database.test.ts
  • tests/e2e/build.test.ts
  • tests/integration/platform-worker.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/daemon/pid.ts
  • src/runtime/queue-runtime.ts

Comment thread tests/e2e/build.test.ts Outdated
@greptile-apps

greptile-apps Bot commented Mar 4, 2026

Copy link
Copy Markdown
Additional Comments (3)

src/db/database.ts, line 433
Transaction fallback: ROLLBACK failure masks original exception

In the BEGIN IMMEDIATE fallback path, if fn() throws and then this.db.exec("ROLLBACK") also throws (e.g. due to SQLITE_IOERR or a connection in a broken WAL state), the original error from fn() is silently discarded and the ROLLBACK error propagates instead. This makes diagnosing actual transaction failures very difficult.

The fix is to wrap the rollback in a best-effort try-catch so the original error is always re-thrown:

			} catch (error) {
				try {
					this.db.exec("ROLLBACK");
				} catch {
					// best-effort rollback; preserve original error
				}
				throw error;

src/daemon/manager.ts, line 200
SIGUSR1 unavailable on Windows silently degrades OS-signal fallback

SIGUSR1 is not a valid signal on Windows — process.kill(pid, "SIGUSR1") throws Error: Unknown signal: SIGUSR1 there. The outer try/catch catches this and returns { ok: false, state: "delivery-failed" }, so nothing crashes, but any user on Windows who has an external daemon process (no subprocess reference) will always see delivery-failed for the OS-signal path with no clear explanation.

If Windows support is a goal, consider adding an explicit platform guard before reaching this line:

				if (process.platform !== "win32") {
					process.kill(status.pid, "SIGUSR1");
				} else {
					return {
						ok: false,
						state: "unsupported-signal",
						via: "none",
						pid: status.pid,
						message,
						error: "SIGUSR1 is not supported on Windows",
					};
				}

src/db/advisory-lock.ts, line 425
New SharedArrayBuffer allocation on every sleepSync call

Each call to sleepSync allocates a fresh 4-byte SharedArrayBuffer, which is then used once and immediately abandoned. Under high lock contention (many processes retrying every 50 ms), this produces a steady stream of short-lived SharedArrayBuffer objects that put pressure on the GC.

A simple improvement is to allocate the buffer and its Int32Array view once at module level:

const _sleepSAB = new Int32Array(new SharedArrayBuffer(4));

function sleepSync(ms: number): void {
	if (ms <= 0) return;
	Atomics.wait(_sleepSAB, 0, 0, ms);
}

Comment thread src/db/database.ts
Comment thread src/db/database.ts
Comment thread src/platform-worker.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@package.json`:
- Line 57: The "docs:build" npm script in package.json currently runs "bun
install --frozen-lockfile" which fails because there is no docs/bun.lock or
docs/bun.lockb; either commit a docs lockfile to docs/ (bun.lock or bun.lockb)
and keep the --frozen-lockfile flag, or remove the --frozen-lockfile option from
the "docs:build" script so bun can install without a pre-existing lockfile;
update the "docs:build" entry accordingly to resolve CI/e2e failures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9c885e72-a296-437c-91ce-031d8bd0f732

📥 Commits

Reviewing files that changed from the base of the PR and between 65dca80 and 5f3d282.

📒 Files selected for processing (2)
  • package.json
  • tests/e2e/build.test.ts

Comment thread package.json Outdated
Comment thread src/daemon/manager.ts
@greptile-apps

greptile-apps Bot commented Mar 4, 2026

Copy link
Copy Markdown
Additional Comments (1)

src/db/database.ts, line 432
If fn() throws an error and ROLLBACK also throws (e.g., if the connection is closed), the ROLLBACK exception propagates instead of the original error, silently swallowing the root cause.

		} catch (error) {
			try {
				this.db.exec("ROLLBACK");
			} catch {
				// preserve the original fn() error — do not swallow it
			}
			throw error;

@greptile-apps

greptile-apps Bot commented Mar 4, 2026

Copy link
Copy Markdown
Additional Comments (3)

src/db/database.ts, line 452
ROLLBACK error masks original transaction error

In the fallback BEGIN IMMEDIATE path, if fn() throws error and then this.db.exec("ROLLBACK") also throws (e.g., because SQLite auto-rolled back the transaction due to the error, leaving no active transaction), the ROLLBACK exception replaces error. The caller then sees a confusing "cannot rollback - no transaction is active" error instead of the root cause.

Wrap the ROLLBACK in a try/catch to preserve the original error:

			} catch (error) {
				try {
					this.db.exec("ROLLBACK");
				} catch {
					// Ignore ROLLBACK failure — preserve the original error
				}
				throw error;

src/platform-worker.ts, line 221
DaemonManager instantiated with empty daemonScript has no guard against start()

A comment says start() must not be called, but there is no programmatic enforcement. If any future refactor or code path inadvertently calls state.daemonManager.start(), it will attempt to spawn a subprocess with daemonScript: "" — likely causing a silent spawn failure or unexpected behavior.

Consider throwing or logging an error inside start() when daemonScript is empty, or introducing a dedicated read-only DaemonObserver type so the invariant is enforced by the type system rather than a comment:

// Option A: Guard inside start() (quick fix)
start(): boolean {
    if (!this.daemonScript) {
        throw new Error("start() must not be called on an observer-only DaemonManager (daemonScript is empty)");
    }
    // ... existing logic
}

src/db/database.ts, line 81
RETURNING keyword check may trigger false-positive write-lock acquisitions

The check /\bRETURNING\b/ is applied as the very first test on any SQL string. A purely read-only query that references a column or alias named returning (e.g., SELECT returning FROM audit_log) would be treated as mutating and unnecessarily acquire the advisory write lock. While rare, it adds unneeded cross-process contention for read queries.

A more precise fix is to move the RETURNING check to fire only after confirming the statement already starts with a DML keyword (INSERT, UPDATE, DELETE, REPLACE). The current check at lines 79–81 can be removed since those token families are already covered by WRITE_INTENT_LEADING_TOKENS higher in the function, and RETURNING only needs a special path for the leading-token fallback.

Comment thread src/db/database.ts
Comment thread src/db/database.ts
Comment thread src/db/database.ts
Comment thread src/db/database.ts
@greptile-apps

greptile-apps Bot commented Mar 4, 2026

Copy link
Copy Markdown
Additional Comments (4)

src/db/advisory-lock.ts, line 141
TOCTOU race condition in concurrent stale lock reaping

The double-check pattern (re-read owner before unlinkSync) reduces the race window but doesn't eliminate it. Two processes can both:

  1. Read the stale metadata (same dead PID)
  2. Verify isProcessAlivefalse
  3. Re-read owner metadata — both see the same stale owner; sameOwner passes for both
  4. Process A calls unlinkSync → succeeds, then immediately acquires a new lock and writes new metadata
  5. Process B calls unlinkSync → deletes Process A's new lock file
  6. Process B acquires a new lock — now both A and B believe they hold the lock

This can only be fixed with an atomic compare-and-delete primitive (not available in POSIX). A pragmatic mitigation is to verify post-deletion that the lock can be immediately re-acquired:

try {
    unlinkSync(lockPath);
    // After reaping, caller will retry the O_EXCL open; no further action here
    return true;
} catch (error) {
    const code = (error as { code?: string }).code;
    if (code === "ENOENT") return false; // another reaper beat us, that's fine
    throw error;
}

The existing behavior already retries the openSync loop, so if the unlink races, the next attempt will see either EEXIST (new owner) or succeed. The risk is strictly the window where A has acquired the lock but B's stale-unlink fires before A's inProcessReentrantLocks.set — impossible in a single JS thread but possible cross-process. Consider documenting the known limitation and reducing the blast radius by only reaping when the lock is >= N seconds old.


src/daemon/manager.ts, line 178
signal() can return ok: true when subprocess IPC channel is silently dropped

When the daemon was started in-process (via DaemonManager.start()), this.subprocess remains non-null even after the daemon exits — unless stop() is explicitly called. If Bun's subprocess.send() silently discards the message instead of throwing (e.g., when the IPC channel has drained after exit), the call succeeds without error and the code returns ok: true with state: "delivered".

The caller in platform-worker.ts uses result.ok to decide whether to fall back to in-process mode:

const result = state.daemonManager?.signal("PROCESS_NOW");
if (!result?.ok) {
    fallbackToInProcess(state);
}

A false ok: true here skips the fallback. Items are enqueued expecting daemon processing that will never happen — until the 30-second liveness timer fires. Add a post-send liveness check before reporting delivery:

if (this.subprocess) {
    try {
        this.subprocess.send(message);
    } catch {
        // fall through to catch block
        throw new Error("IPC send failed");
    }
    const status = readStatusSafely();
    if (!status?.running) {
        return {
            ok: false,
            state: "daemon-dead",
            via: "none",
            pid: null,
            message,
            error: "Daemon exited after IPC send",
        };
    }
    return { ok: true, state: "delivered", via: "ipc", pid: status.pid ?? null, message };
}

src/platform-worker.ts, line 324
Liveness timer should be cleared before processBatch() in shutdown

The daemonLivenessTimer is cleared after processBatch() completes. If the 30-second interval fires during processBatch(), fallbackToInProcess() is invoked — changing the queue mode and potentially calling queue.start() — while shutdown is actively draining items. This creates a benign but confusing mid-shutdown mode change that generates spurious [open-mem] Daemon died warnings.

Clear the timer first:

	const shutdown = async () => {
		if (shuttingDown) return;
		shuttingDown = true;
		if (state.daemonLivenessTimer) {
			clearInterval(state.daemonLivenessTimer);
			state.daemonLivenessTimer = null;
		}
		try {
			await state.queue.processBatch();
		} catch {}
		try {
			state.queueRuntime.stop();
			state.db.close();
		} finally {
			cleanupPid();
			process.exit(0);
		}
	};

src/db/database.ts, line 466
Advisory write lock held for the entire migration sequence

migrate() acquires the advisory write lock at the top level, holding it across ALL pending migrations (including the SELECT version FROM _migrations read and every per-migration transaction()). Each inner transaction(), exec(), and run() call then reacquires it via in-process reentrancy (correct, but the effective hold time equals the sum of all migrations).

With the default timeoutMs of 5 000 ms, any other process attempting a write during a slow or large migration batch will throw AdvisoryLockTimeoutError. Consider restructuring so the advisory lock is held only per-migration transaction (which transaction() already does internally) rather than around the entire loop:

public migrate(migrations: Migration[]): void {
    // Read applied versions without holding the write lock
    this.ensureMigrationTable();
    const applied = this.withRetry("migrate.applied_versions", () =>
        this.db.query("SELECT version FROM _migrations ORDER BY version").all() as { version: number }[]
    );
    const appliedVersions = new Set(applied.map((m) => m.version));
    const pending = migrations
        .filter((m) => !appliedVersions.has(m.version))
        .sort((a, b) => a.version - b.version);

    // Each transaction already holds the advisory lock via transaction()
    for (const migration of pending) {
        this.transaction(() => {
            this.exec(migration.up);
            this.run("INSERT INTO _migrations (version, name) VALUES ($version, $name)", [
                { $version: migration.version, $name: migration.name },
            ]);
        });
    }
}

This still guarantees each migration is atomic while avoiding holding the cross-process lock unnecessarily across reads and multiple sequential writes.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment thread src/db/database.ts
@greptile-apps

greptile-apps Bot commented Mar 4, 2026

Copy link
Copy Markdown
Additional Comments (3)

src/db/database.ts, line 670
The checkpointWal method holds the advisory write lock for the entire checkpoint duration (lines 650–669). For PASSIVE mode this is typically fast, but for FULL, RESTART, or TRUNCATE modes the checkpoint must wait for all active readers to finish, which can easily exceed the default DEFAULT_TIMEOUT_MS of 5 seconds.

Any concurrent write operation from a plugin, daemon, or platform worker that tries to acquire the advisory lock during a long checkpoint will fail with AdvisoryLockTimeoutError, surfacing as an unexpected write failure to the caller rather than a contention-related delay.

Consider either:

  1. Omitting the advisory lock for checkpointWal when in PASSIVE mode (which is non-blocking) and only taking it for destructive modes, or
  2. Documenting that callers should use PASSIVE when other writers are active and reserve FULL/RESTART/TRUNCATE for maintenance windows where no other processes hold the DB.

src/db/database.ts, line 612
The fallback BEGIN IMMEDIATE path (lines 576–612) is reached only when this.db.transaction(fn).immediate is not a function. In this branch, this.db.exec("BEGIN IMMEDIATE") is called directly outside of withRetry, meaning a transient SQLITE_BUSY during transaction start is not retried at the application level. While Bun's SQLite sets busy_timeout = 5000 for SQLite's own busy handler, application-level retry provides exponential backoff and better logging.

If this fallback path is intentional defensive code (e.g., for non-Bun runtimes), consider adding a warning log so the fallback is visible during debugging:

console.warn("[open-mem] transaction(): wrapped.immediate unavailable, using manual BEGIN IMMEDIATE fallback");
this.db.exec("BEGIN IMMEDIATE");

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


src/platform-worker.ts, line 221
The DaemonManager is created with an empty daemonScript to prevent start() from being called (lines 215–221). However, DaemonManager.start() provides no guard to enforce this invariant. If start() is ever called (e.g., during refactoring), it will silently spawn bun run "" --project ..., fail after a 2-second poll timeout, and return false — leaving no trace of the mistake.

Consider adding a guard in DaemonManager.start():

start(): boolean {
    if (!this.daemonScript) {
        throw new Error("DaemonManager is observation-only (daemonScript is empty); start() must not be called");
    }
    // ... rest of method
}

@greptile-apps

greptile-apps Bot commented Mar 4, 2026

Copy link
Copy Markdown
Additional Comments (1)

src/daemon.ts, line 137
SIGUSR1 conflicts with Node.js/Bun debugger and profiling tools

In Node.js (and by extension Bun), SIGUSR1 is the conventional signal to start the V8 inspector / CPU profiler. Installing a custom handler here silently suppresses that default behavior, making it impossible to attach a debugger or profiler to the daemon process without modifying the source.

Consider using SIGUSR2 instead, which is far less likely to conflict with platform tooling:

process.on("SIGUSR2", () => {
	worker.handleMessage("PROCESS_NOW");
});

The corresponding sender in DaemonManager.signal() would need to match:

process.kill(status.pid, "SIGUSR2");

@clopca clopca merged commit 003c4be into main Mar 4, 2026
4 checks passed
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.

1 participant