Files
codebase-memory-mcp/src/watcher/watcher.h
T
Martin Vogel 3794085492 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 (680bc72e).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-04 18:51:12 +02:00

189 lines
10 KiB
C

/*
* watcher.h — File change watcher for auto-reindexing.
*
* Polls indexed projects for git changes (HEAD movement or dirty working tree)
* and triggers re-indexing via a callback. Uses adaptive polling intervals
* based on project size (5s base + 1s per 500 files, capped at 60s).
* Non-git roots are not polled unless cbm_watcher_set_poll_non_git() opts in.
*
* Depends on: foundation, store (for project metadata)
*/
#ifndef CBM_WATCHER_H
#define CBM_WATCHER_H
#include <stdbool.h>
#include <stdint.h>
/* Forward declarations */
typedef struct cbm_store cbm_store_t;
/* ── Opaque handle ──────────────────────────────────────────────── */
typedef struct cbm_watcher cbm_watcher_t;
/* ── Index callback ─────────────────────────────────────────────── */
/* Called when file changes are detected. Return 0 on success, a POSITIVE
* value when the reindex was skipped and should be retried on the next poll
* (e.g. another pipeline holds the lock), negative on error. Only a 0 return
* commits the watcher's change baselines — a skipped or failed reindex keeps
* the change pending so it is retried, never silently lost (#937).
* project_name: project identifier
* root_path: absolute path to the repository root */
typedef int (*cbm_index_fn)(const char *project_name, const char *root_path, void *user_data);
/* Optional daemon coordination for destructive stale-root pruning. begin is
* non-blocking: a false result preserves the watch and retries on a later
* poll. A successful begin is paired with end. pruned is called after the
* physical watch and cached DB have been removed so the daemon can invalidate
* its logical subscriptions. All callbacks use the same borrowed context. */
typedef bool (*cbm_watcher_project_mutation_begin_fn)(void *context, const char *project);
typedef void (*cbm_watcher_project_mutation_end_fn)(void *context, const char *project);
typedef void (*cbm_watcher_project_pruned_fn)(void *context, const char *project);
/* ── Lifecycle ──────────────────────────────────────────────────── */
/* Create a new watcher. store is used for project metadata lookups.
* index_fn is called when file changes are detected.
* user_data is passed to index_fn. */
cbm_watcher_t *cbm_watcher_new(cbm_store_t *store, cbm_index_fn index_fn, void *user_data);
/* Free the watcher and all per-project state. NULL-safe.
* Precondition: cbm_watcher_stop() + thread join must have completed. */
void cbm_watcher_free(cbm_watcher_t *w);
/* Install or clear daemon-owned stale-root coordination. Passing NULL for
* begin/end clears all callbacks. The setter waits for an in-flight prune
* callback to finish before returning. */
void cbm_watcher_set_project_mutation_guard(cbm_watcher_t *w,
cbm_watcher_project_mutation_begin_fn begin,
cbm_watcher_project_mutation_end_fn end,
cbm_watcher_project_pruned_fn pruned, void *context);
/* Opt in to polling NON-GIT project roots (#1948; default off — non-git roots
* are not watched). When on, a non-git root is polled on the same adaptive
* cadence by a tree signature: the indexer's own discovery walk (same skip
* lists, .gitignore and .cbmignore rules), folded over each file's (relative
* path, size, mtime). A changed signature triggers index_fn; the first poll
* after baseline reindexes once, since nothing records which tree state the
* index holds. Paths discovery skips — including cbm's own .codebase-memory
* artifact directory and cache directory — never change the signature.
* Read at each project's baseline, so set it before registering projects. */
void cbm_watcher_set_poll_non_git(cbm_watcher_t *w, bool enabled);
/* ── Watch list management ──────────────────────────────────────── */
/* Add a project to the watch list. root_path is copied. Returns true only when
* the physical registration exists (including an identical existing watch).
* A stopped watcher rejects new registrations. */
bool cbm_watcher_watch(cbm_watcher_t *w, const char *project_name, const char *root_path);
/* Remove a project from the watch list. Any not-yet-admitted callback in the
* current poll snapshot is invalidated before this function returns. */
void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name);
/* Refresh a project's timestamp (resets adaptive backoff). */
void cbm_watcher_touch(cbm_watcher_t *w, const char *project_name);
/* ── Polling ────────────────────────────────────────────────────── */
/* Run a single poll cycle — check each watched project for changes.
* Returns the number of projects that were reindexed. */
int cbm_watcher_poll_once(cbm_watcher_t *w);
/* Run the blocking poll loop. Polls every base_interval_ms until
* cbm_watcher_stop() is called. Returns 0 on clean shutdown. */
int cbm_watcher_run(cbm_watcher_t *w, int base_interval_ms);
/* Request the run loop to stop (thread-safe). */
void cbm_watcher_stop(cbm_watcher_t *w);
/* ── Introspection (for testing) ────────────────────────────────── */
/* Return the number of projects in the watch list. */
int cbm_watcher_watch_count(cbm_watcher_t *w);
/* Return a watched project's consecutive hard-index-failure count, or -1 when
* it is not watched. Exposed so the failure state machine (increment on a
* hard error, reset on success) can be asserted directly rather than inferred
* from the poll deadline it feeds.
*
* Memory visibility: this reads under projects_lock, but poll_project WRITES
* the counter outside that lock — it runs against a state snapshot taken
* while the lock was held, which is the same discipline the other
* poll-mutated fields here already follow (last_head, last_dirty_sig,
* interval_ms, next_poll_ns, missing_root_count). It is NOT the discipline of
* every field: active_git is serialized by projects_lock and registered is an
* atomic_bool. Reading concurrently with a live poll is therefore formally a
* data race and may observe a stale value; it is a diagnostic and test
* accessor, not a synchronisation point.
* Single-threaded callers (the tests, and any caller between poll cycles)
* always see the current value. Do not build scheduling decisions on it
* without first giving the counter atomic accessors. */
int cbm_watcher_index_failure_count(cbm_watcher_t *w, const char *project_name);
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
/* Test seam: number of unwatched/replaced project states parked on the
* deferred-free list, read under projects_lock. The drain in poll_once is
* otherwise unobservable — cbm_watcher_free frees the same list, so a poll
* that stopped draining would neither crash nor leak. Test builds only. */
int cbm_watcher_test_pending_free_count(cbm_watcher_t *w);
#endif
/* Per-project watch status, for index_status (#2167). */
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_t;
typedef struct {
cbm_watcher_strategy_t strategy;
int poll_interval_ms; /* current adaptive cadence */
int64_t last_scan_unix_s; /* wall clock of the last completed scan; 0 = none */
} cbm_watcher_project_info_t;
/* Return true when project_name is physically watched and fill *out with its
* published status; false (and *out zeroed) otherwise. Safe to call while the
* poll loop runs: the fields are published atomically by the poll path. */
bool cbm_watcher_project_info(cbm_watcher_t *w, const char *project_name,
cbm_watcher_project_info_t *out);
/* Return the adaptive poll interval (ms) for a given file count. */
int cbm_watcher_poll_interval_ms(int file_count);
/* Return the delay (ms) before the next index attempt for a project with
* `consecutive_failures` consecutive hard index failures. Zero failures
* yields `interval_ms` unchanged; each further failure doubles the delay, so
* a permanently failing project stops re-forking a worker at the poll cadence
* without ever being abandoned.
*
* Doubling stops at a fixed shift cap, so the delay plateaus at
* `interval_ms << INDEX_FAIL_SHIFT_MAX` — which is the ceiling only when
* `interval_ms` is large enough to reach it (>= 4688 ms for the current cap
* and ceiling). Below that the plateau sits strictly under the ceiling. Every
* interval this watcher generates is >= POLL_BASE_MS, so in practice the
* ceiling is always reached; the distinction is stated because this is an
* exported function and a caller may pass a smaller interval.
*
* The result is clamped at BOTH ends: never above the ceiling, and never
* below `interval_ms`, so backing off can only ever delay the next attempt,
* never bring it forward. That lower clamp matters only for an `interval_ms`
* already above the ceiling, which the current constants cannot produce
* (POLL_MAX_MS < the ceiling) — it is stated because this is an exported
* function and the guarantee should hold for any argument a caller passes,
* not only for the ones today's constants generate.
*
* Negative inputs are treated as zero. */
int cbm_watcher_index_backoff_ms(int interval_ms, int consecutive_failures);
/* Classify a stat() errno observed on a watched project root: returns true
* only for values that mean the root itself is gone (ENOENT, ENOTDIR) and
* may count toward stale-root pruning (#286). Any other failure (EACCES,
* EIO, transient mounts, macOS TCC revocation) must NOT count — the cached
* DB holds user-authored data and is unrecoverable once pruned. Exposed
* for direct unit testing with injected errno values. */
bool cbm_watcher_root_missing_errno(int err);
#endif /* CBM_WATCHER_H */