From 55f3d63d7d7e01d799d9cf2125c7a3705e02e0cf Mon Sep 17 00:00:00 2001 From: fredericrous Date: Thu, 29 Oct 2026 10:27:47 +0100 Subject: [PATCH] feat(sweep): stop language servers with their tree fleet-lsp starts one language server per worktree and nothing stopped it when the worktree went away: on 2026-10-29 twelve rust-analyzers of deleted worktrees had swap at 20 of 21.5 GB. worktree-sweep now stops the fleet-lsp children rooted in a worktree it removes, and every run lists (with --apply, stops) the ones whose working directory no longer exists. --lsp does only that, without forge lookups; worktree-task's teardown and abandon paths call it. Co-Authored-By: Claude Opus 5.5 --- .../skills/worktree-sweep/SKILL.md.tmpl | 13 ++ dot_claude/skills/worktree-task/SKILL.md | 7 +- .../worktree-sweep/executable_sweep.py.tmpl | 142 +++++++++++++++++- 3 files changed, 159 insertions(+), 3 deletions(-) diff --git a/dot_claude/skills/worktree-sweep/SKILL.md.tmpl b/dot_claude/skills/worktree-sweep/SKILL.md.tmpl index f9eacdc..83e4d1f 100644 --- a/dot_claude/skills/worktree-sweep/SKILL.md.tmpl +++ b/dot_claude/skills/worktree-sweep/SKILL.md.tmpl @@ -14,8 +14,21 @@ python3 ~/.claude/tools/worktree-sweep/sweep.py # audit, change nothi python3 ~/.claude/tools/worktree-sweep/sweep.py --apply # audit, then remove SWEEP-verdict worktrees python3 ~/.claude/tools/worktree-sweep/sweep.py --repo trade-agents --apply python3 ~/.claude/tools/worktree-sweep/sweep.py --json # machine-readable +python3 ~/.claude/tools/worktree-sweep/sweep.py --lsp --apply # only stop language servers of deleted checkouts ``` +**Language servers go with their checkout.** fleet-lsp starts one server per +worktree it is asked about (rust-analyzer, gopls, pyright, the TypeScript +server under node), and nothing stopped them when the worktree went away: on +2026-10-29, twelve rust-analyzers of deleted worktrees had swap at 20 of +21.5 GB. Now a removal stops the servers rooted in that worktree, and every +run lists — with `--apply`, stops — the servers whose working directory no +longer exists (`ORPHAN-LSP` lines; `LSP-STOPPED` in the log). Only direct +children of a `fleet-lsp` process are touched, never an editor's own; a +stopped server comes back if that checkout is asked about again. `--lsp` does +only this, without forge lookups, for right after a `git worktree remove` by +hand. + Removals are automatic under `--apply` — no per-worktree confirmation. That is deliberate: every gate below must pass first, and a swept worktree is one `git worktree add` from being recreated. Every removal is appended to diff --git a/dot_claude/skills/worktree-task/SKILL.md b/dot_claude/skills/worktree-task/SKILL.md index 08ef8bd..b7ffad9 100644 --- a/dot_claude/skills/worktree-task/SKILL.md +++ b/dot_claude/skills/worktree-task/SKILL.md @@ -381,8 +381,10 @@ below names one of them. cd && python3 ~/.claude/tools/worktree-sweep/sweep.py --repo --apply ``` It removes only worktrees that are clean, fully pushed, merged and - contained in a release tag, and leaves anything it cannot verify. See - the `worktree-sweep` skill. + contained in a release tag, and leaves anything it cannot verify. It + also stops the language servers (rust-analyzer, gopls, pyright, the + TypeScript server) of every checkout that no longer exists, the one + removed in step 1 included. See the `worktree-sweep` skill. 3. In the report, say what was removed and what the sweep kept, one line each. @@ -398,6 +400,7 @@ cd git status --short # sanity check what's about to be discarded, from the worktree path git worktree remove --force ../-wt- git branch -D # only if the branch was never pushed / isn't needed +python3 ~/.claude/tools/worktree-sweep/sweep.py --lsp --apply # stop its language servers ``` Confirm with the user before this mode if the worktree has any uncommitted work — this is the one destructive path in this skill. diff --git a/dot_claude/tools/worktree-sweep/executable_sweep.py.tmpl b/dot_claude/tools/worktree-sweep/executable_sweep.py.tmpl index 95b99d7..8ec4164 100644 --- a/dot_claude/tools/worktree-sweep/executable_sweep.py.tmpl +++ b/dot_claude/tools/worktree-sweep/executable_sweep.py.tmpl @@ -771,6 +771,126 @@ def log(msg: str): fh.write(f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {msg}\n") +# -------------------------------------------------------------------------- +# language servers (fleet-lsp's children) +# -------------------------------------------------------------------------- +# +# Claude Code's LSP tool runs `fleet-lsp serve ` once per session, +# and fleet-lsp starts one server per repository or worktree it is asked +# about (rust-analyzer, gopls, pyright, typescript-language-server), with +# that checkout as its working directory. Nothing stops a server when its +# checkout goes away: on 2026-10-29 twelve rust-analyzers of deleted +# worktrees held enough memory that swap was 20 of 21.5 GB. So a removal +# also stops the servers rooted in what it removed, and every sweep stops +# servers whose working directory no longer exists. Only direct children of +# a `fleet-lsp` process are touched: an editor's own servers are not ours. + +LSP_PARENT = "fleet-lsp" +# How long a SIGTERMed server gets to exit before SIGKILL. +LSP_GRACE_SECONDS = 3.0 + + +@dataclass +class LangServer: + pid: int + command: str + cwd: Path | None + + +def _process_table() -> list[tuple[int, int, str]]: + ok, out = run(["ps", "-Ao", "pid=,ppid=,comm="], timeout=30) + rows = [] + if not ok: + return rows + for line in out.splitlines(): + parts = line.split(None, 2) + if len(parts) == 3 and parts[0].isdigit() and parts[1].isdigit(): + rows.append((int(parts[0]), int(parts[1]), parts[2].strip())) + return rows + + +def language_servers() -> list[LangServer]: + """fleet-lsp's servers, each with its working directory (None when it + could not be read).""" + table = _process_table() + parents = {pid for pid, _, comm in table if Path(comm).name == LSP_PARENT} + servers = [(pid, Path(comm).name) for pid, ppid, comm in table if ppid in parents] + if not servers: + return [] + cwds: dict[int, Path] = {} + ok, out = run(["lsof", "-a", "-d", "cwd", "-Fpn", "-p", ",".join(str(p) for p, _ in servers)], + timeout=30) + current = None + for line in out.splitlines() if out else []: + if line.startswith("p") and line[1:].isdigit(): + current = int(line[1:]) + elif line.startswith("n") and current is not None: + cwds[current] = Path(line[1:]) + return [LangServer(pid, command, cwds.get(pid)) for pid, command in servers] + + +def _descendants(pid: int, table: list[tuple[int, int, str]]) -> list[int]: + out, frontier = [], [pid] + while frontier: + nxt = [child for child, ppid, _ in table if ppid in frontier] + out.extend(nxt) + frontier = nxt + return out + + +def stop_server(server: LangServer, why: str) -> bool: + """SIGTERM the server and what it started (tsserver runs under node), then + SIGKILL whatever is still there after LSP_GRACE_SECONDS. fleet-lsp starts + a fresh one if that checkout is ever asked about again.""" + import signal + tree = [server.pid] + _descendants(server.pid, _process_table()) + for pid in tree: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + except PermissionError: + return False + deadline = time.time() + LSP_GRACE_SECONDS + while time.time() < deadline and any(pid_alive(p) for p in tree): + time.sleep(0.1) + for pid in tree: + if pid_alive(pid): + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + log(f"LSP-STOPPED pid={server.pid} {server.command} cwd={server.cwd} ({why})") + return True + + +def _within(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + return True + except (ValueError, OSError): + return False + + +def orphan_servers(servers: list[LangServer]) -> list[LangServer]: + """Servers whose working directory no longer exists.""" + return [s for s in servers if s.cwd is not None and not s.cwd.exists()] + + +def report_orphan_servers(apply: bool): + orphans = orphan_servers(language_servers()) + if not orphans: + return + print() + for s in orphans: + print(f"ORPHAN-LSP pid {s.pid:<7} {s.command:<26} {s.cwd}") + if not apply: + print(f"{len(orphans)} language server(s) of deleted checkouts — re-run with --apply to stop them.") + return + stopped = sum(stop_server(s, "checkout gone") for s in orphans) + print(f"Stopped {stopped} language server(s) of deleted checkouts.") + + def remove(wt: Worktree, prune_branch: bool = False) -> tuple[bool, str]: """Remove a SWEEP worktree. Never --force: an unexpected untracked file must abort the removal, not be bulldozed.""" @@ -789,9 +909,14 @@ def remove(wt: Worktree, prune_branch: bool = False) -> tuple[bool, str]: return False, f"could not discard edits to {', '.join(wt.drop)}" log(f"DROPPED {wt.path} uncommitted {' '.join(wt.drop)}") + # Read before removal: once the directory is gone, a server's cwd can no + # longer be resolved against it. + rooted = [s for s in language_servers() if s.cwd is not None and _within(s.cwd, wt.path)] ok, out = run(["git", "-C", str(wt.primary), "worktree", "remove", str(wt.path)]) if not ok: return False, "git worktree remove refused" + for s in rooted: + stop_server(s, f"worktree {wt.path} removed") detail = f"released={wt.release}" if wt.release else "no-release-tag-gate" if wt.pushed == "" and wt.reason.startswith("HEAD on origin/"): detail = "content-gate" @@ -1178,9 +1303,12 @@ def report(repos: list[Repo], apply: bool): print() if not sweepable: print("Nothing to sweep.") + report_orphan_servers(apply) return 0 if not apply: - print(f"{len(sweepable)} sweepable, {human(total)} — re-run with --apply to remove.") + print(f"{len(sweepable)} sweepable, {human(total)} — re-run with --apply to remove " + f"(and stop their language servers).") + report_orphan_servers(apply) return 0 freed = 0 @@ -1194,6 +1322,7 @@ def report(repos: list[Repo], apply: bool): # --nudge reads that file. A stale cache here nags about work already done. save_state(repos) print(f"\nFreed {human(freed)}. Log: {LOG_FILE}") + report_orphan_servers(apply) return 0 @@ -1340,6 +1469,9 @@ def main(): "in worktrees idle >= --deps-days; source is never touched") ap.add_argument("--deps-days", type=int, default=PRUNE_DEPS_DAYS, help=f"idle threshold for --prune-deps (default {PRUNE_DEPS_DAYS})") + ap.add_argument("--lsp", action="store_true", + help="only list (with --apply, stop) fleet-lsp language servers whose " + "checkout no longer exists; no forge lookups") args = ap.parse_args() global PRUNE_BRANCHES, START_CWD, REPO_SCOPE @@ -1349,6 +1481,14 @@ def main(): if args.nudge: return nudge() + if args.lsp: + orphans = orphan_servers(language_servers()) + if not orphans: + print("No language servers of deleted checkouts.") + return 0 + report_orphan_servers(args.apply) + return 0 + if not shutil.which("git"): print("git not found", file=sys.stderr) return 1