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
15 changes: 6 additions & 9 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1431,14 +1431,11 @@ export async function createAdeRuntime(args: {
prService: headlessLinearServices.prService,
aiIntegrationService,
});
const prMergeAutoSettlementService = agentChatService
? createPrMergeAutoSettlementService({
db,
sessionService,
agentChatService,
emitEvent: emitPrEvent,
})
: null;
const prMergeAutoSettlementService = createPrMergeAutoSettlementService({
db,
sessionService,
emitEvent: emitPrEvent,
});

// GitHub polling fallback. Runtime-bound desktop windows route PR reads to
// this daemon instead of the desktop main process, so the daemon must own
Expand All @@ -1454,7 +1451,7 @@ export async function createAdeRuntime(args: {
headlessLinearServices.githubService.getBackgroundRequestPauseUntilMs(),
onEvent: emitPrEvent,
onPullRequestsSnapshot: (snapshot) =>
prMergeAutoSettlementService?.processSnapshot(snapshot),
prMergeAutoSettlementService.processSnapshot(snapshot),
onPullRequestsChanged: async ({ changedPrs, changes }) => {
if (changedPrs.length > 0) {
// Poll results must not start another hot-refresh window; doing so
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3509,7 +3509,6 @@ app.whenReady().then(async () => {
prMergeAutoSettlementServiceRef = createPrMergeAutoSettlementService({
db,
sessionService,
agentChatService,
emitEvent: emitPrEvent,
});
laneTeardownDeps.agentChatService = {
Expand Down
160 changes: 110 additions & 50 deletions apps/desktop/src/main/services/prs/prAsync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,14 +819,14 @@ describe("prMergeAutoSettlementService", () => {
};
}

it("settles only eligible lane agent sessions after a newly observed merge", async () => {
it("settles lane agent sessions even when a merge has settlement blockers", async () => {
const db = createMemoryDb();
const settleSessionsWithOutcome = vi.fn((ids: string[]) => ids);
const settledSessionIds = new Set<string>();
const settleSessionsWithOutcome = vi.fn((ids: string[]) => {
ids.forEach((id) => settledSessionIds.add(id));
return ids;
});
const emitEvent = vi.fn();
const getSettlementBlockers = vi.fn(async (sessionId: string) =>
sessionId === "cli-blocked"
? [{ code: "scheduled_work", message: "Complete scheduled work." }]
: []);
const service = createPrMergeAutoSettlementService({
db: db as any,
sessionService: {
Expand All @@ -835,24 +835,23 @@ describe("prMergeAutoSettlementService", () => {
id: "chat-ready",
toolType: "codex-chat",
archivedAt: null,
settledAt: null,
settledAt: settledSessionIds.has("chat-ready") ? "2026-03-24T12:01:05.000Z" : null,
},
{
id: "cli-blocked",
toolType: "codex",
archivedAt: null,
settledAt: null,
settledAt: settledSessionIds.has("cli-blocked") ? "2026-03-24T12:01:05.000Z" : null,
},
{
id: "raw-shell",
toolType: "shell",
archivedAt: null,
settledAt: null,
settledAt: settledSessionIds.has("raw-shell") ? "2026-03-24T12:01:05.000Z" : null,
},
]),
settleSessionsWithOutcome,
} as any,
agentChatService: { getSettlementBlockers } as any,
emitEvent,
});
const openPr = createSummary({ state: "open" });
Expand All @@ -872,29 +871,115 @@ describe("prMergeAutoSettlementService", () => {
polledAt: "2026-03-24T12:01:05.000Z",
});

expect(getSettlementBlockers).toHaveBeenCalledTimes(2);
expect(getSettlementBlockers).toHaveBeenCalledWith(
"chat-ready",
{ includeCurrentTurn: true },
);
expect(settleSessionsWithOutcome).toHaveBeenCalledWith(
["chat-ready"],
"PR #101 merged",
"2026-03-24T12:01:05.000Z",
"pr_merge",
);
expect(settleSessionsWithOutcome).toHaveBeenCalledWith(
["cli-blocked"],
"PR #101 merged",
"2026-03-24T12:01:05.000Z",
"pr_merge",
);
expect(emitEvent).toHaveBeenCalledWith(expect.objectContaining({
type: "pr-sessions-auto-settled",
prId: "pr-1",
settledSessionIds: ["chat-ready"],
settledCount: 1,
settledSessionIds: ["chat-ready", "cli-blocked"],
settledCount: 2,
}));

await service.processSnapshot({
prs: [mergedPr],
polledAt: "2026-03-24T12:02:00.000Z",
});
expect(settleSessionsWithOutcome).toHaveBeenCalledTimes(2);
});

it("does not re-settle after reactivation, but settles for a later PR", async () => {
const db = createMemoryDb();
let settled = false;
const settleSessionsWithOutcome = vi.fn((ids: string[]) => {
settled = true;
return ids;
});
const service = createPrMergeAutoSettlementService({
db: db as any,
sessionService: {
list: vi.fn(() => [{
id: "chat-waiting",
toolType: "claude-chat",
archivedAt: null,
settledAt: settled ? "2026-03-24T12:01:05.000Z" : null,
}]),
settleSessionsWithOutcome,
} as any,
emitEvent: vi.fn(),
});
const openPr = createSummary({
state: "open",
githubPrNumber: 101,
chatSessionIds: ["chat-waiting"],
});
const openSecondPr = createSummary({
id: "pr-2",
githubPrNumber: 202,
state: "open",
chatSessionIds: ["chat-waiting"],
});
const mergedPr = createSummary({
state: "merged",
mergedAt: "2026-03-24T12:01:00.000Z",
chatSessionIds: ["chat-waiting"],
});
const mergedSecondPr = {
...openSecondPr,
state: "merged" as const,
mergedAt: "2026-03-24T12:03:00.000Z",
};

await service.processSnapshot({
prs: [openPr, openSecondPr],
polledAt: "2026-03-24T12:00:00.000Z",
});
await service.processSnapshot({
prs: [mergedPr, openSecondPr],
polledAt: "2026-03-24T12:01:05.000Z",
});

expect(settleSessionsWithOutcome).toHaveBeenCalledTimes(1);
expect(settleSessionsWithOutcome).toHaveBeenLastCalledWith(
["chat-waiting"],
"PR #101 merged",
"2026-03-24T12:01:05.000Z",
"pr_merge",
);
expect(getPrMergeAutoSettlementState(db as any)?.handledPrIds).toEqual(["pr-1"]);

// The user reactivates the chat. The old merged PR is already handled, so
// it must not immediately file the chat again.
settled = false;
await service.processSnapshot({
prs: [mergedPr, openSecondPr],
polledAt: "2026-03-24T12:02:00.000Z",
});
expect(settleSessionsWithOutcome).toHaveBeenCalledTimes(1);

// A distinct PR on the same lane gets its own one-shot settlement.
await service.processSnapshot({
prs: [mergedPr, mergedSecondPr],
polledAt: "2026-03-24T12:03:05.000Z",
});

expect(settleSessionsWithOutcome).toHaveBeenLastCalledWith(
["chat-waiting"],
"PR #202 merged",
"2026-03-24T12:03:05.000Z",
"pr_merge",
);
expect(settleSessionsWithOutcome).toHaveBeenCalledTimes(2);
expect(getPrMergeAutoSettlementState(db as any)?.handledPrIds).toEqual(["pr-1", "pr-2"]);
});

it("baselines old merges and only settles merges observed after re-enabling", async () => {
Expand All @@ -911,7 +996,6 @@ describe("prMergeAutoSettlementService", () => {
}]),
settleSessionsWithOutcome,
} as any,
agentChatService: { getSettlementBlockers: vi.fn(async () => []) } as any,
emitEvent: vi.fn(),
});
const oldMerge = createSummary({
Expand Down Expand Up @@ -975,7 +1059,6 @@ describe("prMergeAutoSettlementService", () => {
it("settles only the chats explicitly linked to a merged PR", async () => {
const db = createMemoryDb();
const settleSessionsWithOutcome = vi.fn((ids: string[]) => ids);
const getSettlementBlockers = vi.fn(async () => []);
const service = createPrMergeAutoSettlementService({
db: db as any,
sessionService: {
Expand All @@ -985,7 +1068,6 @@ describe("prMergeAutoSettlementService", () => {
]),
settleSessionsWithOutcome,
} as any,
agentChatService: { getSettlementBlockers } as any,
emitEvent: vi.fn(),
});
const openPr = createSummary({ state: "open" });
Expand All @@ -998,8 +1080,6 @@ describe("prMergeAutoSettlementService", () => {
});
await service.processSnapshot({ prs: [mergedPr], polledAt: "2026-03-24T12:01:05.000Z" });

expect(getSettlementBlockers).toHaveBeenCalledTimes(1);
expect(getSettlementBlockers).toHaveBeenCalledWith("chat-owned", { includeCurrentTurn: true });
expect(settleSessionsWithOutcome).toHaveBeenCalledWith(
["chat-owned"],
"PR #101 merged",
Expand Down Expand Up @@ -1031,7 +1111,6 @@ describe("prMergeAutoSettlementService", () => {
}]),
settleSessionsWithOutcome,
} as any,
agentChatService: { getSettlementBlockers: vi.fn(async () => []) } as any,
emitEvent,
});

Expand Down Expand Up @@ -1081,16 +1160,8 @@ describe("prMergeAutoSettlementService", () => {
}));
});

it("honors a concurrent disable while blocker checks are in flight", async () => {
it("honors disabling auto-settle before a merged PR is processed", async () => {
const db = createMemoryDb();
let releaseBlockerCheck: (() => void) | null = null;
const blockerCheckStarted = new Promise<void>((resolve) => {
releaseBlockerCheck = resolve;
});
let finishBlockerCheck!: () => void;
const blockerCheckFinished = new Promise<void>((resolve) => {
finishBlockerCheck = resolve;
});
const settleSessionsWithOutcome = vi.fn((ids: string[]) => ids);
const service = createPrMergeAutoSettlementService({
db: db as any,
Expand All @@ -1103,13 +1174,6 @@ describe("prMergeAutoSettlementService", () => {
}]),
settleSessionsWithOutcome,
} as any,
agentChatService: {
getSettlementBlockers: vi.fn(async () => {
releaseBlockerCheck?.();
await blockerCheckFinished;
return [];
}),
} as any,
emitEvent: vi.fn(),
});
const openPr = createSummary({ state: "open" });
Expand All @@ -1121,26 +1185,22 @@ describe("prMergeAutoSettlementService", () => {
state: "merged",
mergedAt: "2026-03-24T12:01:00.000Z",
});

const processing = service.processSnapshot({
prs: [mergedPr],
polledAt: "2026-03-24T12:01:05.000Z",
});
await blockerCheckStarted;
setSessionLifecycleSettings({
db: db as any,
settings: { autoSettleLaneSessionsOnPrMerge: false },
currentPrs: [mergedPr],
now: "2026-03-24T12:01:06.000Z",
currentPrs: [openPr],
now: "2026-03-24T12:01:00.000Z",
});
await service.processSnapshot({
prs: [mergedPr],
polledAt: "2026-03-24T12:01:05.000Z",
});
finishBlockerCheck();
await processing;

expect(settleSessionsWithOutcome).not.toHaveBeenCalled();
expect(getSessionLifecycleSettings(db as any).autoSettleLaneSessionsOnPrMerge).toBe(false);
expect(getPrMergeAutoSettlementState(db as any)).toEqual({
enabledSince: null,
handledPrIds: ["pr-1"],
handledPrIds: [],
});
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { createAgentChatService } from "../chat/agentChatService";
import type { createSessionService } from "../sessions/sessionService";
import type { AdeDb } from "../state/kvDb";
import type { PrEventPayload, PrSummary } from "../../../shared/types";
Expand All @@ -22,7 +21,6 @@ function isMergeAtOrAfter(mergedAt: string | null | undefined, enabledSince: str
export function createPrMergeAutoSettlementService(args: {
db: Pick<AdeDb, "getJson" | "setJson">;
sessionService: Pick<ReturnType<typeof createSessionService>, "list" | "settleSessionsWithOutcome">;
agentChatService: Pick<ReturnType<typeof createAgentChatService>, "getSettlementBlockers">;
emitEvent: (event: PrEventPayload) => void;
}) {
/**
Expand Down Expand Up @@ -107,10 +105,6 @@ export function createPrMergeAutoSettlementService(args: {

const settledSessionIds: string[] = [];
for (const session of rows) {
const blockers = await args.agentChatService.getSettlementBlockers(
session.id,
{ includeCurrentTurn: true },
);
const currentSettings = getSessionLifecycleSettings(args.db);
const currentState = getPrMergeAutoSettlementState(args.db);
if (
Expand All @@ -121,18 +115,23 @@ export function createPrMergeAutoSettlementService(args: {
) {
break;
}
if (blockers.length === 0) {
settledSessionIds.push(...args.sessionService.settleSessionsWithOutcome(
[session.id],
`PR #${pr.githubPrNumber} merged`,
polledAt,
"pr_merge",
));
}
// A merged PR is an explicit lifecycle decision: file the linked
// session even when it still owns scheduled work, a background task,
// or another normal settlement blocker. Real activity can unsettle it
// again, while handledPrIds prevents this PR from filing it twice.
settledSessionIds.push(...args.sessionService.settleSessionsWithOutcome(
[session.id],
`PR #${pr.githubPrNumber} merged`,
polledAt,
"pr_merge",
));
}

const finalSettings = getSessionLifecycleSettings(args.db);
const finalState = getPrMergeAutoSettlementState(args.db);
// Mark this PR handled even when its session had background work. The
// merge itself is the explicit override, and a later user reactivation
// belongs to a new lifecycle rather than this already-consumed merge.
if (
finalSettings.autoSettleLaneSessionsOnPrMerge
&& finalState?.enabledSince
Expand All @@ -145,10 +144,11 @@ export function createPrMergeAutoSettlementService(args: {
});
}

// Settling still happens for a PR we are meeting for the first time —
// its sessions really are finished and should be filed. Announcing it
// does not: "PR #977 merged" is news only if you did not already know,
// and a PR that was merged before we ever laid eyes on it is history.
// Filing still happens for a PR we are meeting for the first time — the
// merge is an explicit decision to file its linked sessions. Announcing
// it does not: "PR #977 merged" is news only if you did not already
// know, and a PR that was merged before we ever laid eyes on it is
// history.
//
// Gated here rather than in the toast so every consumer inherits it —
// desktop toasts, mobile push, and anything added later.
Expand Down
Loading