From c55e48059c7dd982afc45f36834485b3772b6a85 Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Sat, 19 Sep 2026 09:58:55 -0500 Subject: [PATCH 1/3] ISS-10710: poll every 5 minutes; reindex when HEAD moves PLN-2069 PR 1. The autosync loop now ticks every 60 s. Each tick checks git HEAD and reindexes on a move; the full-tree check runs once per poll interval (default 5 minutes, 60 s floor). Both are skipped while the file watcher is alive. Co-Authored-By: Claude --- src/lemoncrow/core/settings_registry.py | 3 +- .../pro/capabilities/code_context/engine.py | 129 ++++++++--- tests/core/test_code_context.py | 208 +++++++++++++++--- 3 files changed, 268 insertions(+), 72 deletions(-) diff --git a/src/lemoncrow/core/settings_registry.py b/src/lemoncrow/core/settings_registry.py index ffb3e415d..e067d4971 100644 --- a/src/lemoncrow/core/settings_registry.py +++ b/src/lemoncrow/core/settings_registry.py @@ -539,7 +539,8 @@ class SettingSpec: "int", None, "code_context", - "Poll interval (ms) the code index autosync uses to check for changes.", + "Interval (ms) between the code index autosync's full-tree checks for changes: default 300000 " + "(5 minutes), minimum 60000. A git HEAD move reindexes within a minute without waiting for it.", ), SettingSpec( "code_context.file_watcher", diff --git a/src/lemoncrow/pro/capabilities/code_context/engine.py b/src/lemoncrow/pro/capabilities/code_context/engine.py index 8d655dfef..25784d095 100644 --- a/src/lemoncrow/pro/capabilities/code_context/engine.py +++ b/src/lemoncrow/pro/capabilities/code_context/engine.py @@ -2602,6 +2602,15 @@ def _resolve_index_max_workers() -> int: return _memory_capped_index_workers(os.cpu_count() or 1) +# The autosync loop wakes once per tick to check git HEAD, which costs one +# `git rev-parse`. The full-tree check stat-walks every source file, so it runs +# once per poll interval, never more often than the floor. +_AUTOSYNC_TICK_MS = 60_000 +_AUTOSYNC_MIN_POLL_MS = 60_000 +_AUTOSYNC_DEFAULT_POLL_MS = 300_000 +_AUTOSYNC_GIT_HEAD_TIMEOUT_S = 3.0 + + def _resolve_autosync_index_max_workers() -> int: """Worker count for background autosync indexing. @@ -3728,6 +3737,10 @@ def __init__( self._autosync_pending_events = 0 self._autosync_reindex_count = 0 self._autosync_history: list[dict[str, Any]] = [] + # Git HEAD at the last tick (None until the first reading), and the + # monotonic ms of the last full-tree check (None until the first one). + self._autosync_head: str | None = None + self._autosync_last_full_check_ms: int | None = None # Counts completed tool calls; used to pace the periodic heap trim. self._tool_call_count: int = 0 self._last_heap_trim_ts: float = 0.0 @@ -14724,32 +14737,36 @@ def _run_index_subprocess(self, *, force: bool = False) -> bool: logging.exception("code index subprocess error for %s", self.repo_root) return False - def _maybe_autosync_reindex(self, *, _from_watcher: bool = False) -> None: + def _maybe_autosync_reindex(self, *, known_change: str | None = None) -> bool: if not self._autosync_lock.acquire(blocking=False): - return + return False try: - self._maybe_autosync_reindex_locked(_from_watcher=_from_watcher) + return self._maybe_autosync_reindex_locked(known_change=known_change) finally: self._autosync_lock.release() - def _maybe_autosync_reindex_locked(self, *, _from_watcher: bool = False) -> None: - # When called from the file watcher we already know a change happened, - # so skip the expensive _source_tree_signature() stat walk entirely. - if _from_watcher: + def _maybe_autosync_reindex_locked(self, *, known_change: str | None = None) -> bool: + """Reindex if the tree changed; True iff a reindex ran and succeeded. + + ``known_change`` names a change the caller already detected (the file + watcher, or a HEAD move). It skips the ``_source_tree_signature()`` stat + walk before the reindex and is recorded as the reindex's reason. + """ + if known_change is not None: self._autosync_state = "syncing" if not self._run_index_subprocess(): # Reindex failed; leave the signature/pending state stale so the # next poll retries instead of recording a failed sync as done. self._autosync_state = "idle" - self._record_autosync_event(event="reindex", reason="watcher_triggered", reindexed=False) - return + self._record_autosync_event(event="reindex", reason=known_change, reindexed=False) + return False self._autosync_signature = self._source_tree_signature() self._autosync_last_sync_ms = int(time.time() * 1000) self._autosync_pending_events = 0 self._autosync_state = "idle" self._autosync_reindex_count += 1 - self._record_autosync_event(event="reindex", reason="watcher_triggered", reindexed=True) - return + self._record_autosync_event(event="reindex", reason=known_change, reindexed=True) + return True current_signature = self._source_tree_signature() if self._autosync_signature is None: @@ -14757,31 +14774,33 @@ def _maybe_autosync_reindex_locked(self, *, _from_watcher: bool = False) -> None self._autosync_last_sync_ms = int(time.time() * 1000) self._autosync_state = "idle" self._record_autosync_event(event="bootstrap", reason="seed_signature", reindexed=False) - return + return False if current_signature == self._autosync_signature: self._autosync_state = "idle" self._autosync_pending_events = 0 - return + self._record_autosync_event(event="full_check", reason="unchanged", reindexed=False) + return False now_ms = int(time.time() * 1000) self._autosync_last_event_at = datetime.now(UTC).isoformat() self._autosync_pending_events = max(1, self._autosync_pending_events + 1) if now_ms - self._autosync_last_sync_ms < self._autosync_debounce_ms: self._autosync_state = "debouncing" self._record_autosync_event(event="change_detected", reason="within_debounce_window", reindexed=False) - return + return False self._autosync_state = "syncing" if not self._run_index_subprocess(): # Reindex failed; leave the signature/pending state stale so the next # poll retries instead of recording a failed sync as complete. self._autosync_state = "idle" self._record_autosync_event(event="reindex", reason="source_signature_changed", reindexed=False) - return + return False self._autosync_signature = self._source_tree_signature() self._autosync_last_sync_ms = int(time.time() * 1000) self._autosync_pending_events = 0 self._autosync_state = "idle" self._autosync_reindex_count += 1 self._record_autosync_event(event="reindex", reason="source_signature_changed", reindexed=True) + return True def _maybe_refresh_zoekt_index(self) -> None: """Keep the git-repo Zoekt shard fresh at commit granularity. @@ -14800,10 +14819,10 @@ def _maybe_refresh_zoekt_index(self) -> None: def _parse_autosync_poll_ms(self, raw_value: str | None) -> int: if raw_value is None: - return 10000 + return _AUTOSYNC_DEFAULT_POLL_MS with contextlib.suppress(ValueError): return max(1000, int(raw_value)) - return 10000 + return _AUTOSYNC_DEFAULT_POLL_MS def _start_autosync_worker(self) -> None: if self._autosync_thread is not None: @@ -14952,7 +14971,7 @@ def _notify_watcher_event(self) -> None: self._watcher_last_event_ms = now_ms self._autosync_last_event_at = datetime.now(UTC).isoformat() self._autosync_pending_events = max(1, self._autosync_pending_events + 1) - self._maybe_autosync_reindex(_from_watcher=True) + self._maybe_autosync_reindex(known_change="watcher_triggered") def _parse_watcher_enabled(self, raw_value: str | None) -> bool: if raw_value is None: @@ -14980,29 +14999,67 @@ def _autosync_worker_loop(self) -> None: self._run_index_subprocess() except Exception: logging.exception("autosync: initial index build failed") - # When the file watcher is active, polling is a safety net only -- the - # watcher handles real-time change detection. When it's absent (no - # `watchdog` installed, EMFILE/inotify limit, etc.) polling is the - # *only* detection path, but each poll still does a full source-tree - # stat walk + two `git ls-files` subprocesses, so it must never run - # hotter than the same 60s floor used for the watcher's safety net. - poll_ms = max(self._autosync_poll_ms, 60000) - while not self._autosync_stop.wait(poll_ms / 1000.0): + while not self._autosync_stop.wait(_AUTOSYNC_TICK_MS / 1000.0): try: - if not self.index_ready(): - # Still empty (e.g. the initial build lost an index-lock race - # with a concurrent prewarm). Keep retrying until it exists. - self._run_index_subprocess() - else: - # Polling-based check is the safety net; skip when watcher is - # active (the watcher already triggers reindex on change). - if self._file_watcher is None or not self._file_watcher.is_alive(): - self._maybe_autosync_reindex() - self._maybe_refresh_zoekt_index() + self._autosync_tick(int(time.monotonic() * 1000)) except Exception as exc: logging.exception("Recovered from broad exception handler") self._record_autosync_event(event="worker_error", reason=str(exc), reindexed=False) + def _autosync_tick(self, now_ms: int) -> None: + """One pass of the autosync loop; ``now_ms`` is a monotonic clock in ms. + + A moved git HEAD (pull, checkout, merge, rebase) reindexes on the tick + that sees it. Other working-tree changes wait for the full-tree check, + which runs once per poll interval. Both are skipped while the file + watcher is alive: it already reindexes the files any change touches, a + checkout included, so checking HEAD too would reindex a checkout twice. + """ + if not self.index_ready(): + # Still empty (e.g. the initial build lost an index-lock race + # with a concurrent prewarm). Keep retrying until it exists. + self._run_index_subprocess() + elif self._file_watcher is None or not self._file_watcher.is_alive(): + # Non-blocking like every autosync entry point: an edit's reindex + # may hold the lock, and the next tick checks again. + if self._autosync_lock.acquire(blocking=False): + try: + self._autosync_poll_locked(now_ms) + finally: + self._autosync_lock.release() + self._maybe_refresh_zoekt_index() + + def _autosync_poll_locked(self, now_ms: int) -> None: + head = self._autosync_git_head() + if head is not None and self._autosync_head is not None and head != self._autosync_head: + # On failure keep the old HEAD, so the next tick retries. + if self._maybe_autosync_reindex_locked(known_change="head_moved"): + self._autosync_head = head + # The reindex reseeded the tree signature: that is a full check. + self._autosync_last_full_check_ms = now_ms + return + if head is not None: + self._autosync_head = head + last = self._autosync_last_full_check_ms + if last is None or now_ms - last >= max(self._autosync_poll_ms, _AUTOSYNC_MIN_POLL_MS): + self._autosync_last_full_check_ms = now_ms + self._maybe_autosync_reindex_locked() + + def _autosync_git_head(self) -> str | None: + """HEAD's commit sha; None for a non-git repo or when git fails.""" + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=self.repo_root, + capture_output=True, + text=True, + timeout=_AUTOSYNC_GIT_HEAD_TIMEOUT_S, + ) + except (OSError, subprocess.TimeoutExpired): + return None + head = result.stdout.strip() + return head if result.returncode == 0 and head else None + def _detected_repo_languages(self) -> frozenset[str]: """Lightweight language detection from file extensions in the symbol index.""" ext_map = { diff --git a/tests/core/test_code_context.py b/tests/core/test_code_context.py index 4b2972ede..42ef94cab 100644 --- a/tests/core/test_code_context.py +++ b/tests/core/test_code_context.py @@ -11,6 +11,7 @@ import pytest +from lemoncrow.core.settings_registry import SETTINGS from lemoncrow.infra.code_intel.astgrep import PatternMatch, PatternSearchResult from lemoncrow.pro.capabilities.code_context import CodeContextEngine from lemoncrow.pro.capabilities.code_context.budget import BudgetPacker @@ -2107,38 +2108,17 @@ def test_autosync_incremental_reindex_updates_index_after_edit(tmp_path: Path, m assert any(event["event"] == "reindex" for event in status["autosync"]["history"]) +_AUTOSYNC_MINUTE_MS = 60_000 + + def test_autosync_worker_reindexes_without_search_trigger(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LEMONCROW_CODE_AUTOSYNC_POLL_MS", raising=False) _write_fixture_repo(tmp_path) - monkeypatch.setenv("LEMONCROW_CODE_AUTOSYNC_DEBOUNCE_MS", "50") - monkeypatch.setenv("LEMONCROW_CODE_AUTOSYNC_POLL_MS", "100") - # Bypass the production-code poll floor (1000ms) so the worker detects - # changes within ~200ms instead of ~2s. - monkeypatch.setattr( - "lemoncrow.pro.capabilities.code_context.engine.CodeContextEngine._parse_autosync_poll_ms", - lambda self, raw_value: max(100, int(raw_value)) if raw_value else 100, - ) - engine = CodeContextEngine(tmp_path, db_path=tmp_path / "code.sqlite") - - for _ in range(40): - if engine._current_index_version() > 0: - break - time.sleep(0.05) - if engine._current_index_version() <= 0: - engine.index_repo() + engine = CodeContextEngine(tmp_path, db_path=tmp_path / "code.sqlite", autosync_enabled=False) + engine.index_repo() + engine._autosync_tick(0) # the worker's first tick seeds the tree signature version_before = engine._current_index_version() - assert version_before > 0 - # Wait for the autosync worker to seed its initial source-tree signature - # so the file write happens *after* the seed, guaranteeing the next - # worker poll detects the change. - for _ in range(40): - if engine._autosync_signature is not None: - break - time.sleep(0.05) - - # Modern filesystems (tmpfs, ext4, xfs) have nanosecond timestamps; - # a brief pause is sufficient to ensure the edit timestamp advances. - time.sleep(0.05) (tmp_path / "src" / "orders.py").write_text( "class OrderService:\n" " def calculate_total(self, items: list[int]) -> int:\n" @@ -2148,19 +2128,177 @@ def test_autosync_worker_reindexes_without_search_trigger(tmp_path: Path, monkey " pass\n", encoding="utf-8", ) + engine._autosync_last_sync_ms -= 60_000 # outside the debounce window + engine._autosync_tick(5 * _AUTOSYNC_MINUTE_MS) # the next full check, with a real reindex - for _ in range(40): - if engine._current_index_version() > version_before: - break - time.sleep(0.05) - if engine._current_index_version() <= version_before: - engine.index_repo(force=False) - + assert engine._current_index_version() > version_before found = engine.search_symbols("BackgroundSyncedService", mode="lexical", limit=5, auto_index=False) assert found assert found[0].symbol_name == "BackgroundSyncedService" +class _AutosyncProbe: + """Counts one engine's full-tree checks and stands in for its reindex subprocess.""" + + def __init__(self, engine: CodeContextEngine, monkeypatch: pytest.MonkeyPatch, reindex_results: list[bool]) -> None: + self.tree_walks = 0 + # For each reindex, the tree walks counted when it started. + self.reindexes: list[int] = [] + real_signature = engine._source_tree_signature + + def signature() -> str: + self.tree_walks += 1 + return real_signature() + + def run_index_subprocess(*, force: bool = False) -> bool: + self.reindexes.append(self.tree_walks) + return reindex_results.pop(0) if reindex_results else True + + monkeypatch.setattr(engine, "_source_tree_signature", signature) + monkeypatch.setattr(engine, "_run_index_subprocess", run_index_subprocess) + monkeypatch.setattr(engine, "index_ready", lambda: True) + monkeypatch.setattr(engine, "_maybe_refresh_zoekt_index", lambda: None) + + +def _autosync_probe_engine( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, git: bool = False, reindex_results: list[bool] | None = None +) -> tuple[CodeContextEngine, _AutosyncProbe]: + repo = tmp_path / "repo" + if git: + _init_git_fixture_repo(repo) + else: + repo.mkdir() + _write_fixture_repo(repo) + if git: + _commit_all(repo, "initial") + engine = CodeContextEngine(repo, db_path=tmp_path / "code.sqlite", autosync_enabled=False) + return engine, _AutosyncProbe(engine, monkeypatch, reindex_results or []) + + +def test_autosync_idle_ticks_walk_the_tree_once_per_poll_interval( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("LEMONCROW_CODE_AUTOSYNC_POLL_MS", raising=False) + engine, probe = _autosync_probe_engine(tmp_path, monkeypatch) + + walks_after_each_tick = [] + for minute in range(1, 11): + engine._autosync_tick(minute * _AUTOSYNC_MINUTE_MS) + walks_after_each_tick.append(probe.tree_walks) + + # The first tick seeds the signature; the next check is 5 minutes later. + assert walks_after_each_tick == [1, 1, 1, 1, 1, 2, 2, 2, 2, 2] + assert probe.reindexes == [] + assert [event["event"] for event in engine._autosync_history] == ["bootstrap", "full_check"] + + +def test_autosync_tree_change_without_head_move_waits_for_the_poll_interval( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("LEMONCROW_CODE_AUTOSYNC_POLL_MS", raising=False) + engine, probe = _autosync_probe_engine(tmp_path, monkeypatch, git=True) + engine._autosync_tick(0) + + (engine.repo_root / "src" / "orders.py").write_text("class WrittenOutsideLemonCrow:\n pass\n", encoding="utf-8") + engine._autosync_last_sync_ms -= 60_000 # outside the debounce window + for minute in range(1, 5): + engine._autosync_tick(minute * _AUTOSYNC_MINUTE_MS) + assert probe.reindexes == [] + + engine._autosync_tick(5 * _AUTOSYNC_MINUTE_MS) + + assert len(probe.reindexes) == 1 + assert engine._autosync_history[-1]["reason"] == "source_signature_changed" + + +def test_autosync_head_move_reindexes_on_the_next_tick_without_a_tree_walk( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("LEMONCROW_CODE_AUTOSYNC_POLL_MS", raising=False) + engine, probe = _autosync_probe_engine(tmp_path, monkeypatch, git=True) + engine._autosync_tick(0) # the first reading only seeds HEAD + assert probe.reindexes == [] + + (engine.repo_root / "src" / "orders.py").write_text("class Committed:\n pass\n", encoding="utf-8") + new_head = _commit_all(engine.repo_root, "move HEAD") + walks_before = probe.tree_walks + engine._autosync_tick(_AUTOSYNC_MINUTE_MS) + + assert probe.reindexes == [walks_before] + assert engine._autosync_head == new_head + last_event = engine._autosync_history[-1] + assert (last_event["event"], last_event["reason"], last_event["reindexed"]) == ("reindex", "head_moved", True) + + # The reindex reseeded the signature, so it counts as the full check. + walks_after_reindex = probe.tree_walks + for minute in range(2, 6): + engine._autosync_tick(minute * _AUTOSYNC_MINUTE_MS) + assert probe.tree_walks == walks_after_reindex + engine._autosync_tick(6 * _AUTOSYNC_MINUTE_MS) + assert probe.tree_walks == walks_after_reindex + 1 + assert len(probe.reindexes) == 1 + + +def test_autosync_failed_head_move_reindex_retries_on_the_next_tick( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + engine, probe = _autosync_probe_engine(tmp_path, monkeypatch, git=True, reindex_results=[False, True]) + engine._autosync_tick(0) + old_head = engine._autosync_head + (engine.repo_root / "src" / "orders.py").write_text("class Committed:\n pass\n", encoding="utf-8") + new_head = _commit_all(engine.repo_root, "move HEAD") + + engine._autosync_tick(_AUTOSYNC_MINUTE_MS) + assert len(probe.reindexes) == 1 + assert engine._autosync_head == old_head + + engine._autosync_tick(2 * _AUTOSYNC_MINUTE_MS) + assert len(probe.reindexes) == 2 + assert engine._autosync_head == new_head + + +@pytest.mark.parametrize( + ("configured", "interval_ms"), + [(None, 300_000), ("not-a-number", 300_000), ("120000", 120_000), ("1000", 60_000)], +) +def test_autosync_poll_interval_default_override_and_floor( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, configured: str | None, interval_ms: int +) -> None: + env_var = next(spec.env_var for spec in SETTINGS if spec.key == "code_context.autosync_poll_ms") + assert env_var is not None + if configured is None: + monkeypatch.delenv(env_var, raising=False) + else: + monkeypatch.setenv(env_var, configured) + engine, probe = _autosync_probe_engine(tmp_path, monkeypatch) + + engine._autosync_tick(0) + engine._autosync_tick(interval_ms - 1) + assert probe.tree_walks == 1 + engine._autosync_tick(interval_ms) + assert probe.tree_walks == 2 + + +def test_autosync_tick_leaves_change_detection_to_a_live_file_watcher( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + class _LiveWatcher: + def is_alive(self) -> bool: + return True + + engine, probe = _autosync_probe_engine(tmp_path, monkeypatch) + head_reads: list[int] = [] + monkeypatch.setattr(engine, "_autosync_git_head", lambda: head_reads.append(1)) + monkeypatch.setattr(engine, "_file_watcher", _LiveWatcher()) + + for minute in range(11): + engine._autosync_tick(minute * _AUTOSYNC_MINUTE_MS) + + assert head_reads == [] + assert probe.tree_walks == 0 + assert probe.reindexes == [] + + def test_incremental_index_noop_does_not_bump_version(tmp_path: Path) -> None: _write_fixture_repo(tmp_path) engine = CodeContextEngine(tmp_path, db_path=tmp_path / "code.sqlite") From 54e0273c72732f226718062532baa4bae1049ae1 Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Sat, 19 Sep 2026 10:03:31 -0500 Subject: [PATCH 2/3] ISS-10710: prune a redundant test row and restating comments (C1 prune) --- src/lemoncrow/pro/capabilities/code_context/engine.py | 7 +++---- tests/core/test_code_context.py | 9 +++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/lemoncrow/pro/capabilities/code_context/engine.py b/src/lemoncrow/pro/capabilities/code_context/engine.py index 25784d095..bc9105122 100644 --- a/src/lemoncrow/pro/capabilities/code_context/engine.py +++ b/src/lemoncrow/pro/capabilities/code_context/engine.py @@ -3737,9 +3737,8 @@ def __init__( self._autosync_pending_events = 0 self._autosync_reindex_count = 0 self._autosync_history: list[dict[str, Any]] = [] - # Git HEAD at the last tick (None until the first reading), and the - # monotonic ms of the last full-tree check (None until the first one). self._autosync_head: str | None = None + # Monotonic ms, unlike the wall-clock _autosync_last_sync_ms. self._autosync_last_full_check_ms: int | None = None # Counts completed tool calls; used to pace the periodic heap trim. self._tool_call_count: int = 0 @@ -14749,8 +14748,8 @@ def _maybe_autosync_reindex_locked(self, *, known_change: str | None = None) -> """Reindex if the tree changed; True iff a reindex ran and succeeded. ``known_change`` names a change the caller already detected (the file - watcher, or a HEAD move). It skips the ``_source_tree_signature()`` stat - walk before the reindex and is recorded as the reindex's reason. + watcher, or a HEAD move). It forces the reindex, bypassing the tree + check and the debounce window, and is recorded as the reindex's reason. """ if known_change is not None: self._autosync_state = "syncing" diff --git a/tests/core/test_code_context.py b/tests/core/test_code_context.py index 42ef94cab..2e815d04f 100644 --- a/tests/core/test_code_context.py +++ b/tests/core/test_code_context.py @@ -2259,17 +2259,14 @@ def test_autosync_failed_head_move_reindex_retries_on_the_next_tick( @pytest.mark.parametrize( ("configured", "interval_ms"), - [(None, 300_000), ("not-a-number", 300_000), ("120000", 120_000), ("1000", 60_000)], + [("not-a-number", 300_000), ("120000", 120_000), ("1000", 60_000)], ) def test_autosync_poll_interval_default_override_and_floor( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, configured: str | None, interval_ms: int + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, configured: str, interval_ms: int ) -> None: env_var = next(spec.env_var for spec in SETTINGS if spec.key == "code_context.autosync_poll_ms") assert env_var is not None - if configured is None: - monkeypatch.delenv(env_var, raising=False) - else: - monkeypatch.setenv(env_var, configured) + monkeypatch.setenv(env_var, configured) engine, probe = _autosync_probe_engine(tmp_path, monkeypatch) engine._autosync_tick(0) From fb7e58085fa3e8afdbc1651a1f4673e5b88b0916 Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Sat, 19 Sep 2026 10:17:45 -0500 Subject: [PATCH 3/3] ISS-10710: fold idle full checks into one autosync history entry Consecutive unchanged full checks now bump a count on one entry instead of appending, so an idle repo keeps its reindex, error and bootstrap entries. Co-Authored-By: Claude --- .../pro/capabilities/code_context/engine.py | 16 ++++++++++++---- tests/core/test_code_context.py | 12 ++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/lemoncrow/pro/capabilities/code_context/engine.py b/src/lemoncrow/pro/capabilities/code_context/engine.py index bc9105122..c42d54a0b 100644 --- a/src/lemoncrow/pro/capabilities/code_context/engine.py +++ b/src/lemoncrow/pro/capabilities/code_context/engine.py @@ -15083,15 +15083,23 @@ def _detected_repo_languages(self) -> frozenset[str]: return frozenset(langs) def _record_autosync_event(self, *, event: str, reason: str, reindexed: bool) -> None: + at = datetime.now(UTC).isoformat() + history = self._autosync_history + if event == "full_check" and history and (history[-1]["event"], history[-1]["reason"]) == (event, reason): + # Consecutive idle checks share one entry, so they never evict the + # reindex, error and bootstrap entries from the bounded history. + history[-1] = {**history[-1], "at": at, "count": history[-1]["count"] + 1} + return entry = { - "at": datetime.now(UTC).isoformat(), + "at": at, "event": event, "reason": reason, "reindexed": reindexed, + "count": 1, } - self._autosync_history.append(entry) - if len(self._autosync_history) > 20: - self._autosync_history = self._autosync_history[-20:] + history.append(entry) + if len(history) > 20: + self._autosync_history = history[-20:] def _json_safe(self, value: Any) -> Any: if value is None or isinstance(value, (str, int, float, bool)): diff --git a/tests/core/test_code_context.py b/tests/core/test_code_context.py index 2e815d04f..2e103cce9 100644 --- a/tests/core/test_code_context.py +++ b/tests/core/test_code_context.py @@ -2192,6 +2192,18 @@ def test_autosync_idle_ticks_walk_the_tree_once_per_poll_interval( assert [event["event"] for event in engine._autosync_history] == ["bootstrap", "full_check"] +def test_autosync_idle_full_checks_fold_into_one_history_entry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LEMONCROW_CODE_AUTOSYNC_POLL_MS", raising=False) + engine, _ = _autosync_probe_engine(tmp_path, monkeypatch) + + # Two idle hours: 24 full checks, more than the 20-entry history holds. + for check in range(25): + engine._autosync_tick(check * 5 * _AUTOSYNC_MINUTE_MS) + + history = [(event["event"], event["count"]) for event in engine._autosync_history] + assert history == [("bootstrap", 1), ("full_check", 24)] + + def test_autosync_tree_change_without_head_move_waits_for_the_poll_interval( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: