worktender

How it decides what to remove

Two ideas do most of the work here.

An event is a trigger, never a fact. The event handler does not act on what the event says. It reads it for one thing — which repository — and then runs the same whole-repository pass that sync runs, against live state. An event payload describes the world as it was before this process started, so it is out of date on arrival. This also means the event path and the reconciler cannot disagree, because they are the same code.

Whether work has landed is not decidable from git topology. Across fast-forward, squash, rebase and merge-commit workflows the graph shapes overlap: a branch merged by fast-forward looks exactly like a branch that never committed, and a branch forked off already-merged work looks exactly like one that landed. So topology never removes anything on its own here, ambiguity always resolves to keeping the worktree, and the reason is printed rather than dressed up as a verdict.

An un-pruned worktree costs disk. A wrongly pruned one costs work that exists nowhere else. Those are not comparable, so the tie never goes to deletion.

Two things can authorise a removal:

  1. A merged pull request. The only unambiguous "yes" available, and the only one that covers squash and rebase workflows — those rewrite commits, so the branch is not an ancestor of base at all and no amount of topology will say so.
  2. A deleted upstream, together with the branch's commits already being in base. Both halves are required.

The second exists because the first goes inert in a repository that does not use pull requests. It works because a deleted remote branch is a human action rather than a graph shape, and that is exactly the fact topology is missing. The ambiguous case is "did this branch land, or was it forked off work that had already landed?" — indistinguishable by shape, since they can be the same commit, but not by publication history: a branch forked off merged work and never pushed has no upstream to delete, while a branch that landed was pushed and had its remote ref removed, which is what a merge button does by default.

Neither half is enough alone. A deleted upstream by itself is equally what abandoning work looks like, so it is reported and the worktree kept. Being an ancestor of base by itself is the original ambiguity.

That principle shows up as a set of guards, each re-checked immediately before anything is removed rather than trusted from the plan:

A guard that cannot be checked counts as unsatisfied. If herdr cannot be asked whether an agent is running, the worktree is kept rather than removed.

The agent that has finished

"Has an agent" is not "is busy", and after a dispatch the two come apart. herdr frees an agent when its pane goes away and at no other moment — there is no agent release — so a worker that finished its task still occupies the pane it was started in. Read as presence, the agent guard makes the ordinary end state of a successful dispatch a worktree nothing can ever remove: five merged pull requests, five checkouts, five lines reading agent running.

So the guard turns on what the agent is doing. working and blocked are live work; so is any status this build has no name for, because an unreadable guard is an unsatisfied one. idle and done are an agent sitting at a prompt with nothing in hand.

That does not widen what may be removed. A finished agent whose branch has not landed is kept by the verdict, exactly as it would be with no agent at all. What changes is the one case where the verdict says remove and the only thing left standing in the way is a pane:

skipped  keep  fix/80-agent-names  PR merged, but a finished agent still holds the pane — `prune-apply --release-agents` closes its workspace and removes it

--release-agents is the deliberate second step, and it takes the pane away by closing the workspace, which is the only thing that frees an agent. It reaches an agent that has stopped and nothing else: one that picked work up between the plan and the removal is re-read at execution time and skipped, like every other guard here. It is not a herdr action and cannot be — an action is a fixed command array with no argument surface — so it is always something a person typed.