From 6bda6e26dec10968934b745d1805f96db13461ca Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 4 Oct 2026 20:34:28 +0200 Subject: [PATCH] fix(watcher): report a tree-polled non-git root as strategy "tree" Since #1948 a non-git root can be polled by a file-tree scan when watch_non_git is on. index_status still reported such a root as strategy "none", which the enum documents as "not a git project: never polled", so a root that is actively polled looked unwatched. publish_status now maps a tree-polled root to the new CBM_WATCHER_STRATEGY_TREE, rendered as "tree" (the value the watcher.baseline log already uses). "none" keeps its meaning: a registered non-git root that is never polled. CONFIGURATION.md lists the four values. Test: daemon_application_index_status_reports_tree_strategy_issue2167 fails without the production change and passes with it; suites daemon_application, watcher and mcp: 507 passed, 4 platform skips. Signed-off-by: Martin Vogel --- docs/CONFIGURATION.md | 6 +++ src/daemon/application.c | 2 + src/mcp/mcp.h | 2 +- src/watcher/watcher.c | 12 +++-- src/watcher/watcher.h | 1 + tests/test_daemon_application.c | 85 +++++++++++++++++++++++++++++++++ 6 files changed, 104 insertions(+), 4 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index fd60035bd..590cd6ca1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -93,6 +93,12 @@ Current keys: | `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. | | `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. | +For a watched project, `index_status` names the polling method in `watch.strategy`: +`git` (HEAD and dirty-state polling), `tree` (a non-git root polled by the +`watch_non_git` file-tree scan), `none` (a non-git root that is registered but never +polled, the default for a plain directory) or `pending` (registered; the first poll +has not run yet). + > **`watcher_enabled` vs `auto_watch`.** `watcher_enabled` controls whether the > watcher *subsystem* starts at all (the background poll thread). `auto_watch` is > narrower: it only controls whether a connecting session registers *its own* diff --git a/src/daemon/application.c b/src/daemon/application.c index aa38c3482..0dd236024 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -569,6 +569,8 @@ static const char *application_watch_strategy_name(cbm_watcher_strategy_t strate switch (strategy) { case CBM_WATCHER_STRATEGY_GIT: return "git"; + case CBM_WATCHER_STRATEGY_TREE: + return "tree"; case CBM_WATCHER_STRATEGY_NONE: return "none"; default: diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index 90c6b010c..3a5fce5ff 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -186,7 +186,7 @@ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w); typedef struct { bool watched; const char *reason; /* why not watched (set when !watched) */ - const char *strategy; /* "pending" | "git" | "none" (set when watched) */ + const char *strategy; /* "pending" | "git" | "tree" | "none" (set when watched) */ int poll_interval_ms; /* current adaptive cadence (when watched) */ int64_t last_scan_unix_s; /* last completed scan, wall clock; 0 = none yet */ } cbm_mcp_watch_status_t; diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 25a1047b1..efb46ac72 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -1303,10 +1303,16 @@ int cbm_watcher_watch_count(cbm_watcher_t *w) { /* ── Single poll cycle ──────────────────────────────────────────── */ /* Publish what index_status reports: the strategy, the current cadence and, - * when a check just completed, the wall-clock time of that scan. */ + * when a check just completed, the wall-clock time of that scan. A non-git + * root is "tree" when watch_non_git polls it (#1948), else "none". */ static void publish_status(project_state_t *s, bool scanned) { - atomic_store(&s->status_strategy, - s->is_git ? CBM_WATCHER_STRATEGY_GIT : CBM_WATCHER_STRATEGY_NONE); + cbm_watcher_strategy_t strategy = CBM_WATCHER_STRATEGY_NONE; + if (s->is_git) { + strategy = CBM_WATCHER_STRATEGY_GIT; + } else if (s->tree_poll) { + strategy = CBM_WATCHER_STRATEGY_TREE; + } + atomic_store(&s->status_strategy, strategy); atomic_store(&s->status_interval_ms, s->interval_ms); if (scanned) { atomic_store(&s->status_last_scan_s, (int64_t)time(NULL)); diff --git a/src/watcher/watcher.h b/src/watcher/watcher.h index f76657f44..0959853ae 100644 --- a/src/watcher/watcher.h +++ b/src/watcher/watcher.h @@ -135,6 +135,7 @@ typedef enum { CBM_WATCHER_STRATEGY_PENDING = 0, /* registered; first poll not done yet */ CBM_WATCHER_STRATEGY_GIT = 1, /* git HEAD + dirty-state polling */ CBM_WATCHER_STRATEGY_NONE = 2, /* not a git project: never polled */ + CBM_WATCHER_STRATEGY_TREE = 3, /* not a git project: file-tree polling (watch_non_git) */ } cbm_watcher_strategy_t; typedef struct { diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 8c38dff95..1f77e1e58 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -1301,6 +1301,90 @@ TEST(daemon_application_index_status_watcher_disabled_issue2167) { PASS(); } +/* #2167 with #1948: a non-git root that watch_non_git polls by file-tree scan + * reports strategy "tree". "none" stays reserved for a registered root that is + * never polled (a plain directory without the opt-in). */ +TEST(daemon_application_index_status_reports_tree_strategy_issue2167) { + const char *old_cache = getenv("CBM_CACHE_DIR"); + bool had_cache = old_cache != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-tree-root-XXXXXX", cbm_tmpdir()); + snprintf(cache, sizeof(cache), "%s/cbm-app-tree-cache-XXXXXX", cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; + bool env_ok = (!had_cache || saved_cache) && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL; + char db_path[APP_TEST_PATH_CAP] = {0}; + bool seeded = + project && app_test_seed_project_db(cache, project, root, db_path, sizeof(db_path)); + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL); + /* What the daemon host does at startup when watch_non_git is true. */ + cbm_watcher_set_poll_non_git(watcher, true); + cbm_daemon_application_config_t config = {.watcher = watcher, .config = NULL}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 23); + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + /* Context + one MCP request registers the session root's watch. */ + bool registered = seeded && app_test_context_request(root, root, &context, &context_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ping\"}", + &ping, &ping_length) && + app_test_request(&callbacks, session, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + registered = + registered && app_test_request(&callbacks, session, ping, ping_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + registered = registered && cbm_watcher_watch_count(watcher) == 1; + /* The first poll completes the baseline: the plain directory gets tree + * polling and its strategy is published. */ + if (registered) { + (void)cbm_watcher_poll_once(watcher); + } + + char *status = registered ? app_test_index_status(&callbacks, session, project) : NULL; + bool tree = app_test_response_has(status, "watched", "true") && + app_test_response_has(status, "strategy", "\\\"tree\\\""); + + callbacks.session_cancel(callbacks.context, session); + callbacks.session_close(callbacks.context, session); + cbm_daemon_application_free(application); + cbm_watcher_stop(watcher); + cbm_watcher_free(watcher); + cbm_store_close(store); + free(status); + free(context); + free(ping); + free(project); + app_test_unlink_project_db(db_path); + (void)cbm_rmdir(root); + (void)cbm_rmdir(cache); + if (saved_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1); + } else if (!had_cache) { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(saved_cache); + + ASSERT_TRUE(env_ok); + ASSERT_TRUE(seeded); + ASSERT_TRUE(registered); + ASSERT_TRUE(tree); + PASS(); +} + TEST(daemon_application_free_releases_live_watch_once) { const char *old_cache = getenv("CBM_CACHE_DIR"); bool had_cache = old_cache != NULL; @@ -6705,6 +6789,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_reference_counts_one_shared_watch); RUN_TEST(daemon_application_index_status_reports_watch_issue2167); RUN_TEST(daemon_application_index_status_watcher_disabled_issue2167); + RUN_TEST(daemon_application_index_status_reports_tree_strategy_issue2167); RUN_TEST(daemon_application_free_releases_live_watch_once); RUN_TEST(daemon_application_prune_clears_logical_watch_for_reregistration); RUN_TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions);