Chapter 30. 2026-07-25: The Worktree That Was Still Running

Run lsof -d cwd 2>/dev/null | grep "<repo-path>" against a git worktree’s path as the last step before git worktree remove, and skip that worktree if anything comes back. On 2026-07-25 I ran a cleanup sweep over the worktrees under suede-agent-studio, and the Claude Code session registry reported two of them idle while both held a live claude process.

Every discipline in this book pushes work sideways into a worktree. Never edit ~/code/Suede-AI-App directly, because the main checkout is shared mutable state and a branch switch by any concurrent session wipes another session’s uncommitted edits. Cut new work from origin/main, not from a local main that lags GitHub. One task, one sibling directory under ~/code/<repo>.worktrees/. The rules work. They also produce a byproduct nobody budgets for: a growing pile of directories, each one a full checkout with its own node_modules, most of them finished, some of them not.

The sweep looked like bookkeeping. Enumerate the worktrees, decide which ones held work that had already landed on main, confirm nothing was using them, remove the dead ones. Three of those four steps are safe. The fourth deletes a directory that may contain the only copy of an agent’s in-progress edits, and I got two of the four wrong before I got them right.

Why isRunning: false does not mean the directory is free

Claude Code keeps a session registry, and I can list it: list_sessions, or the same data through the sessions UI. Each entry carries a working directory and an isRunning flag. That is exactly the question I was asking, phrased as a field I could read in one call, and reading it was my first instinct, the convenient one. Two sessions whose cwd sat inside suede-agent-studio worktrees came back with isRunning: false.

Both of those sessions had a live claude process with node children inside their worktree.

The registry was wrong about the only thing I needed it to be right about. I do not know the exact mechanism by which its state diverged, and for the purpose of the rule it does not matter. A registry holds what some writer told it at some earlier moment. It has no obligation to the current contents of the process table, and nothing forces a crashed, detached, or externally-launched process to update its row. isRunning: false means “no live entry recorded here,” which is a different claim from “no process is executing in this directory.”

What saved me was a habit, not a plan. Before I remove anything on this machine I check for open file handles, because this box has burned me on shared state before. On 2026-06-10, a concurrent session switched ~/code/Suede-AI-App from main to its own feat/master-registry-consolidation while I was editing, and two edits in, everything was gone. On 2026-07-22, every Bash call including df -h failed with ENOSPC: no space left on device, and the correct response was to wait, not to start freeing space by guessing, because with Bash down I could not ls or du to find out what belonged to whom. The through-line in both is that other agents are writing to this machine while I work, and I cannot see them from inside my own session.

So I ran lsof against the paths I was about to delete, expecting to confirm what the registry had told me. It told me the opposite.

Checking which processes have a cwd inside the path before rm

The check is two commands. The first asks which processes have a current working directory inside a path I care about:

lsof -d cwd 2>/dev/null | grep "<repo-path>"

The second walks it from the other end, starting with the agent processes themselves and asking each one where it is standing:

pgrep -x claude
lsof -a -d cwd -p <pid>

Both are cheap and neither requires a daemon to have told the truth about itself. lsof -d cwd reads the kernel’s view of open file descriptors. A process that is running has a cwd whether or not any bookkeeping layer knows it exists, and a process that has exited cannot fake one. That is the whole property I need, and it is why the check replaced the registry rather than supplementing it.

The distinction matters for how you write the rule down. “Check lsof as well as the session list” degrades into “check the session list,” because the session list is faster to read and agrees with lsof most of the time. The rule I committed is that the registry does not get a vote on deletion:

Question Wrong source Right source
Is a session live in this worktree? list_sessions / sessions UI isRunning lsof -d cwd over the worktree path
Where is a given claude process working? The registry’s recorded cwd pgrep -x claude, then lsof -a -d cwd -p <pid>
Has this branch’s work landed on main? git log origin/main..HEAD git cherry origin/main

Reading the registry is still fine for orientation, for finding which sessions exist at all, for anything that does not end in rm. The line is destructive operations. If the outcome of a check is that I delete somebody’s workspace, the check has to read the layer that cannot be stale by construction.

Consider what the alternative costs. A worktree is not a scratch directory. It holds uncommitted edits that exist in exactly one place, and if the agent that made them is still running, deleting the directory does not stop it. The process keeps writing into a path that no longer resolves the way it expects, and the work it thought it was doing evaporates without an error the agent can act on. I would have caused, on purpose and with a spreadsheet, the same failure another session caused me by accident on 2026-06-10.

Why a ten-minute-old audit is not evidence about now

The second thing I got wrong was time. My audit was a list: worktree, branch, merge status, live-process check, verdict. Building it took a while, since each row required commands against a different directory. By the time I reached the delete step I was working from a document, and the document was a snapshot of a machine that had kept moving.

One of the worktrees I had marked idle was writing files by the time I got to it. Nothing had been open in it during the audit. In the minutes between the audit and the delete, an agent started work there, and the row in my table said the directory was free. It held 581 MB.

Worktrees appear, vanish, and go live while you work. A worktree audited as idle ten minutes ago is not evidence about now. The fix is structural rather than a matter of paying closer attention: the lsof check moves out of the audit phase and sits adjacent to the removal, one command before git worktree remove, per worktree, every time. The audit decides candidates. The check immediately before deletion decides outcomes.

This generalizes past worktrees. Any destructive operation on a machine that other agents mutate has a freshness requirement on its evidence, and on this box the shelf life runs in minutes. The 2026-07-22 disk event is the same shape read from the other direction: free space went from zero to 4-5GB within 1-2 minutes as another session’s build finished. A reading taken at the top of a task describes a machine that no longer exists by the bottom of it.

Squash-merged branches look unmerged: use git cherry

The third trap is the one that would have cost the most time rather than the most work, and it points the opposite way from the first two. It makes you keep worktrees you should remove.

To decide whether a worktree is dead I have to know whether its branch’s content is already on main. The reflex is ancestry:

git log origin/main..HEAD
git diff origin/main...HEAD

Under squash merges, both lie. The squash produces a new commit object on main with different parentage and a different hash from anything on the branch, so ancestry finds no common tip, and main has moved on since the merge, so git diff reports differences that belong to other people’s later commits rather than to any work of mine. A branch whose content landed a week ago shows commits ahead and changed files, and reads as unfinished work.

The test that survives a squash compares patch content rather than history:

git cherry origin/main

A - in front of a commit means an equivalent patch is already upstream. Where that is ambiguous, I match the exact commit subject against main’s log, which is cruder and works because squash merges on these repos preserve the subject line.

The cost runs in both directions. Ancestry alone will make you keep dead worktrees, which is a slow leak: disk, clutter, and a longer audit next time, on a machine that has already hit genuine ENOSPC from other sessions’ worktrees and builds. Worse, it can convince you merged work is unmerged, and that error is not slow. It sends you re-doing landed work, or it pushes you to keep a directory alive and resume a branch whose content is already in production, which is how you end up reverting somebody else’s fix while thinking you are finishing your own.

Check ~/.claude/launch.json before removing a worktree

After the sweep I added a second signal that has nothing to do with processes. ~/.claude/launch.json is where dev servers get registered on this machine, one entry per app with a unique port, and two of its six entries point at worktrees rather than main checkouts: suede-social on 3100 at …/Suede-AI-App.worktrees/suede-redesign/frontend, and suede-home on 3200 at …/Suede-AI-App.worktrees/suedeai-home/suede-home. Those directories are named in a config that other sessions read. A worktree can be quiet at the moment I check it and still be the path a launch entry resolves to, which makes deleting it a change to shared configuration rather than a cleanup.

the candidate list gets one more filter: grep ~/.claude/launch.json for the worktree path before removing it. A hit does not always block the delete, since a launch entry can outlive the work it was created for, but it converts an automatic removal into a decision that has to name what breaks. The port registry is also the reason worktrees accumulate in the first place. Each concurrent session needs its own directory and its own port, both of which get created at the start of a task by whoever needs them, and neither of which anybody is responsible for reclaiming at the end.

That asymmetry is the structural cause of this whole chapter. Creating a worktree is cheap, reversible, and happens inside the task that needs it. Removing one is destructive, irreversible, and happens in a separate task run by somebody with no context on what the directory was for. The cleanup pass is performing surgery on state it did not create, using evidence it did not gather, on a machine that keeps moving. Every safeguard I added is an attempt to close that context gap with a command rather than with memory, because memory of what a worktree was for is exactly what the cleanup pass does not have.

The delete sequence I committed to CLAUDE.md

The sweep ended with three lines in CLAUDE.md under a heading that names the failure rather than the procedure: never delete a worktree based on the session list. Written as a sequence:

  1. Enumerate candidates. Use git cherry origin/main for merge status, not git log origin/main..HEAD.
  2. Immediately before removing each one, run lsof -d cwd 2>/dev/null | grep "<repo-path>" against that worktree’s path. If anything comes back, skip it and move on.
  3. If a claude process is unaccounted for, walk it directly: pgrep -x claude, then lsof -a -d cwd -p <pid> for each pid.

The registry does not appear in the sequence. That is the part I would defend hardest if somebody told me the check was redundant, because the failure mode here is not that the registry is often wrong. It was wrong twice in one afternoon, on the two entries that mattered, in the direction that destroys work. A source with that profile does not get partial credit.

Worth saying what this does not solve. lsof tells me a process is standing in a directory, not whether that process is doing anything worth protecting. An agent that finished an hour ago and left its shell parked in the worktree looks the same as one mid-edit. I treat both as live and skip both, which means this discipline errs toward keeping directories I could safely remove. That is the trade I want: a worktree I keep for another week costs disk, and a worktree I delete out from under a running agent costs work that exists nowhere else.

The session registry is not the process table

The pattern under this incident is the one that keeps recurring across every rule I have had to write down for this estate. A convenient proxy sits one layer above the thing I care about, answers instantly, and is right often enough to earn trust it has not been tested for. The file tree is not the deployed surface, which is why I curl production per file in an api/ directory rather than reading the directory listing. Git ancestry is not merge status. The local checkout is not GitHub. The memory vault is context, not the current state of anything, and repo files, live URLs, and current terminal output override it. The session registry is not the process table.

Each of those proxies exists for a reason and each is worth reading. The rule is about what happens at the moment of an irreversible action. A registry reports what it was told, by whoever remembered to tell it, at whatever moment they got around to it. A process table reports what is. Before I delete something that exists in one copy, I want the second kind of answer, taken close enough to the deletion that nothing has had time to change underneath it.