[Bug]: Deleting a thread leaves its subagent threads behind, so "Delete worktrees with deleted threads" never removes the worktree#17812

opened14d agobyTtris203

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. In Settings → Storage, enable Delete worktrees with deleted threads (worktreeOnDelete) and leave the other worktree rules off.
  2. Start a thread in a new worktree and let the agent start at least one subagent (delegate_task, Claude's Agent tool, or a Codex native subagent).
  3. Let everything finish, leave the checkout clean, close its terminals.
  4. Delete the thread.
  5. Run storage cleanup (or wait for the sweep the delete triggers).

I ran the cleanup half as a test against main @ f7664fdf42; the UI steps above are derived from the code and I have not run them end to end. In apps/server/src/storageCleanup.test.ts, make the fixture's SqlClient mock return a deleted parent row for the deleted_at IS NOT NULL query, set worktreeOnDelete: true / worktreeAfterDays: null, and add:

ts
1const parentId = ThreadId.make("thread-1");
2const subagent = (id: string, worktree: string) =>
3 shell({
4 id: ThreadId.make(id),
5 title: `Subagent: ${id}`,
6 branch: "feature",
7 worktreePath: worktree,
8 status: "completed",
9 creationSource: "mcp",
10 lineage: { rootThreadId: parentId, parentThreadId: parentId, relationshipToParent: "subagent" },
11 });
12
13// deleted parent row: JSON.stringify(shell({ id: parentId, branch: "feature",
14// worktreePath: worktree, deletedAt: at(-DAY_MS) })) with workspaceRoot: repo
15
16fixture.setThreads([subagent("child-1", fixture.worktree), subagent("child-2", fixture.worktree)]);
17const report = yield* fixture.service.runNow;
18expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); // fails

With fixture.setThreads([]) (no surviving subagent threads) the same test passes and the worktree is removed.

Expected behavior

Deleting a thread removes its worktree under the "deleted threads" rule, whether or not the thread ever used a subagent. The subagent threads are not separate conversations the user can still reach, so they should not keep the checkout alive.

Actual behavior

The worktree is kept indefinitely.

  • planThreadDeletion (apps/server/src/orchestration-v2/ThreadDeletion.ts) emits thread.deleted only for the commanded thread. Its subagent threads stay live, still carrying the parent's worktreePath (copied by makeSubagentChildThread).
  • cleanWorktrees drops a deleted thread as a candidate when any live thread uses the same path (deletedThreads.filter((thread) => !groups.has(...))), and the recheck before removal keeps on latest.length > 0.
  • The surviving subagent thread becomes the candidate instead. Report entries from the test:
Surviving subagent threadsOutcomeReasonEntry attributed to
0removedThread was deletedthe deleted thread
1keptHas commits beyond the default branchSubagent: child-1
2keptShared by 2 threadsSubagent: child-1

The one-subagent reason is wrong for this checkout: the fixture worktree has no commits beyond main.

The orphaned subagent threads are hidden from the web sidebar and the mobile thread list, and the deleted parent's Agents surface was their only entry point, so the user has no way to delete them and release the worktree.

The web delete flow looks to have the same gap. getOrphanedWorktreePathForThread (apps/web/src/worktreeCleanup.ts) treats any other thread on the path as a sharer, without looking at lineage, so the "Delete the worktree too?" prompt should not appear for a thread with subagents. I read this from the code and did not test it.

Related:

  • #16472 / #16474 cover the live-parent case (a thread and its own subagents counting as sharers). The triage comment on #16472 mentions this deleted case in one bullet, but #16474 does not change the deleted-thread filter or its recheck. Its storageCleanupWorktreeOwner returns null when the parent is missing from the snapshot, so with that PR a path held only by orphaned subagent threads would produce no candidate at all, including the one-subagent case that age and merge rules can still reach today.
  • #16457 is a different cause of the same symptom (a provider session left ready after delete).

Two possible directions, for a maintainer to pick: cascade thread.delete to subagent descendants in planThreadDeletion, or have cleanup treat subagent threads whose parent is deleted as part of the deleted thread. A cascade alone would not release worktrees held by orphans that already exist.

Impact

Major degradation or frequent failure

Version or commit

main @ f7664fdf42; also T3 Code Nightly 0.0.46-nightly.20261010.2922 (same code path, not separately tested)

Environment

Linux 7.1.5, Node 24.3.0 (test run)

Workaround

Remove the checkout by hand with git worktree remove <path>, or delete the whole project, which deletes every thread in it.