mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(wrap): skip Serena project setup outside real project roots (#2574)
## 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) <noreply@anthropic.com>
This commit is contained in:
parent
aebe19539f
commit
0994ea04c8
2 changed files with 57 additions and 0 deletions
|
|
@ -1840,6 +1840,27 @@ def _scope_serena_languages(*, verbose: bool = False) -> None:
|
||||||
click.echo(f" Serena: could not scope languages ({e})")
|
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:
|
def _index_serena_project(*, verbose: bool = False) -> None:
|
||||||
"""Warm Serena's symbol cache for the current project (non-fatal).
|
"""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
|
# ``serena project index`` respects the scope. Each step is best-effort and
|
||||||
# non-fatal — none of them block the wrap.
|
# non-fatal — none of them block the wrap.
|
||||||
_inject_serena_instructions(_serena_instruction_file(registrar), verbose=verbose)
|
_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)
|
_scope_serena_languages(verbose=verbose)
|
||||||
_index_serena_project(verbose=verbose)
|
_index_serena_project(verbose=verbose)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")))
|
monkeypatch.setattr(wrap_cli, "run", Mock(side_effect=RuntimeError("boom")))
|
||||||
# Must not propagate.
|
# Must not propagate.
|
||||||
wrap_cli._index_serena_project(verbose=True)
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue