Repository navigation
What unit does the agent-worktree sweep reason about #445
Description
Question
What unit does the agent-worktree sweep reason about: the directory, the clone, or the registration graph?
This is the keystone. Three fix rounds on #426 each closed an instance and left the shape intact, and both surviving defects on PR #442 are the same idea wearing different clothes:
- T2: the gate's domain is the plan's enumeration, but
git worktree prune's blast radius is the whole clone. A worktree the container registers after the plan is in neither the plan's keeps nor the acting pass's outcomes, so the gate stays open and the prune takes its registration. Run 2 then removes the directory asForgotten. - T1:
sweep_clonedescends only into worktrees it keeps, anddirt_indrops nested worktrees from the parent's dirt, so a worktree nested inside one that is going is protected by neither path. Reproduced three ways, includinggit worktree locked, which breaks dl --prune cannot reclaim agent worktrees inside live workspaces' clones (104 GB measured) #426's Ask 2 outright.
Both reach past the list the pass walked. So the question is not "which guard is missing" but what the sweep's unit of reasoning is, such that reaching past it has no representation.
Candidate shapes, to be argued rather than assumed:
- Directory (status quo): each candidate path classified independently. Cheap; both defects live here, because a directory does not know what contains it and the metadata prune is not per-directory.
- Clone: the clone is the unit, its worktrees a set decided together, and the metadata prune is an operation on the whole set. Matches
git worktree prune's actual blast radius, which is the T2 mismatch, at the cost of a coarser plan. - Registration graph: nodes are registrations and directories, edges are containment and registration, and removal is defined on a subtree. Makes T1 structural, at the cost of being the largest of the three.
Whatever wins must make it unrepresentable, not merely guarded, that (a) a removal takes anything the plan did not name, and (b) an operation's blast radius exceeds the unit the gate reasons over. Principle 3's constructive-modeling clause is the test: if the answer is "and then we check", it has not answered.
Note that TOCTOU is unclosable from the host, so the answer cannot depend on the plan still being true when it is acted on. It has to be sound when the plan is stale, which is what the re-check plus fold achieved for S1 and what T2 shows was not enough one level up.
Run /constructive-modeling on the shape before settling.
How the answer gets tested: the three T1 probes and the T2 reproduction become tests that fail against the current design and pass against the new one, with at least one asserting the absence of a representable path rather than a guard firing.
Activity
blooop commentedon Aug 25, 2026
breadcrumb: unblocked, and the blocker did more than unblock it. #448 establishes two facts that change what this ticket is choosing between.
git worktree remove <recorded path>is a supported single-registration prune. It drops exactly one registration when the recorded path does not resolve, which is every container-registered worktree seen from a host. It refuses when the path does resolve to an unrelated host directory, and-f -fdoes not get past that refusal, so it fails towards keeping where it matters. That means candidate shape 2 (clone as the unit, to match the clone-wide prune blast radius) is answering a mismatch that no longer has to exist. NoteGit::worktree_prune's doc comment atclients/git.rs:513-515asserts the opposite and is false as written.git records no containment relation at all. A nested worktree's admin dir is a sibling of its parent's, flat under the main repo; its gitfile points at the main repository;
commondiris../..at any depth. So candidate shape 3 cannot read a graph from git, it has to derive containment from a prefix relation between recorded path strings. That is still a graph, but the edges are ours to compute and to be right about.And T1 is worse than the ticket says:
git worktree remove ../outersucceeds silently when the nested directory is gitignored by the parent, deleting the inner tree with no flag and no output. Gitignoring an agent worktree directory is the ordinary arrangement, so this is the common case rather than an edge.blooop commentedon Aug 25, 2026
breadcrumb: the run on this ticket died on an API spend limit, not on the work, and left no resolution. Claim released so the ticket returns to the frontier.
What stands: the two facts from #448 and the breadcrumb above them. What does not stand: the run had a design forming and was at the point of verifying the behaviours it would rest on when it died. That design was never recorded and must not be guessed at — a resume starts the decision over from the git facts, which are the durable part.
Nothing was written to the repo and no code was changed.
blooop commentedon Aug 25, 2026
agent-decided (principle 1, no data loss): the unit is a nesting site together with everything nested inside it — one subtree, decided bottom-up and conjunctively — and the only metadata operation is
git worktree remove <the path git printed>, per registration, by name. The clone-widegit worktree pruneis deleted rather than gated.That is candidate 3, cut down and with its edges taken from somewhere else. The reason it is not candidate 2 is the reason the question was hard, so it is worth walking.
The question is not which unit, it is which operations exist
A unit only means something against an operation. So I enumerated the operations and measured each one's blast radius before choosing anything.
remove_tree_as_far_as_it_goes(dir)dir, every nested worktree in it included, other repositories' includedgit worktree prunereaddirover$GIT_DIR/worktreesat act time, minus locksgit worktree remove <recorded path>Read the middle row again, because it settles candidate 2. Candidate 2 exists to make the unit match the prune's radius. The prune's radius is not a set, it is a syscall at a later time. Three registration states are pruned and never appear in any listing,
duplicate entrydrops a live worktree's registration with no age gate and no path check, and a registration created after the plan is in the domain too. No plan-time unit can equal that — not the clone, not the repository, not anything. Making the clone the unit does not close the T2 mismatch, it renames it and makes the plan coarser. The second review's suggested fix ("before pruning, ask whether every present registration is accounted for") fails on the same fact: the three unlisted states are not in the listing that check would read, so the check cannot contain them either.So the honest move is not to find the unit that fits
git worktree prune. It is to notice thatgit worktree pruneis the only operation here whose radius is unnameable, and delete it. Principle 1 is what makes that the decision rather than a preference: an operation whose domain devlaunch cannot enumerate is an operation devlaunch cannot fail towards keeping with.And the constraint that put it there has been withdrawn. #448 established, and I re-measured, that
git worktree remove <recorded path>is a supported single-registration prune. The two remaining operations both have nameable radii. Units are radii: a subtree for the one that deletes bytes, a name for the one that deletes a registration. Two operations, two units, and they nest, which is why this reads as one answer and not two.What I re-measured, and four things the research did not
Verified myself on git 2.51.1 and 2.43.0, identical on both. Scripts were throwaway; nothing was written to the repo.
The three facts the ticket asked me not to take on trust:
remove <recorded path>drops exactly one registration. Devlaunch's exact shape: one clone,agent-a,agent-b(locked, reasonclaude session), andnestedinsideagent-a, everygitdirrewritten to/workspaces/clone/….remove /workspaces/clone/.claude/worktrees/agent-a→ exit 0, no output,agent-bandnestedstill registered, the host directoryagent-auntouched, branchastill there. The locked one →fatal: cannot remove a locked working tree, exit 128. A recorded path that resolves to an unrelated host directory →fatal: validation failed … '<path>/.git' does not exist, and-f -fgives the same refusal with the file in that directory intact. SoGit::worktree_prune's doc comment atclients/git.rs:513-515is false, as the breadcrumb says — and so isflows/agent_worktrees.rs:64-66, which saysgit worktree remove"is no use from a host — it resolves the registered path". It works because the path does not resolve. Both go with the fix; that is a third false claim, on top of the two Nested worktree guarantees and git worktree prune's real blast radius #448 named.outer,outer/inner,outer/inner/deep: three flat sibling admin directories under the main repo, each gitfile naming the main repository,commondir../..for all three, and nothing ininner's admin directory mentionsouter. Containment is a fact about two strings..claude/in its own.gitignore, inner worktree holdingUNSAVED.txt, innergit worktree locked with a reason. Plaingit worktree remove outer, no flag: exit 0, no output, outer gone,UNSAVED.txtgone. Inner's registration survives readinglocked claude sessionwith noprunableline, so the next listing cannot even tell that its directory is missing.Four things I measured because the decision rests on them and the findings file does not cover them:
remove /workspaces/clone/.claude/worktrees/agent-a/.git→fatal: '…/.git' is not a working tree, exit 128. So the operation's argument is a value copied out of a listing, never a path assembled from parts..git/worktrees/wa/gitdir, sowais absent fromworktree list --porcelainwhileprune -n -vsaysRemoving worktrees/wa: gitdir file does not exist. Thenremove <wa's own host path>→fatal: '…/wa' is not a working tree, exit 128, admin directory intact, directory intact. The per-registration operation physically cannot act on the states the clone-wide one reaches. Same for a name a third party already pruned: refusal, exit 128, nothing changes. That is the strongest form of the T2 answer available: not "the gate holds", but "there is no invocation that touches it".remove <container path>applies no dirty check at all. The cleanliness check sits insideif (file_exists(wt->path)), and a container path does not resolve, so a registration whose host directory holds uncommitted work is dropped, exit 0, silently. Measured:f.txtreadshi dirtyon disk afterwards, registration gone. The metadata operation protects nothing about the bytes. This is the single most load-bearing measurement in the decision: it is why a forget can only ever be a consequence of a directory removal that completed, and never an operation with a domain of its own.rm -rfthe outer host subtree, then forgetdeep,outer,innerby their recorded paths in that deliberately wrong order: three exit 0s,.git/worktreesempty, all four branches (main o i d) alive, nothing else touched. Nested registrations really are order-independent siblings.The shape
Discovery produces a forest of sites. A site is a place inside the clone,
.claude/worktrees/<leaf>[/.claude/worktrees/<leaf>…]— theinsidekey the current module already computes. Nodes come from the union of two reads: the directories the walk finds, and the places the listing's registrations name. Edges come from the walk, and this is the one place I depart from what both #448 and the second review proposed. They say derive containment from a prefix relation over recorded path strings. Do not: on a host every recorded path is a string about another machine, and the cross-repository case has no edge there at all, since r1's listing never mentions r2's nested worktree. The filesystem knows what contains what. Recorded paths are used for exactly two things, neither of which resolves them: the join key, and the argument toremove.Recordedis a newtype over the path git printed whose only constructor parses a listing.Insidelikewise. Both private-field.Three properties carry the whole answer.
The verdict is derived, not supplied.
verdict_of(site, pass, probe)recurses into the children itself and returnsStandingthe moment any child isStanding. So the collectable arm is reachable only when every recursion handed one back. A caller cannot pass a doctored child list, because it passes none.The payloads are structs with private fields. I probed this rather than assuming it, and the naive version fails: variant fields of a
pub enumare as public as the enum, so a downstream crate forgesCollectable::Directory { nested: vec![] }and compiles. With the payload asGoingDirectorywith private fields, the same line iserror[E0451]: fields at, forgets and nested of struct GoingDirectory are private. That is the mechanism, and this repo'spublic-api.rest.txtsnapshot is where its absence gets asserted — the test #445 asks for, that pins the absence of a path rather than a guard firing, is a snapshot row that does not exist.One recursion, two authorities. The plan and the act are the same function under
Pass::Planning { insistence }orPass::Acting(&Approvals), where anApprovedis minted only by the planning pass and carries the insistence given for that site. UnderActing, a site with noApprovedcannot be collectable — but it is still classified, so it still objects, so it still makes every parent stand. That is the map's second precedent (a plan and the outcome acting on it must not be able to disagree) held by there being one implementation rather than two predicates, which is the same lesson asMetadataGate's fold, one level up and structural instead of folded.Carrying out takes the
Collectableby value, never a path: directory first, then forget every name that rode in on it, and forget nothing on a partial removal.What has no representation now
Collectablewhose subtree holds aStandingsite. T1's three probes — nested holding an uncommitted note, nested holding an unpushed commit, nestedgit worktree locked — are not guarded, they are unconstructible. Ask 2 becomes structural: with no flag typed, a locked nested worktree cannot be inside anything that goes.Approved, noCollectable. The approved set can shrink and cannot grow, now over a tree rather than a list.remove <a Recorded>, and aRecordedexists only because a listing printed it — so a registration created after the plan is never named, and finding 5 says the three unlisted states could not be acted on even if one were.MetadataGate,metadata_gate(),registration_goes_on_protecting,metadata_held_backandGit::worktree_pruneall go. The S1 fix was a correct guard on an operation that should not exist.sweep_clonedoescontinue— not reported, not descended into, and deleted with its parent. As aNotOurssite it isStandingand pins its parent, which is review S4's other half.What I am not claiming
TOCTOU is not closed, and no host-side design closes it. What changes is its radius. It was: every registration in the clone, listed or not, plus every directory a later run then reclassifies as forgotten. It is now: one subtree the acting pass walked microseconds earlier, or one registration it just looked at. The unit and the residual radius are the same thing, which is the bar (b) asked for. Every other way the world can move between plan and act lands on keeping, and I walked them one at a time: a new nested site (no approval → parent stands), a new top-level site (untouched), a new registration (never named), an approved site going dirty (withheld, parent stands), a registration vanishing under
gc.worktreePruneExpire(a forgotten arm, probes intact), a directory vanishing (nothing removed), a recorded path starting to resolve (the removal targets the discovered directory, and git refuses the forget if it resolves to something unrelated).Devlaunch stops reclaiming two things, on purpose. The three unlisted registration states, and
duplicate entry. Neither is safely reclaimable from here —duplicate entryderegisters a live worktree with no age gate — and both are reclaimed bygit gc'sworktree prune --expireundergc.worktreePruneExpire, three months by default. Under principle 2 that is a named third party rather than a straggler, and it is a row for #455 with its reason, not silence. The same fact cuts the other way and is worth writing down: a registration can vanish between twodlruns with nothing devlaunch did, so no arm may treat a missing registration as evidence about the bytes.Rejected
Handoffs
#454's scope changes, and it is a net simplification.
MetadataGatemoves from the carry-over list to the delete list, with the reason above.Git::worktree_pruneis deleted rather than called. T2's test stops asserting that a gate closed and starts asserting that no git invocation ever names a registration the pass did not read from a listing — an absence, which is what the ticket asked for. T3 needs no fix.NotOurssites become classified rather than skipped. Three false claims are corrected in the same change:clients/git.rs:513-515,flows/agent_worktrees.rs:64-66, anddocs/cleanup.md:283-286. The.claude/worktrees/exclusion in the parent's dirty check survives only as "drop the entries the child forest already accounts for", which is a set the walk produced rather than a place.Constraints handed to #446, which owns the predicate and not the unit. Whatever it decides has to compose conjunctively over a subtree, so its verdict type has to be foldable rather than a bare boolean.
NotOursneeds a verdict, and it can only be stands. And finding 6 is a fact its "could not be proved" arm has to survive: the metadata operation contributes no safety of its own.Fog now worth its own tickets.
--pruneinside a container. Every recorded path resolves there, soremove <recorded>deletes a working tree and does apply the dirty check, and the site unit's two radii collapse into one. Today the only thing stopping a container collecting its own live worktrees is theHeldarm, which is an accident of classification rather than a decision. Doesdl --prunehave a coherent job in there at all?--force-worktrees. Whether devlaunch should read its gitfile and ask that repository is a decision, not a build detail.The map's third "Not yet specified" bullet is answered and can be struck:
Git::worktree_pruneshould not exist, and the third-party pruner half becomes a #455 row.