From d30291fe1964bab2e84ce2be0a997e59bc32882c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:41:47 -0400 Subject: [PATCH 1/2] Fix lane deletion chat teardown --- apps/ade-cli/src/bootstrap.ts | 28 +++++++- .../src/tuiClient/__tests__/appInput.test.ts | 3 + apps/ade-cli/src/tuiClient/app.tsx | 3 + apps/desktop/src/main/main.ts | 4 ++ .../main/services/chat/agentChatService.ts | 43 +++++++++++++ .../main/services/lanes/laneService.test.ts | 64 ++++++++++++++++++- .../src/main/services/lanes/laneService.ts | 30 ++++++++- apps/desktop/src/renderer/browserMock.ts | 1 + .../components/chat/AgentChatPane.test.tsx | 32 ++++++++++ .../components/chat/AgentChatPane.tsx | 27 ++++++++ .../renderer/components/lanes/LanesPage.tsx | 1 + .../lanes/ManageLaneDialog.test.tsx | 18 +++++- .../components/lanes/ManageLaneDialog.tsx | 8 +++ apps/desktop/src/shared/types/lanes.ts | 2 + 14 files changed, 259 insertions(+), 5 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index b3cda0d3a..b31169fd9 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -12,7 +12,7 @@ import { } from "../../desktop/src/main/services/projects/adeProjectService"; import { createConfigReloadService } from "../../desktop/src/main/services/projects/configReloadService"; import { createOperationService } from "../../desktop/src/main/services/history/operationService"; -import { createLaneService } from "../../desktop/src/main/services/lanes/laneService"; +import { createLaneService, type LaneDeleteTeardownDeps } from "../../desktop/src/main/services/lanes/laneService"; import { createSessionService, STALE_RUNNING_SESSION_FRESH_ACTIVITY_GRACE_MS, @@ -440,6 +440,7 @@ export async function createAdeRuntime(args: { getIssueTracker: () => linearIssueTrackerRef, log: (event, fields) => logger.warn(event, fields), }); + const laneTeardownDeps: LaneDeleteTeardownDeps = {}; const laneService = createLaneService({ db, @@ -498,6 +499,7 @@ export async function createAdeRuntime(args: { }); }, onLinearIssueSessionLinked: publishLinearChatLink, + teardownDeps: laneTeardownDeps, logger, }); await laneService.ensurePrimaryLane(); @@ -752,6 +754,20 @@ export async function createAdeRuntime(args: { getLaneRuntimeEnv: getHeadlessLaneRuntimeEnv, broadcastEvent: (event) => pushEvent("runtime", event as unknown as Record), }); + laneTeardownDeps.processService = { + listRuntime: (laneId) => processService.listRuntime(laneId), + stopAll: (args) => processService.stopAll(args), + }; + laneTeardownDeps.ptyService = { + countActiveForLane: (laneId) => ptyService.countActiveForLane(laneId), + disposeForLane: (laneId) => ptyService.disposeForLane(laneId), + }; + laneTeardownDeps.autoRebaseService = { + cancelForLane: (laneId) => autoRebaseService.cancelForLane(laneId), + }; + laneTeardownDeps.rebaseSuggestionService = { + dismiss: (args) => rebaseSuggestionService.dismiss(args), + }; const ctoStateService = createCtoStateService({ db, @@ -894,6 +910,10 @@ export async function createAdeRuntime(args: { }); linearIssueTrackerRef = headlessLinearServices.linearIssueTracker; githubServiceRef = headlessLinearServices.githubService as ReturnType; + laneTeardownDeps.fileWatcherService = { + countActiveForWorkspace: (id) => headlessLinearServices.fileService.countActiveWatchersForWorkspace(id), + stopAllForWorkspace: (id) => headlessLinearServices.fileService.stopAllWatchersForWorkspace(id), + }; const linearOAuthService = createLinearOAuthService({ credentials: headlessLinearServices.linearCredentialService, logger, @@ -956,6 +976,12 @@ export async function createAdeRuntime(args: { } } agentChatServiceHolder.current = agentChatService; + if (agentChatService) { + laneTeardownDeps.agentChatService = { + countActiveForLane: (laneId) => agentChatService.countActiveForLane(laneId), + disposeForLane: (laneId) => agentChatService.disposeForLane(laneId), + }; + } if (resolvedArgs.chatRuntime === "agent" && !agentChatService) { throw new Error("Agent chat runtime was requested but the agent chat service was not initialized."); } diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index 3a007d8bc..24199d4cd 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -737,6 +737,7 @@ describe("formatLaneDeleteRisk", () => { unpushedCommitCount: 0, remoteBranchExists: false, runningProcessCount: 0, + activeChatCount: 0, activePtyCount: 0, activeWatcherCount: 0, envInitialized: false, @@ -749,6 +750,7 @@ describe("formatLaneDeleteRisk", () => { hasUnpushedCommits: true, unpushedCommitCount: 1, runningProcessCount: 2, + activeChatCount: 1, activePtyCount: 1, remoteBranchExists: true, }); @@ -756,6 +758,7 @@ describe("formatLaneDeleteRisk", () => { expect(summary).toContain("1 unpushed commit"); expect(summary).not.toContain("1 unpushed commits"); expect(summary).toContain("2 running processes"); + expect(summary).toContain("1 chat session"); expect(summary).toContain("1 terminal"); expect(summary).toContain("remote branch exists"); expect(summary.startsWith("⚠")).toBe(true); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 177c6dc7f..78654fe09 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -309,6 +309,9 @@ export function formatLaneDeleteRisk(risk: LaneDeleteRisk): string { if (risk.runningProcessCount > 0) { parts.push(`${risk.runningProcessCount} running process${risk.runningProcessCount === 1 ? "" : "es"}`); } + if (risk.activeChatCount > 0) { + parts.push(`${risk.activeChatCount} chat session${risk.activeChatCount === 1 ? "" : "s"}`); + } if (risk.activePtyCount > 0) parts.push(`${risk.activePtyCount} terminal${risk.activePtyCount === 1 ? "" : "s"}`); if (risk.remoteBranchExists) parts.push("remote branch exists"); return parts.length ? `⚠ ${parts.join(" · ")}` : "Clean — no unpushed work or running processes."; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 24d0fd684..069419941 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -2932,6 +2932,10 @@ app.whenReady().then(async () => { }, }); agentChatServiceRef = agentChatService; + laneTeardownDeps.agentChatService = { + countActiveForLane: (laneId) => agentChatService.countActiveForLane(laneId), + disposeForLane: (laneId) => agentChatService.disposeForLane(laneId), + }; setImmediate(() => { void Promise.resolve() .then(() => agentChatService.cleanupStaleAttachments()) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 727297b11..f1c6633cc 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -21829,6 +21829,47 @@ export function createAgentChatService(args: { return false; }; + const managedSessionBelongsToLane = (managed: ManagedChatSession, laneId: string): boolean => { + const normalizedLaneId = laneId.trim(); + if (!normalizedLaneId) return false; + return [ + trimLine(managed.session.laneId), + trimLine(managed.selectedExecutionLaneId), + trimLine(managed.preferredExecutionLaneId), + ].some((candidate) => candidate === normalizedLaneId); + }; + + const countActiveForLane = (laneId: string): number => { + let count = 0; + for (const managed of managedSessions.values()) { + if (managed.closed || managed.deleted) continue; + if (managedSessionBelongsToLane(managed, laneId)) count += 1; + } + return count; + }; + + const disposeForLane = async (laneId: string): Promise => { + const sessionIds = Array.from(new Set( + [...managedSessions.values()] + .filter((managed) => !managed.closed && !managed.deleted && managedSessionBelongsToLane(managed, laneId)) + .map((managed) => managed.session.id), + )); + let disposed = 0; + const errors: string[] = []; + for (const sessionId of sessionIds) { + try { + await dispose({ sessionId }); + disposed += 1; + } catch (error) { + errors.push(`${sessionId}: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (errors.length > 0) { + throw new Error(`Failed to close ${errors.length} chat session${errors.length === 1 ? "" : "s"}: ${errors.join("; ")}`); + } + return disposed; + }; + const ensureIdentitySession = async (args: { identityKey: AgentChatIdentityKey; laneId: string; @@ -25294,6 +25335,8 @@ export function createAgentChatService(args: { getSessionSummary, hasActiveWorkloads, hasRetainableSessions, + countActiveForLane, + disposeForLane, getChatTranscript, getCodexResumeContext, getChatEventHistory, diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 1ed239c1b..9a5ab77d0 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -2785,6 +2785,13 @@ describe("laneService delete teardown + cancellation + streaming", () => { calls.push("stop_processes"); }), }; + const agentChatService = { + countActiveForLane: vi.fn(() => 0), + disposeForLane: vi.fn(async () => { + calls.push("stop_chats"); + return 0; + }), + }; const ptyService = { countActiveForLane: vi.fn(() => 2), disposeForLane: vi.fn(() => { @@ -2809,7 +2816,7 @@ describe("laneService delete teardown + cancellation + streaming", () => { // already counted by cancel_auto_rebase step; do not duplicate }), }; - return { calls, processService, ptyService, fileWatcherService, autoRebaseService, rebaseSuggestionService }; + return { calls, processService, agentChatService, ptyService, fileWatcherService, autoRebaseService, rebaseSuggestionService }; } async function setupWithLane(opts: { teardown: ReturnType; events: any[]; createWorktree?: boolean }) { @@ -2829,6 +2836,7 @@ describe("laneService delete teardown + cancellation + streaming", () => { onDeleteEvent: (event) => opts.events.push(event), teardownDeps: { processService: opts.teardown.processService, + agentChatService: opts.teardown.agentChatService, ptyService: opts.teardown.ptyService, fileWatcherService: opts.teardown.fileWatcherService, autoRebaseService: opts.teardown.autoRebaseService, @@ -2842,6 +2850,11 @@ describe("laneService delete teardown + cancellation + streaming", () => { it("runs teardown steps before git_worktree_remove and broadcasts per-step progress", async () => { const events: any[] = []; const fake = makeFakeServices(); + fake.agentChatService.countActiveForLane.mockReturnValue(1); + fake.agentChatService.disposeForLane.mockImplementation(async () => { + fake.calls.push("stop_chats"); + return 1; + }); const { service } = await setupWithLane({ teardown: fake, events }); // git status: clean. git_worktree_remove: succeeds. branch ref check: not found (skip branch delete). vi.mocked(runGit).mockImplementation(async (args: string[]) => { @@ -2864,6 +2877,7 @@ describe("laneService delete teardown + cancellation + streaming", () => { // Teardown happens before the git destructive step. const wtIdx = fake.calls.indexOf("git_worktree_remove"); expect(fake.calls.indexOf("stop_processes")).toBeLessThan(wtIdx); + expect(fake.calls.indexOf("stop_chats")).toBeLessThan(wtIdx); expect(fake.calls.indexOf("stop_ptys")).toBeLessThan(wtIdx); expect(fake.calls.indexOf("stop_watchers")).toBeLessThan(wtIdx); expect(fake.calls.indexOf("cancel_auto_rebase")).toBeLessThan(wtIdx); @@ -3301,6 +3315,31 @@ describe("laneService delete teardown + cancellation + streaming", () => { "insert into terminal_sessions(id, lane_id, title, started_at, transcript_path, status) values (?, ?, ?, ?, ?, ?)", ["session-child", "lane-child", "Child session", now, path.join(repoRoot, "session.log"), "ended"], ); + db.run( + ` + insert into claude_sessions(session_id, lane_id, chat_session_id, title, tags_json, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, ?) + `, + ["chat-child", "lane-child", null, "Child chat", null, now, now], + ); + db.run( + ` + insert into session_linear_issues( + id, project_id, session_id, lane_id, issue_id, issue_json, role, source, + include_in_pr, close_on_merge, evidence_json, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ["session-link-terminal", projectId, "session-child", "lane-child", "issue-child", JSON.stringify(makeLinearIssue()), "worked", "chat_attach", 1, 0, null, now, now], + ); + db.run( + ` + insert into session_linear_issues( + id, project_id, session_id, lane_id, issue_id, issue_json, role, source, + include_in_pr, close_on_merge, evidence_json, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ["session-link-chat", projectId, "chat-child", "lane-child", "issue-child", JSON.stringify(makeLinearIssue()), "worked", "chat_attach", 1, 0, null, now, now], + ); db.run( ` insert into session_deltas( @@ -3433,6 +3472,8 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(count("review_runs", "id = ?", ["review-run-child"])).toBe(0); expect(count("review_reviewer_runs", "id = ?", ["reviewer-run-child"])).toBe(0); expect(count("review_candidate_findings", "id = ?", ["candidate-child"])).toBe(0); + expect(count("claude_sessions", "lane_id = ?", ["lane-child"])).toBe(0); + expect(count("session_linear_issues", "lane_id = ? or session_id in (?, ?)", ["lane-child", "session-child", "chat-child"])).toBe(0); expect(count("terminal_sessions", "lane_id = ?", ["lane-child"])).toBe(0); expect(count("session_deltas", "lane_id = ?", ["lane-child"])).toBe(0); expect(count("checkpoints", "lane_id = ?", ["lane-child"])).toBe(0); @@ -3446,6 +3487,23 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(count("lane_worktree_locks", "lane_id = ?", ["lane-child"])).toBe(0); }); + it("keeps the lane intact when active chat teardown fails", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + fake.agentChatService.countActiveForLane.mockReturnValue(1); + fake.agentChatService.disposeForLane.mockRejectedValue(new Error("chat refused to close")); + const { service, db } = await setupWithLane({ teardown: fake, events, createWorktree: false }); + vi.mocked(runGit).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); + vi.mocked(runGitOrThrow).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); + + await expect(service.delete({ laneId: "lane-child", deleteBranch: false })).rejects.toThrow("chat refused to close"); + + expect(db.get<{ id: string }>("select id from lanes where id = ?", ["lane-child"])?.id).toBe("lane-child"); + const last = events[events.length - 1]; + expect(last.progress.overallStatus).toBe("failed"); + expect(last.progress.steps.find((s: any) => s.name === "stop_chats")?.status).toBe("failed"); + }); + it("does not cancel a lane delete after it starts", async () => { const events: any[] = []; const fake = makeFakeServices(); @@ -3471,9 +3529,10 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(last.progress.steps.find((step: any) => step.name === "git_worktree_remove")?.status).toBe("completed"); }); - it("getDeleteRisk reports running processes, ptys, watchers, and unpushed commits", async () => { + it("getDeleteRisk reports running processes, chats, ptys, watchers, and unpushed commits", async () => { const events: any[] = []; const fake = makeFakeServices(); + fake.agentChatService.countActiveForLane.mockReturnValue(1); const { service } = await setupWithLane({ teardown: fake, events }); // 3 unpushed commits + remote branch exists. vi.mocked(runGit).mockImplementation(async (args: string[]) => { @@ -3487,6 +3546,7 @@ describe("laneService delete teardown + cancellation + streaming", () => { const risk = await service.getDeleteRisk("lane-child"); expect(risk.runningProcessCount).toBe(1); + expect(risk.activeChatCount).toBe(1); expect(risk.activePtyCount).toBe(2); expect(risk.activeWatcherCount).toBe(1); expect(risk.hasUnpushedCommits).toBe(true); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 022646f4e..5e612d58c 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -886,6 +886,10 @@ export type LaneDeleteTeardownDeps = { listRuntime: (laneId: string) => ProcessRuntime[]; stopAll: (args: { laneId: string }) => Promise; }; + agentChatService?: { + countActiveForLane: (laneId: string) => number; + disposeForLane: (laneId: string) => Promise; + }; ptyService?: { countActiveForLane: (laneId: string) => number; disposeForLane: (laneId: string) => number; @@ -2583,6 +2587,19 @@ export function createLaneService({ db.run("delete from conflict_predictions where project_id = ? and (lane_a_id = ? or lane_b_id = ?)", [projectId, laneId, laneId]); db.run("delete from checkpoints where lane_id = ? and project_id = ?", [laneId, projectId]); db.run("delete from session_deltas where lane_id = ? and project_id = ?", [laneId, projectId]); + db.run( + ` + delete from session_linear_issues + where project_id = ? + and ( + lane_id = ? + or session_id in (select id from terminal_sessions where lane_id = ?) + or session_id in (select session_id from claude_sessions where lane_id = ?) + ) + `, + [projectId, laneId, laneId, laneId], + ); + db.run("delete from claude_sessions where lane_id = ?", [laneId]); db.run("delete from terminal_sessions where lane_id = ?", [laneId]); db.run("delete from operations where lane_id = ? and project_id = ?", [laneId, projectId]); db.run("delete from packs_index where lane_id = ? and project_id = ?", [laneId, projectId]); @@ -4578,7 +4595,7 @@ export function createLaneService({ (fs.existsSync(row.worktree_path) || worktreeRegistered); const stepNames: LaneDeleteStepName[] = []; if (hasWorktree) stepNames.push("git_status"); - stepNames.push("cancel_auto_rebase", "stop_processes", "stop_ptys", "stop_watchers", "cleanup_env"); + stepNames.push("cancel_auto_rebase", "stop_processes", "stop_chats", "stop_ptys", "stop_watchers", "cleanup_env"); if (hasWorktree) stepNames.push("git_worktree_remove"); if (deleteBranch && row.branch_ref) stepNames.push("git_branch_delete"); if (deleteRemoteBranch && row.branch_ref) stepNames.push("git_remote_branch_delete"); @@ -4683,6 +4700,15 @@ export function createLaneService({ return { detail: `stopped ${active.length} ${active.length === 1 ? "process" : "processes"}` }; }); + await runStep("stop_chats", async () => { + const svc = teardownDeps?.agentChatService; + if (!svc) return { detail: "no service" }; + const before = svc.countActiveForLane(laneId); + if (before === 0) return { detail: "none active" }; + const disposed = await svc.disposeForLane(laneId); + return { detail: `closed ${disposed} ${disposed === 1 ? "chat" : "chats"}` }; + }); + await runStep("stop_ptys", async () => { const svc = teardownDeps?.ptyService; if (!svc) return { detail: "no service" }; @@ -4891,6 +4917,7 @@ export function createLaneService({ const runningProcessCount = teardownDeps?.processService ? teardownDeps.processService.listRuntime(laneId).filter(isActiveProcess).length : 0; + const activeChatCount = teardownDeps?.agentChatService?.countActiveForLane(laneId) ?? 0; const activePtyCount = teardownDeps?.ptyService?.countActiveForLane(laneId) ?? 0; const activeWatcherCount = teardownDeps?.fileWatcherService?.countActiveForWorkspace(laneId) ?? 0; return { @@ -4901,6 +4928,7 @@ export function createLaneService({ unpushedCommitCount, remoteBranchExists, runningProcessCount, + activeChatCount, activePtyCount, activeWatcherCount, envInitialized: false diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index f149936f6..52ac2c889 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4319,6 +4319,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { unpushedCommitCount: 0, remoteBranchExists: false, runningProcessCount: 0, + activeChatCount: 0, activePtyCount: 0, activeWatcherCount: 0, envInitialized: false, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index d42eb0df7..8917c304c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -3620,6 +3620,38 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("ignores duplicate auto-create submits for the same draft while lane naming is pending", async () => { + const { suggestLaneName } = installAdeMocks({ sessions: [] }); + suggestLaneName.mockImplementation(() => new Promise(() => { + // Keep the first launch in-flight so duplicate clicks race the same snapshot. + })); + + renderAutoCreateDraftPane(); + + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("tab", { name: /^OpenAI$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /Auto-create lane/i })); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Launch once even if clicked twice." } }); + const launchButton = await screen.findByRole("button", { name: "Auto-create in background" }); + await act(async () => { + launchButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + launchButton.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + await waitFor(() => { + expect(suggestLaneName).toHaveBeenCalledTimes(1); + expect(screen.getAllByText(/Creating lane for chat/i)).toHaveLength(1); + }); + }); + it("keeps every in-flight background draft launch visible past the completed-notice cap", async () => { const { suggestLaneName } = installAdeMocks({ sessions: [] }); suggestLaneName.mockImplementation(() => new Promise(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 3711c0732..3d082e2f4 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -331,6 +331,20 @@ function pruneDraftLaunchJobs(jobs: DraftLaunchJob[]): DraftLaunchJob[] { ]; } +function draftLaunchRequestKey(args: { + kind: DraftLaunchKind; + mode: DraftLaunchMode; + autoCreate: boolean; + snapshot: DraftLaunchSnapshot; +}): string { + return JSON.stringify({ + kind: args.kind, + mode: args.mode, + autoCreate: args.autoCreate, + snapshot: args.snapshot, + }); +} + function createTemporaryAutoLaneName(date = new Date()): string { const pad = (value: number) => String(value).padStart(2, "0"); return [ @@ -2586,6 +2600,7 @@ export function AgentChatPane({ const pendingSelectedSessionIdRef = useRef(null); const submitInFlightRef = useRef(false); const latestForegroundDraftLaunchJobIdRef = useRef(null); + const draftLaunchInFlightKeysRef = useRef>(new Set()); const createSessionPromiseRef = useRef | null>(null); const pendingNativeControlUpdateRef = useRef<{ sessionId: string; @@ -5943,6 +5958,16 @@ export function AgentChatPane({ : "Add a message before sending."); return; } + const requestKey = draftLaunchRequestKey({ + kind, + mode, + autoCreate: draftLaunchTargetIsAutoCreate, + snapshot, + }); + if (draftLaunchInFlightKeysRef.current.has(requestKey)) { + return; + } + draftLaunchInFlightKeysRef.current.add(requestKey); const jobId = createDraftLaunchJobId(); if (mode === "foreground") { @@ -6032,6 +6057,8 @@ export function AgentChatPane({ autoOpen: false, }); setError(message); + } finally { + draftLaunchInFlightKeysRef.current.delete(requestKey); } }, [ buildDraftLaunchSnapshotForCurrentState, diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 49703843c..c963b36db 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -337,6 +337,7 @@ const LANE_DELETE_STEP_LABELS: Record = { git_status: "dirty-state check", cancel_auto_rebase: "auto-rebase cancellation", stop_processes: "process shutdown", + stop_chats: "chat shutdown", stop_ptys: "terminal shutdown", stop_watchers: "file watcher shutdown", cleanup_env: "environment cleanup", diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx index 7d3033721..4dfa61fd5 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { LaneDeleteRisk, LaneSummary } from "../../../shared/types"; import { ManageLaneDialog } from "./ManageLaneDialog"; @@ -15,6 +15,7 @@ const deleteRisk: LaneDeleteRisk = { unpushedCommitCount: 0, remoteBranchExists: true, runningProcessCount: 0, + activeChatCount: 0, activePtyCount: 0, activeWatcherCount: 0, envInitialized: true, @@ -143,4 +144,19 @@ describe("ManageLaneDialog tabs", () => { expect(selectedTabLabel()).toBe("Archive"); }); + + it("shows active chat sessions in the delete preflight", async () => { + (window as any).ade.lanes.getDeleteRisk.mockResolvedValueOnce({ + ...deleteRisk, + activeChatCount: 1, + }); + + render(); + + fireEvent.click(screen.getByRole("tab", { name: "Delete" })); + + await waitFor(() => { + expect(screen.getByText("1 chat session")).toBeTruthy(); + }); + }); }); diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx index 9c48e5ea1..c5c95f4d0 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx @@ -7,6 +7,7 @@ import { CircleNotch, Palette, Terminal, + ChatCircle, Cpu, Eye, Cube, @@ -37,6 +38,7 @@ const STEP_LABELS: Record = { git_status: "Checking dirty state", cancel_auto_rebase: "Cancelling auto-rebase", stop_processes: "Stopping processes", + stop_chats: "Closing chat sessions", stop_ptys: "Closing terminal sessions", stop_watchers: "Stopping file watchers", cleanup_env: "Cleaning environment", @@ -616,6 +618,12 @@ function PreflightPanel({ label: `${risk.runningProcessCount} running ${risk.runningProcessCount === 1 ? "process" : "processes"}` }); } + if (risk && risk.activeChatCount > 0) { + willStop.push({ + icon: , + label: `${risk.activeChatCount} chat ${risk.activeChatCount === 1 ? "session" : "sessions"}` + }); + } if (risk && risk.activePtyCount > 0) { willStop.push({ icon: , diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index b8affea5b..526c8bad8 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -347,6 +347,7 @@ export type DeleteLaneArgs = { export type LaneDeleteStepName = | "stop_processes" + | "stop_chats" | "stop_ptys" | "stop_watchers" | "cancel_auto_rebase" @@ -400,6 +401,7 @@ export type LaneDeleteRisk = { unpushedCommitCount: number; remoteBranchExists: boolean; runningProcessCount: number; + activeChatCount: number; activePtyCount: number; activeWatcherCount: number; envInitialized: boolean; From 1e1917a96cf75604b84edcab22d31f32c4422410 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:20:37 -0400 Subject: [PATCH 2/2] Address lane delete chat teardown review --- .../main/services/chat/agentChatService.ts | 31 +++++++++++++++++++ .../main/services/lanes/laneService.test.ts | 10 +++--- .../src/main/services/lanes/laneService.ts | 2 +- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index f1c6633cc..e7542af57 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -21832,6 +21832,8 @@ export function createAgentChatService(args: { const managedSessionBelongsToLane = (managed: ManagedChatSession, laneId: string): boolean => { const normalizedLaneId = laneId.trim(); if (!normalizedLaneId) return false; + // Include execution-routing lane ids so a chat homed elsewhere but actively + // using this lane cannot keep a runtime pointed at a deleted worktree. return [ trimLine(managed.session.laneId), trimLine(managed.selectedExecutionLaneId), @@ -21839,6 +21841,26 @@ export function createAgentChatService(args: { ].some((candidate) => candidate === normalizedLaneId); }; + const forceDisposeManagedSession = (managed: ManagedChatSession, reason: string): void => { + const sessionId = managed.session.id; + rejectActiveSessionTurnCollector(sessionId, reason); + clearSubagentSnapshots(sessionId); + for (const pending of managed.localPendingInputs.values()) { + pending.resolve({ decision: "cancel" }); + } + managed.localPendingInputs.clear(); + abortActiveBashControllers(managed, reason); + managed.closed = true; + managed.endedNotified = true; + managed.ctoSessionStartedAt = null; + teardownRuntime(managed, "ended_session"); + managed.deleted = true; + flushQueuedTranscriptWrite(managed.transcriptPath); + flushQueuedTranscriptWrite(path.join(chatTranscriptsDir, `${sessionId}.jsonl`)); + managedSessions.delete(sessionId); + eventHistoryBySession.delete(sessionId); + }; + const countActiveForLane = (laneId: string): number => { let count = 0; for (const managed of managedSessions.values()) { @@ -21861,6 +21883,15 @@ export function createAgentChatService(args: { await dispose({ sessionId }); disposed += 1; } catch (error) { + const managed = managedSessions.get(sessionId); + if (managed) { + try { + forceDisposeManagedSession(managed, "Session force-closed during lane deletion."); + disposed += 1; + } catch (forceError) { + errors.push(`${sessionId}: force close failed: ${forceError instanceof Error ? forceError.message : String(forceError)}`); + } + } errors.push(`${sessionId}: ${error instanceof Error ? error.message : String(error)}`); } } diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 9a5ab77d0..a5313911f 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -3487,7 +3487,7 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(count("lane_worktree_locks", "lane_id = ?", ["lane-child"])).toBe(0); }); - it("keeps the lane intact when active chat teardown fails", async () => { + it("continues lane delete with a warning when active chat teardown fails", async () => { const events: any[] = []; const fake = makeFakeServices(); fake.agentChatService.countActiveForLane.mockReturnValue(1); @@ -3496,12 +3496,12 @@ describe("laneService delete teardown + cancellation + streaming", () => { vi.mocked(runGit).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); vi.mocked(runGitOrThrow).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); - await expect(service.delete({ laneId: "lane-child", deleteBranch: false })).rejects.toThrow("chat refused to close"); + await service.delete({ laneId: "lane-child", deleteBranch: false }); - expect(db.get<{ id: string }>("select id from lanes where id = ?", ["lane-child"])?.id).toBe("lane-child"); + expect(db.get<{ id: string }>("select id from lanes where id = ?", ["lane-child"])).toBeNull(); const last = events[events.length - 1]; - expect(last.progress.overallStatus).toBe("failed"); - expect(last.progress.steps.find((s: any) => s.name === "stop_chats")?.status).toBe("failed"); + expect(last.progress.overallStatus).toBe("completed_with_warnings"); + expect(last.progress.steps.find((s: any) => s.name === "stop_chats")?.status).toBe("warning"); }); it("does not cancel a lane delete after it starts", async () => { diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 5e612d58c..535d7769f 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -4707,7 +4707,7 @@ export function createLaneService({ if (before === 0) return { detail: "none active" }; const disposed = await svc.disposeForLane(laneId); return { detail: `closed ${disposed} ${disposed === 1 ? "chat" : "chats"}` }; - }); + }, { fatal: false }); await runStep("stop_ptys", async () => { const svc = teardownDeps?.ptyService;