codebrain 0.3.4__py3-none-any.whl → 0.3.5__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.3.5"
codebrain/graph/store.py CHANGED
@@ -113,6 +113,11 @@ class GraphStore:
113
113
  ).fetchone()
114
114
  return row["content_hash"] if row else None
115
115
 
116
+ def all_file_paths(self) -> list[str]:
117
+ """Return every file path currently in the index."""
118
+ rows = self.conn.execute("SELECT path FROM files").fetchall()
119
+ return [row["path"] for row in rows]
120
+
116
121
  # ------------------------------------------------------------------
117
122
  # Node operations
118
123
  # ------------------------------------------------------------------
@@ -167,7 +167,7 @@ def _remove_pid_file(pid_file: Path) -> None:
167
167
  pass
168
168
 
169
169
 
170
- def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
170
+ def _find_watch_target(start_pid: int) -> tuple[int, float | None, bool]:
171
171
  """Pick the PID whose death should kill the MCP.
172
172
 
173
173
  Walks up the ancestor chain (up to ANCESTOR_WALK_DEPTH levels) looking
@@ -175,6 +175,10 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
175
175
  real IDE host. Falls back to ``start_pid`` if no hint matches, psutil
176
176
  is unavailable, or ``CODEBRAIN_MCP_DISABLE_ANCESTOR_WALK=1`` is set.
177
177
 
178
+ Returns ``(pid, create_time, host_anchored)``. ``host_anchored`` is True
179
+ only when an actual IDE host process was found — callers use it to decide
180
+ whether the idle-timeout backstop is needed at all.
181
+
178
182
  Why: on Windows, Claude Code spawns the MCP via a transient launcher
179
183
  (cmd.exe wrapper or Electron worker shell). The launcher exits soon
180
184
  after the python child starts, so watching ``os.getppid()`` directly
@@ -183,16 +187,16 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
183
187
  try:
184
188
  import psutil
185
189
  except ImportError:
186
- return start_pid, None
190
+ return start_pid, None, False
187
191
 
188
192
  fallback_create_time: float | None = None
189
193
  try:
190
194
  fallback_create_time = psutil.Process(start_pid).create_time()
191
195
  except (psutil.NoSuchProcess, psutil.AccessDenied):
192
- return start_pid, None
196
+ return start_pid, None, False
193
197
 
194
198
  if os.environ.get("CODEBRAIN_MCP_DISABLE_ANCESTOR_WALK") == "1":
195
- return start_pid, fallback_create_time
199
+ return start_pid, fallback_create_time, False
196
200
 
197
201
  try:
198
202
  proc = psutil.Process(start_pid)
@@ -202,7 +206,7 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
202
206
  except (psutil.NoSuchProcess, psutil.AccessDenied):
203
207
  break
204
208
  if any(hint in name for hint in HOST_PROCESS_NAME_HINTS):
205
- return proc.pid, proc.create_time()
209
+ return proc.pid, proc.create_time(), True
206
210
  try:
207
211
  parent = proc.parent()
208
212
  except (psutil.NoSuchProcess, psutil.AccessDenied):
@@ -212,7 +216,7 @@ def _find_watch_target(start_pid: int) -> tuple[int, float | None]:
212
216
  proc = parent
213
217
  except (psutil.NoSuchProcess, psutil.AccessDenied):
214
218
  pass
215
- return start_pid, fallback_create_time
219
+ return start_pid, fallback_create_time, False
216
220
 
217
221
 
218
222
  def _parent_watchdog(initial_ppid: int, initial_create_time: float | None) -> None:
@@ -241,16 +245,35 @@ def _parent_watchdog(initial_ppid: int, initial_create_time: float | None) -> No
241
245
  _log.debug("parent watchdog tick error: %s", exc)
242
246
 
243
247
 
244
- def _idle_watchdog() -> None:
245
- if IDLE_TIMEOUT_SECONDS <= 0:
248
+ def _effective_idle_timeout(host_anchored: bool) -> int:
249
+ """Resolve the idle timeout in seconds (0 disables the idle watchdog).
250
+
251
+ The idle watchdog is a *backstop* for when the parent watchdog has no
252
+ reliable IDE host to watch. When we are anchored to a real host
253
+ (claude/cursor/vscode), the parent watchdog deterministically tears the
254
+ server down at session end — an idle timeout on top of that only kills
255
+ the file watcher mid-session and leaves the index stale. So:
256
+
257
+ - explicit ``CODEBRAIN_MCP_IDLE_TIMEOUT`` env var always wins
258
+ - otherwise: disabled when host-anchored, 30-min backstop when not
259
+ """
260
+ if "CODEBRAIN_MCP_IDLE_TIMEOUT" in os.environ:
261
+ return IDLE_TIMEOUT_SECONDS
262
+ if host_anchored:
263
+ return 0
264
+ return IDLE_TIMEOUT_SECONDS
265
+
266
+
267
+ def _idle_watchdog(timeout: int) -> None:
268
+ if timeout <= 0:
246
269
  return
247
- poll = max(5, min(60, IDLE_TIMEOUT_SECONDS // 4))
270
+ poll = max(5, min(60, timeout // 4))
248
271
  while True:
249
272
  time.sleep(poll)
250
273
  with _last_activity_lock:
251
274
  idle = time.time() - _last_activity
252
- if idle > IDLE_TIMEOUT_SECONDS:
253
- _exit(f"idle for {idle:.0f}s (limit {IDLE_TIMEOUT_SECONDS}s)")
275
+ if idle > timeout:
276
+ _exit(f"idle for {idle:.0f}s (limit {timeout}s)")
254
277
 
255
278
 
256
279
  def _lifetime_watchdog(start: float) -> None:
@@ -285,13 +308,16 @@ def install_watchdogs(repo_root: Path | None = None) -> None:
285
308
  atexit.register(_remove_pid_file, pid_file)
286
309
 
287
310
  immediate_ppid = os.getppid()
288
- initial_ppid, initial_create_time = _find_watch_target(immediate_ppid)
311
+ initial_ppid, initial_create_time, host_anchored = _find_watch_target(immediate_ppid)
312
+ idle_timeout = _effective_idle_timeout(host_anchored)
289
313
 
290
314
  _log.info(
291
- "MCP watchdogs installed (ppid=%d via=%d, idle_timeout=%ds, max_lifetime=%ds)",
315
+ "MCP watchdogs installed (ppid=%d via=%d, host_anchored=%s, "
316
+ "idle_timeout=%ds, max_lifetime=%ds)",
292
317
  initial_ppid,
293
318
  immediate_ppid,
294
- IDLE_TIMEOUT_SECONDS,
319
+ host_anchored,
320
+ idle_timeout,
295
321
  MAX_LIFETIME_SECONDS,
296
322
  )
297
323
 
@@ -303,6 +329,7 @@ def install_watchdogs(repo_root: Path | None = None) -> None:
303
329
  ).start()
304
330
  threading.Thread(
305
331
  target=_idle_watchdog,
332
+ args=(idle_timeout,),
306
333
  name="cb-idle-watchdog",
307
334
  daemon=True,
308
335
  ).start()
@@ -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.3.5
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,4 +1,4 @@
1
- codebrain/__init__.py,sha256=-sDaW43PeyYkocMq06I_oQkqnC3tH_w3dnSmTgmUMuk,392
1
+ codebrain/__init__.py,sha256=x27U2sCVt0bfIOA_zO8t7cwyzq-sFjkbLTNm-MpjHME,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
@@ -23,7 +23,7 @@ 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
26
+ codebrain/mcp_lifecycle.py,sha256=fpwajl4-5YdZe0cEMmE2_o_FFkyrh3bl3Jqwrp-XGAM,15332
27
27
  codebrain/mcp_server.py,sha256=Lb5ob3NI-S0Ln7Yp8G8HxWsKvAuzVzl123N08ZkxPyw,110648
28
28
  codebrain/migration.py,sha256=ZMcug6OsvvK-DVdfmqhWCUx-oVj0KQD0EGlBVx6Jxvk,29489
29
29
  codebrain/modernize.py,sha256=4usRjIFONEThpYhemLIjoYXb9ohEbO7j2wzVO15Dz5A,38525
@@ -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=pqJu7c6krXgBYDQ5e-KZ8nHWVvNU4OeeXVtDVSNY7pI,15937
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.3.5.dist-info/licenses/LICENSE,sha256=Dxb0L3H90lGFFik90WQXfMkM_8utGA1BDqizqdCT3UE,1079
78
+ codebrain-0.3.5.dist-info/METADATA,sha256=As_yyzhW3AO24DXeNzezMPpyvNb8HYb8G6LhrtVUIGs,10129
79
+ codebrain-0.3.5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
80
+ codebrain-0.3.5.dist-info/entry_points.txt,sha256=FHKSyI7le7GK78HxAKQK54-yzt-yHNxX0df3VpGC5wg,44
81
+ codebrain-0.3.5.dist-info/top_level.txt,sha256=mUxCZc80EyNOMzd2vAm22uIhnOb1Aw7ZOAUT_u-ksx4,10
82
+ codebrain-0.3.5.dist-info/RECORD,,