feat(config): add watcher_enabled master switch for the background watcher (#335)

Add a persistent `watcher_enabled` config key (default true). When false, the
daemon host skips building the background watcher entirely: the poll thread
never starts and no projects are registered. Manual index_repository is
unaffected, and the config wiring stays unconditional so auto_index /
auto_watch keep working even when the watcher is off.

This is distinct from auto_watch, which only gates per-session registration
while the watcher IS running. watcher_enabled is the master switch that stops
the subsystem from starting at all — the request in #335 (doc-heavy repos where
every save churns the graph baseline).

The key is read once, when the daemon builds its host state. Changing it takes
effect when the daemon next starts; docs/CONFIGURATION.md says so explicitly.
A disabled watcher is not a daemon-startup failure — the daemon still comes up
and still owns IPC, HTTP/UI, project locks, and manual indexing; only the
watcher and the in-memory store backing it go unbuilt.

- cli.h/cli.c: CBM_CONFIG_WATCHER_ENABLED key + cbm_config_watcher_enabled()
  predicate (NULL-safe, default true), registered as a CONFIG_KEYS row next to
  auto_watch. The table is no longer only a print list: since #1522/#1558 it
  also backs key validation, so `config set/get/reset watcher_enabled` would be
  rejected as an unknown key without that row, and the default recorded there
  (true) is the one the table comment asks to keep in sync with the runtime
  reader's fallback.
- daemon/host.c: gate watcher construction in host_state_prepare() and the
  thread create in host_background_start(); log watcher.disabled when skipped.
  Teardown needed no change — stop NULL-checks, join is gated on
  watcher_started, and cbm_watcher_free is NULL-safe.
- test_cli.c: cli_config_watcher_enabled_default_and_persist pins the config
  layer — default on, disable/re-enable spellings, persistence across reopen,
  and a NULL-config default (a config-store failure must never silently
  disable the watcher).
- docs: document watcher_enabled, and add the previously-undocumented auto_watch
  row to CONFIGURATION.md so the watcher knobs are described together; README
  example.

Closes #335.

Signed-off-by: soren <nguiasoren@gmail.com>
This commit is contained in:
soren
2026-08-24 07:25:34 +09:00
parent 010569fa6c
commit 680bc72e75
6 changed files with 102 additions and 6 deletions
+1
View File
@@ -667,6 +667,7 @@ codebase-memory-mcp config list # show all settings
codebase-memory-mcp config set auto_index true # auto-index on session start
codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index
codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true)
codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true)
codebase-memory-mcp config reset auto_index # reset to default
```
+3
View File
@@ -77,6 +77,7 @@ codebase-memory-mcp config list
codebase-memory-mcp config get auto_index
codebase-memory-mcp config set auto_index true
codebase-memory-mcp config set auto_index_limit 50000
codebase-memory-mcp config set watcher_enabled false
codebase-memory-mcp config reset auto_index
```
@@ -86,6 +87,8 @@ Current keys:
|---|---|---|
| `auto_index` | `false` | Automatically index new projects when an MCP session starts. |
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
| `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). |
| `watcher_enabled` | `true` | Run the background watcher thread that auto-reindexes projects when they change. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. |
## 3. UI Settings
+10
View File
@@ -6734,6 +6734,14 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key) {
return rc;
}
/* Whether the background watcher subsystem should run (default true). The
* daemon host gates watcher construction and thread startup on this; see
* cbm_config_watcher_enabled in cli.h. NULL-safe via cbm_config_get_bool (a
* NULL cfg returns the default). */
bool cbm_config_watcher_enabled(cbm_config_t *cfg) {
return cbm_config_get_bool(cfg, CBM_CONFIG_WATCHER_ENABLED, true);
}
/* ── Config CLI subcommand ────────────────────────────────────── */
/* THE config-key table. list, get, help, and key validation all read this one
@@ -6753,6 +6761,8 @@ static const config_key_def_t CONFIG_KEYS[] = {
{CBM_CONFIG_AUTO_INDEX, "false", "Enable auto-indexing on MCP session start"},
{CBM_CONFIG_AUTO_INDEX_LIMIT, "50000", "Max files for auto-indexing new projects"},
{CBM_CONFIG_AUTO_WATCH, "true", "Register background git watcher on session connect"},
{CBM_CONFIG_WATCHER_ENABLED, "true",
"Run the background watcher thread (auto-reindex); false to disable"},
{CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"},
{CBM_CONFIG_UI_ENABLED, "false", "Serve the graph UI on a loopback HTTP port"},
{CBM_CONFIG_UI_PORT, "9749", "Port for the graph UI listener when enabled"},
+10
View File
@@ -422,11 +422,21 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key);
#define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit"
#define CBM_CONFIG_AUTO_WATCH "auto_watch"
#define CBM_CONFIG_UI_LANG "ui-lang"
#define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled"
/* #1558: the graph UI's loopback listener. Stored in the UI config file rather
* than the key-value store, but surfaced through `config` so it is findable. */
#define CBM_CONFIG_UI_ENABLED "ui_enabled"
#define CBM_CONFIG_UI_PORT "ui_port"
/* Whether the background watcher subsystem should run at all (default true).
* When false, the daemon host skips building and starting the watcher entirely:
* the poll thread never starts and no projects are registered (#335). Read once
* at daemon startup. Distinct from auto_watch, which only gates per-session
* registration while the watcher IS running. NULL-safe — a NULL cfg returns the
* default (true), so a failure to open the config store never silently disables
* the watcher. */
bool cbm_config_watcher_enabled(cbm_config_t *cfg);
/* ── Binary activation safety ─────────────────────────────────── */
typedef void *cbm_cli_activation_lock_t;
+28 -6
View File
@@ -583,9 +583,22 @@ static bool host_state_prepare(host_state_t *host, const cbm_daemon_ipc_endpoint
cbm_log_error("daemon.runtime_config_open_failed", "reason", "config_db_unavailable");
return false;
}
host->watch_store = cbm_store_open_memory();
host->project_locks = cbm_project_lock_manager_new(endpoint);
host->watcher = cbm_watcher_new(host->watch_store, host_watcher_index, host);
/* #335: watcher_enabled (default true) is the master switch for the
* background watcher. When false the daemon never builds the watcher or the
* in-memory store backing it, so the poll thread never starts
* (host_background_start has nothing to run) and no project ever registers
* (register_watcher_if_enabled early-returns on a NULL watcher). The daemon
* still starts and still owns everything else: IPC, HTTP/UI, project locks,
* and manual index_repository. Read once here at daemon startup, so a change
* takes effect when the daemon next starts — see docs/CONFIGURATION.md. */
bool watcher_enabled = cbm_config_watcher_enabled(host->runtime_config);
if (watcher_enabled) {
host->watch_store = cbm_store_open_memory();
host->watcher = cbm_watcher_new(host->watch_store, host_watcher_index, host);
} else {
cbm_log_info("watcher.disabled", "reason", "config");
}
cbm_daemon_application_config_t application_config = {
.watcher = host->watcher,
.config = host->runtime_config,
@@ -598,7 +611,12 @@ static bool host_state_prepare(host_state_t *host, const cbm_daemon_ipc_endpoint
if (host->application && host->permanent) {
cbm_daemon_application_set_permanent(host->application, true);
}
if (!host->watch_store || !host->watcher || !host->project_locks || !host->application) {
if (!host->project_locks || !host->application) {
return false;
}
/* A watcher deliberately left unbuilt by watcher_enabled=false is not a
* startup failure; an allocation failure while it is enabled still is. */
if (watcher_enabled && (!host->watch_store || !host->watcher)) {
return false;
}
return true;
@@ -824,10 +842,14 @@ bool cbm_daemon_host_http_thread_create_failure_lifecycle_for_test(void) {
}
static bool host_background_start(host_state_t *host) {
if (cbm_thread_create(&host->watcher_thread, 0, host_watcher_thread, host->watcher) != 0) {
return false;
/* No watcher object when watcher_enabled=false (#335) — nothing to run, and
* the daemon must still come up with its remaining subsystems. */
if (host->watcher) {
if (cbm_thread_create(&host->watcher_thread, 0, host_watcher_thread, host->watcher) != 0) {
return false;
}
host->watcher_started = true;
}
host->watcher_started = true;
host_http_reconcile_at(host, cbm_now_ms(), true);
return true;
+50
View File
@@ -12291,6 +12291,55 @@ TEST(cli_config_persists) {
PASS();
}
TEST(cli_config_watcher_enabled_default_and_persist) {
/* #335: the background watcher is on by default and can be disabled via the
* persisted `watcher_enabled` key. This pins the config layer only — default
* preservation, the accepted spellings, and persistence across reopen. That
* the daemon host actually honours the key (watcher never built, thread
* never started, no registration) is proven separately by the process-level
* regression in tests/test_watcher_disabled.sh, which fails if the gate in
* host_state_prepare() is removed. */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-cfg-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
/* A NULL config (store failed to open) defaults to ON — a config failure
* must never silently disable the watcher. */
ASSERT_TRUE(cbm_config_watcher_enabled(NULL));
cbm_config_t *cfg = cbm_config_open(tmpdir);
ASSERT_NOT_NULL(cfg);
/* Default: absent key → watcher runs. */
ASSERT_TRUE(cbm_config_watcher_enabled(cfg));
/* Disable: false / 0 / off all turn the watcher off. */
cbm_config_set(cfg, CBM_CONFIG_WATCHER_ENABLED, "false");
ASSERT_FALSE(cbm_config_watcher_enabled(cfg));
cbm_config_set(cfg, CBM_CONFIG_WATCHER_ENABLED, "0");
ASSERT_FALSE(cbm_config_watcher_enabled(cfg));
cbm_config_set(cfg, CBM_CONFIG_WATCHER_ENABLED, "off");
ASSERT_FALSE(cbm_config_watcher_enabled(cfg));
/* Re-enable: true / 1 / on all turn it back on. */
cbm_config_set(cfg, CBM_CONFIG_WATCHER_ENABLED, "on");
ASSERT_TRUE(cbm_config_watcher_enabled(cfg));
cbm_config_set(cfg, CBM_CONFIG_WATCHER_ENABLED, "1");
ASSERT_TRUE(cbm_config_watcher_enabled(cfg));
/* Persistence: the disabled state survives close + reopen. */
cbm_config_set(cfg, CBM_CONFIG_WATCHER_ENABLED, "false");
cbm_config_close(cfg);
cfg = cbm_config_open(tmpdir);
ASSERT_NOT_NULL(cfg);
ASSERT_FALSE(cbm_config_watcher_enabled(cfg));
cbm_config_close(cfg);
test_rmdir_r(tmpdir);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Group H: cbm_replace_binary (update command helper)
* ═══════════════════════════════════════════════════════════════════ */
@@ -13301,6 +13350,7 @@ SUITE(cli) {
RUN_TEST(cli_config_get_int);
RUN_TEST(cli_config_delete);
RUN_TEST(cli_config_persists);
RUN_TEST(cli_config_watcher_enabled_default_and_persist);
/* Replace binary (update command helper — group H) */
#ifndef _WIN32