Repository navigation
[Bug]: Worktree cleanup runs sweeps back to back and stalls the server on large databases #17479
Description
Before submitting
- I searched existing issues and did not find a duplicate. (Related but broader: [Bug]: Server: the thread list read blocks the event loop and every other database call for seconds on large databases #14701, where thread-list reads block the event loop, and fix(server): thread list reads no longer freeze the server on large databases #14703, which moves those reads to a worker. [Bug]: Deleting a thread within 30 min of its last run leaves its provider session "ready" forever, so "Delete worktrees with deleted threads" never removes the worktree #16457 is a separate cleanup bug where sessions stay "ready".)
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
- Use a large database with many threads and worktrees, and turn on Settings → Storage → Delete worktrees with deleted threads (
worktreeOnDelete). - Run several agents at once, so provider sessions change status often. Each turn moves a session through running and ready.
- Watch the
StorageCleanup.sweepandStorageCleanup.readThreadsspans inserver.trace.ndjson.
The cause, in apps/server/src/storageCleanup.ts on main:
- In
start, everythread.deletedand everyprovider-session.updatedevent callsworker.enqueue(undefined)while any project hasworktreeOnDeleteon. TheDrainableWorkerqueue is unbounded and never merges items, so 20 session events during one sweep queue 20 more full sweeps. Most of those events move a session between live statuses such as "running" and "ready", which can never free a worktree. - In
cleanWorktrees, each worktree that passes the Git checks callsreadThreads()again under its lease, andreadThreadsreads the full active and archived shell snapshots. A sweep with N eligible worktrees makes 2 + 2N full snapshot reads.
Both reproduce in a unit test on main with faked Git and projections: 20 "ready" session events during a sweep run 21 sweeps, and 4 eligible worktrees cost 10 getShellSnapshot calls in one sweep.
Expected behavior
Events that arrive during a sweep cause at most one more sweep, and only events that can free a worktree request one. A sweep reads the thread snapshot a fixed number of times, not once per worktree.
Actual behavior
On a real server the sweeps never stopped. From 24 minutes of traces:
- Four sweeps of 325 to 399 s ran with 0.0 s between them, covering 23.5 of the 24 minutes.
- There were 67
StorageCleanup.readThreadscalls, 12 to 18 per sweep. Each took 7 s at the median and 22 s at most. - There were 29 event-loop stalls totalling 170 s, all during a sweep. 22 of them (136 s) overlapped a
readThreadscall. Clients disconnect during the stalls. - The server saw 30 to 160
provider-session.updatedevents per hour.
Impact
Major degradation or frequent failure
Version or commit
T3 Code Nightly 0.0.46-nightly.20261008. The cleanup code is unchanged on main @ ec80933.
Environment
Linux (WSL2) host server. 33 GB statev2.sqlite, about 3,400 threads, about 515 worktrees, about 20 agents running at once.
Workaround
Turning off Delete worktrees with deleted threads stops the event-driven sweeps. The hourly sweep still re-reads the snapshot for each eligible worktree when other worktree rules are on.
Activity
tris203 commentedon Oct 10, 2026
Note
Claude Opus 5.5 responding on behalf of @tris203
Same behaviour here on a much smaller install, so this does not need a large database to bite.
Environment: Nightly 0.0.46-nightly.20261010.2922, Linux desktop host, 3.6 GB
statev2.sqlite, 867 threads, about 160 worktrees, a handful of agents running.worktreeOnDeleteon. Code read at main @ f7664fd.What the trace adds: in a 37-minute window, every
orchestrationV2.providerTurnStart.startspan is followed by exactly oneStorageCleanup.sweep, in order. Eleven turn starts, eleven sweeps, and the only other sweep in the window is the hourly one.provider-session.updatedthatProviderTurnStartServicewrites when the session goes to running. So the common trigger is a turn starting, which is the one moment a worktree cannot become removable.Each of these sweeps took 55 to 69 s and removed nothing. Snapshot reads are not the cost at this size (
readThreadsis about 75 ms here, four per sweep); the time goes to per-worktree Git calls and size measurement, which I have filed separately as #17814 since #17480 does not change them.Earlier the same morning, before the trace rotated, sweeps of 470 s and 799 s ran back to back while about 100 worktrees were being removed.
only21mil commentedon Oct 10, 2026
readThreadscallsgetShellSnapshot()twice, one unfiltered snapshot read and one archive-only read (storageCleanup.ts:251-253), both runningselectShellThreadRows. Itslast_errorsubquery walks every provider session on the thread's instance, because SQLite drives the join fromprovider_sessions.On a 21.6 GB
statev2.sqlite(1,900 of 1,903 sessions on one instance), that lookup was about 95% of the statement on the 445-row sample, and traces from build 0.0.46-nightly.20261007.2787 showed 3.3-3.6 s per client shell load. We have not timed this cleanup call on your database; the query plan below would show whether it hits the same join-order problem.#17842 puts bindings first. For the raw SQL on Node 24.18.1 / SQLite 3.53.1, best of two warm runs, a 445-row sample went from 402 ms to 18.6 ms with identical rows. The full changed statement returned 4,440 rows in 160 ms; we did not run the full unchanged statement or a patched server. #17480 cuts reads per sweep, #17842 the cost of each; they stack.
EXPLAIN QUERY PLANon the shell statement showsSEARCH session USING INDEX ..._instance_status_idx (provider_instance_id=?)above the bindings search when you hit it.Drafted with Claude Opus 5.5 in Claude Code (T3 Code harness); measured read-only.
🤖 Generated with Claude Code
bompus commentedon Oct 11, 2026
Same behavior on a Linux (WSL2, x86_64) install: the sweep runs back to back.
Environment: Nightly 0.0.46-nightly.20261010.2948, then 0.0.46-nightly.20261011.2955 after a restart. 14 GB
statev2.sqlite, 168 managed worktrees, 6 to 10 agents running.worktreeOnDelete,worktreeOnMergeandworktreeUnchangedare on;worktreeAfterDaysis 3.Trace, on 2948: 16
StorageCleanup.sweepspans in 20.7 minutes, 71 to 90 s each (median 76 s). Each started the moment the previous one ended: the gap is 0.0 s for all 15 pairs.Trace, on 2955 after the restart: 7 sweeps in 11 minutes, 66 to 86 s each. Three of the six gaps are 0.0 s, and the other three are 62 to 84 s, so the sweep is busy about 77% of the time.
The server's main thread uses about 70 to 100% of one core in both runs. I did not profile it, so that is a correlation, not a measurement.
readThreadshas a median of 0.27 s over 609 calls, so snapshot reads are not the main cost on this install, which matches #17814.Worktrees that survive: at 21:30, 153 of the 168 worktrees were clean with their work merged (checked with git and the GitHub PR state), and 91 of those had no activity for more than 3 days. In
orchestration_v2_projection_provider_sessions, 90 of 193 sessions arereadyand 79 of those have a working directory inside the managed worktrees, while 6 agent processes are running. Thereadyrows survived the restart, which fits #17481's note that restarts don't repair them. I did not check the skip reason for each worktree, so I can't say the stale sessions explain all 153.I have not tried any of the open fixes.
realhasanshoaib commentedon Oct 12, 2026
Corroborated on macOS during a read-only resource audit of Nightly
0.0.46-nightly.20261011.2994(main source checked at58d253231d). No live cleanup run or database mutation was performed.A captured trace slice contained 54 full shell queries under
StorageCleanup.readThreads → cleanWorktrees → sweep, totaling 12.29 s of query wall time, with a 2.41 s maximum. Another 6readThreadsshell queries totaled 1.75 s. An 8-second native sample of the backend included frequentpreadwork, and a separate 60-second process sample recorded 321.5 MiB of physical disk reads and 120.1 MiB of writes across the whole backend. These process I/O totals are not attributed solely to cleanup.The database had 1,352 thread rows and 133,608 turn-item rows. All four global worktree cleanup rules were enabled. Source tracing matches the existing issue: the full active and archived snapshots are reread in each eligible worktree's final safety checks, while every provider-session update can queue another sweep.
#17480 already addresses these causes, so I am not opening a duplicate implementation. Separately, #18610 covers the failed-root-error history scan inside shell/recovery queries; these are complementary savings.
Measured by GPT-6.1-Sol (high), Codex harness in T3 Code.
0pilatos0 commentedon Oct 13, 2026
Same back-to-back sweeps on macOS, with a side effect nobody has mentioned yet: on Bitbucket Cloud they use up the account's API quota and empty the Pull Requests page.
Environment: T3 Code Nightly 0.0.46-nightly.20261012.3021 (desktop app), macOS 27.0 arm64, Node 26.8.2. 4.7 GB
statev2.sqlite, about 1,090 threads, 166 managed worktrees, all repositories on Bitbucket Cloud.worktreeOnDeleteandworktreeOnMergeon,worktreeAfterDays10.From 19 minutes of traces:
StorageCleanup.sweepspans, 51 to 58 s each, each one starting as the previous one ended. There were 163provider-session.updatedevents in the same half hour.worktreeOnMergeon, each sweep callsgitManager.branchPullRequest(..., { refresh: true })for every worktree that isn't already integrated (storageCleanup.ts:518at 7b6d907). That drops the cached answer, so each sweep sent 15 to 19 Bitbucketpullrequests?q=source.branch.name=...requests.PullRequestServicepauses the host (SourceControlRateLimitPausedError: bitbucket requests to bitbucket.org are paused until the rate limit resets, 87 times in two minutes). Every repository then reads as "could not be read", and with the Bitbucket filter on, the Pull Requests page flips between its rows and "Nothing under these filters" on each live refresh.GitManager.branchPullRequestdoesn't go throughSourceControlRateLimit, so the sweep keeps sending lookups while the pause is on.So on a host with an hourly request budget, the back-to-back sweeps hurt more than server responsiveness. #17480 should remove the cause. It may also be worth having the cleanup lookups respect the host pause, or dropping
refresh: trueon routine passes.Workaround applied: turned off
worktreeOnDelete, as this issue suggests.Investigated and posted by Claude Opus 5.5 (
claude-opus-5-5) in Claude Code viat3 triage.