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
28 changes: 27 additions & 1 deletion apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -498,6 +499,7 @@ export async function createAdeRuntime(args: {
});
},
onLinearIssueSessionLinked: publishLinearChatLink,
teardownDeps: laneTeardownDeps,
logger,
});
await laneService.ensurePrimaryLane();
Expand Down Expand Up @@ -752,6 +754,20 @@ export async function createAdeRuntime(args: {
getLaneRuntimeEnv: getHeadlessLaneRuntimeEnv,
broadcastEvent: (event) => pushEvent("runtime", event as unknown as Record<string, unknown>),
});
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,
Expand Down Expand Up @@ -894,6 +910,10 @@ export async function createAdeRuntime(args: {
});
linearIssueTrackerRef = headlessLinearServices.linearIssueTracker;
githubServiceRef = headlessLinearServices.githubService as ReturnType<typeof createGithubService>;
laneTeardownDeps.fileWatcherService = {
countActiveForWorkspace: (id) => headlessLinearServices.fileService.countActiveWatchersForWorkspace(id),
stopAllForWorkspace: (id) => headlessLinearServices.fileService.stopAllWatchersForWorkspace(id),
};
const linearOAuthService = createLinearOAuthService({
credentials: headlessLinearServices.linearCredentialService,
logger,
Expand Down Expand Up @@ -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.");
}
Expand Down
3 changes: 3 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,7 @@ describe("formatLaneDeleteRisk", () => {
unpushedCommitCount: 0,
remoteBranchExists: false,
runningProcessCount: 0,
activeChatCount: 0,
activePtyCount: 0,
activeWatcherCount: 0,
envInitialized: false,
Expand All @@ -749,13 +750,15 @@ describe("formatLaneDeleteRisk", () => {
hasUnpushedCommits: true,
unpushedCommitCount: 1,
runningProcessCount: 2,
activeChatCount: 1,
activePtyCount: 1,
remoteBranchExists: true,
});
expect(summary).toContain("uncommitted changes");
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);
Expand Down
3 changes: 3 additions & 0 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
74 changes: 74 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21829,6 +21829,78 @@ export function createAgentChatService(args: {
return false;
};

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),
trimLine(managed.preferredExecutionLaneId),
].some((candidate) => candidate === normalizedLaneId);
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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()) {
if (managed.closed || managed.deleted) continue;
if (managedSessionBelongsToLane(managed, laneId)) count += 1;
}
return count;
};

const disposeForLane = async (laneId: string): Promise<number> => {
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) {
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)}`);
}
}
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;
Expand Down Expand Up @@ -25294,6 +25366,8 @@ export function createAgentChatService(args: {
getSessionSummary,
hasActiveWorkloads,
hasRetainableSessions,
countActiveForLane,
disposeForLane,
getChatTranscript,
getCodexResumeContext,
getChatEventHistory,
Expand Down
64 changes: 62 additions & 2 deletions apps/desktop/src/main/services/lanes/laneService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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<typeof makeFakeServices>; events: any[]; createWorktree?: boolean }) {
Expand All @@ -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,
Expand All @@ -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[]) => {
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -3446,6 +3487,23 @@ describe("laneService delete teardown + cancellation + streaming", () => {
expect(count("lane_worktree_locks", "lane_id = ?", ["lane-child"])).toBe(0);
});

it("continues lane delete with a warning 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 service.delete({ laneId: "lane-child", deleteBranch: false });

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("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 () => {
const events: any[] = [];
const fake = makeFakeServices();
Expand All @@ -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[]) => {
Expand All @@ -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);
Expand Down
Loading
Loading