fix(watcher): baseline dirty worktrees

Record the git dirty hash during watcher baseline so an already-dirty worktree registered after explicit MCP indexing is not treated as a new change on the next watcher poll. This prevents watcher-driven full reindex from mutating the project DB after index_repository has returned.

Also preserve adaptive backoff after normal baseline snapshots while still honoring a real touch during an in-flight poll by recording the snapshot's original next_poll_ns.

Validation: CBM_ONLY_SUITE=watcher make -f Makefile.cbm test -> 63 passed (/private/tmp/cbm-pan1-20260703T0538Z-watcher-baseline-suite.log); make -f Makefile.cbm lint-source-safety -> rc 0 (/private/tmp/cbm-pan1-20260703T0544Z-lint-source-safety.log); make -f Makefile.cbm cbm -> rc 0 (/private/tmp/cbm-pan1-20260703T0546Z-watcher-cbm-build.log); targeted MCP self-dogfood one_source_file -> rc 0 and canonical graph equal (/private/tmp/cbm-pan1-20260703T0549Z-watcher-baseline-one-source-mcp.json).
Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-07-03 00:41:57 -04:00
parent b5dd6f87f7
commit ea218e17e9
2 changed files with 41 additions and 11 deletions
+10 -4
View File
@@ -8,7 +8,7 @@
* Per-project state tracks:
* - Last git HEAD hash (detects commits, checkout, pull)
* - Last dirty-tree hash (djb2 of git status --porcelain; prevents reindex
* loop when tree is permanently dirty — only reindexes when content changes)
* loops when the worktree remains in the same dirty status)
* - Last poll time + adaptive interval
* - Whether the project is a git repo
*
@@ -69,6 +69,7 @@ typedef struct {
int file_count;
int interval_ms;
int64_t next_poll_ns;
int64_t observed_next_poll_ns;
} project_snapshot_t;
/* ── Watcher struct ─────────────────────────────────────────────── */
@@ -158,6 +159,7 @@ static bool snapshot_from_state(project_snapshot_t *dst, const project_state_t *
dst->file_count = src->file_count;
dst->interval_ms = src->interval_ms;
dst->next_poll_ns = src->next_poll_ns;
dst->observed_next_poll_ns = src->next_poll_ns;
return true;
}
@@ -171,7 +173,8 @@ static void snapshot_free(project_snapshot_t *s) {
}
static void state_apply_snapshot(project_state_t *dst, const project_snapshot_t *src) {
bool touched_during_poll = dst->next_poll_ns == 0 && src->next_poll_ns != 0;
bool touched_during_poll =
src->observed_next_poll_ns != 0 && dst->next_poll_ns == 0 && src->next_poll_ns != 0;
memcpy(dst->last_head, src->last_head, sizeof(dst->last_head));
memcpy(dst->last_dirty_hash, src->last_dirty_hash, sizeof(dst->last_dirty_hash));
dst->is_git = src->is_git;
@@ -321,7 +324,9 @@ static void init_baseline(project_snapshot_t *s, const cbm_watcher_t *w) {
}
cbm_git_snapshot_t snap = {0};
if (cbm_git_snapshot_read(s->root_path, CBM_GIT_SNAPSHOT_HEAD | CBM_GIT_SNAPSHOT_FILE_COUNT,
if (cbm_git_snapshot_read(s->root_path,
CBM_GIT_SNAPSHOT_HEAD | CBM_GIT_SNAPSHOT_DIRTY |
CBM_GIT_SNAPSHOT_FILE_COUNT,
&snap) != 0) {
s->baseline_done = true;
s->is_git = false;
@@ -334,6 +339,7 @@ static void init_baseline(project_snapshot_t *s, const cbm_watcher_t *w) {
if (s->is_git) {
memcpy(s->last_head, snap.head, sizeof(s->last_head));
memcpy(s->last_dirty_hash, snap.dirty_hash, sizeof(s->last_dirty_hash));
s->file_count = snap.file_count;
s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count, w->poll_base_ms, w->poll_max_ms);
cbm_log_info("watcher.baseline", "project", s->project_name, "strategy", "git", "files",
@@ -367,7 +373,7 @@ static bool check_changes(project_snapshot_t *s) {
memcpy(s->last_head, snap.head, sizeof(s->last_head));
}
/* Check working tree: only reindex if content changed since last poll. */
/* Check working tree: only reindex if git porcelain status changed since last poll. */
if (snap.dirty_bytes <= 0) {
/* Clean tree: clear hash so future dirt is always caught. */
s->last_dirty_hash[0] = '\0';
+31 -7
View File
@@ -900,8 +900,9 @@ TEST(watcher_continued_dirty) {
}
TEST(watcher_baseline_dirty_repo) {
/* Baseline on a repo that already has uncommitted changes.
* Port of TestGitSentinelDetectsEdit (dirty from the start). */
/* Baseline on a repo that already has uncommitted changes. Watcher
* registration means "current state was just observed", so the same dirty
* state must not trigger a redundant reindex on the next poll. */
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_bld_XXXXXX");
if (!cbm_mkdtemp(tmpdir))
@@ -909,7 +910,8 @@ TEST(watcher_baseline_dirty_repo) {
if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); }
{ char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); }
wt_git(tmpdir, "add file.txt");
{ char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file2.txt"), "world\n"); }
wt_git(tmpdir, "add file.txt file2.txt");
wt_git(tmpdir, "commit -q -m init");
/* Make dirty BEFORE baseline */
@@ -924,11 +926,22 @@ TEST(watcher_baseline_dirty_repo) {
cbm_watcher_watch(w, "bld-repo", tmpdir);
index_call_count = 0;
/* Baseline — captures HEAD but doesn't check for dirty */
/* Baseline captures both HEAD and the current dirty hash. */
cbm_watcher_poll_once(w);
ASSERT_EQ(index_call_count, 0); /* baseline never triggers */
/* First real poll — should detect the pre-existing dirty state */
/* Same dirty state — should not reindex. */
cbm_watcher_touch(w, "bld-repo");
cbm_watcher_poll_once(w);
ASSERT_EQ(index_call_count, 0);
/* New dirty status — should reindex once. The watcher hashes porcelain
* status, so appending again to file.txt would keep the same status line. */
{
char _p[1024];
snprintf(_p, sizeof(_p), "%s/file2.txt", tmpdir);
th_append_file(_p, "dirty after baseline\n");
}
cbm_watcher_touch(w, "bld-repo");
cbm_watcher_poll_once(w);
ASSERT_EQ(index_call_count, 1);
@@ -991,7 +1004,8 @@ TEST(watcher_watch_after_unwatch) {
if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); }
{ char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); }
wt_git(tmpdir, "add file.txt");
{ char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file2.txt"), "world\n"); }
wt_git(tmpdir, "add file.txt file2.txt");
wt_git(tmpdir, "commit -q -m init");
cbm_store_t *store = cbm_store_open_memory();
@@ -1019,7 +1033,17 @@ TEST(watcher_watch_after_unwatch) {
cbm_watcher_poll_once(w);
ASSERT_EQ(index_call_count, 0); /* baseline never triggers */
/* Second poll — detects dirty */
/* Same dirty state captured by the re-watch baseline should not reindex. */
cbm_watcher_touch(w, "rewatch-repo");
cbm_watcher_poll_once(w);
ASSERT_EQ(index_call_count, 0);
/* A later dirty-status change still triggers. */
{
char _p[1024];
snprintf(_p, sizeof(_p), "%s/file2.txt", tmpdir);
th_append_file(_p, "dirty after rewatch baseline\n");
}
cbm_watcher_touch(w, "rewatch-repo");
cbm_watcher_poll_once(w);
ASSERT_EQ(index_call_count, 1);