Skip to content

fix(server): cleanup follows the branch an agent checked out - #68

Merged
ScriptedAlchemy merged 11 commits into
mainfrom
fix/worktree-cleanup-branch-sync
Nov 6, 2026
Merged

ScriptedAlchemy merged 11 commits into
mainfrom
fix/worktree-cleanup-branch-sync

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

Why

Maintenance kept every worktree whose agent had switched branches. T3 records a thread's branch at launch and never reads the checkout again, so one git checkout by an agent left the record stale. Cleanup then kept the worktree before it checked age, settlement, or merge state. A replay of the keep rules over one maintainer's instance found this check holding 75 of 107 worktrees. Nearly all of those belonged to delegated child threads, and 73 of the 87 threads on them had already settled.

What changed

  • A new WorktreeBranches service reads HEAD and rebinds every thread on a worktree to the branch that is checked out. Run finalization, thread launch, and cleanup all call it, and the launch rename loop is deleted.
  • Cleanup keeps a worktree for one pass after a rebind, then applies the existing age, settled, and merge rules against the checked-out branch.
  • Cleanup keeps any worktree while its HEAD or reflog holds a commit that no local branch or tag holds, and it repeats that check immediately before removal. Checkpoint refs do not count, because a user cannot recover from them. Remote branches do not count, because a routine git fetch --prune removes them and a stale origin/HEAD can misname the default. Rewritten commits land in the reflog too, so an amended or rebased worktree waits until git gc expires those entries, 30 days by default.
  • Cleanup compares the worktree's repository with the project's before it reads the checkout, so another repository's linked worktree at the path is kept with its files. The comparison uses file identity, so a differently cased project root still matches.
  • Cleanup keeps a detached or default-branch worktree while another checkout holds a branch one of its threads records, or a thread in another worktree records that branch, because only one checkout can hold it at resume.
  • The service writes nothing when a different repository now sits at the worktree path, or when another checkout holds the checked-out branch.
  • Each sync compares HEAD with every thread on the worktree, so a run end corrects a sibling whose write was deferred to it. Cleanup finds threads by the path each one stored, including one with a trailing slash. A thread with no recorded branch is rebound like any other, and is kept with its own reason when HEAD names nothing it can use.
  • HEAD is read without depending on the index lock, so a held lock no longer stops a rebind at launch or run end. Cleanup still fails on a held lock before it removes anything.
  • With no local origin/HEAD, the service asks the remote for its default whatever the name, once per checkout.

Scope

  • The change covers worktrees owned by a thread, including shared and deleted-owner worktrees.
  • The change does not touch folders with no thread row. That maintainer's instance has 23. The two traced so far came from an external script that adds and locks worktrees in T3's folder.
  • A removed detached or default-branch worktree resumes on the thread's recorded branch. Local refs hold its commits, so nothing is lost, but the agent has to find them.
  • If a project root is mid-rebase on a recorded branch during a pass, or checks that branch out after removal, resume fails with Git's message until the root leaves the branch. No work is lost.
  • A subagent with no recorded branch cannot recreate a removed worktree on its own. Its owner's branch decides cleanup, and the owner can recreate it.

Tradeoffs

  • After a rebind, cleanup waits one hourly pass before removal. The write changes the data that every later check reads, so removal in the same pass would need an exception in the activity recheck.
  • If a run starts between the HEAD read and the write, the record can stay stale for that run. The sync at the end of any run on that worktree corrects it, and no removal can happen while a run is active.

Blast radius

The change is server only. Clients already follow the existing metadata event, so branch labels need no client change. Every worktree thread now runs one small git read and one thread lookup at the end of each turn. The lookup took about 5 ms on a real database with 1,135 threads. A thread left on the default branch runs a few more local reads each turn, plus one ls-remote per server lifetime when no origin/HEAD exists. The old check also prevented unreachable commits and resume on a stale name. This change makes both protections explicit and tests them, the second for rebound threads.

Verification

  • The cleanup scenarios use real git repositories. They cover the two-pass rebind, a detached HEAD that only refs/t3 holds, the default branch, a deleted owner, and a holding ref deleted during measurement.
  • vp run check:changed passed format, lint, knip, graphql, the typecheck of 3 packages, and 10 test files with 793 tests.
  • Three independent verifier rounds on gpt-6.1-sol and two adversarial reviews by Claude Opus 5.5, Claude Sonnet 5.5, and gpt-6.1-sol failed earlier versions with real defects, including commit loss after gc. Each defect was rerun before it was fixed, the reviewers' repros pass on this head except the mid-rebase case above, and removing each fix makes a test fail.
  • A dev server from this branch ran with the free replay provider and a scratch repository holding four worktree threads. A thread whose checkout moved to another branch had its record updated when its run ended, and the next pass removed the folder while the branch survived. A worktree with a commit only its reflog held stayed with "Worktree history has commits no branch or tag holds". A worktree whose recorded branch was checked out in the project root stayed with "Another checkout holds the branch a thread here resumes on". A detached worktree with nothing to lose was removed, and sending its thread another message recreated it on its recorded branch. Maintenance showed "2 removed, 2 kept" and the server logged no failures.
  • A replay of these rules over one maintainer's 107 worktrees predicts the result. 60 folders are already gone since the snapshot, and the 23 folders with no thread row are unchanged. 5 become removable within two passes. 9 stay because no local branch or tag holds their history. 10 stay for uncommitted changes or unmerged work. The replay does not see open terminals, live sessions, or submodule rules.
  • Across all of that instance's current worktrees, the reflog rule holds 6 of 31 on named branches and 8 of 13 detached.
  • Some records stay stale until a later pass corrects them, which costs no work: a sibling storing another spelling of the path, a launch onto an existing worktree, and a worktree shared by two top-level threads, which cleanup keeps before it syncs.
  • Windows, desktop, and mobile clients were not run.

Claude Fable 5.1 directed this work through T3 Code. Claude Opus 5.5 wrote the code. Claude Sonnet 5.5, gpt-6.1-sol, gpt-6-astra, GPT-6.1-Sol[1m], and DeepSeek-V4.1-Flash[1m] did research and design review.

🤖 Generated with Claude Code

ScriptedAlchemy and others added 5 commits November 4, 2026 23:12
Maintenance kept every worktree whose agent had switched branches, because
nothing wrote HEAD back to the thread. A new WorktreeBranches service reads
HEAD and rebinds the threads on that worktree. Cleanup keeps for one pass after
a rebind, then applies the existing rules. A detached HEAD stays kept unless a
ref outside refs/t3 holds its commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A thread kept its old branch name after its agent switched branches, so the
pull request refresh skipped it and cleanup had to repair it later. Run
finalization now syncs the worktree branch before that refresh. A sync failure
only logs, so it cannot block the next turn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Launch carried its own copy of the loop that writes a branch to every thread
on a worktree. It now calls WorktreeBranches.sync after the rename, so one path
writes thread.branch for a worktree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pins the resume half of the cleanup change. Once a thread is rebound and its
worktree is removed, the next turn recreates the checkout on the branch the
agent switched to, at that commit, and not on the stale name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A deleted owner's record cannot be rewritten, so only its surviving subagents
are rebound. This pins that the group comparison still lets removal proceed on
the next pass, and that the branch ref survives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: ee5b3ee

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

ScriptedAlchemy and others added 3 commits November 5, 2026 12:22
…lone

An independent check of the cleanup change found three ways to lose or strand
work. A different repository at the worktree path had its branch written to the
thread, so the thread forgot its own branch. A branch another checkout holds was
recorded even though the thread could not recreate its worktree there. And the
detached HEAD check ran before later steps that gave a holding ref time to go.

Sync now compares the worktree's repository with the project's before it writes,
and treats a branch another checkout holds like the default branch. Cleanup checks
the holding ref last, immediately before removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cleanup counted any remote-tracking ref as holding a detached commit. A routine
`git fetch --prune` removes a remote branch that was deleted upstream, so the
commit could become unreachable after its worktree was gone.

Only local branches, tags, and the remote's default branch count now. A detached
worktree held by any other remote branch stays kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y thread

Adversarial review reproduced more ways for this change to lose work or never
finish. Removing a detached or default-branch worktree could lose a commit only
its reflog held, or strand a thread whose recorded branch another checkout held.
A remote-tracking ref counted as protecting a commit even though a routine prune
or a stale origin/HEAD could remove that protection. A run end skipped a sibling
whose write had been deferred to it. Threads with a trailing slash in their path
or no recorded branch never rebound, and mismatched records on a detached or
default-branch worktree were kept forever. A held index lock blocked rebinding,
and main counted as the default branch when the remote's default was trunk.

Cleanup now checks HEAD and the reflog against local branches and tags only, and
keeps a worktree while another checkout holds a recorded branch. Sync compares
HEAD with every thread on the worktree instead of the caller's record, reads HEAD
without depending on the index lock, finds threads by the path each one stored,
and asks the remote when no local HEAD ref names its default.

Model: Opus 5.5
Harness: Claude Code in T3 Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An adversarial review of this branch reproduced commit loss that main prevents.
A rebound worktree counted as bound on its next pass, which skipped the reflog
check, so cleanup removed it and gc then pruned commits only its reflog held. A
deleted owner with no surviving threads skipped the same check. Another
repository's linked worktree at the path skipped the repository check and lost
its untracked files to `git clean`. Two detached worktrees recording one branch
were both removed, and with no local origin/HEAD a default other than main or
master could become a thread's recorded branch.

The reflog check now runs on every worktree. Rewritten commits land in the reflog
too, so an amended or rebased worktree waits until gc expires those entries.
Cleanup compares the worktree's repository with the project's before it reads the
checkout, by file identity so a differently cased path still matches. It keeps a
detached or default-branch worktree while a thread elsewhere records the same
branch. The service asks the remote for its default whatever the name, once per
checkout.

Model: Opus 5.5
Harness: Claude Code in T3 Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ScriptedAlchemy
ScriptedAlchemy enabled auto-merge (squash) November 6, 2026 00:08
ScriptedAlchemy and others added 2 commits November 5, 2026 16:41
The upstream sync split Ultracode out of the effort list into a boolean option.
pstack pins every option except speed, so a role at effort=xhigh now resolves with
ultracode false, which is what that effort meant before: not Ultracode. This test
is fork-only, so the sync did not update it, and it left main's required Fork Check
failing for every pull request.

Model: Opus 5.5
Harness: Claude Code in T3 Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ScriptedAlchemy
ScriptedAlchemy merged commit ede6b4b into main Nov 6, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant