Skip to content

Parallelize workspace deletion for faster teardown#1120

Merged
saddlepaddle merged 1 commit into
mainfrom
parallelize-delete
Feb 2, 2026
Merged

Parallelize workspace deletion for faster teardown#1120
saddlepaddle merged 1 commit into
mainfrom
parallelize-delete

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented Feb 1, 2026

Summary

  • Run terminal kills and teardown scripts concurrently instead of sequentially
  • Narrow project lock scope to only git worktree removal (previously held during teardown)
  • Replace git worktree remove --force with rename + prune + background rm -rf, reducing worktree removal from ~24s to <100ms
  • Rewrite teardown runner with spawn for proper stdout/stderr streaming and process group timeout handling
  • Parallelize terminal session kills with Promise.allSettled
  • Use spawned rm -rf for background cleanup (Node's fs.rm hangs on macOS .app bundles with extended attributes)

Test plan

  • Delete a worktree workspace with teardown configured — should complete in ~4-5s instead of ~60s
  • Delete a worktree workspace without teardown — should be near-instant
  • Delete a branch workspace — should work unchanged
  • Verify no orphaned /tmp/superset-wt-* directories accumulate over time
  • Verify teardown stdout/stderr streams to main process logs

Summary by CodeRabbit

  • Bug Fixes

    • More reliable workspace deletion with safer removal sequencing, preserved in-flight status, and improved error/timeout handling for teardown and removal.
    • Better handling when worktree is already missing to avoid failed deletions.
  • Performance Improvements

    • Faster deletions via parallel terminal shutdowns and non-blocking background cleanup.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 1, 2026

📝 Walkthrough

Walkthrough

Restructures workspace deletion to parallelize terminal kills and teardown, run teardown via spawned shell with output and timeout, perform worktree removal via rename+background rm, use filesystem existence guards, and adjust status/lock handling and logging without changing public APIs.

Changes

Cohort / File(s) Summary
Workspace deletion procedure
apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts
Parallelizes terminal kills and teardown, preserves in-flight deleting status until completion, guards teardown with existsSync(), narrows project lock to the removal step, changes error/log handling, and adds start/teardown logs.
Worktree removal utils
apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
removeWorktree now renames the worktree to a temp sibling (UUID), prunes git metadata, and spawns a detached background /bin/rm -rf to delete the temp path; handles ENOENT by pruning metadata.
Teardown execution
apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts
Replaces exec with spawn for detached shell execution, captures real-time stdout/stderr, adds timeout and process-group kill, returns output in TeardownResult, updates runTeardown signature to include mainRepoPath, and validates .superset/config.json teardown array.
Terminal daemon manager
apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts
killByWorkspaceId parallelized using Promise.allSettled, aggregates killed/failed counts from results, and emits a final warning if any failures occurred.

Sequence Diagram

sequenceDiagram
    participant User
    participant DeleteProc as Delete Procedure
    participant Status as Status DB
    participant Terminal as Terminal Manager
    participant Teardown as Teardown Executor
    participant Git as Git Utilities

    User->>DeleteProc: initiate workspace delete
    DeleteProc->>Status: mark workspace as deleting / set status

    par Parallel operations
        DeleteProc->>Terminal: killByWorkspaceId(workspaceId)
        Terminal->>Terminal: Promise.allSettled(kill panes)
        Terminal-->>DeleteProc: { killed, failed }
    and
        DeleteProc->>Teardown: runTeardown(mainRepoPath, worktreePath, workspaceName) (if exists)
        Teardown->>Teardown: spawn shell, capture stdout/stderr, enforce timeout
        Teardown-->>DeleteProc: { success|fail, output }
    end

    DeleteProc->>Git: removeWorktree(worktreePath)
    Git->>Git: rename -> tempPath (UUID), git worktree prune
    Git->>Git: spawn detached /bin/rm -rf tempPath (background)
    Git-->>DeleteProc: removal initiated / results

    DeleteProc->>Status: update workspace_deleted tracking
    DeleteProc-->>User: deletion complete (status)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Suggested reviewers

  • AviPeltz

Poem

🐰 I nibbled code by moonlit light,

Kills and teardowns raced in flight,
Worktrees moved to a tidy heap,
Background rm sings lullaby deep,
Async hops — the cleanup's bright.

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description provides a comprehensive summary and test plan, but does not follow the repository's template structure with sections for Related Issues, Type of Change, Testing details, Screenshots, or Additional Notes. Consider restructuring the description to match the template: add a 'Type of Change' section (appears to be a Refactor), use the 'Testing' section for the test plan, and organize content into the required template sections.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Parallelize workspace deletion for faster teardown' clearly summarizes the main objective of the PR: parallelizing deletion steps for performance improvement.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch parallelize-delete

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 1, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch
  • ✅ Electric Fly.io app

Thank you for your contribution! 🎉

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts`:
- Around line 116-118: The empty catch after the process.kill(-child.pid,
"SIGKILL") call swallows errors; replace it with a catch that logs the failure
(including child.pid and the caught error) instead of doing nothing—use the
module's existing logger if available (e.g., processLogger or logger) or
console.warn/console.debug as a fallback, and keep behavior non-fatal (do not
rethrow) so benign "no such process" errors won't break teardown.
🧹 Nitpick comments (5)
apps/desktop/src/lib/trpc/routers/workspaces/procedures/init.ts (2)

65-69: Status-based check is correct; consider using TRPCError for consistency.

The transition from workspace.deletingAt to workspace.status === "deleting" correctly aligns with the new status-based lifecycle. However, per coding guidelines, tRPC procedures should throw TRPCError with appropriate codes rather than generic Error.

♻️ Suggested refactor to use TRPCError
+import { TRPCError } from "@trpc/server";
 if (workspace.status === "deleting") {
-  throw new Error(
-    "Cannot retry initialization on a workspace being deleted",
-  );
+  throw new TRPCError({
+    code: "BAD_REQUEST",
+    message: "Cannot retry initialization on a workspace being deleted",
+  });
 }

As per coding guidelines: "Use TRPCError with code BAD_REQUEST for invalid input that passed Zod validation" — this applies to state validation failures like attempting operations on a workspace in an incompatible lifecycle state.


59-77: Pre-existing: Error handling doesn't use TRPCError throughout.

The other error cases in this function also use generic Error instead of TRPCError. While not introduced by this PR, consider aligning them with guidelines in a follow-up:

  • Lines 60, 72, 76: Resource not found errors should use TRPCError with code NOT_FOUND.
apps/desktop/src/lib/trpc/routers/workspaces/utils/workspace-init.ts (1)

96-100: Consider using the setWorkspaceStatus helper for consistency.

The status update works correctly, but db-helpers.ts exports a setWorkspaceStatus helper for this purpose. Using it would consolidate status-update logic in one place and improve consistency across the codebase.

+import { setWorkspaceStatus } from "./db-helpers";
...
-			localDb
-				.update(workspaces)
-				.set({ status: "ready" })
-				.where(eq(workspaces.id, workspaceId))
-				.run();
+			setWorkspaceStatus(workspaceId, "ready");

This applies to all status updates in this file (lines 96-100, 254-258, 280-284, 301-305, 410-414, 444-448).

apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts (2)

128-138: Partial output lost in some error scenarios.

When the process emits an error event (line 106-109), the combined output captured before the error is not included in the returned output field. Consider preserving partial output for debugging.

♻️ Suggested improvement
+	let capturedOutput = "";
 	try {
 		const shellEnv = await getShellEnvironment();
 		// ... spawn logic ...
+		// Store combined in outer scope for error handling
 		const output = await new Promise<string>((resolve, reject) => {
 			// ...
+			// Update: make combined accessible outside
 		});
 		return { success: true, output: output || undefined };
 	} catch (error) {
 		const errorMessage = error instanceof Error ? error.message : String(error);
 		console.error(
 			`Teardown failed for workspace ${workspaceName}:`,
 			errorMessage,
 		);
 		return {
 			success: false,
 			error: errorMessage,
-			output: errorMessage,
+			output: capturedOutput || errorMessage,
 		};
 	}

130-133: Inconsistent logging prefix.

This log message lacks the [teardown] prefix used elsewhere in the file. As per coding guidelines, use prefixed console logging with pattern [domain/operation] message.

♻️ Proposed fix
 		console.error(
-			`Teardown failed for workspace ${workspaceName}:`,
+			`[teardown] Failed for workspace "${workspaceName}":`,
 			errorMessage,
 		);

Comment on lines +116 to +118
try {
if (child.pid) process.kill(-child.pid, "SIGKILL");
} catch {}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Empty catch block silently swallows process.kill errors.

Per coding guidelines, errors should not be silently swallowed. While process.kill failures may be benign (e.g., process already exited), logging helps with debugging.

🔧 Proposed fix
 				try {
 					if (child.pid) process.kill(-child.pid, "SIGKILL");
-				} catch {}
+				} catch (killError) {
+					console.warn(
+						`[teardown] Failed to kill process group:`,
+						killError instanceof Error ? killError.message : String(killError),
+					);
+				}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
if (child.pid) process.kill(-child.pid, "SIGKILL");
} catch {}
try {
if (child.pid) process.kill(-child.pid, "SIGKILL");
} catch (killError) {
console.warn(
`[teardown] Failed to kill process group:`,
killError instanceof Error ? killError.message : String(killError),
);
}
🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts` around lines
116 - 118, The empty catch after the process.kill(-child.pid, "SIGKILL") call
swallows errors; replace it with a catch that logs the failure (including
child.pid and the caught error) instead of doing nothing—use the module's
existing logger if available (e.g., processLogger or logger) or
console.warn/console.debug as a fallback, and keep behavior non-fatal (do not
rethrow) so benign "no such process" errors won't break teardown.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts (1)

770-783: ⚠️ Potential issue | 🟠 Major

Ensure kill runs even if history cleanup fails.

If cleanupHistory throws, the per-pane task rejects before client.kill, which can leave sessions alive and inflate failed counts used for the user warning. Catch cleanup errors, still attempt kill, and log kill failures with context.

💡 Suggested fix
-				await this.historyManager.cleanupHistory(paneId, workspaceId);
-				await this.client.kill({ sessionId: paneId, deleteHistory: true });
+				try {
+					await this.historyManager.cleanupHistory(paneId, workspaceId);
+				} catch (error) {
+					console.warn(
+						`[DaemonTerminalManager] Failed to cleanup history for ${paneId}:`,
+						error,
+					);
+				}
+				try {
+					await this.client.kill({ sessionId: paneId, deleteHistory: true });
+				} catch (error) {
+					console.warn(
+						`[DaemonTerminalManager] Failed to kill session ${paneId}:`,
+						error,
+					);
+					throw error;
+				}

As per coding guidelines: Never swallow errors silently; at minimum log them with context.

apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts (1)

129-137: ⚠️ Potential issue | 🟡 Minor

Prefix teardown failure logs.

This log is missing the required [domain/operation] prefix.

🔧 Suggested fix
-		console.error(
-			`Teardown failed for workspace ${workspaceName}:`,
-			errorMessage,
-		);
+		console.error(
+			`[teardown] Failed for workspace ${workspaceName}:`,
+			errorMessage,
+		);

As per coding guidelines: Use prefixed console logging with pattern [domain/operation] message for all logging.

🤖 Fix all issues with AI agents
In `@apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts`:
- Around line 236-252: The console logs in the error handling of the delete
workspace worktree removal should be prefixed with the domain/operation pattern;
update the console.error and console.warn calls in
apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts (the block
that checks errorMessage and references worktree.path and
clearWorkspaceDeletingStatus) to include a prefix like "[workspaces/delete]" at
the start of each message so both the error logged when removal fails and the
warning when the worktree is not found follow the "[domain/operation] message"
convention.
🧹 Nitpick comments (2)
apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts (1)

39-43: Use a params object for runTeardown.

The function now has 3 positional args; switch to a named params object to match the project guideline and improve call-site clarity.

♻️ Suggested refactor
-export async function runTeardown(
-	mainRepoPath: string,
-	worktreePath: string,
-	workspaceName: string,
-): Promise<TeardownResult> {
+export async function runTeardown(params: {
+	mainRepoPath: string;
+	worktreePath: string;
+	workspaceName: string;
+}): Promise<TeardownResult> {
+	const { mainRepoPath, worktreePath, workspaceName } = params;

As per coding guidelines: Functions with 2+ parameters should accept a single params object with named properties instead of positional arguments.

apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts (1)

196-212: Pass runTeardown parameters as an object.

Align the call with the params-object guideline (and the suggested runTeardown refactor).

♻️ Suggested change
-						teardownPromise = runTeardown(
-							project.mainRepoPath,
-							wt.path,
-							workspace.name,
-						).then((result) => {
+						teardownPromise = runTeardown({
+							mainRepoPath: project.mainRepoPath,
+							worktreePath: wt.path,
+							workspaceName: workspace.name,
+						}).then((result) => {

As per coding guidelines: Functions with 2+ parameters should accept a single params object with named properties instead of positional arguments.

Comment thread apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts
- Run terminal kills and teardown scripts concurrently instead of sequentially
- Narrow project lock scope to only git worktree removal (was held during teardown)
- Replace `git worktree remove --force` with rename + prune + background rm -rf,
  reducing worktree removal from ~24s to <100ms
- Use spawned `rm -rf` for background cleanup (Node's fs.rm hangs on macOS .app bundles)
- Rewrite teardown runner with spawn for proper stdout/stderr streaming and process group timeout
- Parallelize terminal session kills with Promise.allSettled
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts (1)

426-429: ⚠️ Potential issue | 🟡 Minor

Prefix logs in deleteWorktree procedure.

The deleteWorktree procedure also has logs missing the [domain/operation] prefix.

🔧 Suggested fix
 					if (!teardownResult.success) {
 						console.error(
-							`Teardown failed for worktree ${worktree.branch}:`,
+							`[workspace/delete] Teardown failed for worktree ${worktree.branch}:`,
 							teardownResult.error,
 						);
 					}
 ...
 					} else {
 						console.warn(
-							`Worktree ${worktree.path} not found in git, skipping removal`,
+							`[workspace/delete] Worktree ${worktree.path} not found in git, skipping removal`,
 						);
 					}
 ...
-					console.error("Failed to remove worktree:", errorMessage);
+					console.error("[workspace/delete] Failed to remove worktree:", errorMessage);

As per coding guidelines: Use prefixed console logging with pattern [domain/operation] message for all logging.

Also applies to: 437-439, 444-444

🤖 Fix all issues with AI agents
In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts`:
- Around line 681-688: The empty catch on the fallback git prune call hides
failures; update the try/catch around execFileAsync("git", ["-C", mainRepoPath,
"worktree", "prune"], { env, timeout: 10_000 }) so it logs the caught error
instead of swallowing it—use the same logger used elsewhere (or console.error if
none) and include context like mainRepoPath and the error.message; keep the
original behavior (no rethrow) but ensure failures from getGitEnv/execFileAsync
are recorded for debugging.
🧹 Nitpick comments (2)
apps/desktop/src/lib/trpc/routers/workspaces/utils/teardown.ts (1)

56-58: Shell selection logic is duplicated from shell-env.ts.

This same pattern exists in shell-env.ts (lines 40-41). Consider extracting a shared helper to avoid drift between the two implementations.

♻️ Suggested approach

Add a shared helper in shell-env.ts:

export function getDefaultShell(): string {
  return process.env.SHELL || (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash");
}

Then import and use it here:

-		const shell =
-			process.env.SHELL ||
-			(process.platform === "darwin" ? "/bin/zsh" : "/bin/bash");
+		const shell = getDefaultShell();
apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts (1)

670-676: Consider including the exit code in the error log for easier debugging.

The current log shows exit ${code} but code could be null if the process was killed by a signal.

🔧 Suggested enhancement
 		child.on("exit", (code: number | null) => {
-			if (code !== 0) {
+			if (code !== 0 && code !== null) {
 				console.error(
 					`[removeWorktree] Background cleanup of ${tempPath} failed (exit ${code})`,
 				);
 			}
 		});

Or handle the signal case:

-		child.on("exit", (code: number | null) => {
+		child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
 			if (code !== 0) {
 				console.error(
-					`[removeWorktree] Background cleanup of ${tempPath} failed (exit ${code})`,
+					`[removeWorktree] Background cleanup of ${tempPath} failed (exit ${code}, signal ${signal})`,
 				);
 			}
 		});

Comment on lines +681 to +688
try {
const env = await getGitEnv();
await execFileAsync("git", ["-C", mainRepoPath, "worktree", "prune"], {
env,
timeout: 10_000,
});
} catch {}
return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Empty catch block silently swallows git worktree prune errors.

When the worktree directory is already gone (ENOENT), the fallback prune operation's errors are silently discarded. Per coding guidelines, at minimum log the error for debugging.

🔧 Proposed fix
 		if (code === "ENOENT") {
 			try {
 				const env = await getGitEnv();
 				await execFileAsync("git", ["-C", mainRepoPath, "worktree", "prune"], {
 					env,
 					timeout: 10_000,
 				});
-			} catch {}
+			} catch (pruneError) {
+				console.warn(
+					`[removeWorktree] Failed to prune after ENOENT:`,
+					pruneError instanceof Error ? pruneError.message : String(pruneError),
+				);
+			}
 			return;
 		}

Based on learnings: "Never silently swallow errors with catch(() => {}) or catch(e) { return null }"

🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts` around lines 681 -
688, The empty catch on the fallback git prune call hides failures; update the
try/catch around execFileAsync("git", ["-C", mainRepoPath, "worktree", "prune"],
{ env, timeout: 10_000 }) so it logs the caught error instead of swallowing
it—use the same logger used elsewhere (or console.error if none) and include
context like mainRepoPath and the error.message; keep the original behavior (no
rethrow) but ensure failures from getGitEnv/execFileAsync are recorded for
debugging.

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