Skip to content

fix(cache): prevent stale RLM BSL index reads - #326

Merged
zeegin merged 17 commits into
IngvarConsulting:mainfrom
Agrajaga:codex/issue-286-rlm-freshness
Aug 7, 2026
Merged

fix(cache): prevent stale RLM BSL index reads#326
zeegin merged 17 commits into
IngvarConsulting:mainfrom
Agrajaga:codex/issue-286-rlm-freshness

Conversation

@Agrajaga

@Agrajaga Agrajaga commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Что меняется

Fixes #286. RLM-backed чтение больше не принимает fresh от внешнего индексатора как достаточное доказательство актуальности: готовый индекс связывается с поколением BSL/XML-исходников, а workspace-сервис повторно проверяет эту связь непосредственно до и после RLM-вызова. Устаревший результат отбрасывается, сессия завершается, обновление индекса запускается асинхронно и дедуплицируется. unica.code.outline остаётся на current-file AST-пути ADR-0020; исправление охватывает оставшиеся RLM-backed consumers (definition, индексную часть search, meta.profile).

Архитектурный слой

  • Затронутые записи реестра: INV-CACHE-ORCHESTRATOR-OWNED, INV-CACHE-WORKTREE-ISOLATION, INV-APP-LAZY-HIDDEN-SERVICES (контракты сохранены).

  • Решение (ADR), если публичный или архитектурный контракт меняется: нет — публичная поверхность unica.* и архитектурный контракт не меняются; реализация уточняет существующую модель ADR-0018 и сохраняет ADR-0020.

  • Пройден чек-лист изменений в части, относящейся к этому изменению.

Проверка

cargo fmt --all -- --check
cargo test -p unica-coder --lib infrastructure::workspace_services::tests
cargo test -p unica-coder --test issue_89_workspace_service -- --test-threads=1
cargo clippy -p unica-coder --lib -- -D warnings
python -m unittest tests.ci.test_design_documents
python scripts/ci/check-architecture-sync.py --base upstream/main --strict
git diff --check upstream/main...HEAD
  • Новое поведение покрыто тестами rlm_execute_rechecks_generation_after_readiness_before_starting_session, rlm_execute_discards_output_when_source_changes_before_fake_execute_returns, rlm_execute_preserves_active_index_lock_priority_after_source_change и rlm_execute_returns_stale_responses_and_deduplicates_blocked_maintenance.
  • Интеграционные fixture-тесты прогревают generation-bound индекс перед RLM-вызовом.
  • Полный GitHub Actions CI прошёл на Ubuntu, Windows и macOS, включая workspace tests, target bundles, упаковку и bootstrap probes.
  • Документация, описывающая изменённое поведение, обновлена в этом же PR.

Локально затронутый модуль проходит 94/94, интеграционный target issue #89 — 2/2. Полная кроссплатформенная проверка подтверждена GitHub Actions run 30858736899.

Summary by CodeRabbit

  • New Features

    • Workspace indexes now detect source changes and automatically refresh stale indexes.
    • Code search and workspace sessions verify index freshness before running, preventing outdated results.
    • Background maintenance is scheduled when an index is unavailable or out of date.
    • Existing status and recovery information is preserved during index updates.
  • Bug Fixes

    • Improved handling of source changes, interrupted updates, missing files, and legacy index status data.
  • Documentation

    • Added design and implementation documentation for source-generation-based index freshness.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@zeegin, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 49 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9209b9d5-6605-4342-9b74-daf5b4f545f9

📥 Commits

Reviewing files that changed from the base of the PR and between e1e89a6 and 75bd090.

📒 Files selected for processing (1)
  • crates/unica-coder/src/infrastructure/source_roots.rs
📝 Walkthrough

Walkthrough

Changes

The change adds deterministic source-root generation and stores it with workspace index status. Index readiness checks generation, source root, database path, and file existence. RLM execution rejects stale indexes before and after execution, retires sessions, and schedules deduplicated maintenance.

Source-generation-bound index lifecycle

Layer / File(s) Summary
Bounded source-generation fingerprinting
crates/unica-coder/src/infrastructure/source_roots.rs
Adds deterministic, bounded hashing for supported source files and directories. The walk excludes .build and symlinks, and preserves stable ordering.
Generation-aware index status and readiness
crates/unica-coder/src/infrastructure/workspace_index.rs
Stores source generations in status and background jobs. Readiness validates generation, normalized source root, database path, and database existence.
RLM execution and maintenance coordination
crates/unica-coder/src/infrastructure/workspace_services.rs
Validates readiness before and after RLM execution. Stale results are suppressed, sessions are retired, and maintenance requests are deduplicated.
Platform fixture readiness verification
crates/unica-coder/tests/platform/issue_89_workspace_service.rs
Integration fixtures wait for generation-bound readiness and create the database file reported by the fake RLM tool.
Source-generation design and implementation records
docs/design/..., docs/plans/...
Documents generation rules, readiness behavior, compatibility handling, race handling, and verification procedures.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WorkspaceService
  participant WorkspaceIndexService
  participant RLMTransport
  participant IndexMaintenance
  WorkspaceService->>WorkspaceIndexService: validate source generation and index readiness
  WorkspaceIndexService-->>WorkspaceService: return ready or stale status
  WorkspaceService->>RLMTransport: execute RLM request
  RLMTransport-->>WorkspaceService: return execution output
  WorkspaceService->>WorkspaceIndexService: recheck generation after execution
  WorkspaceService->>IndexMaintenance: schedule deduplicated maintenance when stale
Loading

Possibly related PRs

Suggested reviewers: zeegin

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: preventing stale RLM BSL index reads through cache validation.
Linked Issues check ✅ Passed The changes bind index readiness to source generations and reject stale results, addressing issue #286 for outline and definition reads.
Out of Scope Changes check ✅ Passed The implementation, tests, and documentation support source-generation validation and stale-index prevention; no unrelated code changes are evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Agrajaga
Agrajaga marked this pull request as ready for review August 3, 2026 22:42

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
crates/unica-coder/src/infrastructure/workspace_services.rs (2)

6579-6579: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert against SOURCE_GENERATION_STALE_STATUS instead of the literal string.

The constant is already imported at Line 10 and the production code uses it at Line 1724. Using the literal "stale (source generation)" here lets the constant and the test drift apart.

♻️ Proposed change
-        assert_eq!(response.error.as_deref(), Some("stale (source generation)"));
+        assert_eq!(
+            response.error.as_deref(),
+            Some(SOURCE_GENERATION_STALE_STATUS)
+        );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/unica-coder/src/infrastructure/workspace_services.rs` at line 6579,
Update the assertion in the relevant test to compare response.error against the
imported SOURCE_GENERATION_STALE_STATUS constant instead of the hard-coded
"stale (source generation)" literal, preserving the existing assertion behavior.

5453-5473: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the index lock path and payload from the production definitions.

write_active_rlm_index_lock hardcodes locks/bsl_index.lock and hand-builds the lock JSON. The production lock path and lock schema live in workspace_index.rs (lock_path, the lock record type). If either changes, this fixture stops creating an active lock and the test fails with a confusing readiness mismatch instead of a compile error.

Expose a test-only helper from workspace_index (for example a pub(crate) fn lock_path or a #[cfg(test)] lock writer) and call it here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/unica-coder/src/infrastructure/workspace_services.rs` around lines
5453 - 5473, Update write_active_rlm_index_lock to use a test-only helper
exposed by workspace_index for both the production lock path and lock-record
serialization. Remove the hardcoded locks/bsl_index.lock path and hand-built
JSON, delegating lock creation to the existing lock_path or lock-writer symbol
so fixture data stays aligned with production definitions.
crates/unica-coder/tests/platform/issue_89_workspace_service.rs (1)

551-574: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Report the last observed status and yield the CPU between polls.

Two improvements make this helper more useful:

  1. The panic message names only the path. When this wait times out on CI, the operator cannot tell whether the status was building, failed, or missing. Include the last read file content.
  2. The loop calls thread::yield_now() and re-reads the file on every iteration for up to RESPONSE_DEADLINE. That competes for CPU with the index subprocess this helper waits for. Fixture::finish already uses a 10 ms sleep for the same kind of wait.
♻️ Proposed change
     fn wait_for_index_ready(&self, timeout: Duration) {
         let status_path = self.cache.join("caches/bsl_index_status.json");
         let deadline = Instant::now() + timeout;
+        let mut last = String::new();
         while Instant::now() < deadline {
-            let ready = fs::read_to_string(&status_path)
-                .ok()
+            let text = fs::read_to_string(&status_path).unwrap_or_default();
+            if !text.is_empty() {
+                last = text.clone();
+            }
+            let ready = Some(text)
+                .filter(|text| !text.is_empty())
                 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
                 .is_some_and(|status| {
                     status["status"] == "ready"
                         && status["source_generation"].is_u64()
                         && status["db_path"]
                             .as_str()
                             .is_some_and(|path| Path::new(path).is_file())
                 });
             if ready {
                 return;
             }
-            thread::yield_now();
+            thread::sleep(Duration::from_millis(10));
         }
         panic!(
-            "timed out waiting for generation-bound RLM index status at {}",
-            status_path.display()
+            "timed out waiting for generation-bound RLM index status at {}; last status: {last}",
+            status_path.display()
         );
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/unica-coder/tests/platform/issue_89_workspace_service.rs` around lines
551 - 574, Update Fixture::wait_for_index_ready to retain the last successfully
read status file content and include it in the timeout panic alongside the
status path, while preserving useful output when the file is missing or
unreadable. Replace thread::yield_now() with a 10 ms sleep between polls,
matching Fixture::finish and reducing CPU contention with the index subprocess.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/unica-coder/tests/platform/issue_89_workspace_service.rs`:
- Around line 254-260: Separate setup timing from cleanup timing in the test
around the existing started timestamp and index-readiness setup. Record a new
Instant immediately after wait_for_index_ready returns, then use that timestamp
for the 8-second cleanup-bound assertion while leaving the setup operations
unchanged.

---

Nitpick comments:
In `@crates/unica-coder/src/infrastructure/workspace_services.rs`:
- Line 6579: Update the assertion in the relevant test to compare response.error
against the imported SOURCE_GENERATION_STALE_STATUS constant instead of the
hard-coded "stale (source generation)" literal, preserving the existing
assertion behavior.
- Around line 5453-5473: Update write_active_rlm_index_lock to use a test-only
helper exposed by workspace_index for both the production lock path and
lock-record serialization. Remove the hardcoded locks/bsl_index.lock path and
hand-built JSON, delegating lock creation to the existing lock_path or
lock-writer symbol so fixture data stays aligned with production definitions.

In `@crates/unica-coder/tests/platform/issue_89_workspace_service.rs`:
- Around line 551-574: Update Fixture::wait_for_index_ready to retain the last
successfully read status file content and include it in the timeout panic
alongside the status path, while preserving useful output when the file is
missing or unreadable. Replace thread::yield_now() with a 10 ms sleep between
polls, matching Fixture::finish and reducing CPU contention with the index
subprocess.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7683c6bb-32fc-4ea5-b143-ff2de2139a28

📥 Commits

Reviewing files that changed from the base of the PR and between 5ed97e5 and 6d39759.

📒 Files selected for processing (6)
  • crates/unica-coder/src/infrastructure/source_roots.rs
  • crates/unica-coder/src/infrastructure/workspace_index.rs
  • crates/unica-coder/src/infrastructure/workspace_services.rs
  • crates/unica-coder/tests/platform/issue_89_workspace_service.rs
  • docs/design/2026-08-03-issue-286-rlm-source-generation-design.md
  • docs/plans/2026-08-03-issue-286-rlm-source-generation.md

Comment thread crates/unica-coder/tests/platform/issue_89_workspace_service.rs
@zeegin

zeegin commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Механика правильная: связать индекс с состоянием исходников и перестать верить
слову fresh от индексатора — это закрывает #286 по существу. CI на текущем
head зелёный. До мержа осталось два пункта.

1. Перебазировать на main

PR стоит на 5ed97e59, сейчас mergeable: CONFLICTING. Конфликт один —
crates/unica-coder/tests/platform/issue_89_workspace_service.rs, тот же тест
переписан в #309. Два места, где нужно решение:

Бюджет cleanup. Обе ветки независимо сделали один и тот же рефакторинг
(cleanup_started). Оставлять стоит вариант из main: он взводит таймер
непосредственно перед panic!, поэтому окно 8 с меряет только раскрутку и
RAII-drop. Взвод из этого PR стоит раньше и втягивает в бюджет второй
code.search вместе с wait_for_log — его нужно убрать, иначе останутся два
присваивания подряд.

Коллизия JSON-RPC id — её git смёржит молча. Ханки разные, конфликта не
будет, но id: 11 в одной MCP-сессии окажется занят дважды: прогревом индекса
из этого PR и recovery-пробой из #309. receive_ids матчит по id, так что
вторая выборка подхватит ответ по остаточному совпадению, а не по существу.
Прогрев надо перенумеровать в свободный id.

После ребейза main уехал ещё на #306, #308, #314, #348; #308 тоже правит
workspace_services.rs, но сливается чисто. Прогон на перебазированной ветке
локально зелёный: workspace_services 95/95, workspace_index 58/58,
source_roots 13/13, issue_89_workspace_service 2/2 три раза подряд, плюс
fmt, clippy -D warnings и check-architecture-sync --strict.

2. Убрать второй полный обход дерева на RLM-вызов

e950161a добавил пост-проверку, и вместе с ней второй source_generation():
до PR обход был один, теперь два — workspace_services.rs:1672 и :1715.

Замер репликой функции на реальном вендорском конфиге (~43k файлов
.bsl/.xml/.yaml/.yml, прогретый кеш) — ~1.2 с на обход, пять прогонов в
пределах 1.18–1.34 с. То есть каждый code.definition, индексный code.search
и meta.profile дорожает примерно на секунду с четвертью. Оба обхода идут под
захваченным self.rlm, так что это ещё и точка сериализации, а не только
латентность.

Сама пост-проверка нужна — окно между проверкой готовности и возвратом
результата она закрывает правильно, и логика на 1713–1729 читается верно.
Дорога именно реализация через второй полный stat-обход.

Дешевле она может быть за счёт уже существующего событийного пути: invalidate()
(:1504) взводит rlm_invalidated по доменным событиям, то есть все правки,
прошедшие через саму Unica, уже дают сигнал бесплатно. Полный обход нужен против
изменений мимо Unica — внешний редактор, git checkout, запись из Конфигуратора.
Но для пост-проверки окно равно длительности одного RLM-вызова, и внешняя
правка ровно внутри него — заметно более редкая гонка, которую в любом случае
поймает pre-проверка следующего вызова. Конкретный способ на ваше усмотрение;
условие — не платить вторым полным обходом.

По AGENTS.md дефект, привнесённый этим же PR,
правится в нём же, поэтому пункт сюда, а не в отдельную задачу.

Не в этот PR

Отпечаток не смотрит глубже 8 уровней (hash_source_path, обрыв на
depth > 8). Происхождение — d4df0e67 от 2026-06-23, задолго до этого PR,
поэтому втягивать сюда не нужно; завожу отдельной задачей с базой main.

Замечу для контекста, потому что PR меняет цену этого ограничения: раньше
поколение решало только, переиспользовать ли живую сессию, теперь оно
персистится и становится доказательством свежести. При этом на баг #286 дыра не
влияет — BSL-модули упираются в 7 уровней, их глубину задаёт раскладка выгрузки
(Catalogs/X/Forms/Y/Ext/Form/Module.bsl). За порог на реальном конфиге уходят
только 7 XML вложенных подсистем, это про состав подсистем, а не про сигнатуры
методов. Запас у BSL — один уровень.


Ребейз с обоими разрешениями у меня уже собран и прогнан. Пушить в head форка не
могу — по AGENTS.md в таком случае предоставляю патч,
скажите, и приложу.

…-freshness

# Conflicts:
#	crates/unica-coder/tests/platform/issue_89_workspace_service.rs
@Agrajaga Agrajaga closed this Aug 6, 2026
@Agrajaga Agrajaga reopened this Aug 6, 2026
@Agrajaga Agrajaga closed this Aug 7, 2026
@Agrajaga Agrajaga reopened this Aug 7, 2026

@zeegin zeegin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Направление верное, реализация аккуратная, тесты содержательные. Локально на ветке PR: workspace_services::tests 95/95, issue_89_workspace_service 2/2, clippy -p unica-coder --lib -- -D warnings чист.

Ниже — то, что стоит решить до мержа.

Существенное

1. Четыре полных обхода дерева на каждый RLM-вызов (~4 с на реальной конфигурации)

source_generation в crates/unica-coder/src/infrastructure/source_roots.rs:201 — рекурсивный read_dir + metadata по всему дереву. Замер на реальном дампе 8.3.27 (43 299 файлов .bsl/.xml), release-сборка, прогретый кеш, APFS:

run 0: 1.255s   run 1: 1.039s   run 2: 1.039s   run 3: 1.038s

~1.04 с за один вызов. Вызовов на один unica.code.definition:

Точка Обходов
handle_rlm_readystart_for_workspace_cancellable (workspace_index.rs:267) 1
handle_rlm_readyready_index_cancellable (workspace_index.rs:376) 1
handle_rlm_mcppre_execution_generation (workspace_services.rs:1672) 1
handle_rlm_mcppost_execution_generation (workspace_services.rs:1715) 1

До PR обход был один (только для инвалидации тёплой сессии в handle_rlm_mcp). Стало четыре → +3 с чистого stat-а на вызов на этом корпусе, и это macOS; на Windows stat заметно дороже. При RLM_EXECUTE_TIMEOUT = 45 s это ещё не таймаут, но latency инструмента меняется на порядок.

Отдельно: и pre_execution_generation, и post_execution_generation считаются под удержанным self.rlm mutexself.rlm.lock() берётся строкой выше. Два секундных обхода сериализуют все параллельные RLM-вызовы поверх и без того узкой rlm_lane.

Предложение: считать поколение один раз на запрос и протаскивать значение (handle_rlm_ready вычисляет его дважды подряд для одного и того же корня — чистое дублирование), плюс мемоизация per-source_root с коротким TTL. Как минимум post_execution_generation можно считать после drop(rlm).

2. Тихие обрезания в source_generation стали дырой в корректности

if depth > 8 (source_roots.rs:210) и .take(20_000) (source_roots.rs:246) — код перенесённый, не новый. Но его роль изменилась: раньше это была best-effort эвристика для сброса тёплых сессий, теперь это авторитетное доказательство свежести. Пропущенный файл больше не «лишний перезапуск сессии», а ровно тот дефект, который PR чинит.

На том же реальном корпусе:

  • максимальная глубина .bslровно 8, то есть запас нулевой;
  • 7 реальных XML уже за границей, на глубине 10 — вложенные подсистемы вида Subsystems/ЭлектронноеВзаимодействие/Subsystems/.../Subsystems/КонвертацияЭлектронныхДокументовСлужебный.xml.

Достаточно, чтобы source_root разрешился на уровень выше корня выгрузки — и весь BSL уходит за depth 8, поколение перестаёт меняться, fresh снова проходит, #286 воспроизводится молча. Просьба либо снять/поднять предел, либо при срабатывании обрезания помечать поколение недостоверным и не отдавать Ready.

3. Терминальный failed-маркер превратился в тупик без выхода

Тест переименован: fresh_info_replaces_matching_failed_markerfresh_info_preserves_matching_failed_marker, и теперь утверждает status == "failed" и runner.backgrounds.borrow().is_empty().

Раньше fresh от info писал ready-статус и тем самым перезатирал терминальный маркер — это был единственный автоматический путь восстановления. Теперь ready-маркер пишет только фоновая операция, а matching_failed блокирует её старт в обеих точках (workspace_index.rs:279 и workspace_index.rs:383). Круг замкнут: фоновое задание не стартует, потому что маркер failed; маркер failed не снимается, потому что его снимает только фоновое задание.

Маркер достижим (recovery_exhausted && Stale в workspace_index.rs:996 — например, битая БД или переполненный диск в момент recovery build). После этого definition/search/profile навсегда отдают failed, и лечится только ручным удалением bsl_index_status.json. Нужен явный выход — самое естественное: снимать терминальный маркер, когда текущее поколение исходников отличается от того, при котором он записан (сейчас terminal_failure пишет source_generation: None, так что для этого его придётся заполнять).

4. Гонка mtime+size остаётся ровно на симптоме #286

Поколение строится из имени, размера и mtime. Правка Процедура Тест(А)Процедура Тест(Б) не меняет размер; на ФС с посекундной гранулярностью mtime (сетевые монтирования, ext3, часть CI-образов) в пределах одной секунды поколение не изменится → fresh пройдёт → вернётся старая сигнатура. На APFS/NTFS с наносекундами практически не стреляет, но раз уж вся конструкция — про доказательство свежести, ограничение стоит проговорить в дизайн-записке (или взять хеш содержимого для BSL, что ещё сильнее ударит по п. 1).

Помельче

  • DefaultHasher теперь персистится на диск. Std явно не гарантирует стабильность его хеша между релизами Rust. Направление отказа безопасное (несовпадение → переиндексация), но пересборка Unica на новом тулчейне разом инвалидирует все маркеры у всех пользователей. Стоит либо зафиксировать стабильный хеш, либо записать это как осознанный выбор.

  • Поток обслуживания не отслеживается. request_rlm_index_maintenance (workspace_services.rs:1621) клонирует токен запроса. OperationGuard::drop только снимает операцию с регистрации, не отменяя токен — значит begin_shutdown уже не сможет отменить обслуживание, стартовавшее под завершившийся запрос; и наоборот, cancel_operation, попавший в узкое окно до возврата, убьёт только что запланированное обновление. Логичнее собственный токен, привязанный к жизни рантайма.

  • Пост-проверка использует pre_execution_generation. Если фоновое задание успело записать более новое поколение, пока шёл execute, корректный результат будет отброшен. Направление консервативное и безопасное, но неочевидное — просится комментарий.

  • Дизайн-записка сама себе противоречит. Альтернатива №2 отклонена в том числе формулировкой «полный обход перед каждым чтением отдаёт политику производительности внешнему инструменту» (docs/design/2026-08-03-issue-286-rlm-source-generation-design.md:60). Выбранный подход делает полный обход перед каждым чтением, четырежды, внутри процесса. Обоснование стоит переписать честно: цена перенесена в Unica, а не устранена.

  • Ветка «метаданные недоступны» в hash_source_path хеширует 0_u8 и выходит, не подмешав путь, — в отличие от остальных веток. Сегодня безопасно, потому что stored_path_matches отдельно пришивает маркер к корню, но асимметрия неочевидна.

Тесты

Покрытие хорошее и по делу: обе временны́е границы (...before_starting_session, ...before_fake_execute_returns), приоритет активной блокировки, дедупликация обслуживания, чтение legacy-маркера, фиксация поколения до команды. Блокирующая fixture на rustc с файловым рандеву тяжеловата, но для проверки «изменение исходников ровно в середине execute» альтернатив немного.

Чего не хватает: теста на выход из терминального маркера (п. 3) и стража на п. 2 — например, что файл глубже предела не проходит незамеченным.

Интеграционные тесты теперь ждут wait_for_index_ready с RESPONSE_DEADLINE = 10 s и зависят от реального планирования фонового задания. Локально проходят за 4.7 с, но это новая точка потенциальной флакости на нагруженной CI-машине.

Итог

Блокирующими считаю п. 1 (4× обход, ~+3 с на вызов, два из них под мьютексом) и п. 3 (невосстановимый терминальный маркер). П. 2 — не регрессия этого PR, но именно он делает старое ограничение опасным, так что закрыть его логичнее здесь же.

Ревью IngvarConsulting#326: обход стал корректным доказательством свежести, поэтому
тихие обрезания убраны, а его цена снижена и измерена.

- source_generation: без следования симлинкам, глубина 64 вместо 8,
  без потолка в 20 000 записей, имена вместо абсолютных путей,
  file_type из перечисления каталога вместо лишнего stat, фан-аут по
  верхнеуровневым детям (883 мс -> 438 мс на 43k файлов)
- WorkspaceIndexService мемоизирует обход на время запроса: три обхода
  на вызов вместо четырёх
- терминальный маркер пишет своё поколение и перестаёт блокировать
  перезапуск при изменении исходников
- обслуживание индекса получило собственный токен отмены
@zeegin

zeegin commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Положил в ветку PR коммит e1e89a67 с исправлениями по замечаниям выше (maintainerCanModify). Откатывается одним git revert, если что-то не по вкусу.

Что изменено

П. 1 — стоимость проверки. Обход упирается в метаданные ФС, поэтому работал по двум направлениям. file_type() из перечисления каталога вместо is_dir() плюс отдельного metadata() — было 2–3 syscall на запись; имена вместо path.display().to_string() на каждую из 43k записей; фан-аут по верхнеуровневым детям, где каждое поддерево хешируется независимым hasher-ом и сворачивается в отсортированном порядке.

Замер числа воркеров на том же корпусе: 1 → 883 мс, 2 → 541, 4 → 438, 8 → 496, 16 → 610. Дальше четырёх масштабирование отрицательное из-за контеншена на метаданных, поэтому предел зафиксирован на четырёх, а не на available_parallelism.

Плюс WorkspaceIndexService мемоизирует обход на время своего экземпляра: handle_rlm_ready больше не считает поколение дважды подряд для одного корня. Экземпляры создаются на запрос, так что мемо не переживает решение, ради которого взято.

Итого: 1.13 с → 0.40 с за обход и 4 → 3 обхода на вызов, порядка 4.5 с → 1.2 с на unica.code.definition для конфигурации в 43 299 файлов. Два обхода в handle_rlm_mcp не сворачиваются намеренно — в их независимости и состоит проверка границы.

П. 2 — тихие обрезания. Симлинки не обходятся, поэтому дерево не может зациклиться; глубина 64 вместо 8; потолок в 20 000 записей убран. Три регрессионных теста: правка ниже прежнего предела меняет поколение, симлинк-петля не ломает обход, фан-аут даёт стабильное значение.

П. 3 — терминальный маркер. Пишет поколение, к которому относится, и перестаёт блокировать автоматический перезапуск, когда исходники изменились. Маркер без поколения считается неблокирующим и даёт ровно одну попытку — иначе воркспейсы, уже застрявшие на старом маркере, остались бы застрявшими навсегда. Два новых теста; восемь существующих фикстур переведены на хелпер terminal_failure_for_source.

Мелочи. Обслуживание индекса получило собственный токен отмены, привязанный к жизни рантайма и отменяемый в begin_shutdown: токен запроса снимался с регистрации раньше, чем поток успевал отработать, так что begin_shutdown его уже не видел, а поздний cancel по завершившемуся чтению мог убить только что запланированное обновление. Плюс комментарий, объясняющий выбор pre_execution_generation на границе.

Дизайн-записка. Добавлен раздел «Стоимость проверки» с замерами, раздел про ограничение mtime+size (п. 4 — закрыть его полностью можно только хешированием содержимого, что несовместимо со стоимостью обхода), правила про симлинки и глубину, правило про освобождение терминального маркера. Обоснование альтернативы №2 переписано: цена обхода перенесена в Unica, а не устранена.

Проверка

cargo fmt --all -- --check, cargo clippy -p unica-coder --lib --tests -- -D warnings, pytest tests/ci/test_design_documents.py (8/8), check-architecture-sync.py --base main --strict (surface unchanged), git diff --check — чисто. cargo test -p unica-coder --lib — 1949 passed. --test issue_89_workspace_service -- --test-threads=1 — 2/2.

Локально в полном параллельном прогоне падают application::metadata::tests::preview_effects_follow_operation_order и meta_remove_reauthorizes_support_state_after_reference_and_subsystem_planning; по отдельности проходят обе, первая падает и на 50ac0f88 до этих правок. Предсуществующая гонка в слое application, к изменению отношения не имеет.

Что осталось незакрытым

Даже после этого проверка стоит порядка 1.2 с на вызов на конфигурации такого размера — это нижняя граница для модели «полный обход перед каждым чтением». Уйти ниже можно только сменой модели обнаружения изменений: подписка на события ФС либо переиспользование собственного сигнала инвалидации workspace-сервиса. Это отдельная работа за рамками issue; зафиксировал в разделе «Стоимость проверки».

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/unica-coder/src/infrastructure/source_roots.rs`:
- Around line 367-379: Update hash_source_file so any failure reading or
converting the modification timestamp—metadata.modified() or
duration_since(UNIX_EPOCH)—hashes UNREADABLE_ENTRY before returning. Preserve
hashing the file length and the existing seconds/nanoseconds timestamp values
when both operations succeed.
- Around line 603-625: Update the test source-generation fixture in
source_generation_is_stable_across_repeated_walks so all 64 generated
Module{index} directories are created directly under source_root rather than
beneath CommonModules. Keep the repeated source_generation calls and stability
assertion unchanged, ensuring source_root has enough immediate children for
child_digests to select the parallel fan-out path.
- Around line 360-365: Update is_source_file_name to normalize the extracted
extension to a consistent case before matching, so bsl, xml, yaml, and yml are
accepted regardless of capitalization while preserving the existing
supported-extension set.

In `@docs/design/2026-08-03-issue-286-rlm-source-generation-design.md`:
- Around line 59-65: Update the design’s post-execution validation flow for each
definition call to avoid two full source-tree traversals: retain the required
post-check, but reuse the existing workspace invalidation signal or another
bounded check for the second validation. Revise the related cost section to
document the resulting traversal count and timing, including the impact on RLM
lock duration.
- Around line 169-174: Revise the generation-fingerprint design to avoid
claiming unconditional freshness from filename, size, and coarse mtime alone.
Either use a stronger source-revision signal or explicitly narrow the freshness
guarantee, and update the readiness behavior for definition, search, and
meta.profile accordingly; add a regression test covering same-size edits within
one-second mtime granularity.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 642a1c2a-1c3b-4224-a669-f74f3261bbb5

📥 Commits

Reviewing files that changed from the base of the PR and between 6a7e42f and e1e89a6.

📒 Files selected for processing (4)
  • crates/unica-coder/src/infrastructure/source_roots.rs
  • crates/unica-coder/src/infrastructure/workspace_index.rs
  • crates/unica-coder/src/infrastructure/workspace_services.rs
  • docs/design/2026-08-03-issue-286-rlm-source-generation-design.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/unica-coder/src/infrastructure/workspace_services.rs
  • crates/unica-coder/src/infrastructure/workspace_index.rs

Comment on lines +360 to +365
fn is_source_file_name(name: &OsStr) -> bool {
Path::new(name)
.extension()
.and_then(|value| value.to_str())
.is_some_and(|extension| matches!(extension, "bsl" | "xml" | "yaml" | "yml"))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Match source extensions case-insensitively.

is_source_file_name compares the extension with a case-sensitive matches!. A file named Module.BSL or Form.XML therefore fails the filter and is dropped from the fingerprint. Edits to such a file never change the generation, which is the stale-read class this walk prevents. Windows-authored and tool-generated 1C exports can carry uppercase extensions.

🐛 Proposed fix for the extension filter
 fn is_source_file_name(name: &OsStr) -> bool {
     Path::new(name)
         .extension()
         .and_then(|value| value.to_str())
-        .is_some_and(|extension| matches!(extension, "bsl" | "xml" | "yaml" | "yml"))
+        .is_some_and(|extension| {
+            ["bsl", "xml", "yaml", "yml"]
+                .iter()
+                .any(|candidate| extension.eq_ignore_ascii_case(candidate))
+        })
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn is_source_file_name(name: &OsStr) -> bool {
Path::new(name)
.extension()
.and_then(|value| value.to_str())
.is_some_and(|extension| matches!(extension, "bsl" | "xml" | "yaml" | "yml"))
}
fn is_source_file_name(name: &OsStr) -> bool {
Path::new(name)
.extension()
.and_then(|value| value.to_str())
.is_some_and(|extension| {
["bsl", "xml", "yaml", "yml"]
.iter()
.any(|candidate| extension.eq_ignore_ascii_case(candidate))
})
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/unica-coder/src/infrastructure/source_roots.rs` around lines 360 -
365, Update is_source_file_name to normalize the extracted extension to a
consistent case before matching, so bsl, xml, yaml, and yml are accepted
regardless of capitalization while preserving the existing supported-extension
set.

Comment on lines +367 to +379
fn hash_source_file(hasher: &mut DefaultHasher, entry: &fs::DirEntry) {
let Ok(metadata) = entry.metadata() else {
UNREADABLE_ENTRY.hash(hasher);
return;
};
metadata.len().hash(hasher);
if let Ok(modified) = metadata.modified() {
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
duration.as_secs().hash(hasher);
duration.subsec_nanos().hash(hasher);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Emit UNREADABLE_ENTRY when the modification time cannot be read.

The constant at lines 216-218 states that the walk marks anything it knows exists but cannot read. hash_source_file does not follow that rule for the timestamp. If metadata.modified() fails, or duration_since(UNIX_EPOCH) fails, the file contributes only its length and no marker. Losing timestamp access then reads as "nothing changed" for every same-length edit to that file. modified() returns an error on platforms and filesystems that do not expose the field, so this is reachable.

🐛 Proposed fix for the unmarked timestamp failure
 fn hash_source_file(hasher: &mut DefaultHasher, entry: &fs::DirEntry) {
     let Ok(metadata) = entry.metadata() else {
         UNREADABLE_ENTRY.hash(hasher);
         return;
     };
     metadata.len().hash(hasher);
-    if let Ok(modified) = metadata.modified() {
-        if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
-            duration.as_secs().hash(hasher);
-            duration.subsec_nanos().hash(hasher);
-        }
-    }
+    match metadata
+        .modified()
+        .ok()
+        .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
+    {
+        Some(duration) => {
+            duration.as_secs().hash(hasher);
+            duration.subsec_nanos().hash(hasher);
+        }
+        None => UNREADABLE_ENTRY.hash(hasher),
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn hash_source_file(hasher: &mut DefaultHasher, entry: &fs::DirEntry) {
let Ok(metadata) = entry.metadata() else {
UNREADABLE_ENTRY.hash(hasher);
return;
};
metadata.len().hash(hasher);
if let Ok(modified) = metadata.modified() {
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
duration.as_secs().hash(hasher);
duration.subsec_nanos().hash(hasher);
}
}
}
fn hash_source_file(hasher: &mut DefaultHasher, entry: &fs::DirEntry) {
let Ok(metadata) = entry.metadata() else {
UNREADABLE_ENTRY.hash(hasher);
return;
};
metadata.len().hash(hasher);
match metadata
.modified()
.ok()
.and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
{
Some(duration) => {
duration.as_secs().hash(hasher);
duration.subsec_nanos().hash(hasher);
}
None => UNREADABLE_ENTRY.hash(hasher),
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/unica-coder/src/infrastructure/source_roots.rs` around lines 367 -
379, Update hash_source_file so any failure reading or converting the
modification timestamp—metadata.modified() or duration_since(UNIX_EPOCH)—hashes
UNREADABLE_ENTRY before returning. Preserve hashing the file length and the
existing seconds/nanoseconds timestamp values when both operations succeed.

Comment on lines +603 to +625
#[test]
fn source_generation_is_stable_across_repeated_walks() {
let context = fixture(&[("main", "CONFIGURATION", "src")]);
let source_root = context.workspace_root.join("src");
for index in 0..64 {
let module = source_root.join(format!("CommonModules/Module{index}/Ext/Module.bsl"));
fs::create_dir_all(module.parent().unwrap()).unwrap();
fs::write(
&module,
format!("Процедура Тест{index}()\nКонецПроцедуры\n"),
)
.unwrap();
}

let first = source_generation(&source_root);

assert_eq!(
source_generation(&source_root),
first,
"the fan-out walk must fold worker results back in a stable order"
);
cleanup(&context);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The determinism test does not reach the parallel path.

child_digests computes workers as the minimum of available_parallelism, children.len(), and MAX_SOURCE_WALK_WORKERS. children here means the children of the source root only. This fixture puts all 64 modules under one directory, CommonModules, so the root has about one child. workers is then 1 and child_digests returns through the sequential branch at lines 291-293. The assertion message at line 622 claims the fan-out walk is covered, but the scatter and gather code never runs.

Create the modules as direct children of source_root so the root fans out.

💚 Proposed fix to exercise the fan-out path
         for index in 0..64 {
-            let module = source_root.join(format!("CommonModules/Module{index}/Ext/Module.bsl"));
+            let module = source_root.join(format!("CommonModules{index}/Module/Ext/Module.bsl"));
             fs::create_dir_all(module.parent().unwrap()).unwrap();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[test]
fn source_generation_is_stable_across_repeated_walks() {
let context = fixture(&[("main", "CONFIGURATION", "src")]);
let source_root = context.workspace_root.join("src");
for index in 0..64 {
let module = source_root.join(format!("CommonModules/Module{index}/Ext/Module.bsl"));
fs::create_dir_all(module.parent().unwrap()).unwrap();
fs::write(
&module,
format!("Процедура Тест{index}()\nКонецПроцедуры\n"),
)
.unwrap();
}
let first = source_generation(&source_root);
assert_eq!(
source_generation(&source_root),
first,
"the fan-out walk must fold worker results back in a stable order"
);
cleanup(&context);
}
#[test]
fn source_generation_is_stable_across_repeated_walks() {
let context = fixture(&[("main", "CONFIGURATION", "src")]);
let source_root = context.workspace_root.join("src");
for index in 0..64 {
let module = source_root.join(format!("CommonModules{index}/Module/Ext/Module.bsl"));
fs::create_dir_all(module.parent().unwrap()).unwrap();
fs::write(
&module,
format!("Процедура Тест{index}()\nКонецПроцедуры\n"),
)
.unwrap();
}
let first = source_generation(&source_root);
assert_eq!(
source_generation(&source_root),
first,
"the fan-out walk must fold worker results back in a stable order"
);
cleanup(&context);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/unica-coder/src/infrastructure/source_roots.rs` around lines 603 -
625, Update the test source-generation fixture in
source_generation_is_stable_across_repeated_walks so all 64 generated
Module{index} directories are created directly under source_root rather than
beneath CommonModules. Keep the repeated source_generation calls and stability
assertion unchanged, ensuring source_root has enough immediate children for
child_digests to select the parallel fan-out path.

Comment on lines +59 to +65
внутренние настройки закреплённой версии поставщика, а ограниченная выборка не
доказывает свежесть каждого файла большого проекта.

Полный обход перед каждым чтением остаётся и в выбранном подходе — разница в
том, что политику обхода задаёт Unica, а не закреплённая версия поставщика. Цена
обхода не устранена, а перенесена внутрь; она измерена в разделе «Стоимость
проверки».

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Avoid two full post-execution traversals per definition call.

The design records about 1.3 seconds per call for a 43,299-file workspace and keeps the RLM lock across both checks. The PR objective requires the post-check, but it also requires avoiding the second full source-tree traversal. Reuse the existing workspace invalidation signal or another bounded check, then update this cost section.

Also applies to: 122-141

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/design/2026-08-03-issue-286-rlm-source-generation-design.md` around
lines 59 - 65, Update the design’s post-execution validation flow for each
definition call to avoid two full source-tree traversals: retain the required
post-check, but reuse the existing workspace invalidation signal or another
bounded check for the second validation. Revise the related cost section to
document the resulting traversal count and timing, including the impact on RLM
lock duration.

Comment on lines +169 to +174
Поколение строится из имени, размера и времени изменения. Правка, не меняющая
размер файла, на файловой системе с посекундной гранулярностью `mtime` (сетевые
монтирования, ext3, часть CI-образов) в пределах одной секунды не изменит
поколение. На APFS и NTFS с наносекундным `mtime` ограничение не проявляется.
Закрыть его полностью можно только хешированием содержимого, что несовместимо со
стоимостью обхода из раздела выше.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not ship an unconditional freshness guarantee with this fingerprint.

On filesystems with one-second mtime granularity, a same-size BSL edit within one second produces the same generation. The readiness check can then accept an old RLM result, which conflicts with the objective that definition, search, and meta.profile reflect the current source revision. Use a stronger signal or narrow the guarantee and add a regression test for this case.

🧰 Tools
🪛 LanguageTool

[uncategorized] ~170-~170: Прилагательное не согласуется с существительным по роду.
Context: ...е с посекундной гранулярностью mtime (сетевые монтирования, ext3, часть CI-образов) в пределах одн...

(Unify_Adj_NN_gender)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/design/2026-08-03-issue-286-rlm-source-generation-design.md` around
lines 169 - 174, Revise the generation-fingerprint design to avoid claiming
unconditional freshness from filename, size, and coarse mtime alone. Either use
a stronger source-revision signal or explicitly narrow the freshness guarantee,
and update the readiness behavior for definition, search, and meta.profile
accordingly; add a regression test covering same-size edits within one-second
mtime granularity.

Страж ADR-0009 запрещает cfg(unix) и std::os вне platform-фасада.
create_dir_symlink_for_test уже покрывает обе ОС, поэтому тест ещё и
перестал быть unix-only: на Windows без привилегии он возвращает None и
тест штатно выходит.
@zeegin
zeegin merged commit e4db7e2 into IngvarConsulting:main Aug 7, 2026
19 checks passed
@zeegin zeegin added this to the v0.12 milestone Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: code outline and definition can read stale BSL index

2 participants