diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 12241279e..9af0f8172 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -2775,7 +2775,7 @@ describe("laneService delete teardown + cancellation + streaming", () => { listRuntime: vi.fn(() => [ { status: "running", processId: "vite", laneId: "lane-target", runId: "r1" } as any, ]), - stopAll: vi.fn(async () => { + stopAll: vi.fn(async (_args: { laneId: string }) => { calls.push("stop_processes"); }), }; @@ -3000,7 +3000,65 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(service.hasRunningDelete()).toBe(false); }); - it("queues lane creation while an in-flight delete owns the worktree mutation slot", async () => { + it("runs independent lane delete teardown concurrently", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { service, db, repoRoot } = await setupWithLane({ teardown: fake, events }); + const now = "2026-03-11T12:00:00.000Z"; + const siblingPath = path.join(repoRoot, "sibling"); + fs.mkdirSync(siblingPath, { recursive: true }); + db.run( + ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ["lane-sibling", "proj-delete", "Sibling", null, "worktree", "feature/parent", "feature/sibling", siblingPath, null, 0, "lane-parent", null, null, null, "active", now, null], + ); + + const order: string[] = []; + const startedStops = new Set(); + let releaseStops: (() => void) | null = null; + const stopGate = new Promise((resolve) => { + releaseStops = resolve; + }); + fake.processService.stopAll.mockImplementation(async ({ laneId }: { laneId: string }) => { + startedStops.add(laneId); + order.push(`stop:${laneId}`); + await stopGate; + }); + vi.mocked(runGit).mockImplementation(async (args: string[], opts?: { cwd?: string }) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + if (args[0] === "status") return { exitCode: 0, stdout: "", stderr: "" } as any; + if (args[0] === "show-ref") return { exitCode: 1, stdout: "", stderr: "" } as any; + if (args[0] === "worktree" && args[1] === "remove") { + order.push(`worktree:${opts?.cwd ?? ""}:${args[2] ?? args[3] ?? ""}`); + return { exitCode: 0, stdout: "", stderr: "" } as any; + } + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); + + const deletePromises = [ + service.delete({ laneId: "lane-child", deleteBranch: false, force: true }), + service.delete({ laneId: "lane-sibling", deleteBranch: false, force: true }), + ]; + await new Promise((resolve) => setTimeout(resolve, 40)); + + expect([...startedStops].sort()).toEqual(["lane-child", "lane-sibling"]); + expect(order.some((entry) => entry.startsWith("worktree:"))).toBe(false); + + expect(releaseStops).not.toBeNull(); + releaseStops!(); + await Promise.all(deletePromises); + + expect(db.get<{ id: string }>("select id from lanes where id = ?", ["lane-child"])).toBeNull(); + expect(db.get<{ id: string }>("select id from lanes where id = ?", ["lane-sibling"])).toBeNull(); + }); + + it("allows lane creation while an in-flight delete is still in teardown", async () => { const events: any[] = []; const fake = makeFakeServices(); const order: string[] = []; @@ -3043,8 +3101,11 @@ describe("laneService delete teardown + cancellation + streaming", () => { await stopStartedPromise; const createPromise = service.create({ name: "New lane", parentLaneId: "lane-parent" }); - await new Promise((r) => setTimeout(r, 20)); - expect(order).not.toContain("create:worktree_add"); + for (let i = 0; i < 10 && !order.includes("create:worktree_add"); i += 1) { + await new Promise((r) => setTimeout(r, 5)); + } + expect(order).toContain("create:worktree_add"); + expect(order).not.toContain("delete:worktree_remove"); expect(releaseStop).not.toBeNull(); releaseStop!(); @@ -3052,7 +3113,7 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(order.indexOf("delete:worktree_remove")).toBeGreaterThanOrEqual(0); expect(order.indexOf("create:worktree_add")).toBeGreaterThanOrEqual(0); - expect(order.indexOf("delete:worktree_remove")).toBeLessThan(order.indexOf("create:worktree_add")); + expect(order.indexOf("create:worktree_add")).toBeLessThan(order.indexOf("delete:worktree_remove")); }); it("deletes the lane locally when optional remote branch cleanup fails", async () => { diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index de0c61e2c..4745e891e 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -4152,7 +4152,6 @@ export function createLaneService({ broadcastDeleteEvent(progress); - await runGitWorktreeMutation(async () => { try { if (hasWorktree) { await runStep("git_status", async () => { @@ -4342,7 +4341,6 @@ export function createLaneService({ finalize("failed"); throw error; } - }); }, cancelDelete(laneId: string): { cancelled: boolean; reason?: string } { diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts index e2d2e03a4..8cec3e3b9 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts @@ -9,7 +9,7 @@ import { resolveCreateLaneRequest, resolveLaneIdsDeepLinkSelection, resolveVisibleLaneIds, - runLaneDeleteBatchSequentially, + runLaneDeleteBatchWithConcurrency, selectGithubLanePrTag, selectLaneTabPrTag, selectLanePrTag, @@ -390,40 +390,47 @@ describe("selectLanePrTag", () => { }); }); -describe("runLaneDeleteBatchSequentially", () => { - it("runs independent lane deletes one at a time and preserves failures", async () => { +describe("runLaneDeleteBatchWithConcurrency", () => { + it("runs independent lane deletes two at a time and preserves failures", async () => { const lanes = [ { id: "lane-a", parentLaneId: null }, { id: "lane-b", parentLaneId: null }, { id: "lane-c", parentLaneId: null }, ]; - const order: string[] = []; + const starts: string[] = []; let active = 0; let maxActive = 0; + let releaseFirstWave: (() => void) | null = null; + const firstWaveGate = new Promise((resolve) => { + releaseFirstWave = resolve; + }); + let firstWaveStarted: (() => void) | null = null; + const firstWaveStartedPromise = new Promise((resolve) => { + firstWaveStarted = resolve; + }); - const results = await runLaneDeleteBatchSequentially(lanes, async (lane) => { + const resultsPromise = runLaneDeleteBatchWithConcurrency(lanes, async (lane) => { active += 1; maxActive = Math.max(maxActive, active); - order.push(`start:${lane.id}`); - await Promise.resolve(); + starts.push(lane.id); + if (starts.length === 2) firstWaveStarted?.(); + if (lane.id !== "lane-c") await firstWaveGate; if (lane.id === "lane-b") { active -= 1; - order.push(`fail:${lane.id}`); throw new Error("locked"); } active -= 1; - order.push(`done:${lane.id}`); }); - expect(maxActive).toBe(1); - expect(order).toEqual([ - "start:lane-a", - "done:lane-a", - "start:lane-b", - "fail:lane-b", - "start:lane-c", - "done:lane-c", - ]); + await firstWaveStartedPromise; + expect(starts).toEqual(["lane-a", "lane-b"]); + expect(maxActive).toBe(2); + + expect(releaseFirstWave).not.toBeNull(); + releaseFirstWave!(); + const results = await resultsPromise; + + expect(starts).toEqual(["lane-a", "lane-b", "lane-c"]); expect(results.map((result) => [result.lane.id, result.status])).toEqual([ ["lane-a", "fulfilled"], ["lane-b", "rejected"], diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 3398f0471..994e051c2 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -36,7 +36,7 @@ import { resolveLaneDeleteStartSelection, resolveLaneIdsDeepLinkSelection, resolveVisibleLaneIds, - runLaneDeleteBatchSequentially, + runLaneDeleteBatchWithConcurrency, selectLaneTabPrTag, shouldApplyLaneIdsDeepLink, sortLaneListRows, @@ -1620,7 +1620,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { }); if (runnable.length === 0) continue; - const results = await runLaneDeleteBatchSequentially( + const results = await runLaneDeleteBatchWithConcurrency( runnable, async (lane) => { const args = deleteArgsByLaneId.get(lane.id); diff --git a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts index 850fc9f70..d22480bd9 100644 --- a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts +++ b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts @@ -12,6 +12,8 @@ export type LaneDeleteBatchResult = | { status: "fulfilled"; lane: T } | { status: "rejected"; lane: T; reason: unknown }; +export const LANE_DELETE_BATCH_CONCURRENCY = 2; + export type LaneTabPrTag = { source: "ade" | "github"; id: string; @@ -100,19 +102,34 @@ export function planLaneDeleteBatches( +export async function runLaneDeleteBatchWithConcurrency( lanes: T[], deleteLane: (lane: T) => Promise, + concurrency = LANE_DELETE_BATCH_CONCURRENCY, ): Promise[]> { - const results: LaneDeleteBatchResult[] = []; - for (const lane of lanes) { - try { - await deleteLane(lane); - results.push({ status: "fulfilled", lane }); - } catch (reason) { - results.push({ status: "rejected", lane, reason }); + if (lanes.length === 0) return []; + + const results = new Array>(lanes.length); + const normalizedConcurrency = Number.isFinite(concurrency) + ? Math.floor(concurrency) + : LANE_DELETE_BATCH_CONCURRENCY; + const workerCount = Math.min(lanes.length, Math.max(1, normalizedConcurrency)); + let nextIndex = 0; + + await Promise.all(Array.from({ length: workerCount }, async () => { + while (nextIndex < lanes.length) { + const index = nextIndex; + nextIndex += 1; + const lane = lanes[index]!; + try { + await deleteLane(lane); + results[index] = { status: "fulfilled", lane }; + } catch (reason) { + results[index] = { status: "rejected", lane, reason }; + } } - } + })); + return results; } diff --git a/apps/ios/ADE/Views/Lanes/LaneBatchManageSheet.swift b/apps/ios/ADE/Views/Lanes/LaneBatchManageSheet.swift index 174702ff8..e2d867387 100644 --- a/apps/ios/ADE/Views/Lanes/LaneBatchManageSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneBatchManageSheet.swift @@ -1,5 +1,85 @@ import SwiftUI +let laneDeleteBatchConcurrency = 2 + +struct LaneBatchOperationResult { + let laneId: String + let result: Result +} + +func laneDeleteDependencyBatches(snapshots: [LaneListSnapshot]) -> [[String]] { + let laneIds = snapshots.map(\.lane.id) + let parentById = Dictionary(uniqueKeysWithValues: snapshots.compactMap { snapshot -> (String, String)? in + guard let parentLaneId = snapshot.lane.parentLaneId else { return nil } + return (snapshot.lane.id, parentLaneId) + }) + var remaining = Set(laneIds) + var batches: [[String]] = [] + + while !remaining.isEmpty { + let leafIds = laneIds.filter { laneId in + guard remaining.contains(laneId) else { return false } + return !laneIds.contains { candidateId in + remaining.contains(candidateId) && parentById[candidateId] == laneId + } + } + + let batchIds = leafIds.isEmpty + ? laneIds.first(where: { remaining.contains($0) }).map { [$0] } ?? [] + : leafIds + guard !batchIds.isEmpty else { break } + batches.append(batchIds) + for laneId in batchIds { + remaining.remove(laneId) + } + } + + return batches +} + +@MainActor +func runLaneDeleteBatchWithConcurrency( + laneIds: [String], + concurrency: Int = laneDeleteBatchConcurrency, + operation: @escaping (String) async throws -> Value +) async -> [LaneBatchOperationResult] { + guard !laneIds.isEmpty else { return [] } + + let normalizedConcurrency = max(1, min(laneIds.count, min(concurrency, laneDeleteBatchConcurrency))) + var results: [LaneBatchOperationResult] = [] + results.reserveCapacity(laneIds.count) + + var index = 0 + while index < laneIds.count { + let nextIndex = min(index + normalizedConcurrency, laneIds.count) + let chunk = Array(laneIds[index..( + laneId: String, + operation: (String) async throws -> Value +) async -> LaneBatchOperationResult { + do { + return LaneBatchOperationResult(laneId: laneId, result: .success(try await operation(laneId))) + } catch { + return LaneBatchOperationResult(laneId: laneId, result: .failure(error)) + } +} + struct LaneBatchManageSheet: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var syncService: SyncService @@ -25,34 +105,6 @@ struct LaneBatchManageSheet: View { .map(\.id) } - private var laneIdsDescendantFirst: [String] { - let selectedIds = Set(laneIds) - let parentById = Dictionary(uniqueKeysWithValues: snapshots.compactMap { snapshot -> (String, String)? in - guard let parentLaneId = snapshot.lane.parentLaneId else { return nil } - return (snapshot.lane.id, parentLaneId) - }) - - func selectedAncestorDepth(for laneId: String, visited: Set = []) -> Int { - guard !visited.contains(laneId), - let parentLaneId = parentById[laneId], - selectedIds.contains(parentLaneId) - else { return 0 } - - var nextVisited = visited - nextVisited.insert(laneId) - return 1 + selectedAncestorDepth(for: parentLaneId, visited: nextVisited) - } - - return laneIds.sorted { lhs, rhs in - let lhsDepth = selectedAncestorDepth(for: lhs) - let rhsDepth = selectedAncestorDepth(for: rhs) - if lhsDepth == rhsDepth { - return lhs < rhs - } - return lhsDepth > rhsDepth - } - } - var body: some View { NavigationStack { ScrollView { @@ -221,20 +273,29 @@ struct LaneBatchManageSheet: View { var deletedLaneIds: [String] = [] var failures: [String] = [] - let sortedIds = laneIdsDescendantFirst + let deleteBranch = deleteMode != .worktree + let deleteRemoteBranch = deleteMode == .remoteBranch + let remoteName = deleteRemoteName + let force = deleteForce - for laneId in sortedIds { - do { + for batch in laneDeleteDependencyBatches(snapshots: snapshots) { + let results = await runLaneDeleteBatchWithConcurrency(laneIds: batch) { laneId in try await syncService.deleteLane( laneId, - deleteBranch: deleteMode != .worktree, - deleteRemoteBranch: deleteMode == .remoteBranch, - remoteName: deleteRemoteName, - force: deleteForce + deleteBranch: deleteBranch, + deleteRemoteBranch: deleteRemoteBranch, + remoteName: remoteName, + force: force ) - deletedLaneIds.append(laneId) - } catch { - failures.append("\(laneId) (\(error.localizedDescription))") + } + + for result in results { + switch result.result { + case .success: + deletedLaneIds.append(result.laneId) + case .failure(let error): + failures.append("\(result.laneId) (\(error.localizedDescription))") + } } } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 74c64aa06..d8ba6e656 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -13,6 +13,65 @@ private func recentIso8601Fixture() -> String { return f.string(from: Date()) } +private actor LaneBatchDeleteRecorder { + private var started: [String] = [] + private var active = 0 + private var maxActive = 0 + private var startedWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var releaseWaiters: [CheckedContinuation] = [] + private var released = false + + func start(_ laneId: String) { + started.append(laneId) + active += 1 + maxActive = max(maxActive, active) + resumeStartedWaiters() + } + + func finish() { + active = max(0, active - 1) + } + + func waitForStartedCount(_ count: Int) async { + if started.count >= count { return } + await withCheckedContinuation { continuation in + startedWaiters.append((count: count, continuation: continuation)) + } + } + + func waitForRelease() async { + if released { return } + await withCheckedContinuation { continuation in + releaseWaiters.append(continuation) + } + } + + func release() { + released = true + let waiters = releaseWaiters + releaseWaiters = [] + for waiter in waiters { + waiter.resume() + } + } + + func startedIds() -> [String] { + started + } + + func maxActiveCount() -> Int { + maxActive + } + + private func resumeStartedWaiters() { + let ready = startedWaiters.filter { started.count >= $0.count } + startedWaiters.removeAll { started.count >= $0.count } + for waiter in ready { + waiter.continuation.resume() + } + } +} + final class ADETests: XCTestCase { func testTerminalDisplayReplaysCarriageReturnProgressUpdates() { let output = sanitizeTerminalOutputForDisplay("Downloading 10%\rDownloading 80%\rDownloading 100%\nDone") @@ -4979,6 +5038,82 @@ final class ADETests: XCTestCase { ) } + func testLaneDeleteDependencyBatchesKeepAncestorsAfterSelectedDescendants() { + let status = LaneStatus(dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false) + let runtime = LaneRuntimeSummary(bucket: "ended", runningCount: 0, awaitingInputCount: 0, endedCount: 1, sessionCount: 1) + + func snapshot(_ id: String, parentLaneId: String? = nil) -> LaneListSnapshot { + var snapshot = makeLaneListSnapshot( + id: id, + name: id, + laneType: "worktree", + baseRef: "main", + branchRef: "ade/\(id)", + worktreePath: "/project/.ade/worktrees/\(id)", + description: nil, + status: status, + runtime: runtime, + createdAt: "2026-03-20T00:00:00.000Z", + archivedAt: nil + ) + snapshot.lane.parentLaneId = parentLaneId + return snapshot + } + + let batches = laneDeleteDependencyBatches(snapshots: [ + snapshot("lane-parent"), + snapshot("lane-child-b", parentLaneId: "lane-parent"), + snapshot("lane-grandchild", parentLaneId: "lane-child-a"), + snapshot("lane-child-a", parentLaneId: "lane-parent"), + snapshot("lane-sibling"), + ]) + + XCTAssertEqual(batches, [ + ["lane-child-b", "lane-grandchild", "lane-sibling"], + ["lane-child-a"], + ["lane-parent"], + ]) + } + + @MainActor + func testLaneDeleteBatchRunnerStartsTwoDeletesAtOnceAndPreservesOrder() async { + enum DeleteError: Error { + case expected + } + + let recorder = LaneBatchDeleteRecorder() + + let task = Task { @MainActor in + await runLaneDeleteBatchWithConcurrency(laneIds: ["lane-a", "lane-b", "lane-c"]) { laneId -> String in + await recorder.start(laneId) + if laneId != "lane-c" { + await recorder.waitForRelease() + } + await recorder.finish() + if laneId == "lane-b" { + throw DeleteError.expected + } + return "\(laneId)-done" + } + } + + await recorder.waitForStartedCount(2) + let firstStartedIds = await recorder.startedIds() + let firstMaxActiveCount = await recorder.maxActiveCount() + XCTAssertEqual(firstStartedIds, ["lane-a", "lane-b"]) + XCTAssertEqual(firstMaxActiveCount, 2) + + await recorder.release() + let results = await task.value + + let finalMaxActiveCount = await recorder.maxActiveCount() + XCTAssertEqual(results.map(\.laneId), ["lane-a", "lane-b", "lane-c"]) + XCTAssertEqual(finalMaxActiveCount, 2) + XCTAssertEqual(try? results[0].result.get(), "lane-a-done") + XCTAssertThrowsError(try results[1].result.get()) + XCTAssertEqual(try? results[2].result.get(), "lane-c-done") + } + func testLaneCardRebaseWarningPrefersAutoRebaseStatusOverSuggestion() { var snapshot = makeLaneListSnapshot( id: "lane-rebase", diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index b1b700691..06773ca1d 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -56,7 +56,7 @@ Desktop fallback services (`apps/desktop/src/main/services/lanes/`): | File | Responsibility | |------|---------------| -| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, startup repair routines, branch switching, macOS VM placement wiring, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, verifies residual worktree files are gone before DB cleanup, and cleans the pack directory + DB rows. Deletes now run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. Branch switching rolls git checkout back to the previous branch when the database update fails. macOS VM placement links the lane to the current VM, rolls placement back on critical link failure, starts mirror sync best-effort, and emits a placement-change event. | +| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, startup repair routines, branch switching, macOS VM placement wiring, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, verifies residual worktree files are gone before DB cleanup, and cleans the pack directory + DB rows. Independent deletes can progress through teardown concurrently; only the `git_worktree_remove` step enters the shared worktree-mutation guard, so lane creation is not held behind unrelated stop/cleanup steps but still avoids concurrent edits to Git's worktree registry. Deletes run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. Branch switching rolls git checkout back to the previous branch when the database update fails. macOS VM placement links the lane to the current VM, rolls placement back on critical link failure, starts mirror sync best-effort, and emits a placement-change event. | | `autoRebaseService.ts` | Auto-rebase worker for stacked lanes, attention state, head-change handlers. Consults `resolvePrRebaseMode` to determine whether a lane with a linked PR should auto-rebase (`pr_target` strategy) or only surface manual attention (`lane_base` strategy). `listStatuses({ includeAll: true })` returns stored statuses without recomputing lane git status for PR workflow views. | | `rebaseSuggestionService.ts` | Emits rebase suggestions when a parent lane advances, dismiss/defer lifecycle. Each suggestion may include up to 20 `RebaseTargetCommit` entries showing the behind commits the rebase would pull in. | | `laneEnvironmentService.ts` | Environment init pipeline: env files, docker services, dependencies, mount points, copy paths (Phase 5 W1) | @@ -73,8 +73,8 @@ Renderer components: | File | Responsibility | |------|---------------| | `renderer/components/app/App.tsx` | Project tab host and route keep-alive shell. Keeps the Work surface mounted after first visit and now does the same for `/lanes`, parking the inactive Lanes surface with `inert` / `aria-hidden` instead of unmounting it. During cold project switches it renders a transition veil over the old project surface until the target project hydrates. | -| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer live same-branch GitHub repo inventory over terminal/stale ADE rows, then fall back to ADE-linked PR rows, so externally created PRs and open-after-closed branch reuse stay visible; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. The page forces one GitHub snapshot refresh on project/branch-signature changes and otherwise uses cached snapshot/event refreshes to avoid repeated PR polling from the Lanes tab. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` through `useAppStore().laneDeleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). On mount/project switch it hydrates active backend delete progress when available, but also keeps stored active delete progress long enough to move selection away and queue a refresh if the backend list is missing, stale, or temporarily failed. Batch deletes still run selected child lanes before their selected parents, but each batch deletes one lane at a time and records per-lane failures; a parent remains blocked if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. Work-tab action deeplinks scrub `action`, `laneId`, and `laneIds` after handling so modal routing cannot also rewrite split selection state. | -| `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR matching, same-repo GitHub PR guardrails for fork branch-name collisions, ADE-vs-GitHub PR tag precedence, terminal-state GitHub overrides for stale ADE PR rows, deep-link lane selection, action-deeplink query cleanup, create-lane request normalization, delete-start selection fallback, parent-before-child-safe batch delete planning, and `runLaneDeleteBatchSequentially` for serialized per-batch teardown. | +| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer live same-branch GitHub repo inventory over terminal/stale ADE rows, then fall back to ADE-linked PR rows, so externally created PRs and open-after-closed branch reuse stay visible; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. The page forces one GitHub snapshot refresh on project/branch-signature changes and otherwise uses cached snapshot/event refreshes to avoid repeated PR polling from the Lanes tab. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` through `useAppStore().laneDeleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). On mount/project switch it hydrates active backend delete progress when available, but also keeps stored active delete progress long enough to move selection away and queue a refresh if the backend list is missing, stale, or temporarily failed. Batch deletes still run selected child lanes before their selected parents; within each dependency-safe batch the page dispatches up to two lane deletes at a time and records per-lane failures, and a parent remains blocked if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. Work-tab action deeplinks scrub `action`, `laneId`, and `laneIds` after handling so modal routing cannot also rewrite split selection state. | +| `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR matching, same-repo GitHub PR guardrails for fork branch-name collisions, ADE-vs-GitHub PR tag precedence, terminal-state GitHub overrides for stale ADE PR rows, deep-link lane selection, action-deeplink query cleanup, create-lane request normalization, delete-start selection fallback, parent-before-child-safe batch delete planning, and `runLaneDeleteBatchWithConcurrency` for limited parallel teardown inside each dependency-safe batch. | | `renderer/state/appStore.ts` | Shared renderer project/lane state. Stores `laneDeleteProgressByLaneId` so in-flight lane deletion UI survives local `LanesPage` remounts and project metadata updates; the map clears only when the project root changes or the project is closed/reset. Warm project-tab switches restore cached lanes/snapshots, lane selection, focused session, and loading state before the backend round trip finishes, and cache pruning retains Work/lane/session state for all open project tabs in addition to the active and recent projects. | | `renderer/components/lanes/laneUtils.ts` | Pure lane list/filter helpers plus default pane trees, including the work-focused tiling tree used by parallel chat launch deep links. | | `renderer/components/lanes/laneColorPalette.ts` | Curated lane color palette split into `LANE_CLASSIC_COLORS` and `LANE_RAINBOW_COLORS`, then combined as `LANE_COLOR_PALETTE`, plus helpers (`getLaneAccent`, `colorsInUse`, `nextAvailableColor`, `laneColorName`). The first 8 classic hexes form `LANE_FALLBACK_COLORS`, the legacy index-based fallback used for lanes that don't have an explicit color assigned. | @@ -326,7 +326,12 @@ a lane parented to primary would always show zero behind. filesystem cleanup uses `fs.promises.rm` instead of synchronous `rmSync`, and `git_worktree_remove` checks the managed worktree path after a successful git removal so residual files are removed and - `git worktree prune` runs before the lane row disappears. The + `git worktree prune` runs before the lane row disappears. Multiple + delete calls can progress through non-Git teardown independently; + the shared worktree-mutation guard is held only while + `git_worktree_remove` mutates Git's worktree registry, so lane + creation can start while another lane is still stopping processes, + PTYs, watchers, or environment resources. The `database_cleanup` step wraps every cascade delete inside a single `begin immediate` / `commit` transaction so a partial failure rolls back to a consistent DB state instead of leaving lane rows diff --git a/docs/features/lanes/worktree-isolation.md b/docs/features/lanes/worktree-isolation.md index 30f2e2bb0..1f23ec97e 100644 --- a/docs/features/lanes/worktree-isolation.md +++ b/docs/features/lanes/worktree-isolation.md @@ -80,19 +80,35 @@ auto-clean it. ## Deleting a worktree -`laneService.deleteLane()`: +`laneService.delete()` runs a multi-step teardown rather than deleting +the directory first: 1. Fetch the row; reject if `is_edit_protected = 1` (primary). -2. If managed worktree: `git worktree remove --force `. If - Git reports success but residual files remain, ADE removes the - directory with `fs.promises.rm` and runs `git worktree prune` before - continuing. If attached: skip. -3. If caller requested `deleteBranch`: `git branch -D `. -4. Delete the lane row. Stale state in `key_value`, `operations`, +2. Check worktree dirtiness when a managed worktree exists; dirty + lanes require the caller's force acknowledgement. +3. Cancel auto-rebase and dismiss rebase suggestions for the lane. +4. Stop ADE-managed processes, PTYs, and file watchers for the lane, + then run any lane-environment cleanup supplied by the runtime. +5. If managed worktree: enter the shared worktree-mutation guard and + run `git worktree remove --force `. If Git reports success + but residual files remain, ADE removes the directory with + `fs.promises.rm` and runs `git worktree prune` before continuing. + If attached: skip. +6. If caller requested `deleteBranch`: `git branch -D `. + Optional remote branch cleanup uses `git push --delete + ` and is non-fatal. +7. Remove lane pack artifacts and delete the lane's database rows in + one transaction. Stale state in `key_value`, `operations`, `sessions`, etc. that references the lane is either cascaded (via FK ON DELETE) or retained for audit as documented on each table. +Independent lane deletes can run through the pre-removal teardown at +the same time. The shared guard is scoped to the actual +`git worktree remove` registry mutation, which prevents concurrent +Git worktree metadata edits without making lane creation wait for +unrelated process, PTY, watcher, or environment cleanup. + A worktree that has been manually removed from disk but still has a row is repaired by `laneService.removeStaleWorktrees()` at startup. @@ -160,10 +176,12 @@ worktree, but a full parallel development environment. - **Git lock files**: a stray `.git/index.lock` in one worktree can block operations in that lane but not others. ADE does not auto- remove stale locks — users must. -- **Stopping a running dev server on delete**: `deleteLane` does not - terminate processes launched inside the worktree. `runtimeDiagnosticsService` - may still report a port as responding for a short period after - delete; the proxy route is removed synchronously. +- **Stopping a running dev server on delete**: ADE-managed processes, + PTYs, and watchers are stopped before worktree removal, but processes + that were launched outside ADE may still hold file handles or keep a + port alive briefly. The delete pipeline recovers residual files after + a successful `git worktree remove` and runtime diagnostics may lag + until the external process exits. - **Attached lane path resolution**: attached paths are stored as given after `path.resolve`. If the user renames the containing directory outside ADE, `ade.lanes.list` will still return the row