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
1 change: 1 addition & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,7 @@ export async function createAdeRuntime(args: {
} = { run: null, report: null, residue: null, subagentLinks: null };
const sessionService = createSessionService({
db,
logger,
runSettleTeardown: async (sessionId, ctx) =>
settleTeardownRef.run ? await settleTeardownRef.run(sessionId, ctx) : { residue: [], confirmed: false },
onRemoteSettleWrite: (args) => settleTeardownRef.report?.(args),
Expand Down
16 changes: 14 additions & 2 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import { SCENE_GUIDE } from "../../desktop/src/shared/sceneGuide";
import { captureError, flushSentry } from "../../desktop/src/shared/sentry/capture";
import { initBrainSentry } from "./sentry/initBrain";
import { buildDeeplink, type DeeplinkEnvelope } from "../../desktop/src/shared/deeplinks";
import { laneIsOnBaseBranch } from "../../desktop/src/shared/laneBaseResolution";
import { ARCHIVE_ITEM_KINDS, type ArchiveItemKind } from "../../desktop/src/shared/types/archive";
import { archiveKindCountParts } from "../../desktop/src/shared/archive";
import { USER_BROWSER_TARGET_PREFIX } from "../../desktop/src/shared/userBrowserLabels";
Expand Down Expand Up @@ -33120,10 +33121,21 @@ function createLinkEnvelopeResolver(
// deeplink envelope with the PR a human would name: open before draft
// before terminal history, newest first. Same comparator as the desktop
// lane badge, so a link minted here points where the badge points.
const pr = pickPrimaryPrRecord(records(prsValue, ["prs", "items", "result"])
//
// A primary lane parked on its base branch names no PR at all (its row
// cannot be deleted, so history would otherwise be stamped onto `main`
// forever). The badge answers the same way, so a link and a badge agree —
// the rule is imported from `shared/laneBaseResolution.ts`, not restated.
const laneBranch = asString(lane?.branchRef)?.replace(/^refs\/heads\//, "") ?? null;
const primaryOnBase = laneIsOnBaseBranch({
laneType: asString(lane?.laneType),
branchRef: asString(lane?.branchRef),
baseRef: asString(lane?.baseRef),
});
const pr = primaryOnBase ? null : pickPrimaryPrRecord(records(prsValue, ["prs", "items", "result"])
.filter((candidate) => asString(candidate.laneId) === context.laneId));

const branch = asString(lane?.branchRef)?.replace(/^refs\/heads\//, "") ?? null;
const branch = laneBranch;
const laneIssue = isRecord(lane?.linearIssue) ? lane.linearIssue : null;
const linearIssue = asString(laneIssue?.identifier);
// `pickPrimaryPrRecord` accepts `githubPrNumber`, `number`, or `prNumber`,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3788,6 +3788,7 @@ app.whenReady().then(async () => {
} = { run: null, report: null, residue: null, subagentLinks: null };
const sessionService = createSessionService({
db,
logger,
onRemoteSettleWrite: (args) => settleTeardownRef.report?.(args),
onSettleResidue: (args) => settleTeardownRef.residue?.(args),
// Read at call time: the automation service is built later.
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/main/services/lanes/laneService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5608,6 +5608,52 @@ describe("laneService delete teardown + cancellation + streaming", () => {
expect(decodeActiveDayKeys(tombstone.first_active_day, tombstone.active_day_bits)).toHaveLength(3);
});

it("drops the PR links held by a deleted lane's sessions, including cross-lane edges", async () => {
const events: any[] = [];
const fake = makeFakeServices();
const { db, service } = await setupWithLane({ teardown: fake, events });
vi.mocked(runGit).mockImplementation(async (args: string[]) => {
const laneBranchGitStub = defaultLaneBranchGitStub(args);
if (laneBranchGitStub) return laneBranchGitStub;
return { exitCode: 0, stdout: "", stderr: "" } as any;
});
vi.mocked(runGitOrThrow).mockImplementation(async () => ({ exitCode: 0, stdout: "", stderr: "" }) as any);

seedLaneActivity(db, "lane-child");
const now = "2026-03-12T12:00:00.000Z";
db.run(
`insert into pull_requests(
id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url,
state, base_branch, head_branch, created_at, updated_at
) values ('pr-parent', 'proj-delete', 'lane-parent', 'arul28', 'ADE', 1630,
'https://github.com/arul28/ADE/pull/1630', 'open', 'main', 'ade/parent-work', ?, ?)`,
[now, now],
);
// A stack-coordinator-style link: the session lives in the deleted lane,
// the PR belongs to another lane, so the lane's own `pr_id`-keyed detach
// cannot see it. A dangling edge would flip into a claim on `pr-parent`.
db.run(
`insert into pull_request_chat_sessions(id, project_id, pr_id, lane_id, session_id, created_at, updated_at)
values ('edge-cross', 'proj-delete', 'pr-parent', 'lane-parent', 'sess-chat', ?, ?)`,
[now, now],
);
db.run(
`insert into pull_request_chat_session_dismissals(id, project_id, pr_id, session_id, created_at, updated_at)
values ('dismissal-cross', 'proj-delete', 'pr-parent', 'sess-chat', ?, ?)`,
[now, now],
);

await service.delete({ laneId: "lane-child", deleteBranch: false });

expect(db.all("select id from pull_request_chat_sessions where session_id = 'sess-chat'")).toEqual([]);
expect(
db.all("select id from pull_request_chat_session_dismissals where session_id = 'sess-chat'"),
).toEqual([]);
// The other lane's PR row is history and survives with its lane mapping.
expect(db.get<{ id: string; lane_id: string }>("select id, lane_id from pull_requests where id = 'pr-parent'"))
.toMatchObject({ id: "pr-parent", lane_id: "lane-parent" });
});

it("stores no free text a deleted lane could be reconstructed from", async () => {
const events: any[] = [];
const fake = makeFakeServices();
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/main/services/lanes/laneService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import type { AdeDb } from "../state/kvDb";
import { getHeadSha, parseGitCheckoutProgressLine, runGit, runGitOrThrow, type GitCheckoutProgress } from "../git/git";
import { detachPullRequestRowsForLane } from "../prs/pullRequestRowCleanup";
import { detachPullRequestRowsForLane, deletePullRequestChatLinksForSessions } from "../prs/pullRequestRowCleanup";
import { isWithinDir, normalizeBranchName, resolvePathWithinRoot } from "../shared/utils";
import { fetchRemoteTrackingBranch } from "../shared/remoteTrackingBranch";
import { isPathInside, pathKey, pathsEqual } from "../shared/pathCompare";
Expand Down Expand Up @@ -4613,6 +4613,17 @@ export function createLaneService({
[laneId, laneId],
);
db.run("delete from claude_sessions where lane_id = ?", [laneId]);
// A chat that goes with its lane takes its PR links with it. The detach
// above only clears edges for PRs THIS lane owns; a cross-lane link (a
// stack coordinator, an explicit pick) is keyed to another lane's PR, and
// once its session row is gone the dangling edge flips from a cross-lane
// reference to a claim — which pins that other lane's PR forever. Select
// the ids before the session rows go.
deletePullRequestChatLinksForSessions(
db,
db.all<{ id: string }>("select id from terminal_sessions where lane_id = ?", [laneId])
.map((session) => session.id),
);
db.run("delete from terminal_sessions where lane_id = ?", [laneId]);
// The settle-lifecycle token table is local-only with no foreign key, so a
// bulk session delete would otherwise leave permanent orphans. Sweep by
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/main/services/prs/prService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,33 @@ describe("prService.getForLane", () => {
expect(service.getForLane(lane.id)).toBeNull();
});

it("hands no history row to the bridge for a primary lane back on its base branch", () => {
// The lane cannot be deleted, so the single-value bridge must not answer
// with the newest previous-branch row either — that is what pinned an old
// merged PR to `main` on lane links, Linear cards and proof owners.
const lane = makeFakeLane({
laneType: "primary",
branchRef: "main",
baseRef: "main",
});
const service = buildGetForLaneService(lane, [
makePrRow({
lane_id: lane.id,
state: "merged",
head_branch: "fix/update-recovery",
}),
]);

expect(service.getForLane(lane.id)).toBeNull();

// The same lane checked out on the PR's branch still answers with it.
const working = buildGetForLaneService(
makeFakeLane({ id: lane.id, laneType: "primary", branchRef: "fix/update-recovery", baseRef: "main" }),
[makePrRow({ lane_id: lane.id, state: "merged", head_branch: "fix/update-recovery" })],
);
expect(working.getForLane(lane.id)?.githubPrNumber).toBe(90);
});

it("returns a stale PR row as lane history when the lane moved on", () => {
const lane = makeFakeLane({
branchRef: "refs/heads/current-feature",
Expand Down
35 changes: 25 additions & 10 deletions apps/desktop/src/main/services/prs/prService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ import { claimingSessionIds, selectStackSiblings } from "../../../shared/prChatS
import { hasMergeConflictMarkers, parseGitStatusPorcelain } from "./integrationValidation";
import { fetchRemoteTrackingBranch } from "../shared/remoteTrackingBranch";
import { asNumber, asString, getErrorMessage, isRecord, normalizeBranchName, nowIso, resolvePathWithinRoot } from "../shared/utils";
import { branchNameFromLaneRef, resolveStableLaneBaseBranch } from "../../../shared/laneBaseResolution";
import { branchNameFromLaneRef, laneIsOnBaseBranch, resolveStableLaneBaseBranch } from "../../../shared/laneBaseResolution";
import {
buildLaneBranchHistoryIndex,
pickLaneBranchHistoryPr,
Expand Down Expand Up @@ -2153,15 +2153,29 @@ export function createPrService({
[laneId, projectId],
);

/**
* A primary lane on its base branch shows no PR anywhere. The checkout is
* the project's own and cannot be deleted, so nothing may pin history to it:
* not the current-branch match, and not the single-value history fallback in
* `getForLane`. The rule itself lives in `shared/laneBaseResolution.ts` so
* the renderer's lane surfaces and the CLI's lane-link envelope answer it
* identically; this maps the lane row onto it. The host reads the RECORDED
* branch (no live checkout here); the lane list re-syncs the primary lane's
* record to its live HEAD, so a lane parked on a drifted HEAD converges.
*/
const laneRowIsOnBaseBranch = (lane: LanePrLookupRow): boolean =>
laneIsOnBaseBranch({
laneType: lane.lane_type,
branchRef: lane.branch_ref,
baseRef: lane.base_ref,
});

const rowMatchesLaneBranchForDisplay = (row: PullRequestRow, lane: LanePrLookupRow): boolean => {
const laneBranch = normalizeBranchName(branchNameFromRef(lane.branch_ref ?? ""));
const prHeadBranch = normalizeBranchName(row.head_branch);
if (!laneBranch || !prHeadBranch || laneBranch !== prHeadBranch) return false;

if (lane.lane_type === "primary") {
const laneBaseBranch = normalizeBranchName(branchNameFromRef(lane.base_ref ?? ""));
if (laneBranch && laneBaseBranch && laneBranch === laneBaseBranch) return false;
}
if (laneRowIsOnBaseBranch(lane)) return false;

return true;
};
Expand Down Expand Up @@ -2285,11 +2299,7 @@ export function createPrService({
if (args.lane.archived_at) return null;
const laneBranch = normalizeBranchName(branchNameFromRef(args.lane.branch_ref ?? ""));
if (!laneBranch) return null;

if (args.lane.lane_type === "primary") {
const laneBaseBranch = normalizeBranchName(branchNameFromRef(args.lane.base_ref ?? ""));
if (laneBaseBranch && laneBranch === laneBaseBranch) return null;
}
if (laneRowIsOnBaseBranch(args.lane)) return null;

const mappedCandidates: LanePrDisplayCandidate[] = args.mappedRows
.filter((row) => row.lane_id === args.laneId && rowMatchesLaneBranchForDisplay(row, args.lane))
Expand Down Expand Up @@ -12303,6 +12313,11 @@ export function createPrService({
if (!lane || lane.archived_at || !normalizeBranchName(branchNameFromRef(lane.branch_ref ?? ""))) {
return null;
}
// ...but a primary lane back on its base branch has no PR to hand out —
// not even as history. It cannot be deleted, so returning "the newest
// retained row" here is what pinned an old merged PR to `main` on every
// consumer of this bridge (lane links, Linear cards, proof owners).
if (laneRowIsOnBaseBranch(lane)) return null;
const previous = getRowForLane(laneId);
const laneBranch = normalizeBranchName(branchNameFromRef(lane.branch_ref ?? ""));
const previousBranch = normalizeBranchName(branchNameFromRef(previous?.head_branch ?? ""));
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/src/main/services/prs/pullRequestRowCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,37 @@ export function detachPullRequestRowsByIds(
provenance: countLaneProvenance(db, projectId, laneId),
});
}

/** Bound variables per delete statement; lane teardown can carry many sessions. */
const SESSION_LINK_DELETE_CHUNK_SIZE = 500;

/**
* Remove PR link rows (and unlink tombstones) for sessions that no longer
* exist.
*
* Both ways a session disappears land here: deleting one chat
* (`sessionService.deleteSession` — the cascade every chat/CLI delete path
* uses) and deleting a whole lane (`laneService`, which bulk-deletes its
* sessions). A link row outliving its chat is routing metadata aimed at
* nobody, and a dangling edge for a session a reader cannot resolve flips
* from a cross-lane *reference* to a *claim*, which pins another lane's PR.
* Each table is attempted on its own and tolerates a database that predates it.
*/
export function deletePullRequestChatLinksForSessions(db: DbLike, sessionIds: string[]): void {
const ids = uniqueIds(sessionIds);
if (ids.length === 0) return;
for (let offset = 0; offset < ids.length; offset += SESSION_LINK_DELETE_CHUNK_SIZE) {
const chunk = ids.slice(offset, offset + SESSION_LINK_DELETE_CHUNK_SIZE);
const placeholders = chunk.map(() => "?").join(", ");
runDeleteAllowMissingTable(
db,
`delete from pull_request_chat_sessions where session_id in (${placeholders})`,
chunk,
);
runDeleteAllowMissingTable(
db,
`delete from pull_request_chat_session_dismissals where session_id in (${placeholders})`,
chunk,
);
}
}
58 changes: 58 additions & 0 deletions apps/desktop/src/main/services/sessions/sessionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,64 @@ describe("sessionService resume metadata", () => {
activeDisposers.push(async () => db.close());
});

it("drops the PR links that pointed at a deleted session", async () => {
const projectRoot = makeProjectRoot("ade-session-service-");
const dbPath = path.join(projectRoot, ".ade", "ade.db");
const db = await openKvDb(dbPath, createLogger() as any);
insertProjectGraph(db);
const service = createSessionService({ db });
const now = "2026-03-17T00:10:00.000Z";

for (const sessionId of ["session-with-links", "session-survivor"]) {
service.create({
sessionId,
laneId: "lane-1",
ptyId: null,
tracked: true,
title: "Chat",
startedAt: now,
transcriptPath: `/tmp/${sessionId}.log`,
toolType: "claude-chat",
});
}
db.run(
`insert into pull_requests(
id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url,
state, base_branch, head_branch, created_at, updated_at
) values ('pr-linked', 'project-1', 'lane-1', 'arul28', 'ADE', 1501,
'https://github.com/arul28/ADE/pull/1501', 'merged', 'main', 'fix/update-recovery', ?, ?)`,
[now, now],
);
for (const sessionId of ["session-with-links", "session-survivor"]) {
db.run(
`insert into pull_request_chat_sessions(id, project_id, pr_id, lane_id, session_id, created_at, updated_at)
values (?, 'project-1', 'pr-linked', 'lane-1', ?, ?, ?)`,
[`edge-${sessionId}`, sessionId, now, now],
);
}
db.run(
`insert into pull_request_chat_session_dismissals(id, project_id, pr_id, session_id, created_at, updated_at)
values ('dismissal-1', 'project-1', 'pr-linked', 'session-with-links', ?, ?)`,
[now, now],
);

expect(service.deleteSession("session-with-links")).toBe(true);

// The dead session's edge and tombstone go; the surviving chat keeps its
// claim on the PR, and the PR row itself is history that must survive.
expect(
db.all<{ session_id: string }>(
"select session_id from pull_request_chat_sessions where pr_id = 'pr-linked'",
).map((row) => row.session_id),
).toEqual(["session-survivor"]);
expect(
db.all("select id from pull_request_chat_session_dismissals where session_id = 'session-with-links'"),
).toEqual([]);
expect(db.get<{ id: string }>("select id from pull_requests where id = 'pr-linked'")?.id).toBe("pr-linked");

activeDisposers.push(async () => db.close());
});

it("reattaches an existing tracked session to a new PTY without changing its identity", async () => {
const projectRoot = makeProjectRoot("ade-session-service-");
const dbPath = path.join(projectRoot, ".ade", "ade.db");
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/main/services/sessions/sessionService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import fs from "node:fs";
import type { AdeDb, RemoteSettleTupleChange } from "../state/kvDb";
import type { Logger } from "../logging/logger";
import { deletePullRequestChatLinksForSessions } from "../prs/pullRequestRowCleanup";
import { DEFAULT_PROCESS_REGISTRY_LIVENESS_WINDOW_MS } from "../runtime/processRegistryService";
import { chatToolProvider, isChatToolType } from "../../../shared/sessionSpawnNesting";
import { createSettleLifecycleWriter } from "./settleLifecycleWriter";
Expand Down Expand Up @@ -447,13 +449,16 @@ type SettleCommitGuard = (sessionId: string) => boolean;

export function createSessionService({
db,
logger,
runSettleTeardown,
onRemoteSettleWrite,
onSettleResidue,
onChatSettled,
listSubagentLinks,
}: {
db: AdeDb;
/** Optional: only the delete cascade's best-effort PR-link purge reports through it. */
logger?: Logger;
/**
* Stop the session's background work. Injected rather than per-call: teardown
* is a service capability, not something a caller decides.
Expand Down Expand Up @@ -2617,6 +2622,18 @@ export function createSessionService({
// already cascaded here.
settleLifecycle.forget(trimmed);
clearSettleResidue(trimmed);
// PR links are routing metadata for one chat; a link outliving its chat
// pins its lane's PR (the primary lane can never shed those). The row is
// already gone, so a failed purge must not report the delete as failed —
// but it still says so, because the orphan it leaves is otherwise invisible.
try {
deletePullRequestChatLinksForSessions(db, [trimmed]);
} catch (error) {
logger?.warn("sessions.pr_chat_link_purge_failed", {
sessionId: trimmed,
error: error instanceof Error ? error.message : String(error),
});
}
emitChanged({ sessionId: trimmed, reason: "deleted" });
return true;
},
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/renderer/components/lanes/LanesPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,4 +852,21 @@ describe("lane vs chat PR scoping", () => {

expect(selectChatPrs(lane, [detachedOwned, detachedForeign], "sess-a")).toEqual([]);
});

it("keeps a primary lane's PR visible to the chat that linked it while the lane surface hides it", () => {
const primary = makeLane({ id: "lane-1", laneType: "primary", branchRef: "main", baseRef: "main" });
const linked = makePr({
id: "pr-linked",
laneId: "lane-1",
headBranch: "fix/update-recovery",
state: "merged",
chatSessionIds: ["sess-a"],
});

// The lane is parked on main, so its own surfaces show nothing...
expect(selectLanePrs(primary, [linked])).toEqual([]);
// ...but the thread that opened the PR still shows the PR it worked on.
expect(selectChatPrs(primary, [linked], "sess-a").map((pr) => pr.id)).toEqual(["pr-linked"]);
expect(selectChatPrs(primary, [linked], "sess-b")).toEqual([]);
});
});
Loading
Loading