Skip to content

What unit does the agent-worktree sweep reason about #445

Closed

Description

@blooop

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 as Forgotten.
  • T1: sweep_clone descends only into worktrees it keeps, and dirt_in drops nested worktrees from the parent's dirt, so a worktree nested inside one that is going is protected by neither path. Reproduced three ways, including git 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:

  1. 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.
  2. 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.
  3. 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

  1. blooop commented on Aug 25, 2026

    @blooop
    OwnerAuthor

    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 -f does 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. Note Git::worktree_prune's doc comment at clients/git.rs:513-515 asserts 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; commondir is ../.. 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 ../outer succeeds 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.

  2. self-assigned this
    on Aug 25, 2026
  3. blooop commented on Aug 25, 2026

    @blooop
    OwnerAuthor

    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.

  4. assigned and unassigned on Aug 25, 2026
  5. blooop commented on Aug 25, 2026

    @blooop
    OwnerAuthor

    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-wide git worktree prune is 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.

    operation blast radius nameable at plan time?
    remove_tree_as_far_as_it_goes(dir) the whole subtree at dir, every nested worktree in it included, other repositories' included yes: it is a subtree
    git worktree prune a readdir over $GIT_DIR/worktrees at act time, minus locks no
    git worktree remove <recorded path> exactly one registration when the path does not resolve here; plus the subtree at that path when it does; nothing when it resolves to something else yes: it is one name

    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 entry drops 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 that git worktree prune is 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:

    1. remove <recorded path> drops exactly one registration. Devlaunch's exact shape: one clone, agent-a, agent-b (locked, reason claude session), and nested inside agent-a, every gitdir rewritten to /workspaces/clone/…. remove /workspaces/clone/.claude/worktrees/agent-a → exit 0, no output, agent-b and nested still registered, the host directory agent-a untouched, branch a still 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 -f gives the same refusal with the file in that directory intact. So Git::worktree_prune's doc comment at clients/git.rs:513-515 is false, as the breadcrumb says — and so is flows/agent_worktrees.rs:64-66, which says git 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.
    2. No containment relation, at depth 3. 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 in inner's admin directory mentions outer. Containment is a fact about two strings.
    3. T1 is the common case, and worse than "no flag needed". Outer worktree with .claude/ in its own .gitignore, inner worktree holding UNSAVED.txt, inner git worktree locked with a reason. Plain git worktree remove outer, no flag: exit 0, no output, outer gone, UNSAVED.txt gone. Inner's registration survives reading locked claude session with no prunable line, 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:

    1. The argument has to be the recorded path exactly. 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.
    2. The three unlisted states are unreachable by name, not merely unreached by policy. Deleted .git/worktrees/wa/gitdir, so wa is absent from worktree list --porcelain while prune -n -v says Removing worktrees/wa: gitdir file does not exist. Then remove <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".
    3. remove <container path> applies no dirty check at all. The cleanliness check sits inside if (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.txt reads hi dirty on 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.
    4. The full sequence works and is order-independent. Container paths at three depths; rm -rf the outer host subtree, then forget deep, outer, inner by their recorded paths in that deliberately wrong order: three exit 0s, .git/worktrees empty, 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>…] — the inside key 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 to remove.

    /// What occupies one site. Three arms, no catch-all.
    enum SiteKind {
        /// A linked worktree of this clone, present here.
        OursHere { at: PathBuf, admin: Option<PathBuf> },
        /// Registered, with nothing at its place in this clone. No bytes; the
        /// removal is the forget alone.
        OursGone,
        /// A directory in the worktrees place this clone cannot account for: a plain
        /// directory, a worktree of another repository, an unreadable gitfile.
        /// Never provable safe, so never collectable.
        NotOurs { at: PathBuf, why: Unaccountable },
    }
    
    struct Site { inside: Inside, kind: SiteKind, registered: Vec<Recorded>, nested: Vec<Site> }
    
    enum SiteVerdict { Collectable(Collectable), Standing(Standing) }
    
    enum Collectable {
        /// A directory to remove, and every registration the one removal accounts
        /// for: this site's and every nested site's.
        Directory(GoingDirectory),
        /// No directory. The forget is the whole of the work, and there is at least
        /// one to do or this would not be a site.
        Registration(GoingRegistration),
    }

    Recorded is a newtype over the path git printed whose only constructor parses a listing. Inside likewise. 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 returns Standing the moment any child is Standing. 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 enum are as public as the enum, so a downstream crate forges Collectable::Directory { nested: vec![] } and compiles. With the payload as GoingDirectory with private fields, the same line is error[E0451]: fields at, forgets and nested of struct GoingDirectory are private. That is the mechanism, and this repo's public-api.rest.txt snapshot 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 } or Pass::Acting(&Approvals), where an Approved is minted only by the planning pass and carries the insistence given for that site. Under Acting, a site with no Approved cannot 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 as MetadataGate's fold, one level up and structural instead of folded.

    Carrying out takes the Collectable by 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

    1. A Collectable whose subtree holds a Standing site. T1's three probes — nested holding an uncommitted note, nested holding an unpushed commit, nested git 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.
    2. A removal of a site the plan did not name. No Approved, no Collectable. The approved set can shrink and cannot grow, now over a tree rather than a list.
    3. A metadata operation reaching past the plan. There is no clone-wide prune. The only invocation is remove <a Recorded>, and a Recorded exists 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.
    4. A registration forgotten while its bytes stand. Forgetting is a consequence of a completed removal of the subtree that accounted for it. Given finding 6, that is the only thing standing between a registration and its host directory's uncommitted work.
    5. T3 evaporates. A refused or partial removal forgets nothing, so there is no third outcome to feed anywhere. MetadataGate, metadata_gate(), registration_goes_on_protecting, metadata_held_back and Git::worktree_prune all go. The S1 fix was a correct guard on an operation that should not exist.
    6. The "not ours" hole closes on the way past. A cross-repository nested worktree, a plain directory, an unreadable gitfile: today sweep_clone does continue — not reported, not descended into, and deleted with its parent. As a NotOurs site it is Standing and 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 entry deregisters a live worktree with no age gate — and both are reclaimed by git gc's worktree prune --expire under gc.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 two dl runs with nothing devlaunch did, so no arm may treat a missing registration as evidence about the bytes.

    Rejected

    • Candidate 1, the directory. Both defects live here. A directory does not know what contains it, and both surviving operations have subtree radius.
    • Candidate 2, the clone. Answers a mismatch that cannot be answered that way (the prune's domain is a later syscall, not an enumeration) to satisfy a constraint that no longer exists (a per-registration removal does exist). It would have bought a coarser plan and kept the hole.
    • Candidate 3 as written, a graph whose containment edges come from prefix-comparing recorded path strings. Right instinct, wrong edge source: that computes the most safety-critical relation in the design from the least trustworthy data in it, and it has no edge at all for the cross-repository case. Containment comes from the walk.
    • Gating the clone-wide prune better. Three rounds of correct gates. The gate was never the defect.
    • Re-reading the picture and checking every present registration is accounted for before pruning. Cannot contain the three unlisted states, and is a check, so it fails the ticket's bar twice.

    Handoffs

    #454's scope changes, and it is a net simplification. MetadataGate moves from the carry-over list to the delete list, with the reason above. Git::worktree_prune is 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. NotOurs sites 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, and docs/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. NotOurs needs 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.

    • --prune inside a container. Every recorded path resolves there, so remove <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 the Held arm, which is an accident of classification rather than a decision. Does dl --prune have a coherent job in there at all?
    • A nested worktree of a different repository. Now discovered and reported instead of silently deleted, but unclassifiable, so it stands forever and pins its parent, reclaimable only by --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_prune should not exist, and the third-party pruner half becomes a #455 row.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions