mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
fix(projects): don't promote a deleted workspace to a project
The name-based sibling probe can't reach every dead worktree: a dir renamed away from its repo's prefix (`hermes-salvage-drafts` next to `hermes-agent`) shares nothing to trim back to, and a scratch dir under /tmp was never a worktree at all. Those fall through to the path-only heuristic, which reports the cwd as its own repo root, and Tier 2 promotes it to a top-level project — one that can't be opened and can only be dismissed by hand. Gate auto-project promotion on the directory still existing, threaded in as an injected predicate to keep the builder pure. The guard keys on the directory, not on git-ness, so a plain non-git folder that's still on disk keeps its project; callers that can't stat (remote backends) omit it and keep every candidate. A stale persisted `git_repo_root` gets the same treatment, so a deleted repo can't resurrect from the recorded value alone.
This commit is contained in:
parent
c7fcb73e3d
commit
b572ec3dda
3 changed files with 95 additions and 4 deletions
|
|
@ -505,6 +505,54 @@ def test_deleted_sibling_worktree_subdir_folds_into_parent_home_checkout():
|
|||
assert len(project["repos"][0]["groups"][0]["sessions"]) == 2
|
||||
|
||||
|
||||
def test_deleted_unrelated_workspace_does_not_become_a_project():
|
||||
# A deleted dir the sibling probe can't reach by name (`hermes-salvage-drafts`
|
||||
# shares no prefix with `hermes-agent`; `/tmp/scratch` was never a worktree)
|
||||
# must not be promoted to a phantom project — it can never be opened and can
|
||||
# only be dismissed by hand. The session stays in flat Recents.
|
||||
resolve = _resolver({"/www/hermes-agent": ("/www/hermes-agent", "/www/hermes-agent")})
|
||||
sessions = [
|
||||
_session("/www/hermes-agent", branch="main"),
|
||||
_session("/www/hermes-salvage-drafts/apps/desktop"),
|
||||
_session("/tmp/scratch"),
|
||||
]
|
||||
on_disk = {"/www/hermes-agent"}
|
||||
|
||||
tree = pt.build_tree([], sessions, [], resolve, hydrate=True, exists=lambda p: p in on_disk)
|
||||
|
||||
assert [p["id"] for p in tree["projects"]] == ["/www/hermes-agent"]
|
||||
|
||||
|
||||
def test_existing_non_git_workspace_still_becomes_a_project():
|
||||
# The existence guard keys on the DIRECTORY, not on git-ness: a plain folder
|
||||
# that's still on disk is a legitimate workspace and must keep its project.
|
||||
sessions = [_session("/www/notes")]
|
||||
|
||||
tree = pt.build_tree([], sessions, [], lambda _cwd: None, hydrate=True, exists=lambda _p: True)
|
||||
|
||||
assert [p["id"] for p in tree["projects"]] == ["/www/notes"]
|
||||
|
||||
|
||||
def test_stale_persisted_repo_root_does_not_become_a_project():
|
||||
# A session carrying a git_repo_root whose repo has since been deleted must
|
||||
# not resurrect it as a project on the strength of the persisted value alone.
|
||||
sessions = [_session("/tmp/gone/sub", repo_root="/tmp/gone")]
|
||||
|
||||
tree = pt.build_tree([], sessions, [], lambda _cwd: None, hydrate=True, exists=lambda _p: False)
|
||||
|
||||
assert tree["projects"] == []
|
||||
|
||||
|
||||
def test_exists_defaults_to_keeping_everything():
|
||||
# Omitting `exists` (remote backends, which can't stat) preserves the old
|
||||
# behavior: guessing "gone" would wrongly hide a project on the other host.
|
||||
sessions = [_session("/remote/workspace")]
|
||||
|
||||
tree = pt.build_tree([], sessions, [], lambda _cwd: None, hydrate=True)
|
||||
|
||||
assert [p["id"] for p in tree["projects"]] == ["/remote/workspace"]
|
||||
|
||||
|
||||
def test_sibling_probe_is_bounded():
|
||||
# Each miss costs a git invocation, and this runs per session, so a deeply
|
||||
# nested unresolvable cwd must not fan out into an unbounded probe storm.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@ from typing import Any, Callable, Optional
|
|||
# cwd is not in a git repo (or cannot be probed, e.g. a remote backend).
|
||||
Resolve = Callable[[str], Optional[dict]]
|
||||
|
||||
# A "does this directory still exist?" predicate, injected for the same reason
|
||||
# ``Resolve`` is: the builder stays pure and unit-testable. Defaults to assuming
|
||||
# everything exists, which preserves the previous behavior for callers that
|
||||
# can't stat (remote backends), where guessing "gone" would wrongly hide a
|
||||
# project that lives on the other host.
|
||||
Exists = Callable[[str], bool]
|
||||
|
||||
# Only KANBAN-TASK worktrees (`<repo>/.worktrees/t_<hex>`, the `t_…` id kanban_db
|
||||
# mints) collapse into one lane; user-named "New worktree" dirs under
|
||||
# `.worktrees/` stay as their own lanes.
|
||||
|
|
@ -526,6 +533,7 @@ def build_tree(
|
|||
hydrate: bool = False,
|
||||
is_junk_root: Optional[Callable[[str], bool]] = None,
|
||||
is_junk_cwd: Optional[Callable[[str], bool]] = None,
|
||||
exists: Optional[Exists] = None,
|
||||
) -> dict:
|
||||
"""Build the authoritative project tree.
|
||||
|
||||
|
|
@ -537,7 +545,10 @@ def build_tree(
|
|||
bare home dir, the HERMES_HOME subtree). ``is_junk_cwd`` is the narrower
|
||||
policy for non-git session folders: selected descendants may be intentional
|
||||
workspaces even when their parent tree contains Hermes state. User-created
|
||||
projects are honored regardless.
|
||||
projects are honored regardless. ``exists`` reports whether a directory is
|
||||
still on disk, so a session whose workspace was DELETED (a removed worktree,
|
||||
a scratch dir under /tmp) doesn't get promoted to a phantom AUTO project;
|
||||
omit it (remote backends) to keep every candidate.
|
||||
|
||||
Returns ``{"projects": [...], "scoped_session_ids": [...]}``. When
|
||||
``hydrate`` is False (overview), lane ``sessions`` arrays are emptied but
|
||||
|
|
@ -547,6 +558,7 @@ def build_tree(
|
|||
active_projects = [p for p in projects if not p.get("archived")]
|
||||
_junk = is_junk_root or (lambda _root: False)
|
||||
_junk_cwd = is_junk_cwd or (lambda _cwd: False)
|
||||
_exists = exists or (lambda _path: True)
|
||||
folder_index = _FolderIndex(active_projects)
|
||||
|
||||
by_project: dict[str, list[dict]] = {}
|
||||
|
|
@ -609,8 +621,10 @@ def build_tree(
|
|||
root = _session_repo_root(session, resolve)
|
||||
if root:
|
||||
# A real git root uses the stricter repo policy. Do not reinterpret a
|
||||
# filtered internal repo as a cwd-only project.
|
||||
if not _junk(root):
|
||||
# filtered internal repo as a cwd-only project. A root that no longer
|
||||
# exists is a stale persisted value (the repo was deleted after the
|
||||
# session ran) and must not resurrect as a project.
|
||||
if not _junk(root) and _exists(root):
|
||||
_add_auto(root, session)
|
||||
continue
|
||||
|
||||
|
|
@ -623,7 +637,13 @@ def build_tree(
|
|||
resolve,
|
||||
(session.get("git_repo_root") or "").strip(),
|
||||
)
|
||||
if placement:
|
||||
# A placement that only echoes back the unresolvable cwd is the
|
||||
# path-only heuristic guessing — it never found a repo. When that dir is
|
||||
# also gone from disk (a deleted worktree whose name shares no prefix
|
||||
# with its parent, a removed /tmp scratch dir), promoting it mints a
|
||||
# phantom project that can never be opened and can only be dismissed by
|
||||
# hand. The session keeps its place in flat Recents.
|
||||
if placement and _exists(placement["repo_key"]):
|
||||
_add_auto(placement["repo_key"], session)
|
||||
|
||||
seen: set[str] = set()
|
||||
|
|
|
|||
|
|
@ -13888,12 +13888,34 @@ def _project_tree_inputs(
|
|||
return sessions, projects, discovered, active_id
|
||||
|
||||
|
||||
# Per-build memo for `_dir_exists_cached`. Cleared at the top of every
|
||||
# `_build_project_tree`, so a dir created or deleted between sidebar refreshes
|
||||
# is seen on the next one.
|
||||
_DIR_EXISTS_CACHE: dict[str, bool] = {}
|
||||
|
||||
|
||||
def _dir_exists_cached(path: str) -> bool:
|
||||
"""``os.path.isdir`` for the project tree, memoized per build.
|
||||
|
||||
``build_tree`` asks per SESSION, not per distinct path, so a power user with
|
||||
hundreds of sessions across a handful of dirs would otherwise fire hundreds
|
||||
of redundant stats on every sidebar open. The memo is per build, so a dir
|
||||
created or deleted between refreshes is picked up on the next one.
|
||||
"""
|
||||
hit = _DIR_EXISTS_CACHE.get(path)
|
||||
if hit is None:
|
||||
hit = os.path.isdir(path)
|
||||
_DIR_EXISTS_CACHE[path] = hit
|
||||
return hit
|
||||
|
||||
|
||||
def _build_project_tree(
|
||||
db, *, preview_limit: int, hydrate: bool, session_limit: int, include_discovered: bool
|
||||
) -> tuple[dict, str | None]:
|
||||
"""Gather inputs and run the one authoritative builder. Returns (tree, active_id)."""
|
||||
from tui_gateway import project_tree
|
||||
|
||||
_DIR_EXISTS_CACHE.clear()
|
||||
sessions, projects, discovered, active_id = _project_tree_inputs(
|
||||
db, session_limit, include_discovered=include_discovered
|
||||
)
|
||||
|
|
@ -13906,6 +13928,7 @@ def _build_project_tree(
|
|||
hydrate=hydrate,
|
||||
is_junk_root=_is_repo_junk,
|
||||
is_junk_cwd=_is_session_cwd_junk,
|
||||
exists=_dir_exists_cached,
|
||||
)
|
||||
return tree, active_id
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue