From 0994ea04c869939946b91cbe52ceaf46740786be Mon Sep 17 00:00:00 2001 From: Eyal Mizrachi <145741922+Eyalm321@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:31:00 -0400 Subject: [PATCH] fix(wrap): skip Serena project setup outside real project roots (#2574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `headroom wrap` runs two per-project Serena steps against the cwd: `_scope_serena_languages()` (detect languages, pin them into `.serena/project.yml`) and `_index_serena_project()` (`serena project index`, to warm the symbol cache). Both assume the cwd *is* a project. Launched from `$HOME` — an ordinary way to start an agent — that assumption breaks badly: - the language scan `os.walk`s the entire home directory: `Downloads/`, VM images, backup trees, network mounts; - the pre-index then runs `serena project index` over the same tree and sits there until its full 300s timeout; - so the agent appears to **hang for minutes on every launch**, with no output after the Serena MCP registration line and nothing to suggest indexing is what's blocking; - and the scan writes `project.yml` into `~/.serena`, which is Serena's own config directory rather than a project's `.serena/`. A linked git worktree hits the same code from the other side: it's an ephemeral checkout, so it pays for a full cold index at a path that soon disappears — once per worktree, which adds up under any fan-out workflow. ## Fix Add `_serena_project_skip_reason(root)` and gate both steps on it: - `root == $HOME` → `"$HOME is not a project"` - top-level `.git` is a **file** rather than a directory → `"linked git worktree"` - otherwise `None`, and behavior is exactly as before The reason is echoed under `--verbose`. Nothing else changes: Serena MCP is still registered, instructions are still injected, and in the skipped cases Serena still indexes lazily on demand — so no capability is lost, only the wasted upfront scan. ## Testing Five unit tests in `tests/test_cli/test_wrap_serena_boost.py` covering an ordinary directory, a normal checkout (`.git` dir), `$HOME`, a linked worktree (`.git` file), and a non-existent root. Full file: 22 passed. `ruff format --check` and `ruff check` clean. Verified manually on the reported case: `claude` launched from `$HOME` now starts immediately instead of stalling on the index. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) --- headroom/cli/wrap.py | 26 ++++++++++++++++++++ tests/test_cli/test_wrap_serena_boost.py | 31 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 06de0cc87..8c0f33b2a 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -1840,6 +1840,27 @@ def _scope_serena_languages(*, verbose: bool = False) -> None: click.echo(f" Serena: could not scope languages ({e})") +def _serena_project_skip_reason(root: Path) -> str | None: + """Why Serena's per-project setup must not run for *root* (None = proceed). + + ``$HOME`` is never a project: scanning it walks every unrelated tree + (Downloads, VM images, network mounts) and would write ``project.yml`` into + Serena's own ``~/.serena`` config directory. A linked git worktree (its + top-level ``.git`` is a file, not a directory) is an ephemeral checkout that + would pay for its own index at a path that soon disappears. + """ + try: + resolved = root.resolve() + home = Path.home().resolve() + except OSError: + return None + if resolved == home: + return "$HOME is not a project" + if (resolved / ".git").is_file(): + return "linked git worktree" + return None + + def _index_serena_project(*, verbose: bool = False) -> None: """Warm Serena's symbol cache for the current project (non-fatal). @@ -1947,6 +1968,11 @@ def _setup_serena_mcp( # ``serena project index`` respects the scope. Each step is best-effort and # non-fatal — none of them block the wrap. _inject_serena_instructions(_serena_instruction_file(registrar), verbose=verbose) + skip_reason = _serena_project_skip_reason(Path.cwd()) + if skip_reason is not None: + if verbose: + click.echo(f" Serena: skipping language scope + pre-index ({skip_reason})") + return _scope_serena_languages(verbose=verbose) _index_serena_project(verbose=verbose) diff --git a/tests/test_cli/test_wrap_serena_boost.py b/tests/test_cli/test_wrap_serena_boost.py index 49dc7fe09..c5ca1d9c7 100644 --- a/tests/test_cli/test_wrap_serena_boost.py +++ b/tests/test_cli/test_wrap_serena_boost.py @@ -258,3 +258,34 @@ def test_preindex_generic_error_is_non_fatal(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(wrap_cli, "run", Mock(side_effect=RuntimeError("boom"))) # Must not propagate. wrap_cli._index_serena_project(verbose=True) + + +# --------------------------------------------------------------------------- +# _serena_project_skip_reason — keep per-project setup off non-project roots +# --------------------------------------------------------------------------- + + +def test_skip_reason_none_for_ordinary_project(tmp_path: Path) -> None: + assert wrap_cli._serena_project_skip_reason(tmp_path) is None + + +def test_skip_reason_none_for_normal_checkout(tmp_path: Path) -> None: + (tmp_path / ".git").mkdir() # real checkout: .git is a directory + + assert wrap_cli._serena_project_skip_reason(tmp_path) is None + + +def test_skip_reason_flags_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + assert wrap_cli._serena_project_skip_reason(tmp_path) == "$HOME is not a project" + + +def test_skip_reason_flags_linked_worktree(tmp_path: Path) -> None: + (tmp_path / ".git").write_text("gitdir: /repo/.git/worktrees/wt\n") + + assert wrap_cli._serena_project_skip_reason(tmp_path) == "linked git worktree" + + +def test_skip_reason_survives_unresolvable_root(tmp_path: Path) -> None: + assert wrap_cli._serena_project_skip_reason(tmp_path / "gone") is None