Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 66 additions & 5 deletions apps/desktop/src/main/services/lanes/laneService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}),
};
Expand Down Expand Up @@ -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<string>();
let releaseStops: (() => void) | null = null;
const stopGate = new Promise<void>((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[] = [];
Expand Down Expand Up @@ -3043,16 +3101,19 @@ 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!();
await Promise.all([deletePromise, createPromise]);

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 () => {
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/main/services/lanes/laneService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4152,7 +4152,6 @@ export function createLaneService({

broadcastDeleteEvent(progress);

await runGitWorktreeMutation(async () => {
try {
if (hasWorktree) {
await runStep("git_status", async () => {
Expand Down Expand Up @@ -4342,7 +4341,6 @@ export function createLaneService({
finalize("failed");
throw error;
}
});
},

cancelDelete(laneId: string): { cancelled: boolean; reason?: string } {
Expand Down
43 changes: 25 additions & 18 deletions apps/desktop/src/renderer/components/lanes/LanesPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
resolveCreateLaneRequest,
resolveLaneIdsDeepLinkSelection,
resolveVisibleLaneIds,
runLaneDeleteBatchSequentially,
runLaneDeleteBatchWithConcurrency,
selectGithubLanePrTag,
selectLaneTabPrTag,
selectLanePrTag,
Expand Down Expand Up @@ -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<void>((resolve) => {
releaseFirstWave = resolve;
});
let firstWaveStarted: (() => void) | null = null;
const firstWaveStartedPromise = new Promise<void>((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"],
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/renderer/components/lanes/LanesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
resolveLaneDeleteStartSelection,
resolveLaneIdsDeepLinkSelection,
resolveVisibleLaneIds,
runLaneDeleteBatchSequentially,
runLaneDeleteBatchWithConcurrency,
selectLaneTabPrTag,
shouldApplyLaneIdsDeepLink,
sortLaneListRows,
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 26 additions & 9 deletions apps/desktop/src/renderer/components/lanes/lanePageModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export type LaneDeleteBatchResult<T> =
| { 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;
Expand Down Expand Up @@ -100,19 +102,34 @@ export function planLaneDeleteBatches<T extends Pick<LaneSummary, "id" | "parent
return batches;
}

export async function runLaneDeleteBatchSequentially<T>(
export async function runLaneDeleteBatchWithConcurrency<T>(
lanes: T[],
deleteLane: (lane: T) => Promise<void>,
concurrency = LANE_DELETE_BATCH_CONCURRENCY,
): Promise<LaneDeleteBatchResult<T>[]> {
const results: LaneDeleteBatchResult<T>[] = [];
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<LaneDeleteBatchResult<T>>(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;
}

Expand Down
Loading
Loading