The Liveness Check That Lied Three Different Ways

October 2026

A status card titled Liveness check: three signal rows, Working directory, –session-id in argv, and Agents list membership, each marked CHECKED, with a footer rule reading Agents list unreadable, ABORT whole sweep, last catch for a hand-started session whose cwd also drifted

A reader building an early tool for sharing context across coding agents emailed me this week with a question: when two sessions on this system diverge, what actually decides which changes survive the merge?

The real answer isn’t a clever merge rule. It’s two separate things, each built the hard way: a liveness check that took three real failures to get right, and a decision we never let software make at all.

Three ways to mistake a session as dead (or alive)

This system spawns an isolated, full copy of the vault for every session, so it can work without stepping on anyone else’s files. Something has to decide later whether that copy is safe to remove. “Safe” means one thing: the session behind it is actually gone.

The first version of that check used one signal: the reason text our own session launcher writes when it calls Git’s own worktree lock --reason flag. On July 18, 2026, two completely different worktrees turned up with the identical process ID and start time in that lock reason. The same start time made simple reuse unlikely; one real process was more likely holding both locks at once, something a later incident confirmed can genuinely happen. Either way, a lock reason alone couldn’t prove which worktrees that process still considered current.

The fix added a second signal: look for that session’s own ID inside a live process’s command-line arguments. Run for real, it missed the one session it mattered most to catch. A session started by hand, sitting in front of a person, carries no session ID in its arguments at all. It runs as a bare process with nothing to grep for. The check read a live, human-attended session as dead, which is the one direction a liveness check can’t afford to get wrong.

A third signal went in: match the process’s actual working directory against the worktree. That one failed the other way. A process five days into a run, with a real session ID in its own arguments, had a working directory pointing at a completely different worktree. A directory-only check would have called that session dead while it was still working.

Two of these three failed in the direction that actually matters: they read a live session as dead. A check that does that is worse than no check at all, because deleting a live session’s uncommitted work is damage a bare absence of checking would never cause. The lock-reason collision failed differently. It couldn’t prove liveness either way, which is a gap worth admitting rather than guessing past.

A three-panel comic strip. Panel 1: an engineer grins at his monitor, saying “Finally, cleaned up! Got rid of all those old, unused worktrees,” while the terminal reads “dead-worktree-sweep.sh … SKIP_LIVE: worktree-042 … DEAD.” Panel 2: his finger hovers over the Enter key, tense. Panel 3: a small smiling sprout growing out of a folder pops up next to the monitor, waving, as the startled engineer freezes mid-keystroke; the sprout’s speech bubble reads “Reports of my demise are greatly exaggerated.”

What replaced it

The check now reads three signals for each worktree: its working directory, whether its session ID shows up in a live process’s arguments, and whether it appears in the agent harness’s own listing of every session it currently has running, hand-started or not. Any single one of the three testing positive is enough to leave that worktree alone for this pass. The agents list is treated differently from the other two: if that list itself can’t be read at all, the whole sweep stops rather than finishing on an assumption it can’t check for anything still left to look at.

A related mistake sits on a different axis entirely: not whether a session is alive right now, but whether it’s ever supposed to be swept at all. A Git lock only proves a session is recognized, not that it’s temporary, and the lock reason plays no part in the liveness check above. One piece of permanent, scheduled infrastructure got removed by an earlier pass that read “looks idle” as safe, and a scheduled job went silently unrun for a week before anyone noticed. The fix distinguishes a session’s ordinary auto-generated lock reason from a custom one written for standing infrastructure. A custom reason now means never remove it by default, full stop.

The part we never automated, on purpose

This is the direct answer to the question that started this piece. Merging has a direction, and only one direction is unconditionally safe. A session’s own finished work, merging into the shared history, never touches another session’s live files, so it can run any time. The reverse, pulling the shared history’s latest changes into a session that’s still open, can overwrite work mid-edit. That direction waits until the session is confirmed dead first.

When the same piece of content has genuinely moved forward on both sides at once, differently, it doesn’t get resolved by picking the newer timestamp. It gets flagged by name in a shared log, and a person reads both versions before deciding. A file’s last-edited time records when it was touched, not which branch’s decision should win, so an older copy can still be holding the answer that should survive. “Most recent edit wins” can’t tell the difference.

Where this stands

What changed: a single-signal liveness check became a three-signal one. Any one signal reading positive is enough to leave a worktree alone, and if the one signal that can catch a hand-started session goes unreadable, the whole sweep stops rather than treating that as permission to delete.

Proof two prior blind spots stay closed: a session detectable only by its working directory, and one detectable only through the harness’s own agents list, are both now permanent regression cases. Either one has to still correctly read as alive with nothing else to back it up, or the test fails. A future change can’t quietly reopen either gap, though plenty of other ways to misread a process are still untested.

Proof the sweep behaves safely under its own rules: the cleanup script runs as a dry run unless told otherwise, so a real pass can be re-checked without deleting anything. The most recent full run scanned 513 worktrees: 5 were live, 5 were custom-locked infrastructure, 8 were correctly held back with real unmerged commits, and 495 were removed as actually clean. That accounts for all 513 under the script’s own predicates. It’s not an independent audit of every removal, and it doesn’t prove every liveness signal is accurate on every input; that’s what the regression test above is for.

A horizontal bar chart titled 513 worktrees, one real sweep: Live 5, Custom-locked infrastructure 5, Held back unmerged 8, Removed clean 495

The mechanism, plainly: dead-worktree-sweep.sh checks, in order, whether a worktree is even a session (not permanent infrastructure), whether its lock reason is custom (never touch it), whether any of the three liveness signals says it’s alive, and only then whether it’s actually merged and clean. A single worktree failing any of these checks just gets skipped; the sweep moves on to the next one. Only the agents list itself being unreadable stops the entire run. A hand-started session has no session ID in its arguments, and if its working directory has also drifted, the agents list is the only signal left that can still catch it. If that one source goes dark, the sweep refuses to guess for anyone still unverified, rather than pick up where its last reliable signal left off.

Compared to known practice: Git’s own merge already refuses to guess when two changes overlap on the same lines; it stops and asks a person. What it can’t see is two changes that don’t overlap at all, that are each individually clean, but that combine into something wrong. That’s a known, harder category, usually called a semantic conflict, and ordinary line-based merging has no visibility into it. Our banner rule is the same refusal Git already applies, moved up one level, aimed at exactly the class a text diff can’t see. Where we match that standard: we never let an algorithm silently pick a side. Where we don’t yet: a person still has to notice the open flag, rather than it routing itself to someone. That’s the next step, not a finished one.

If you’re building something that shares context across coding agents, the specific signals here are particular to this system, but two principles underneath them aren’t. Delete only when nothing you can check says alive, and if your one unconditional source of truth goes dark, stop deciding anything until it’s back. Never let software pick a side, either, when two changes have each moved forward correctly on their own. If you can build an authoritative lease or heartbeat instead of inferring liveness from process shape, build that; it’s the cleaner fix, since inferred signals tend to share the same blind spots the moment the machine underneath them is the thing failing. Three independent signals are what you reach for when you can’t.

If this helps you, check out my adblob to support the work.

Share: X LinkedIn Email