Skip to content

Commit 886e6da

Browse files
committed
fix(watcher): show watch state in index_status, correct auto-sync docs (#2167)
The background watcher only watches the project an open MCP session is rooted in, and drops the watch when the last session for that project closes. A repository indexed with `cli index_repository` (or from a session rooted elsewhere) is never watched, but README promised "Auto-sync keeps it fresh after that" for every index_repository target and the MCP instructions said "Indexes auto-refresh". Nothing in the status output said which projects were actually watched, so a stale CLI-indexed repo looked like a watcher bug. What gets watched is unchanged. Instead: - index_status gains a `watch` object: `watched`, plus `strategy`, `poll_interval_ms` and `last_scan_at` when watched, or a `reason` (not_session_project, cli_session, auto_watch_off, watcher_disabled, not_registered, no_watcher) and a re-index hint when not. It does not touch the #1561 `freshness` key. - The watcher publishes per-project strategy, cadence and last completed scan time through atomics and exposes them via cbm_watcher_project_info(); the daemon answers index_status through a session-scoped provider. - README, docs/CONFIGURATION.md, docs/index.html, the index_status tool description and the MCP instructions now state the real scope. `watcher_enabled` already shows in `config list` since v0.11.0 (680bc72). Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
1 parent 64c23fa commit 886e6da

10 files changed

Lines changed: 425 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ codebase-memory-mcp config set auto_index true
151151

152152
When enabled, new projects are indexed automatically on first connection. Previously-indexed projects are registered with the background watcher for ongoing git-based change detection. Configurable file limit: `config set auto_index_limit 50000`.
153153

154+
The watcher follows MCP sessions: it watches the project each open MCP session is rooted in (the client's working directory) and stops when the last session for that project closes. A repository indexed with `cli index_repository`, or indexed from a session rooted somewhere else, is not watched — re-run `index_repository` after changing it. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched.
155+
154156
Watcher registration is controlled separately by `auto_watch` (default `true`). Set `config set auto_watch false` to keep a session from registering its project with the background watcher — useful when working across many projects and you want each session contained to explicit indexing.
155157

156158
To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch` — which is consulted per session — `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings).
@@ -234,7 +236,7 @@ The install script placed beside the binary is **reported, not deleted** — uni
234236

235237
### Distribution & operation
236238
- **Native runtime set, zero infrastructure services**: SQLite-backed, persists to `~/.cache/codebase-memory-mcp/`
237-
- **Auto-sync**: Background watcher detects file changes and re-indexes automatically
239+
- **Auto-sync**: Background watcher detects file changes in the project an MCP session is working in and re-indexes it automatically (other indexed repos: re-run `index_repository`)
238240
- **Route nodes**: REST endpoints are first-class graph entities
239241
- **CLI mode**: `codebase-memory-mcp cli search_graph '{"project": "my-project", "name_pattern": ".*Handler.*"}'`
240242
- **Available on**: npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, `go install`
@@ -669,7 +671,7 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool
669671

670672
| Tool | Description |
671673
|------|-------------|
672-
| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh after that. |
674+
| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh only while it is the project of an open MCP session; repos indexed from the CLI or from another project's session need a re-run (`index_status` shows `watch`). |
673675
| `list_projects` | List all indexed projects with node/edge counts. |
674676
| `delete_project` | Remove a project and all its graph data. |
675677
| `index_status` | Check indexing status of a project. |

‎docs/CONFIGURATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ Current keys:
8888
| `auto_index` | `false` | Automatically index new projects when an MCP session starts. |
8989
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
9090
| `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). |
91-
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. |
91+
| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Only an open MCP session's own project is ever watched; `index_status` reports the current state in its `watch` object. |
9292
| `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. |
9393
| `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. |
9494

‎docs/index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -728,7 +728,7 @@ <h3>Infrastructure-as-code indexing</h3>
728728
</div>
729729
<div class="feature">
730730
<h3>Auto-sync</h3>
731-
<p>A background watcher detects changes and re-indexes incrementally. No manual reindex after editing files.</p>
731+
<p>A background watcher detects changes in the project your MCP session is working in and re-indexes it incrementally. No manual reindex after editing files there.</p>
732732
</div>
733733
<div class="feature">
734734
<h3>Team-shared graph artifact</h3>

‎src/daemon/application.c‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,55 @@ static void application_refresh_watch_locked(cbm_daemon_application_session_t *s
536536
session->watch = watch;
537537
}
538538

539+
static const char *application_watch_strategy_name(cbm_watcher_strategy_t strategy) {
540+
switch (strategy) {
541+
case CBM_WATCHER_STRATEGY_GIT:
542+
return "git";
543+
case CBM_WATCHER_STRATEGY_NONE:
544+
return "none";
545+
default:
546+
return "pending";
547+
}
548+
}
549+
550+
/* index_status watch visibility (#2167). The physical watcher is the truth for
551+
* "watched"; the reasons explain the documented scope: only an MCP session's
552+
* own project is watched, and only while that session is open. Takes the
553+
* application mutex and the watcher lock one after the other, never nested. */
554+
static void application_watch_status(void *context, const char *project,
555+
cbm_mcp_watch_status_t *out) {
556+
cbm_daemon_application_session_t *session = context;
557+
cbm_daemon_application_t *application = session ? session->application : NULL;
558+
if (!application || !application->watcher) {
559+
out->reason = "watcher_disabled";
560+
return;
561+
}
562+
cbm_watcher_project_info_t info;
563+
if (cbm_watcher_project_info(application->watcher, project, &info)) {
564+
out->watched = true;
565+
out->strategy = application_watch_strategy_name(info.strategy);
566+
out->poll_interval_ms = info.poll_interval_ms;
567+
out->last_scan_unix_s = info.last_scan_unix_s;
568+
return;
569+
}
570+
const char *session_project = cbm_mcp_server_session_project(session->mcp);
571+
if (!session_project || strcmp(session_project, project) != 0) {
572+
out->reason = "not_session_project";
573+
return;
574+
}
575+
if (application->config &&
576+
!cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true)) {
577+
out->reason = "auto_watch_off";
578+
return;
579+
}
580+
/* Only MCP sessions run background initialization; a one-shot CLI command
581+
* never holds a watch across calls. */
582+
cbm_mutex_lock(&application->mutex);
583+
bool mcp_session = session->background_eligible;
584+
cbm_mutex_unlock(&application->mutex);
585+
out->reason = mcp_session ? "not_registered" : "cli_session";
586+
}
587+
539588
static void application_refresh_watch(cbm_daemon_application_session_t *session) {
540589
if (!session || !session->application) {
541590
return;
@@ -2385,6 +2434,7 @@ static cbm_daemon_runtime_application_session_t *application_session_open(
23852434
}
23862435
cbm_mcp_server_set_background_tasks(session->mcp, false);
23872436
cbm_mcp_server_set_config(session->mcp, application->config);
2437+
cbm_mcp_server_set_watch_status_provider(session->mcp, application_watch_status, session);
23882438
cbm_mcp_server_set_index_executor(session->mcp, application_index_execute, session);
23892439
cbm_mcp_server_set_project_mutation_guard(session->mcp, application_session_mutation_begin,
23902440
application_session_mutation_end, session);

‎src/mcp/mcp.c‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -676,8 +676,9 @@ static const tool_def_t TOOLS[] = {
676676
"\"project\"]}"},
677677

678678
{"index_status",
679-
"Project readiness, counts, root, and coverage gaps. diagnostics adds coverage rows; verbose "
680-
"adds Git paths. Best-effort only; verify cited paths with check_index_coverage.",
679+
"Project readiness, counts, root, watch state, and coverage gaps. diagnostics adds coverage "
680+
"rows; verbose adds Git paths. Best-effort only; verify cited paths with "
681+
"check_index_coverage.",
681682
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},"
682683
"\"verbose\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Add worktree/"
683684
"shadow Git paths for index-location debugging.\"},"
@@ -1317,7 +1318,8 @@ static const int SUPPORTED_VERSION_COUNT =
13171318
static const char MCP_SERVER_INSTRUCTIONS[] =
13181319
"Graph first: search_graph for symbols, trace_path for relationships, get_code_snippet for "
13191320
"source, query_graph for multi-hop, and get_architecture for overview. Use search_code/grep "
1320-
"for literals or coverage gaps. Indexes auto-refresh. Check cited-path coverage; paginate.";
1321+
"for literals or coverage gaps. The session project's index auto-refreshes; re-index others. "
1322+
"Check cited-path coverage; paginate.";
13211323

13221324
static const char MCP_ANALYSIS_SERVER_INSTRUCTIONS[] =
13231325
"analysis tool profile: read-only graph work via search_graph, trace_path, "
@@ -1658,6 +1660,9 @@ struct cbm_mcp_server {
16581660
bool background_tasks; /* per-server update/auto-index work enabled */
16591661
struct cbm_watcher *watcher; /* external watcher ref (not owned) */
16601662
struct cbm_config *config; /* external config ref (not owned) */
1663+
/* index_status watch visibility (#2167) */
1664+
cbm_mcp_watch_status_fn watch_status_fn;
1665+
void *watch_status_context;
16611666
cbm_mcp_index_executor_fn index_executor;
16621667
void *index_executor_context;
16631668
cbm_proc_log_cb index_log_callback;
@@ -1742,6 +1747,14 @@ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w) {
17421747
}
17431748
}
17441749

1750+
void cbm_mcp_server_set_watch_status_provider(cbm_mcp_server_t *srv, cbm_mcp_watch_status_fn fn,
1751+
void *context) {
1752+
if (srv) {
1753+
srv->watch_status_fn = fn;
1754+
srv->watch_status_context = context;
1755+
}
1756+
}
1757+
17451758
void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) {
17461759
if (srv) {
17471760
srv->config = cfg;
@@ -6747,6 +6760,40 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args
67476760
return result;
67486761
}
67496762

6763+
/* index_status "watch" object (#2167): is this project kept fresh by the
6764+
* background watcher right now, and if not, why. Auto-sync covers the active
6765+
* MCP session's project only; anything else needs index_repository again. */
6766+
static void add_watch_status_json(cbm_mcp_server_t *srv, yyjson_mut_doc *doc, yyjson_mut_val *root,
6767+
const char *project) {
6768+
cbm_mcp_watch_status_t status = {0};
6769+
if (srv->watch_status_fn) {
6770+
srv->watch_status_fn(srv->watch_status_context, project, &status);
6771+
} else {
6772+
status.reason = "no_watcher";
6773+
}
6774+
yyjson_mut_val *watch = yyjson_mut_obj(doc);
6775+
yyjson_mut_obj_add_bool(doc, watch, "watched", status.watched);
6776+
if (status.watched) {
6777+
yyjson_mut_obj_add_str(doc, watch, "strategy", status.strategy ? status.strategy : "");
6778+
yyjson_mut_obj_add_int(doc, watch, "poll_interval_ms", status.poll_interval_ms);
6779+
if (status.last_scan_unix_s > 0) {
6780+
char when[CBM_SZ_32];
6781+
time_t t = (time_t)status.last_scan_unix_s;
6782+
struct tm tm;
6783+
cbm_gmtime_r(&t, &tm);
6784+
if (strftime(when, sizeof(when), "%Y-%m-%dT%H:%M:%SZ", &tm) > 0) {
6785+
yyjson_mut_obj_add_strcpy(doc, watch, "last_scan_at", when);
6786+
}
6787+
}
6788+
} else {
6789+
yyjson_mut_obj_add_str(doc, watch, "reason", status.reason ? status.reason : "unknown");
6790+
yyjson_mut_obj_add_str(doc, watch, "hint",
6791+
"Auto-sync watches only the active MCP session's project; "
6792+
"re-run index_repository to refresh this index.");
6793+
}
6794+
yyjson_mut_obj_add_val(doc, root, "watch", watch);
6795+
}
6796+
67506797
static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
67516798
char *project = get_project_arg(args);
67526799
cbm_store_t *store = resolve_store(srv, project);
@@ -6796,6 +6843,7 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) {
67966843
doc, root, "hint",
67976844
"Project is empty. Re-run index_repository(repo_path=...) to populate.");
67986845
}
6846+
add_watch_status_json(srv, doc, root, project);
67996847
} else {
68006848
yyjson_mut_obj_add_str(doc, root, "status", "no_project");
68016849
}

‎src/mcp/mcp.h‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,22 @@ void cbm_mcp_server_free(cbm_mcp_server_t *srv);
155155
/* Set external watcher reference (for auto-index registration). Not owned. */
156156
void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w);
157157

158+
/* Watch visibility for index_status (#2167). The daemon, which owns the one
159+
* background watcher, answers whether `project` is currently watched. All
160+
* strings are static. A server without a provider runs no watcher at all and
161+
* reports watched=false with reason "no_watcher". */
162+
typedef struct {
163+
bool watched;
164+
const char *reason; /* why not watched (set when !watched) */
165+
const char *strategy; /* "pending" | "git" | "none" (set when watched) */
166+
int poll_interval_ms; /* current adaptive cadence (when watched) */
167+
int64_t last_scan_unix_s; /* last completed scan, wall clock; 0 = none yet */
168+
} cbm_mcp_watch_status_t;
169+
typedef void (*cbm_mcp_watch_status_fn)(void *context, const char *project,
170+
cbm_mcp_watch_status_t *out);
171+
void cbm_mcp_server_set_watch_status_provider(cbm_mcp_server_t *srv, cbm_mcp_watch_status_fn fn,
172+
void *context);
173+
158174
/* Set external config store reference (for auto_index setting). Not owned. */
159175
void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg);
160176

‎src/watcher/watcher.c‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ typedef struct {
8888
* from `rev-parse --show-cdup`. Porcelain paths are repository-relative, so
8989
* the signature needs this to stat them. Resolved once at baseline. */
9090
char repo_cdup[CBM_SZ_4K];
91+
/* Published status for cbm_watcher_project_info (#2167). The poll path
92+
* writes these while index_status reads them from a session thread, so
93+
* they are atomics: the diagnostic read is never a data race. */
94+
atomic_int status_strategy; /* cbm_watcher_strategy_t */
95+
atomic_int status_interval_ms; /* mirrors interval_ms */
96+
_Atomic int64_t status_last_scan_s; /* wall-clock seconds; 0 = none yet */
9197
} project_state_t;
9298

9399
/* ── Watcher struct ─────────────────────────────────────────────── */
@@ -867,6 +873,9 @@ static project_state_t *state_new(const char *name, const char *root_path) {
867873
}
868874
atomic_init(&s->registered, true);
869875
s->interval_ms = POLL_BASE_MS;
876+
atomic_init(&s->status_strategy, CBM_WATCHER_STRATEGY_PENDING);
877+
atomic_init(&s->status_interval_ms, POLL_BASE_MS);
878+
atomic_init(&s->status_last_scan_s, 0);
870879
return s;
871880
}
872881

@@ -1200,6 +1209,25 @@ int cbm_watcher_index_failure_count(cbm_watcher_t *w, const char *project_name)
12001209
return failures;
12011210
}
12021211

1212+
bool cbm_watcher_project_info(cbm_watcher_t *w, const char *project_name,
1213+
cbm_watcher_project_info_t *out) {
1214+
if (out) {
1215+
memset(out, 0, sizeof(*out));
1216+
}
1217+
if (!w || !project_name || !out) {
1218+
return false;
1219+
}
1220+
cbm_mutex_lock(&w->projects_lock);
1221+
project_state_t *s = cbm_ht_get(w->projects, project_name);
1222+
if (s) {
1223+
out->strategy = (cbm_watcher_strategy_t)atomic_load(&s->status_strategy);
1224+
out->poll_interval_ms = atomic_load(&s->status_interval_ms);
1225+
out->last_scan_unix_s = atomic_load(&s->status_last_scan_s);
1226+
}
1227+
cbm_mutex_unlock(&w->projects_lock);
1228+
return s != NULL;
1229+
}
1230+
12031231
int cbm_watcher_watch_count(cbm_watcher_t *w) {
12041232
if (!w) {
12051233
return 0;
@@ -1212,13 +1240,25 @@ int cbm_watcher_watch_count(cbm_watcher_t *w) {
12121240

12131241
/* ── Single poll cycle ──────────────────────────────────────────── */
12141242

1243+
/* Publish what index_status reports: the strategy, the current cadence and,
1244+
* when a check just completed, the wall-clock time of that scan. */
1245+
static void publish_status(project_state_t *s, bool scanned) {
1246+
atomic_store(&s->status_strategy,
1247+
s->is_git ? CBM_WATCHER_STRATEGY_GIT : CBM_WATCHER_STRATEGY_NONE);
1248+
atomic_store(&s->status_interval_ms, s->interval_ms);
1249+
if (scanned) {
1250+
atomic_store(&s->status_last_scan_s, (int64_t)time(NULL));
1251+
}
1252+
}
1253+
12151254
/* Init baseline for a project: check if git, get HEAD, count files */
12161255
static bool init_baseline(cbm_watcher_t *w, project_state_t *s) {
12171256
struct stat st;
12181257
if (stat(s->root_path, &st) != 0) {
12191258
cbm_log_warn("watcher.root_gone", "project", s->project_name, "path", s->root_path);
12201259
s->baseline_done = true;
12211260
s->is_git = false;
1261+
publish_status(s, false);
12221262
return true;
12231263
}
12241264

@@ -1286,6 +1326,7 @@ static bool init_baseline(cbm_watcher_t *w, project_state_t *s) {
12861326
}
12871327

12881328
s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS);
1329+
publish_status(s, true);
12891330
return true;
12901331
}
12911332

@@ -1482,6 +1523,7 @@ static void poll_project(const char *key, void *val, void *ud) {
14821523
if (!check_changes(ctx->w, s, &changed)) {
14831524
return;
14841525
}
1526+
publish_status(s, true);
14851527
if (!changed) {
14861528
s->next_poll_ns = ctx->now + ((int64_t)s->interval_ms * US_PER_MS);
14871529
return;
@@ -1513,6 +1555,7 @@ static void poll_project(const char *key, void *val, void *ud) {
15131555
if (git_file_count(ctx->w, s, &file_count) == WATCHER_GIT_OK) {
15141556
s->file_count = file_count;
15151557
s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count);
1558+
publish_status(s, false);
15161559
}
15171560
} else if (rc > 0) {
15181561
/* Busy-skip: baseline stays uncommitted, next poll retries. */

‎src/watcher/watcher.h‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,25 @@ int cbm_watcher_watch_count(cbm_watcher_t *w);
110110
* without first giving the counter atomic accessors. */
111111
int cbm_watcher_index_failure_count(cbm_watcher_t *w, const char *project_name);
112112

113+
/* Per-project watch status, for index_status (#2167). */
114+
typedef enum {
115+
CBM_WATCHER_STRATEGY_PENDING = 0, /* registered; first poll not done yet */
116+
CBM_WATCHER_STRATEGY_GIT = 1, /* git HEAD + dirty-state polling */
117+
CBM_WATCHER_STRATEGY_NONE = 2, /* not a git project: never polled */
118+
} cbm_watcher_strategy_t;
119+
120+
typedef struct {
121+
cbm_watcher_strategy_t strategy;
122+
int poll_interval_ms; /* current adaptive cadence */
123+
int64_t last_scan_unix_s; /* wall clock of the last completed scan; 0 = none */
124+
} cbm_watcher_project_info_t;
125+
126+
/* Return true when project_name is physically watched and fill *out with its
127+
* published status; false (and *out zeroed) otherwise. Safe to call while the
128+
* poll loop runs: the fields are published atomically by the poll path. */
129+
bool cbm_watcher_project_info(cbm_watcher_t *w, const char *project_name,
130+
cbm_watcher_project_info_t *out);
131+
113132
/* Return the adaptive poll interval (ms) for a given file count. */
114133
int cbm_watcher_poll_interval_ms(int file_count);
115134

0 commit comments

Comments
 (0)