diff --git a/README.md b/README.md index ca39ef418..4c31f22d1 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0ee8058d1..2f80a15e1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 diff --git a/src/cli/cli.c b/src/cli/cli.c index d981af266..21630d302 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -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"}, diff --git a/src/cli/cli.h b/src/cli/cli.h index c2121a940..41ee37cf0 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -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; diff --git a/src/daemon/host.c b/src/daemon/host.c index c8df6c5ca..f475658b8 100644 --- a/src/daemon/host.c +++ b/src/daemon/host.c @@ -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; diff --git a/tests/test_cli.c b/tests/test_cli.c index 6295479f2..75106d2ac 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -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