codebrain 0.3.4__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codebrain/__init__.py CHANGED
@@ -7,4 +7,4 @@ try:
7
7
  except PackageNotFoundError:
8
8
  # Source checkout without installation (e.g. running from a worktree).
9
9
  # Keep in sync with [project.version] in pyproject.toml.
10
- __version__ = "0.3.4"
10
+ __version__ = "0.4.0"
codebrain/cli.py CHANGED
@@ -58,9 +58,17 @@ def _require_index(repo_root: Path) -> Path:
58
58
  import gc
59
59
  gc.collect() # Release any lingering connections
60
60
  try:
61
- db.unlink(missing_ok=True)
62
- for wal in db.parent.glob(f"{db.name}-*"):
63
- wal.unlink(missing_ok=True)
61
+ # Prefer rebuilding in place — deleting the file fails on
62
+ # Windows while any MCP server holds it open. Only fall back
63
+ # to file replacement when the DB is unreadable.
64
+ try:
65
+ from codebrain.graph.store import GraphStore
66
+ with GraphStore(db) as _store:
67
+ _store.clear_all()
68
+ except Exception:
69
+ db.unlink(missing_ok=True)
70
+ for wal in db.parent.glob(f"{db.name}-*"):
71
+ wal.unlink(missing_ok=True)
64
72
  from codebrain.indexer import full_index
65
73
  full_index(repo_root, db)
66
74
  click.echo(click.style("Database rebuilt successfully.", fg="green"), err=True)
@@ -1618,33 +1626,37 @@ def reindex(ctx: click.Context, yes: bool, as_json: bool) -> None:
1618
1626
  db = _db_path(repo_root)
1619
1627
 
1620
1628
  if not yes and not as_json:
1621
- if not click.confirm("This will delete and rebuild the entire index. Continue?"):
1629
+ if not click.confirm("This will rebuild the entire index. Continue?"):
1622
1630
  click.echo("Aborted.")
1623
1631
  return
1624
1632
 
1633
+ # Rebuild IN PLACE (clear tables, re-fill) rather than deleting the DB
1634
+ # file. Deleting fails on Windows whenever any MCP server holds the DB
1635
+ # open — and with session-long server lifetimes that is "always". WAL
1636
+ # mode makes the in-place rebuild safe with readers attached, so reindex
1637
+ # never needs to find, kill, or wait for other processes.
1625
1638
  if db.exists():
1639
+ from codebrain.graph.store import GraphStore
1626
1640
  try:
1627
- db.unlink()
1628
- except PermissionError:
1629
- from codebrain.mcp_lifecycle import find_db_lock_holder, kill_pid
1630
- holder = find_db_lock_holder(repo_root)
1631
- if holder is None:
1632
- click.echo(
1633
- " Database is locked but no recorded MCP holder.\n"
1634
- " Find the holder with `Get-Process python` (Windows) or `lsof` (Unix) and stop it."
1635
- )
1641
+ with GraphStore(db) as _store:
1642
+ _store.clear_all()
1643
+ except Exception as exc:
1644
+ # Corrupted beyond clearing — replacing the file is the only
1645
+ # option left. This fails if another process holds it open.
1646
+ click.echo(click.style(
1647
+ f" Index unreadable ({exc}); replacing the database file.", fg="yellow",
1648
+ ))
1649
+ try:
1650
+ db.unlink()
1651
+ for wal in db.parent.glob(f"{db.name}-*"):
1652
+ wal.unlink(missing_ok=True)
1653
+ except PermissionError:
1654
+ click.echo(click.style(
1655
+ " The corrupted database is held open by a running MCP server.\n"
1656
+ " Close Claude Code sessions for this project (or run "
1657
+ "`brain doctor --kill-stale-mcps`) and retry.", fg="red",
1658
+ ))
1636
1659
  raise
1637
- should_kill = yes or as_json or click.confirm(
1638
- f"Database locked by CodeBrain MCP server PID {holder}. Terminate it and continue?"
1639
- )
1640
- if not should_kill:
1641
- click.echo("Aborted.")
1642
- return
1643
- status = kill_pid(holder)
1644
- click.echo(f" Killed MCP PID {holder}: {status}")
1645
- import time as _time
1646
- _time.sleep(1)
1647
- db.unlink()
1648
1660
 
1649
1661
  files = discover_files(repo_root)
1650
1662
 
@@ -1847,6 +1859,13 @@ def repair(ctx: click.Context) -> None:
1847
1859
  break
1848
1860
  except PermissionError:
1849
1861
  _time.sleep(0.2)
1862
+ else:
1863
+ click.echo(click.style(
1864
+ "Could not replace the corrupted database — a running MCP server "
1865
+ "holds it open.\nClose Claude Code sessions for this project (or "
1866
+ "run `brain doctor --kill-stale-mcps`) and retry.", fg="red",
1867
+ ))
1868
+ sys.exit(1)
1850
1869
  # Also clean up WAL/SHM files
1851
1870
  for suffix in ("-wal", "-shm"):
1852
1871
  wal = db.parent / (db.name + suffix)
codebrain/graph/store.py CHANGED
@@ -22,11 +22,18 @@ class GraphStore:
22
22
  self.db_path.parent.mkdir(parents=True, exist_ok=True)
23
23
  _log.debug("Opening database %s", self.db_path)
24
24
  self.conn = sqlite3.connect(str(self.db_path), timeout=30, check_same_thread=False)
25
- self.conn.row_factory = sqlite3.Row
26
- self.conn.execute("PRAGMA journal_mode=WAL")
27
- self.conn.execute("PRAGMA synchronous=NORMAL")
28
- self.conn.execute("PRAGMA foreign_keys=OFF")
29
- migrate_db(self.conn)
25
+ try:
26
+ self.conn.row_factory = sqlite3.Row
27
+ self.conn.execute("PRAGMA journal_mode=WAL")
28
+ self.conn.execute("PRAGMA synchronous=NORMAL")
29
+ self.conn.execute("PRAGMA foreign_keys=OFF")
30
+ migrate_db(self.conn)
31
+ except Exception:
32
+ # A corrupted/garbage file makes the pragmas raise AFTER the OS
33
+ # handle is open. Without this close, the dangling handle keeps
34
+ # the file locked on Windows and recovery-by-replacement fails.
35
+ self.conn.close()
36
+ raise
30
37
 
31
38
  def close(self) -> None:
32
39
  _log.debug("Closing database %s", self.db_path)
@@ -113,6 +120,11 @@ class GraphStore:
113
120
  ).fetchone()
114
121
  return row["content_hash"] if row else None
115
122
 
123
+ def all_file_paths(self) -> list[str]:
124
+ """Return every file path currently in the index."""
125
+ rows = self.conn.execute("SELECT path FROM files").fetchall()
126
+ return [row["path"] for row in rows]
127
+
116
128
  # ------------------------------------------------------------------
117
129
  # Node operations
118
130
  # ------------------------------------------------------------------
codebrain/indexer.py CHANGED
@@ -490,6 +490,43 @@ def full_index(
490
490
  }
491
491
 
492
492
 
493
+ def scan_stale(
494
+ repo_root: Path,
495
+ store: GraphStore,
496
+ settings: "Settings | None" = None,
497
+ ) -> tuple[list[Path], list[Path]]:
498
+ """Cheap staleness scan: which files changed since they were indexed?
499
+
500
+ Returns ``(changed_candidates, deleted)``. Uses an mtime-vs-last_indexed
501
+ prefilter so no file contents are read here — false candidates are fine
502
+ because :func:`incremental_update` hash-checks before re-parsing. This is
503
+ the fast freshness layer (milliseconds on a warm FS cache); the exhaustive
504
+ hash-everything pass lives in the watcher's startup catch-up sync.
505
+ """
506
+ rows = store.conn.execute("SELECT path, last_indexed FROM files").fetchall()
507
+ stored = {row["path"]: row["last_indexed"] for row in rows}
508
+
509
+ changed: list[Path] = []
510
+ seen: set[str] = set()
511
+ # Files modified within this window before indexing finished may have
512
+ # raced the indexer — treat them as candidates (hash check disambiguates).
513
+ slack = 2.0
514
+ for file_path in discover_files(repo_root, settings):
515
+ rel = normalize_path(file_path, repo_root)
516
+ seen.add(rel)
517
+ last_indexed = stored.get(rel)
518
+ if last_indexed is None:
519
+ changed.append(file_path) # new file
520
+ continue
521
+ try:
522
+ if file_path.stat().st_mtime > last_indexed - slack:
523
+ changed.append(file_path)
524
+ except OSError:
525
+ continue
526
+ deleted = [repo_root / rel for rel in stored if rel not in seen]
527
+ return changed, deleted
528
+
529
+
493
530
  def incremental_update(
494
531
  repo_root: Path,
495
532
  changed_files: list[Path],
@@ -510,6 +547,12 @@ def incremental_update(
510
547
  store.remove_file(rel)
511
548
  removed += 1
512
549
 
550
+ # tree-sitter can hang holding the GIL on Windows — isolate those
551
+ # extensions in a subprocess, same as full_index. A hang here would
552
+ # otherwise freeze the watcher/catch-up thread while it holds the DB
553
+ # lock, silently stopping auto-indexing for the rest of the session.
554
+ _NEEDS_ISOLATION = frozenset({".ts", ".tsx", ".js", ".jsx"})
555
+
513
556
  for file_path in changed_files:
514
557
  rel = normalize_path(file_path, repo_root)
515
558
  try:
@@ -519,9 +562,16 @@ def incremental_update(
519
562
  if current_hash == stored_hash:
520
563
  continue
521
564
 
522
- pf = _parse_file(file_path, repo_root)
523
- store.upsert_file(pf)
524
- updated += 1
565
+ if sys.platform == "win32" and file_path.suffix in _NEEDS_ISOLATION:
566
+ pf, err = _parse_with_timeout(file_path, repo_root, timeout=30)
567
+ if err:
568
+ errors.append(err)
569
+ continue
570
+ else:
571
+ pf = _parse_file(file_path, repo_root)
572
+ if pf is not None:
573
+ store.upsert_file(pf)
574
+ updated += 1
525
575
  except Exception as exc:
526
576
  errors.append(f"{rel}: {exc}")
527
577
 
@@ -79,39 +79,43 @@ def _read_pid_file(pid_file: Path) -> int | None:
79
79
  return None
80
80
 
81
81
 
82
- def _predecessor_has_live_host(pid: int) -> bool:
83
- """True if ``pid``'s ancestor chain contains a live host (claude/cursor/...).
84
-
85
- Such a predecessor belongs to a *concurrent* sibling Claude session and
86
- must not be terminated its disappearance would silently break that
87
- session's MCP. Without psutil we cannot tell, so be safe and assume yes.
82
+ def _predecessor_host_pid(pid: int) -> int | None:
83
+ """Return the PID of the live IDE host (claude/cursor/...) in ``pid``'s
84
+ ancestor chain, or None if there is none.
85
+
86
+ A predecessor with a live host belongs to *some* Claude session — but
87
+ whether it must be spared depends on WHICH session: a different host PID
88
+ means a concurrent sibling window (killing it would silently break that
89
+ session's MCP); the SAME host PID as ours means it is our own session's
90
+ orphaned predecessor left behind by a transport disconnect/reconnect,
91
+ and it must die or two servers fight over the SQLite DB.
88
92
  """
89
93
  try:
90
94
  import psutil
91
95
  except ImportError:
92
- return True
96
+ return None
93
97
  try:
94
98
  proc = psutil.Process(pid)
95
99
  except (psutil.NoSuchProcess, psutil.AccessDenied):
96
- return False
100
+ return None
97
101
  for _ in range(ANCESTOR_WALK_DEPTH):
98
102
  try:
99
103
  parent = proc.parent()
100
104
  except (psutil.NoSuchProcess, psutil.AccessDenied):
101
- return False
105
+ return None
102
106
  if parent is None:
103
- return False
107
+ return None
104
108
  try:
105
109
  name = (parent.name() or "").lower()
106
110
  except (psutil.NoSuchProcess, psutil.AccessDenied):
107
- return False
111
+ return None
108
112
  if any(hint in name for hint in HOST_PROCESS_NAME_HINTS):
109
- return True
113
+ return parent.pid
110
114
  proc = parent
111
- return False
115
+ return None
112
116
 
113
117
 
114
- def _kill_stale_predecessor(pid_file: Path) -> None:
118
+ def _kill_stale_predecessor(pid_file: Path, own_host_pid: int | None = None) -> None:
115
119
  if not pid_file.exists():
116
120
  return
117
121
  old_pid = _read_pid_file(pid_file)
@@ -128,12 +132,27 @@ def _kill_stale_predecessor(pid_file: Path) -> None:
128
132
  # PID was reused by an unrelated process. Don't touch.
129
133
  _log.debug("PID %d reused by unrelated process; leaving alone", old_pid)
130
134
  return
131
- if _predecessor_has_live_host(old_pid):
132
- # Concurrent sibling Claude session is still using this MCP leave it.
133
- # Without this, two Claude windows on the same repo race each other and
134
- # whichever started last kills the other's MCP.
135
- _log.debug("PID %d has a live IDE host; sibling MCP, leaving alone", old_pid)
135
+ pred_host = _predecessor_host_pid(old_pid)
136
+ if pred_host is not None and pred_host != own_host_pid:
137
+ # Live IDE host that is NOT ours concurrent sibling Claude session
138
+ # is still using this MCP leave it. Without this, two Claude
139
+ # windows on the same repo race each other and whichever started
140
+ # last kills the other's MCP.
141
+ _log.debug(
142
+ "PID %d has live IDE host %d (ours: %s); sibling MCP, leaving alone",
143
+ old_pid, pred_host, own_host_pid,
144
+ )
136
145
  return
146
+ if pred_host is not None:
147
+ # Same host as ours → our own session reconnected and left the old
148
+ # server behind with a dead stdio transport. It will never exit on
149
+ # its own (parent still alive, idle timer disabled when anchored)
150
+ # and holds the SQLite DB — kill it.
151
+ _log.warning(
152
+ "Predecessor PID %d shares our IDE host %d — duplicate from a "
153
+ "transport reconnect, terminating it",
154
+ old_pid, pred_host,
155
+ )
137
156
  try:
138
157
  proc = psutil.Process(old_pid)
139
158
  _log.warning("Killing stale CodeBrain MCP predecessor PID %d", old_pid)
@@ -167,7 +186,7 @@ def _remove_pid_file(pid_file: Path) -> None:
167
186
  pass
168
187
 
169
188
 
170
- def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
189
+ def _find_watch_target(start_pid: int) -> tuple[int, float | None, bool]:
171
190
  """Pick the PID whose death should kill the MCP.
172
191
 
173
192
  Walks up the ancestor chain (up to ANCESTOR_WALK_DEPTH levels) looking
@@ -175,6 +194,10 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
175
194
  real IDE host. Falls back to ``start_pid`` if no hint matches, psutil
176
195
  is unavailable, or ``CODEBRAIN_MCP_DISABLE_ANCESTOR_WALK=1`` is set.
177
196
 
197
+ Returns ``(pid, create_time, host_anchored)``. ``host_anchored`` is True
198
+ only when an actual IDE host process was found — callers use it to decide
199
+ whether the idle-timeout backstop is needed at all.
200
+
178
201
  Why: on Windows, Claude Code spawns the MCP via a transient launcher
179
202
  (cmd.exe wrapper or Electron worker shell). The launcher exits soon
180
203
  after the python child starts, so watching ``os.getppid()`` directly
@@ -183,16 +206,16 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
183
206
  try:
184
207
  import psutil
185
208
  except ImportError:
186
- return start_pid, None
209
+ return start_pid, None, False
187
210
 
188
211
  fallback_create_time: float | None = None
189
212
  try:
190
213
  fallback_create_time = psutil.Process(start_pid).create_time()
191
214
  except (psutil.NoSuchProcess, psutil.AccessDenied):
192
- return start_pid, None
215
+ return start_pid, None, False
193
216
 
194
217
  if os.environ.get("CODEBRAIN_MCP_DISABLE_ANCESTOR_WALK") == "1":
195
- return start_pid, fallback_create_time
218
+ return start_pid, fallback_create_time, False
196
219
 
197
220
  try:
198
221
  proc = psutil.Process(start_pid)
@@ -202,7 +225,7 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
202
225
  except (psutil.NoSuchProcess, psutil.AccessDenied):
203
226
  break
204
227
  if any(hint in name for hint in HOST_PROCESS_NAME_HINTS):
205
- return proc.pid, proc.create_time()
228
+ return proc.pid, proc.create_time(), True
206
229
  try:
207
230
  parent = proc.parent()
208
231
  except (psutil.NoSuchProcess, psutil.AccessDenied):
@@ -212,7 +235,7 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
212
235
  proc = parent
213
236
  except (psutil.NoSuchProcess, psutil.AccessDenied):
214
237
  pass
215
- return start_pid, fallback_create_time
238
+ return start_pid, fallback_create_time, False
216
239
 
217
240
 
218
241
  def _parent_watchdog(initial_ppid: int, initial_create_time: float | None) -> None:
@@ -241,16 +264,35 @@ def _parent_watchdog(initial_ppid: int, initial_create_time: float | None) -> No
241
264
  _log.debug("parent watchdog tick error: %s", exc)
242
265
 
243
266
 
244
- def _idle_watchdog() -> None:
245
- if IDLE_TIMEOUT_SECONDS <= 0:
267
+ def _effective_idle_timeout(host_anchored: bool) -> int:
268
+ """Resolve the idle timeout in seconds (0 disables the idle watchdog).
269
+
270
+ The idle watchdog is a *backstop* for when the parent watchdog has no
271
+ reliable IDE host to watch. When we are anchored to a real host
272
+ (claude/cursor/vscode), the parent watchdog deterministically tears the
273
+ server down at session end — an idle timeout on top of that only kills
274
+ the file watcher mid-session and leaves the index stale. So:
275
+
276
+ - explicit ``CODEBRAIN_MCP_IDLE_TIMEOUT`` env var always wins
277
+ - otherwise: disabled when host-anchored, 30-min backstop when not
278
+ """
279
+ if "CODEBRAIN_MCP_IDLE_TIMEOUT" in os.environ:
280
+ return IDLE_TIMEOUT_SECONDS
281
+ if host_anchored:
282
+ return 0
283
+ return IDLE_TIMEOUT_SECONDS
284
+
285
+
286
+ def _idle_watchdog(timeout: int) -> None:
287
+ if timeout <= 0:
246
288
  return
247
- poll = max(5, min(60, IDLE_TIMEOUT_SECONDS // 4))
289
+ poll = max(5, min(60, timeout // 4))
248
290
  while True:
249
291
  time.sleep(poll)
250
292
  with _last_activity_lock:
251
293
  idle = time.time() - _last_activity
252
- if idle > IDLE_TIMEOUT_SECONDS:
253
- _exit(f"idle for {idle:.0f}s (limit {IDLE_TIMEOUT_SECONDS}s)")
294
+ if idle > timeout:
295
+ _exit(f"idle for {idle:.0f}s (limit {timeout}s)")
254
296
 
255
297
 
256
298
  def _lifetime_watchdog(start: float) -> None:
@@ -277,21 +319,30 @@ def install_watchdogs(repo_root: Path | None = None) -> None:
277
319
  start = time.time()
278
320
  mark_activity()
279
321
 
322
+ # Resolve our own IDE host FIRST — the predecessor check needs it to
323
+ # distinguish "our own session's orphan after a transport reconnect"
324
+ # (kill) from "a concurrent sibling window's server" (spare).
325
+ immediate_ppid = os.getppid()
326
+ initial_ppid, initial_create_time, host_anchored = _find_watch_target(immediate_ppid)
327
+ idle_timeout = _effective_idle_timeout(host_anchored)
328
+
280
329
  if repo_root is not None:
281
330
  from codebrain.config import CODEBRAIN_DIR
282
331
  pid_file = repo_root / CODEBRAIN_DIR / PID_FILE_NAME
283
- _kill_stale_predecessor(pid_file)
332
+ _kill_stale_predecessor(
333
+ pid_file,
334
+ own_host_pid=initial_ppid if host_anchored else None,
335
+ )
284
336
  _write_pid_file(pid_file)
285
337
  atexit.register(_remove_pid_file, pid_file)
286
338
 
287
- immediate_ppid = os.getppid()
288
- initial_ppid, initial_create_time = _find_watch_target(immediate_ppid)
289
-
290
339
  _log.info(
291
- "MCP watchdogs installed (ppid=%d via=%d, idle_timeout=%ds, max_lifetime=%ds)",
340
+ "MCP watchdogs installed (ppid=%d via=%d, host_anchored=%s, "
341
+ "idle_timeout=%ds, max_lifetime=%ds)",
292
342
  initial_ppid,
293
343
  immediate_ppid,
294
- IDLE_TIMEOUT_SECONDS,
344
+ host_anchored,
345
+ idle_timeout,
295
346
  MAX_LIFETIME_SECONDS,
296
347
  )
297
348
 
@@ -303,6 +354,7 @@ def install_watchdogs(repo_root: Path | None = None) -> None:
303
354
  ).start()
304
355
  threading.Thread(
305
356
  target=_idle_watchdog,
357
+ args=(idle_timeout,),
306
358
  name="cb-idle-watchdog",
307
359
  daemon=True,
308
360
  ).start()
codebrain/mcp_server.py CHANGED
@@ -110,6 +110,125 @@ def _make_store():
110
110
  return GraphStore(_find_db())
111
111
 
112
112
 
113
+ # ---------------------------------------------------------------------------
114
+ # Freshness-on-read
115
+ #
116
+ # Correctness must not depend on which background processes happen to be
117
+ # alive. The watcher (instant) and the startup catch-up sync (thorough) are
118
+ # optimizations; THIS is the guarantee: before serving a tool call, cheaply
119
+ # verify the index matches the working tree and sync the difference. Even if
120
+ # every watcher thread is dead, the answer the agent gets reflects current
121
+ # code.
122
+ # ---------------------------------------------------------------------------
123
+
124
+ import threading as _threading
125
+ import time as _time
126
+
127
+ FRESHNESS_INTERVAL_SECONDS = float(os.environ.get("CODEBRAIN_FRESHNESS_INTERVAL", "5"))
128
+ # Above this many stale files, sync in the background instead of inline so a
129
+ # huge drift (e.g. branch switch on a big repo) can't eat the tool deadline.
130
+ FRESHNESS_INLINE_LIMIT = 200
131
+
132
+ _freshness_lock = _threading.Lock()
133
+ _last_freshness_check: dict[str, float] = {}
134
+
135
+ # Tools that must NOT trigger a freshness sync:
136
+ # - validators compare new/written content against the PRE-change graph;
137
+ # syncing first would erase the baseline and the structural gate would
138
+ # silently pass everything
139
+ # - reindex_codebase rebuilds anyway
140
+ # - project/memory tools don't read graph data
141
+ _FRESHNESS_EXEMPT_TOOLS = frozenset({
142
+ "validate_change",
143
+ "validate_changes",
144
+ "validate_after_write",
145
+ "propose_change",
146
+ "diff_impact",
147
+ "get_validation_status",
148
+ "reindex_codebase",
149
+ "set_project",
150
+ "get_project",
151
+ "save_memory",
152
+ "recall_memories",
153
+ "list_memories",
154
+ "update_memory",
155
+ "delete_memory",
156
+ })
157
+
158
+
159
+ def _ensure_fresh() -> None:
160
+ """Best-effort staleness sync before serving a tool call.
161
+
162
+ Throttled per database; never blocks on a concurrent sync (a slightly
163
+ stale answer beats a deadlocked one); never raises.
164
+ """
165
+ if FRESHNESS_INTERVAL_SECONDS <= 0:
166
+ return
167
+ try:
168
+ db_path = _find_db()
169
+ except FileNotFoundError:
170
+ return
171
+ key = str(db_path)
172
+ now = _time.monotonic()
173
+ if now - _last_freshness_check.get(key, 0.0) < FRESHNESS_INTERVAL_SECONDS:
174
+ return
175
+ if not _freshness_lock.acquire(blocking=False):
176
+ return # another sync in flight — serve what we have
177
+ try:
178
+ _last_freshness_check[key] = now
179
+ from codebrain.graph.store import GraphStore
180
+ from codebrain.indexer import incremental_update, scan_stale
181
+
182
+ repo_root = db_path.parent.parent
183
+ with GraphStore(db_path) as store:
184
+ changed, deleted = scan_stale(repo_root, store)
185
+ if not changed and not deleted:
186
+ return
187
+ if len(changed) + len(deleted) > FRESHNESS_INLINE_LIMIT:
188
+ _log.info(
189
+ "Freshness: %d files drifted — syncing in background",
190
+ len(changed) + len(deleted),
191
+ )
192
+ _threading.Thread(
193
+ target=_background_freshness_sync,
194
+ args=(repo_root, db_path, changed, deleted),
195
+ name="cb-freshness-sync",
196
+ daemon=True,
197
+ ).start()
198
+ return
199
+ result = incremental_update(repo_root, changed, deleted, store)
200
+ if result["files_updated"] or result["files_removed"]:
201
+ _log.info(
202
+ "Freshness: updated %d, removed %d (%.3fs)",
203
+ result["files_updated"],
204
+ result["files_removed"],
205
+ result["elapsed_seconds"],
206
+ )
207
+ except Exception as exc:
208
+ _log.debug("Freshness sync skipped: %s", exc)
209
+ finally:
210
+ _freshness_lock.release()
211
+
212
+
213
+ def _background_freshness_sync(
214
+ repo_root: Path, db_path: Path, changed: list, deleted: list,
215
+ ) -> None:
216
+ try:
217
+ from codebrain.graph.store import GraphStore
218
+ from codebrain.indexer import incremental_update
219
+
220
+ with GraphStore(db_path) as store:
221
+ result = incremental_update(repo_root, changed, deleted, store)
222
+ _log.info(
223
+ "Background freshness sync: updated %d, removed %d (%.3fs)",
224
+ result["files_updated"],
225
+ result["files_removed"],
226
+ result["elapsed_seconds"],
227
+ )
228
+ except Exception as exc:
229
+ _log.warning("Background freshness sync failed: %s", exc)
230
+
231
+
113
232
  def _safe_tool(fn): # noqa: ANN001, ANN201
114
233
  """Wrap an MCP tool so it cooperates with MCP cancellation.
115
234
 
@@ -214,6 +333,10 @@ def _run_sync_protected(fn, args, kwargs): # noqa: ANN001, ANN202
214
333
  saved_stdout = sys.stdout
215
334
  sys.stdout = sys.stderr
216
335
  try:
336
+ # Freshness gate: sync index drift before answering. Skip for tools
337
+ # that rebuild or don't read the graph state being synced.
338
+ if fn.__name__ not in _FRESHNESS_EXEMPT_TOOLS:
339
+ _ensure_fresh()
217
340
  return fn(*args, **kwargs)
218
341
  finally:
219
342
  sys.stdout = saved_stdout
@@ -11,9 +11,10 @@ from watchdog.observers import Observer
11
11
 
12
12
  from codebrain.config import INDEXABLE_EXTENSIONS, WATCHER_DEBOUNCE_SECONDS
13
13
  from codebrain.graph.store import GraphStore
14
- from codebrain.indexer import incremental_update
14
+ from codebrain.indexer import discover_files, incremental_update
15
+ from codebrain.utils import normalize_path
15
16
  from codebrain.logging import get_logger
16
- from codebrain.settings import load_settings
17
+ from codebrain.settings import Settings, load_settings
17
18
 
18
19
  _log = get_logger("watcher")
19
20
 
@@ -37,6 +38,8 @@ class _DebouncedHandler(FileSystemEventHandler):
37
38
  self._changed: set[Path] = set()
38
39
  self._deleted: set[Path] = set()
39
40
  self._lock = threading.Lock()
41
+ # Serializes DB writes between flush timer threads and the catch-up thread.
42
+ self.db_lock = threading.Lock()
40
43
  self._timer: threading.Timer | None = None
41
44
  self._last_validation: dict[str, object] = {} # rel_path -> ValidationReport
42
45
 
@@ -57,6 +60,14 @@ class _DebouncedHandler(FileSystemEventHandler):
57
60
  if not changed and not deleted:
58
61
  return
59
62
 
63
+ # Live file edits count as activity — without this the idle watchdog
64
+ # kills the MCP (and this watcher with it) mid-editing-session.
65
+ try:
66
+ from codebrain.mcp_lifecycle import mark_activity
67
+ mark_activity()
68
+ except Exception:
69
+ pass
70
+
60
71
  # Redirect stdout to stderr to prevent MCP stdio protocol corruption
61
72
  # when running inside the MCP server process.
62
73
  import sys
@@ -67,7 +78,8 @@ class _DebouncedHandler(FileSystemEventHandler):
67
78
  if changed:
68
79
  self._validate_changed(changed)
69
80
 
70
- result = incremental_update(self.repo_root, changed, deleted, self.store)
81
+ with self.db_lock:
82
+ result = incremental_update(self.repo_root, changed, deleted, self.store)
71
83
  total = result["files_updated"] + result["files_removed"]
72
84
  if total:
73
85
  _log.info(
@@ -147,6 +159,59 @@ class _DebouncedHandler(FileSystemEventHandler):
147
159
  self._schedule_flush()
148
160
 
149
161
 
162
+ def catch_up_sync(
163
+ repo_root: Path,
164
+ store: GraphStore,
165
+ settings: Settings | None = None,
166
+ db_lock: threading.Lock | None = None,
167
+ ) -> dict:
168
+ """Bring the index up to date with changes made while no watcher was alive.
169
+
170
+ The file watcher only sees events that happen while its process is
171
+ running. Edits made between sessions (or after a lifecycle watchdog
172
+ killed the MCP server) are otherwise missed forever, leaving the index
173
+ stale until a manual `brain reindex`. This diffs disk vs. index:
174
+
175
+ - changed/new files: detected by content hash inside incremental_update
176
+ - deleted files: indexed paths that no longer exist on disk
177
+
178
+ Returns the incremental_update summary dict.
179
+ """
180
+ on_disk = discover_files(repo_root, settings)
181
+ disk_rels = {normalize_path(p, repo_root) for p in on_disk}
182
+ deleted = [
183
+ repo_root / rel
184
+ for rel in store.all_file_paths()
185
+ if rel not in disk_rels
186
+ ]
187
+ lock = db_lock if db_lock is not None else threading.Lock()
188
+ with lock:
189
+ result = incremental_update(repo_root, on_disk, deleted, store)
190
+ if result["files_updated"] or result["files_removed"]:
191
+ _log.info(
192
+ "Catch-up sync: updated %d, removed %d (%.3fs)",
193
+ result["files_updated"],
194
+ result["files_removed"],
195
+ result["elapsed_seconds"],
196
+ )
197
+ else:
198
+ _log.info("Catch-up sync: index already current")
199
+ return result
200
+
201
+
202
+ def _catch_up_in_background(
203
+ repo_root: Path, store: GraphStore, settings: Settings, handler: _DebouncedHandler,
204
+ ) -> None:
205
+ # NOTE: deliberately no sys.stdout redirect here — this thread runs
206
+ # concurrently with the MCP initialize handshake on the real stdout;
207
+ # swapping the global stdout would corrupt the JSON-RPC stream.
208
+ # Parsers do not print, so there is nothing to redirect anyway.
209
+ try:
210
+ catch_up_sync(repo_root, store, settings, db_lock=handler.db_lock)
211
+ except Exception as exc:
212
+ _log.warning("Catch-up sync failed: %s", exc)
213
+
214
+
150
215
  def start_watching_background(
151
216
  repo_root: Path, db_path: Path,
152
217
  ) -> tuple[Observer, GraphStore, _DebouncedHandler]:
@@ -166,7 +231,15 @@ def start_watching_background(
166
231
  observer = Observer()
167
232
  observer.daemon = True
168
233
  observer.schedule(handler, str(repo_root), recursive=True)
234
+ # Observer starts BEFORE the catch-up scan so no event falls in the gap;
235
+ # the hash check in incremental_update makes any overlap harmless.
169
236
  observer.start()
237
+ threading.Thread(
238
+ target=_catch_up_in_background,
239
+ args=(repo_root, store, settings, handler),
240
+ name="cb-catchup-sync",
241
+ daemon=True,
242
+ ).start()
170
243
  _log.info("Background watcher started for %s", repo_root)
171
244
  return observer, store, handler
172
245
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebrain
3
- Version: 0.3.4
3
+ Version: 0.4.0
4
4
  Summary: Know what breaks before you break it. Structural knowledge graph for codebases — impact analysis, dead code detection, health scores. No LLM required.
5
5
  Author: CodeBrain Contributors
6
6
  License: MIT License
@@ -1,11 +1,11 @@
1
- codebrain/__init__.py,sha256=-sDaW43PeyYkocMq06I_oQkqnC3tH_w3dnSmTgmUMuk,392
1
+ codebrain/__init__.py,sha256=HrQ5FQd5GTjIxch8KWjHo5Q2CjcKK-ffXNRmY8Cr8ug,392
2
2
  codebrain/__main__.py,sha256=dgd9lRZovV1k1tScEW_wvPPjPEACXc-EcLtJ7AN3M48,115
3
3
  codebrain/agent_bridge.py,sha256=JDny3232R6rfFsQTWOoYygGgykx6rb8ektLRENPUcDQ,6262
4
4
  codebrain/analyzer.py,sha256=RxyajZp0E8XuB_1SkK1UEXD00gOo3e2vtBzS-US6ms8,40701
5
5
  codebrain/api.py,sha256=LmeFK3KyUKR1xz4tAoPteaRA06c09KA965Hk33VhV6I,31653
6
6
  codebrain/api_models.py,sha256=mE5sIsf8WLEWjGm2w6-baeyVoJH_65FOglHwFe3aJhs,1989
7
7
  codebrain/architecture.py,sha256=RCN_LUNTjd0fXYFIYDacwH17qEeFCxNCyF6irbs5yfE,29485
8
- codebrain/cli.py,sha256=UmiCV5O05-I9FdEiuXoJ96WJdBntxMud7al8sOMteOM,169015
8
+ codebrain/cli.py,sha256=RbXLvRDMXihedsPH-CxbQjWTvFyJbsyFCdtRhMbVk2s,170135
9
9
  codebrain/comprehension.py,sha256=eAVf1abecxVo72_2p8aEgRmSeXR7VNMyJnt_DTst47E,83023
10
10
  codebrain/config.py,sha256=UvStpfDNvulMHkN7xnGjEF3EKvdf9tF3Cv4A01FdEQw,1745
11
11
  codebrain/context.py,sha256=BFZ_WWRf242cJli-TXoz5JFqcvsKkwbmuUMBX8WvM9o,10980
@@ -18,13 +18,13 @@ codebrain/export.py,sha256=A4zdzJ2MVmo-sxJQjwoZlMRkm-QXCxE14lYDm9RrQEY,11576
18
18
  codebrain/frontend.py,sha256=4kFIcWoPKS_pLaWAirmkJmYSYTlicCsi_GZgLLjunng,17675
19
19
  codebrain/hook_runner.py,sha256=1shd-lw8Rx8zNOuJfgcbUOzhsNUVIwhmuzDpTQsdnHo,5458
20
20
  codebrain/hooks.py,sha256=tT2Dx_T0iZLEQarL7QYvvVObYfelMUKVNXNz74MU-tM,3179
21
- codebrain/indexer.py,sha256=3xIe8m_zPTKJlq7dAlvyTWP3J2UsF4ondy8ndgm6dSQ,18900
21
+ codebrain/indexer.py,sha256=NaMj-1ycT4cINJfgykjZC9cAs7cxVLfaojY0AKxaEZw,21083
22
22
  codebrain/kt.py,sha256=iXeHArPbSrrr2ObH4nuyXPobdBXSOR3b7JbIqXB0kj4,17199
23
23
  codebrain/kt_video.py,sha256=quaUyPUJh8xXsj52aBCPp_L8fekNC4vHmNYrUTfCqcE,29211
24
24
  codebrain/llm.py,sha256=0D5c5BJMVkLz2OoybfMxlUdJEbz16cZoNgVmm-LzFH0,25606
25
25
  codebrain/logging.py,sha256=ORR5L8REVlh4aJz9vqErmi76aqjLN2xVKoA2gSv_jas,1110
26
- codebrain/mcp_lifecycle.py,sha256=FzZBKk_MLUFC97TgT_M_EG0oNZgUAPZXzukm1U3LUKc,14172
27
- codebrain/mcp_server.py,sha256=Lb5ob3NI-S0Ln7Yp8G8HxWsKvAuzVzl123N08ZkxPyw,110648
26
+ codebrain/mcp_lifecycle.py,sha256=xo-T1-RNCzFRKhWcKUkEF6ZahPCISp6swTfwRrKazGg,16598
27
+ codebrain/mcp_server.py,sha256=tdSmB4w6KYcXi1lHxcxle-adR12Nm-v7mRSoxBDKiD4,115349
28
28
  codebrain/migration.py,sha256=ZMcug6OsvvK-DVdfmqhWCUx-oVj0KQD0EGlBVx6Jxvk,29489
29
29
  codebrain/modernize.py,sha256=4usRjIFONEThpYhemLIjoYXb9ohEbO7j2wzVO15Dz5A,38525
30
30
  codebrain/onboard.py,sha256=m63A0A8pJdZUiGFgAlRD1ySe7Bk_Eo_fiTPzLfkL1ig,15270
@@ -48,7 +48,7 @@ codebrain/actions/test_gen.py,sha256=PAnHfYuLp-AIF4YrQYQaBH5ADWhCqUVEI7bFDquxPjs
48
48
  codebrain/graph/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
49
  codebrain/graph/query.py,sha256=FdkWTIKteCdAGnal01LJDuoSXzSpNp3AT9e0AH-m1eg,44131
50
50
  codebrain/graph/schema.py,sha256=5PItrNUMQt37kTsL3SUVLPB7rP2JtQXx-Y4uxX0A0H0,6521
51
- codebrain/graph/store.py,sha256=eFgXCor-MV-H897s8tcnNOeOiRieOoBmh70sj1Xi3NM,15717
51
+ codebrain/graph/store.py,sha256=x8xkOrSGWQexIjEKP09CwCo8v1E1W4w34QQAUC8gvyE,16272
52
52
  codebrain/memory/__init__.py,sha256=PuFR35m1GPIbRAuUxKu4JYBDlPXJWMPbyrWiggFT2sU,158
53
53
  codebrain/memory/store.py,sha256=W31fe4aNTkdi-Woe8bZ8SZ9aUHpWrfBkb-9dQs0geyI,9394
54
54
  codebrain/parser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -73,10 +73,10 @@ codebrain/parser/typescript_parser.py,sha256=fi0DoXLCi_L0aQzpadumDpFHaSyekfp3wmV
73
73
  codebrain/parser/typescript_treesitter.py,sha256=7RTdcA-HTgrlRhEMwSGa-63saNcjqtPiKZYrEOBmlD4,26391
74
74
  codebrain/parser/vue_parser.py,sha256=ZWtjUOyItn_SNfvD6T8j7alV4aIfcHA94WDCS5P59Ps,13054
75
75
  codebrain/watcher/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
76
- codebrain/watcher/file_watcher.py,sha256=EUXQ5L8gutOCvGgtNZ9wMt6RL74wA6snH7vnDnxm13A,6562
77
- codebrain-0.3.4.dist-info/licenses/LICENSE,sha256=Dxb0L3H90lGFFik90WQXfMkM_8utGA1BDqizqdCT3UE,1079
78
- codebrain-0.3.4.dist-info/METADATA,sha256=zPvG0VbV4DwOg8h9au3Zsnpzr8upsFVbzYuRcYpVSRI,10129
79
- codebrain-0.3.4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
80
- codebrain-0.3.4.dist-info/entry_points.txt,sha256=FHKSyI7le7GK78HxAKQK54-yzt-yHNxX0df3VpGC5wg,44
81
- codebrain-0.3.4.dist-info/top_level.txt,sha256=mUxCZc80EyNOMzd2vAm22uIhnOb1Aw7ZOAUT_u-ksx4,10
82
- codebrain-0.3.4.dist-info/RECORD,,
76
+ codebrain/watcher/file_watcher.py,sha256=6x-fWIP5YBuwq5BCX6HOV-Kc8EVgE4JUoeLxYJtxDf0,9454
77
+ codebrain-0.4.0.dist-info/licenses/LICENSE,sha256=Dxb0L3H90lGFFik90WQXfMkM_8utGA1BDqizqdCT3UE,1079
78
+ codebrain-0.4.0.dist-info/METADATA,sha256=W-Gb7JPxHu6eSD_bDmm42i9NU7TLxxls8Ylp5mTEbsw,10129
79
+ codebrain-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
80
+ codebrain-0.4.0.dist-info/entry_points.txt,sha256=FHKSyI7le7GK78HxAKQK54-yzt-yHNxX0df3VpGC5wg,44
81
+ codebrain-0.4.0.dist-info/top_level.txt,sha256=mUxCZc80EyNOMzd2vAm22uIhnOb1Aw7ZOAUT_u-ksx4,10
82
+ codebrain-0.4.0.dist-info/RECORD,,