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
20 changes: 20 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
},
Comment thread
arul28 marked this conversation as resolved.
onEvent: (event) => pushEvent("runtime", { type: "lane_auto_rebase_event", event }),
});
autoRebaseServiceRef = autoRebaseService;
Expand Down Expand Up @@ -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.");
}
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1996,6 +1996,7 @@ app.whenReady().then(async () => {
};

const laneTeardownDeps: LaneDeleteTeardownDeps = {};
let autoRebaseActivityReady = false;
let macosVmLaunchProviderForProject: MacosVmLaunchProvider | null = null;
const laneService = createLaneService({
db,
Expand Down Expand Up @@ -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,
};
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
onEvent: (event) =>
emitProjectEvent(projectRoot, IPC.lanesAutoRebaseEvent, event),
});
Expand Down Expand Up @@ -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),
});
});
Comment thread
arul28 marked this conversation as resolved.
setImmediate(() => {
void Promise.resolve()
.then(() => agentChatService.cleanupStaleAttachments())
Expand Down
137 changes: 136 additions & 1 deletion apps/desktop/src/main/services/lanes/autoRebaseService.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../shared/utils")>();
const actual = await importOriginal<typeof SharedUtils>();
return {
...actual,
nowIso: vi.fn(() => "2026-03-25T12:00:00.000Z"),
Expand Down Expand Up @@ -104,6 +105,8 @@ describe("autoRebaseService", () => {
let events: AutoRebaseEventPayload[];
let laneList: LaneSummary[];
let rebaseNeedOverrides: Map<string, Partial<RebaseNeed> | null>;
let laneActivityById: Map<string, { activeChatCount?: number; activePtyCount?: number }>;
let laneActivityFailures: Set<string>;
let laneService: any;
let conflictService: any;
let projectConfigService: any;
Expand All @@ -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);
Expand Down Expand Up @@ -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),
});
}
Expand Down Expand Up @@ -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", {
Expand Down
65 changes: 63 additions & 2 deletions apps/desktop/src/main/services/lanes/autoRebaseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AutoRebaseLaneStatus[]>;
Expand Down Expand Up @@ -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.`;
}
Expand Down Expand Up @@ -158,6 +174,7 @@ export function createAutoRebaseService(args: {
laneService: ReturnType<typeof createLaneService>;
conflictService: ReturnType<typeof createConflictService>;
projectConfigService: ReturnType<typeof createProjectConfigService>;
getLaneActivity?: (laneId: string) => LaneActivity | Promise<LaneActivity>;
onEvent?: (event: AutoRebaseEventPayload) => void;
}): AutoRebaseService {
const {
Expand All @@ -166,6 +183,7 @@ export function createAutoRebaseService(args: {
laneService,
conflictService,
projectConfigService,
getLaneActivity,
onEvent
} = args;

Expand Down Expand Up @@ -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<string, LaneSummary>,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

if (!parent) {
baseBranchOverride = need.baseBranch;
targetLabel = need.baseBranch || lane.baseRef || lane.branchRef || lane.name;
Expand Down
Loading