Skip to content

🧹 feat: Retire Stale Linked Worktrees Automatically - #299

Merged
danny-avila merged 5 commits into
mainfrom
danny-avila/retire-stale-worktrees
Oct 3, 2026
Merged

danny-avila merged 5 commits into
mainfrom
danny-avila/retire-stale-worktrees

Conversation

@danny-avila

@danny-avila danny-avila commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Agents give every task or pull request its own linked worktree at <checkout>/.worktrees/<name>, and nothing ever retired them. Each worktree keeps its own node_modules and build output. On the trusted "Skynet" VM the LibreChat checkout reached 231 worktrees, and the disk filled to 100% three times. Each time, workspaces were quarantined because setup and commands could not settle.

Two groups dominate. Squash-merged pull requests whose remote branch was deleted: their commits are in main by content but on no remote-tracking ref. PR Reviewer subagents create a fresh review worktree for every pass (for example review-16677-1f35df8) with its own node_modules and build output, and their best-effort cleanup sometimes fails ("Git reported busy worktree"). Idle, abandoned review worktrees are therefore a major retirement target. Tonight the disk filled to 100% again: 2 MB free at 01:00 UTC, with four lanes quarantined.

With this change, a worker running --linked-worktree-lanes retires stale linked worktrees on its own, on by default and with no configuration.

When it runs

  • A pass runs in the background 2 minutes after startup, then every 6 hours. While a backlog remains, the next pass comes after 5 minutes instead.
  • A pass also runs before managed environment setup would be deferred for low disk space: the existing storage.minFreeBytes + setupReserveBytes floor. Space is then measured once more before setup gives up.
  • Passes are serialized, so a pass requested while one runs joins it. Request handling is only ever held back during an actual removal, and only for requests to that lane or its checkout. Those requests wait for the removal to finish, or for their own cancellation.
  • Each pass reads every .worktrees entry, inspects at most 128 idle worktrees and removes at most 32, oldest first. A rotation makes sure worktrees kept for a lasting reason, such as unpushed work, cannot hide the rest of a larger backlog. Git probes time out after 30 seconds. git worktree remove has no timeout and ignores shutdown, because a half-deleted worktree could no longer be recognized. One worktree failing never stops the pass.

Safety rules

A worktree is retired only when all of these hold. Otherwise it is kept, and the reason is counted and logged at debug level:

  1. It verifies as a linked worktree of a writable registered checkout under .worktrees/, using the same verifyLinkedWorktree check as lane admission. The main checkout, worktrees outside .worktrees/, and forged directories are never touched.

  2. No request for its lane or its checkout is in flight in this worker. It has also been idle longer than the threshold, 7 days by default, measured as the newer of this process's last lane use and on-disk activity: the worktree directory plus its Git HEAD, index, logs/HEAD, ORIG_HEAD and FETCH_HEAD. Removal happens under a lane reservation. A lane or checkout request that arrives meanwhile waits for it, and a lane request then fails as an unknown worktree. Under that reservation, lane use, checkout requests that ran during inspection, quarantine markers, on-disk activity, HEAD, lock and directory identity are all checked again before removal.

  3. It has no modified tracked files and no untracked, non-ignored files. No merge, rebase, cherry-pick, revert or bisect is in progress. It is not git worktree locked, and neither the lane nor its checkout carries a quarantine marker.

  4. Its work is published or merged. Either its HEAD commit, including a detached one, is contained in at least one refs/remotes/* ref, or its work is already in the remote's default branch by content. The content rule applies only when HEAD has no live upstream: it is detached, was never pushed, or its upstream ref is gone (deleted after merge). It then also requires one of:

    • every commit beyond the default branch is patch-equivalent to one in it (git cherry <default> HEAD shows only -, and none is a merge commit), which covers rebase merges;
    • the default branch has identical content at every path the branch changed since their merge base, which covers squash merges.

    The default branch is refs/remotes/<remote>/HEAD, else main or master, as last fetched. Only local Git is used; the worker never fetches or calls GitHub. Commits beyond a live upstream are never treated as merged.

  5. Removal is git worktree remove <path> without --force, so Git refuses a dirty tree itself. It also deletes that worktree's own .git/worktrees/<name> metadata. A repository-wide git worktree prune is deliberately not run, because it would also expire other registered worktrees whose paths are only temporarily unavailable. Branches are not deleted, so git worktree add .worktrees/<name> <branch> restores any retired worktree.

Ignored files such as node_modules, build output and ignored .env files go with the worktree; that is the space being reclaimed. A branch that was not merged and whose remote branch was deleted is still kept, because its content is not in the default branch. Host-side Git runs with core.fsmonitor=false, --no-optional-locks, and no system or global config, like the existing project probes.

Defaults and opt-out

Setting Default Override
Retirement on (lane workers, writable roots) --no-worktree-retirement or LIBRECHAT_CODE_WORKTREE_RETIREMENT=false
Idle threshold 7 days --worktree-idle-days <n> or LIBRECHAT_CODE_WORKTREE_IDLE_DAYS=<n> (1-3650)
Per-worktree reasons not logged LIBRECHAT_CODE_LOG_LEVEL=debug

Invalid values fail startup. Each pass logs one line, for example librechat-code: worktree retirement: retired 3, kept 12 (dirty 2, recent 8, unpushed 2), freed about 4.1 GiB. The freed figure is the gain in free space on the checkouts' filesystems, so other writers make it approximate.

Changes

  • worktree-retirement.ts: the retirement pass, the background scheduler and settings parsing.
  • linked-worktrees.ts: LinkedWorktreeWorkspaceTools now tracks in-flight lane and checkout requests and last lane use. It exposes lastUsed and whileIdle, which reserves a lane during removal.
  • snapshot-lifecycle.ts / environment-preparation.ts: the storage guard accepts a reclaimSpace hook and measures again once before deferring setup.
  • cli.ts: wires the scheduler, opt-out, threshold, quarantine lookups and logging.
  • Docs: a new "Retiring stale linked worktrees" section in the package README. The runbook gains a "Disk filled by task worktrees" recovery section and updated common failures.

Tests

Every test uses real Git repositories with a bare origin remote in temp directories:

  • Retires a clean, pushed, idle worktree. Its branch is kept, it is restorable with git worktree add, and the main checkout is untouched.
  • Keeps worktrees that are dirty, have untracked files, are unpushed (branch or detached HEAD), are locked, are mid-merge, or were recently used. Also keeps a forged .worktrees/<x> and a plain directory.
  • Retires a pushed detached HEAD.
  • Retires a squash-merged branch and a rebase-merged branch whose remote branches were deleted, plus a detached review worktree whose commit was squash-merged. Keeps an unmerged branch whose remote branch was deleted, and a branch whose commit is on main but sits beyond its live upstream. All branches are kept.
  • Never touches the main checkout or worktrees outside .worktrees/.
  • An active lane request, an active checkout request, and this worker's recent lane use each keep the worktree.
  • A lane request that arrives during removal waits, then gets INVALID_REQUEST.
  • Lane and checkout quarantines are respected.
  • One failing worktree does not stop the pass.
  • The rotation prevents starvation.
  • Opt-out and threshold parsing work, the scheduler serializes passes, and the summary format is checked.
  • Low-space reclaim runs once before setup is deferred.

npx tsc --noEmit is clean. In WSL with Node 24.16.0, npm test gives 756 tests: 725 pass, 22 are skipped and 9 fail. All 9 failures also fail on unmodified main (a9528bc) on the same host, which fails 16 there: chmod/ACL storage and programmatic-watchdog tests that need the CI environment. The live SRT lane tests (linked-worktrees-live, root-git-denies-live) pass with LIBRECHAT_CODE_LIVE_SRT_TESTS=1.

Agents create one linked worktree per task under <checkout>/.worktrees and
nothing ever removed them. Each keeps its own dependencies and build output,
so a trusted-VM checkout accumulated 231 worktrees and filled its disk to 100%
three times, quarantining workspaces.

A worker with linked worktree lanes now retires stale worktrees in the
background: two minutes after start, every six hours, sooner while a backlog
remains, and before managed setup would be deferred for low disk space. A
worktree is retired only when it verifies as a linked worktree under
.worktrees, no request uses its lane or checkout, it has been idle for seven
days by both this worker's lane use and on-disk Git activity, it is clean,
unlocked, outside any merge/rebase/cherry-pick/revert/bisect, unquarantined,
and its work is published or merged: HEAD is contained in a remote-tracking
ref, or, with no live upstream (detached, never pushed, or upstream deleted
after merge), its commits are already in the remote's default branch by
content (patch-equivalent per git cherry, or identical content at every path
the branch changed). Squash-merged branches and abandoned review worktrees
therefore qualify; only local Git is consulted. Removal is
`git worktree remove` without --force followed by `git worktree prune`;
branches are kept so `git worktree add` restores any worktree.

Retirement is on by default. --no-worktree-retirement or
LIBRECHAT_CODE_WORKTREE_RETIREMENT=false disables it, and
--worktree-idle-days / LIBRECHAT_CODE_WORKTREE_IDLE_DAYS change the threshold.
LIBRECHAT_CODE_LOG_LEVEL=debug logs every kept worktree with its reason.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Pass 1. Head: d48e1c7

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-03T01:58:06.484017Z 1563f37 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d48e1c7f02

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/code/src/linked-worktrees.ts Outdated
Comment on lines +431 to +435
return await this.tracked(
this.checkoutRequests,
request.workspaceId,
() => this.options.delegate.execute(request, signal),
false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block checkout requests while retiring a child lane

When a checkout request starts after whileIdle performs its one-time checkoutRequests check, this path admits it without waiting for the retirement promise. Root requests can access .worktrees/* (as noted in worker.ts), so such a request can read or write the lane concurrently with the potentially long git worktree remove; a write that begins after Git's cleanliness check can be deleted, or the command can fail as its files disappear. Checkout requests need the same family-level fence used for arriving lane requests.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 82fa757. Valid finding. LinkedWorktreeWorkspaceTools now records each retirement with the workspace ID it belongs to. Checkout requests (execute without a worktree, and programmatic requests without workspace_worktree) now wait for any retirement in progress beneath their checkout before reaching the delegate, the same fence that arriving lane requests already used. whileIdle still refuses to start while a checkout request is in flight. Test: "a lane request that arrives during retirement waits, then finds the lane gone" now also issues a checkout read_file mid-removal, asserts it does not settle until the removal finishes, and then checks that it succeeds.

Comment on lines +593 to +595
for (const checkout of retiredCheckouts) {
try {
await git(checkout, ['worktree', 'prune']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid pruning unrelated worktree metadata

After any managed lane is retired, this repository-wide command can also expire metadata for every other missing registered worktree, including worktrees outside .worktrees whose paths are temporarily unavailable. git worktree prune -h describes the operation as expiring working trees older than the configured time; it is not scoped to the lane just removed. Since successful git worktree remove already deletes that lane's metadata, this extra prune can unexpectedly deregister and break an old external worktree.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 82fa757. Valid finding. The repository-wide git worktree prune is gone. git worktree remove already deletes the retired worktree's own .git/worktrees/<name> metadata, so the prune only ever affected other registered worktrees, including ones that are just temporarily unavailable. New test: "retirement never prunes other registered worktrees that are temporarily missing". It moves an external worktree away, retires a lane, and asserts that the external worktree's metadata survives while the retired lane's metadata is gone. The README and PR description now say so explicitly.

Comment on lines +315 to +317
for await (const entry of directory) {
if (names.length >= SCAN_LIMIT) return { names, truncated: true };
if (isValidLinkedWorktreeName(entry.name)) names.push(entry.name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rotate beyond the directory scan limit

When a checkout contains more than 4,096 entries under .worktrees, every pass reopens the directory from the beginning and returns after the same first batch. The returned truncated flag is only logged and no directory cursor or rotation state advances past these entries, so valid lanes later in the enumeration can remain permanently uninspected and never be reclaimed, precisely for the largest storage-pressure cases.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 82fa757. The per-checkout directory bound is now 65,536 entries, a memory bound far above any real checkout (the production case is ~230), so every pass sees every entry. The per-pass bound that matters is Git inspection: at most 128 idle worktrees inspected and 32 removed. That is already fair across passes through the rotation set added earlier, which inspects not-yet-visited candidates first and keeps scheduling 5-minute continuation passes until a rotation completes. I chose this over a persistent directory cursor because readdir order is not stable across entry creation and deletion, so a cursor could skip entries.

signal?.throwIfAborted();
try {
// Never --force: Git re-checks for changes and untracked files itself and refuses a dirty or locked worktree.
await git(lane.checkoutRoot, ['worktree', 'remove', lane.root], undefined, GIT_REMOVE_TIMEOUT_MS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not time out an in-progress worktree removal

When deleting a large worktree or operating on a slow filesystem takes more than 30 minutes, this timeout causes execFile to terminate Git even though removal is already in progress. The catch then reports an ordinary failed result, but Git may already have deleted the lane's .git file and part of its contents, leaving a half-removed worktree that subsequent passes cannot verify or retire—the exact state the comment above GIT_REMOVE_TIMEOUT_MS says must be avoided.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 82fa757. Valid finding: the 30-minute timeout contradicted the comment above it. git worktree remove now runs with no timeout and no abort signal, so neither a slow filesystem nor worker shutdown can interrupt it mid-delete. WorktreeRetirementScheduler.stop() already awaits the running pass, and the pass checks the abort signal only between worktrees, so shutdown waits for the current removal to finish rather than killing it. The probes before removal (status, rev-parse, for-each-ref, cherry, diff) keep their 30-second timeouts.

Comment on lines +423 to +425
return await this.tracked(this.laneRequests, internalId, async () => {
await this.retiring.get(internalId);
return await task();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor cancellation while waiting for retirement

When a lane request arrives during a long retirement and its caller subsequently aborts, this unconditional await does not observe the request's AbortSignal. The request remains counted as in flight and occupies its assignment or lease until removal settles—potentially for many minutes or indefinitely if removal is allowed to finish safely—and only then invokes the task with an already-aborted signal. The retirement wait should race or otherwise subscribe to cancellation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 82fa757. Valid finding. The wait on a retirement (for lane and checkout requests alike) now races the request's AbortSignal. A cancelled request rejects with the signal's reason as soon as it aborts, leaves the in-flight count through the existing finally, and never runs its task. Test: the same mid-retirement test starts a lane request with an AbortController, aborts it while the removal is gated, and asserts that it rejects with the abort reason before the retirement is released.

…Short

- Checkout requests wait for an in-progress retirement of any lane beneath
  them, since root commands can reach .worktrees/*.
- Requests waiting on a retirement honor their own cancellation.
- git worktree remove runs without a timeout; a killed removal could leave a
  half-deleted worktree that no later pass can verify.
- Drop the repository-wide git worktree prune: remove already deletes the
  retired worktree's metadata, and prune would also expire other registered
  worktrees that are only temporarily unavailable.
- Read every .worktrees entry each pass instead of the first 4096.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Pass 2. Head: 82fa757

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82fa757a28

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +580 to +583
const retire = (): Promise<Kept | 'retired'> => remove(candidate, verdict.head, options.activity, signal);
const reserved = options.activity
? await options.activity.whileIdle(source.workspaceId, name, retire)
: { ran: true as const, value: await retire() };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recheck quarantine after reserving the lane

If a checkout-level request starts after inspect() checks quarantine, fails ambiguously, and finishes before whileIdle() runs, it can leave the checkout quarantined while no longer appearing in checkoutRequests. This path then removes the lane without rereading either checkout or lane quarantine; because checkout commands can reach .worktrees/* and Git permits removal of ignored files, it can delete state from the uncertain mutation that quarantine was meant to preserve. Recheck both quarantine files inside the reserved retire callback before calling remove().

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bf294ed. Valid finding. remove(), which runs inside the whileIdle reservation, now re-reads both the checkout's and the lane's quarantine markers before anything else touches the tree. If either is present it keeps the worktree as quarantined, and an unreadable marker also counts as quarantined. Because the reservation excludes every lane and checkout request from that point until removal finishes, no new quarantine can appear between this check and git worktree remove. New test: "a quarantine that appears after inspection is caught under the lane reservation" makes the checkout marker, and then the lane marker, appear only after inspection, and asserts that the worktree is kept both times.

A checkout or lane request that finished between inspection and the lane
reservation may have left an uncertain mutation quarantined. Re-read both
quarantine markers inside the reservation, immediately before removal.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Pass 3. Head: bf294ed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bf294eddf3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +591 to +594
const retire = (): Promise<Kept | 'retired'> => remove(candidate, verdict.head, options, signal);
const reserved = options.activity
? await options.activity.whileIdle(source.workspaceId, name, retire)
: { ran: true as const, value: await retire() };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Account for checkout requests during inspection

When a successful checkout-level command runs after git status has returned but before this reservation is installed, it can write an existing ignored file—or a file below an existing ignored directory—in .worktrees/<name> and finish. Such a write changes neither the tracked lane activity nor the worktree root directory mtime, checkout requests do not update the lane's lastUsed, and a successful command leaves no quarantine; the final git worktree remove then treats the lane as clean and deletes the newly written ignored data. The new under-reservation quarantine recheck only closes the ambiguous-failure path, so checkout activity must be fenced throughout inspection or recorded and rechecked before removal.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1563f37 with the record-and-recheck option, not by fencing checkout requests during inspection, so a pass never holds back the checkout. LinkedWorktreeWorkspaceTools now exposes checkoutActivity(workspaceId), a counter bumped when each checkout request (outside any lane) starts and when it finishes. Counting both start and finish matters: a request already in flight when inspection begins and finishing before the reservation also changes the counter. The pass captures the counter just before inspecting each worktree. remove() re-reads it under the whileIdle reservation and keeps the worktree as changed for a later pass if any checkout request started or finished in between. From the reservation until removal finishes, checkout requests are fenced as before, so the window is closed end to end. New test: "a checkout request that runs during inspection defers retirement" covers the counter itself, the deferral, and retirement on the next quiet pass.

A checkout request can write ignored files inside .worktrees/<name> that
git status never reports, and finish before the lane reservation. Count
checkout request starts and finishes, capture the count before inspecting
each worktree, and keep the worktree for a later pass when it changed by the
time the reservation is held.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Pass 4 of 4 (final review). Head: 1563f37

This is the final review: please flag only blocking issues.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 1563f37289

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…faultBranch

On CI the bare remote's HEAD names master, so the hosting clone had an empty
HEAD and `git merge --squash` refused to run.
@danny-avila
danny-avila merged commit 032a93f into main Oct 3, 2026
11 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