diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index a94fb45c7..c771c848e 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -441,6 +441,7 @@ export async function createAdeRuntime(args: { log: (event, fields) => logger.warn(event, fields), }); const laneTeardownDeps: LaneDeleteTeardownDeps = {}; + let autoRebaseActivityReady = false; const laneService = createLaneService({ db, @@ -673,6 +674,17 @@ export async function createAdeRuntime(args: { laneService, conflictService, projectConfigService, + getLaneActivity: (laneId) => { + if (!autoRebaseActivityReady) { + throw new Error("Session activity services are not ready."); + } + return { + activeChatCount: + laneTeardownDeps.agentChatService?.countActiveForLane(laneId) ?? 0, + activePtyCount: + laneTeardownDeps.ptyService?.countActiveForLane(laneId) ?? 0, + }; + }, onEvent: (event) => pushEvent("runtime", { type: "lane_auto_rebase_event", event }), }); autoRebaseServiceRef = autoRebaseService; @@ -983,6 +995,14 @@ export async function createAdeRuntime(args: { disposeForLane: (laneId) => agentChatService.disposeForLane(laneId), }; } + autoRebaseActivityReady = true; + void autoRebaseService + .refreshActiveRebaseNeeds("activity_services_ready") + .catch((error) => { + logger.warn("autoRebase.activity_ready_refresh_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); 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/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 96be9e23b..62a2c578c 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1996,6 +1996,7 @@ app.whenReady().then(async () => { }; const laneTeardownDeps: LaneDeleteTeardownDeps = {}; + let autoRebaseActivityReady = false; let macosVmLaunchProviderForProject: MacosVmLaunchProvider | null = null; const laneService = createLaneService({ db, @@ -2337,6 +2338,17 @@ app.whenReady().then(async () => { laneService, conflictService, projectConfigService, + getLaneActivity: (laneId) => { + if (!autoRebaseActivityReady) { + throw new Error("Session activity services are not ready."); + } + return { + activeChatCount: + laneTeardownDeps.agentChatService?.countActiveForLane(laneId) ?? 0, + activePtyCount: + laneTeardownDeps.ptyService?.countActiveForLane(laneId) ?? 0, + }; + }, onEvent: (event) => emitProjectEvent(projectRoot, IPC.lanesAutoRebaseEvent, event), }); @@ -2979,6 +2991,14 @@ app.whenReady().then(async () => { countActiveForLane: (laneId) => agentChatService.countActiveForLane(laneId), disposeForLane: (laneId) => agentChatService.disposeForLane(laneId), }; + autoRebaseActivityReady = true; + void autoRebaseService + .refreshActiveRebaseNeeds("activity_services_ready") + .catch((error) => { + logger.warn("autoRebase.activity_ready_refresh_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); setImmediate(() => { void Promise.resolve() .then(() => agentChatService.cleanupStaleAttachments()) diff --git a/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts index f3c972730..197a76ff0 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.test.ts @@ -1,13 +1,14 @@ import { afterEach, describe, expect, it, beforeEach, vi } from "vitest"; import { createAutoRebaseService } from "./autoRebaseService"; import type { AutoRebaseEventPayload, AutoRebaseLaneStatus, LaneSummary, RebaseNeed } from "../../../shared/types"; +import type * as SharedUtils from "../shared/utils"; vi.mock("../git/git", () => ({ getHeadSha: vi.fn().mockResolvedValue("abc123"), })); vi.mock("../shared/utils", async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, nowIso: vi.fn(() => "2026-03-25T12:00:00.000Z"), @@ -104,6 +105,8 @@ describe("autoRebaseService", () => { let events: AutoRebaseEventPayload[]; let laneList: LaneSummary[]; let rebaseNeedOverrides: Map | null>; + let laneActivityById: Map; + let laneActivityFailures: Set; let laneService: any; let conflictService: any; let projectConfigService: any; @@ -114,6 +117,8 @@ describe("autoRebaseService", () => { events = []; laneList = []; rebaseNeedOverrides = new Map(); + laneActivityById = new Map(); + laneActivityFailures = new Set(); const resolveNeed = (laneId: string): RebaseNeed | null => { const lane = laneList.find((entry) => entry.id === laneId); @@ -151,6 +156,10 @@ describe("autoRebaseService", () => { laneService, conflictService, projectConfigService, + getLaneActivity: (laneId) => { + if (laneActivityFailures.has(laneId)) throw new Error("activity unavailable"); + return laneActivityById.get(laneId) ?? {}; + }, onEvent: (event) => events.push(event), }); } @@ -1040,6 +1049,132 @@ describe("autoRebaseService", () => { ); }); + it("pauses auto-rebase for a lane with active chat or terminal sessions", async () => { + const service = createService(); + const root = makeLane("root"); + const child = makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T01:00:00.000Z", + }); + laneList = [root, child]; + rebaseNeedOverrides.set("child-1", { behindBy: 4, conflictPredicted: false, conflictingFiles: [] }); + laneActivityById.set("child-1", { activeChatCount: 1, activePtyCount: 2 }); + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "pull_ff_only", + }); + + await vi.advanceTimersByTimeAsync(1500); + + expect(laneService.rebaseStart).not.toHaveBeenCalled(); + expect(laneService.rebasePush).not.toHaveBeenCalled(); + expect(db.getJson("auto_rebase:status:child-1")).toMatchObject({ + laneId: "child-1", + parentLaneId: "root", + parentHeadSha: "abc123", + state: "rebasePending", + conflictCount: 0, + message: expect.stringContaining("Auto-rebase paused"), + }); + }); + + it("pauses auto-rebase when session activity lookup fails", async () => { + const service = createService(); + const root = makeLane("root"); + const child = makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T01:00:00.000Z", + }); + laneList = [root, child]; + rebaseNeedOverrides.set("child-1", { behindBy: 3, conflictPredicted: false, conflictingFiles: [] }); + laneActivityFailures.add("child-1"); + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "pull_ff_only", + }); + + await vi.advanceTimersByTimeAsync(1500); + + expect(laneService.rebaseStart).not.toHaveBeenCalled(); + expect(db.getJson("auto_rebase:status:child-1")).toMatchObject({ + laneId: "child-1", + state: "rebasePending", + message: expect.stringContaining("could not verify"), + }); + }); + + it("ignores malformed or negative activity counts", async () => { + const service = createService(); + const root = makeLane("root"); + const child = makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T01:00:00.000Z", + }); + laneList = [root, child]; + rebaseNeedOverrides.set("child-1", { behindBy: 3, conflictPredicted: false, conflictingFiles: [] }); + laneActivityById.set("child-1", { activeChatCount: Number.NaN, activePtyCount: -2 }); + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "pull_ff_only", + }); + + await vi.advanceTimersByTimeAsync(1500); + + expect(laneService.rebaseStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "child-1", + reason: "auto_rebase", + }), + ); + }); + + it("marks descendants pending when an ancestor auto-rebase is paused for active sessions", async () => { + const service = createService(); + const root = makeLane("root"); + const child = makeLane("child-1", { + parentLaneId: "root", + status: { dirty: false, ahead: 0, behind: 2, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T01:00:00.000Z", + }); + const grandchild = makeLane("grandchild-1", { + parentLaneId: "child-1", + status: { dirty: false, ahead: 0, behind: 1, remoteBehind: 0, rebaseInProgress: false }, + createdAt: "2026-03-10T02:00:00.000Z", + }); + laneList = [root, child, grandchild]; + rebaseNeedOverrides.set("child-1", { behindBy: 2, conflictPredicted: false, conflictingFiles: [] }); + rebaseNeedOverrides.set("grandchild-1", { behindBy: 1, conflictPredicted: false, conflictingFiles: [] }); + laneActivityById.set("child-1", { activeChatCount: 1 }); + + await service.onHeadChanged({ + laneId: "root", + preHeadSha: "aaa", + postHeadSha: "bbb", + reason: "sync_rebase", + }); + + await vi.advanceTimersByTimeAsync(1500); + + expect(laneService.rebaseStart).not.toHaveBeenCalled(); + expect(db.getJson("auto_rebase:status:grandchild-1")).toMatchObject({ + laneId: "grandchild-1", + state: "rebasePending", + message: expect.stringContaining("active sessions"), + }); + }); + it("skips legacy parent links when the lane baseRef no longer matches the parent branch", async () => { const service = createService(); const root = makeLane("root", { diff --git a/apps/desktop/src/main/services/lanes/autoRebaseService.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.ts index ec5dfac85..c8fe678cc 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.ts @@ -30,6 +30,10 @@ type AttentionStatusInput = { message?: string | null; source?: "auto" | "manual"; }; +type LaneActivity = { + activeChatCount?: number | null; + activePtyCount?: number | null; +}; export type AutoRebaseService = { listStatuses: (options?: ListStatusesOptions) => Promise; @@ -110,11 +114,23 @@ function byCreatedAtAsc(a: LaneSummary, b: LaneSummary): number { return a.name.localeCompare(b.name); } +function normalizeActivityCount(value: unknown): number { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric) || numeric <= 0) return 0; + return Math.floor(numeric); +} + function blockedMessage( laneId: string | null, - reason: "conflict" | "manual" | "lookup" | "failed" | "unavailable" | null, + reason: "conflict" | "manual" | "lookup" | "failed" | "unavailable" | "active_session" | "activity_lookup" | null, ): string { if (!laneId) return "Pending: auto-rebase stopped at an earlier lane. Open the Rebase/Merge tab to continue."; + if (reason === "active_session") { + return `Pending: ancestor lane '${laneId}' has active sessions. Finish, stop, or resume those sessions before rebasing descendants.`; + } + if (reason === "activity_lookup") { + return `Pending: ADE could not verify whether ancestor lane '${laneId}' has active sessions. Open the Rebase/Merge tab to retry manually.`; + } if (reason === "manual") { return `Pending: ancestor lane '${laneId}' has a fixed PR base. Rebase that lane manually from the Rebase/Merge tab before descendants can continue.`; } @@ -158,6 +174,7 @@ export function createAutoRebaseService(args: { laneService: ReturnType; conflictService: ReturnType; projectConfigService: ReturnType; + getLaneActivity?: (laneId: string) => LaneActivity | Promise; onEvent?: (event: AutoRebaseEventPayload) => void; }): AutoRebaseService { const { @@ -166,6 +183,7 @@ export function createAutoRebaseService(args: { laneService, conflictService, projectConfigService, + getLaneActivity, onEvent } = args; @@ -228,6 +246,32 @@ export function createAutoRebaseService(args: { } }; + const getAutoRebaseActivityBlock = async (laneId: string): Promise<{ reason: "active_session" | "activity_lookup"; message: string } | null> => { + if (!getLaneActivity) return null; + try { + const activity = await getLaneActivity(laneId); + const activeChatCount = normalizeActivityCount(activity?.activeChatCount); + const activePtyCount = normalizeActivityCount(activity?.activePtyCount); + if (activeChatCount <= 0 && activePtyCount <= 0) return null; + const parts: string[] = []; + if (activeChatCount > 0) parts.push(`${activeChatCount} active ${activeChatCount === 1 ? "chat" : "chats"}`); + if (activePtyCount > 0) parts.push(`${activePtyCount} active ${activePtyCount === 1 ? "terminal session" : "terminal sessions"}`); + return { + reason: "active_session", + message: `Auto-rebase paused: ${parts.join(" and ")} in this lane. Rebase manually from the Rebase/Merge tab when those sessions are stopped or safely resumable.`, + }; + } catch (error) { + logger.warn("autoRebase.activity_lookup_failed", { + laneId, + error: error instanceof Error ? error.message : String(error), + }); + return { + reason: "activity_lookup", + message: "Auto-rebase paused: ADE could not verify whether this lane has active sessions. Open the Rebase/Merge tab to retry manually.", + }; + } + }; + const resolveTrackedParent = ( lane: LaneSummary, laneById: Map, @@ -459,7 +503,7 @@ export function createAutoRebaseService(args: { let blocked = false; let blockedLaneId: string | null = null; - let blockedReason: "conflict" | "manual" | "lookup" | "failed" | "unavailable" | null = null; + let blockedReason: "conflict" | "manual" | "lookup" | "failed" | "unavailable" | "active_session" | "activity_lookup" | null = null; let blockedByLookupFailure = false; for (const laneId of cascadeOrder) { lanes = await laneService.list({ includeArchived: false }); @@ -571,6 +615,23 @@ export function createAutoRebaseService(args: { continue; } + const activityBlock = await getAutoRebaseActivityBlock(lane.id); + if (disposed) return; + if (activityBlock) { + blocked = true; + blockedLaneId = lane.id; + blockedReason = activityBlock.reason; + setStatus({ + laneId: lane.id, + parentLaneId: parent?.id ?? null, + parentHeadSha, + state: "rebasePending", + conflictCount: 0, + message: activityBlock.message + }); + continue; + } + if (!parent) { baseBranchOverride = need.baseBranch; targetLabel = need.baseBranch || lane.baseRef || lane.branchRef || lane.name;