fix: include sibling worktree sessions when project ids drift

Resolve all-worktree session lookups by git worktree family instead of a single project row so /sessions, Ctrl+A, and local recall still find sibling worktrees without leaking unrelated directories.
This commit is contained in:
Alex Alecu
2026-04-03 23:54:09 +03:00
parent 663baf9763
commit 0c3c233b92
11 changed files with 537 additions and 1734 deletions
@@ -29,12 +29,11 @@ export function DialogSessionList() {
async ({ query, global: all }) => {
if (!query && !all) return undefined
if (all) {
const project = await sdk.client.project.current({}, { throwOnError: true })
const result = await sdk.client.experimental.session.list(
{
projectID: project.data?.id,
search: query || undefined,
roots: true,
worktrees: true,
limit: 30,
},
{ throwOnError: true },
@@ -0,0 +1,35 @@
// kilocode_change - new file
import { Instance } from "../project/instance"
import { Project } from "../project/project"
import { Filesystem } from "../util/filesystem"
import { git } from "../util/git"
export namespace WorktreeFamily {
export async function list() {
if (Instance.project.vcs !== "git") {
return [Filesystem.resolve(Instance.directory)]
}
const listed = await git(["worktree", "list", "--porcelain"], {
cwd: Instance.worktree,
})
if (listed.exitCode === 0) {
const dirs = listed
.text()
.split("\n")
.map((line) => line.trim())
.flatMap((line) => {
if (!line.startsWith("worktree ")) return []
return [Filesystem.resolve(line.slice("worktree ".length).trim())]
})
if (dirs.length > 0) {
return [...new Set(dirs)]
}
}
const dirs = [Instance.worktree, ...(await Project.sandboxes(Instance.project.id))]
return [...new Set(dirs.map((dir) => Filesystem.resolve(dir)))]
}
}
@@ -13,6 +13,7 @@ import { lazy } from "../../util/lazy"
import { Snapshot } from "../../snapshot" // kilocode_change
import { Review } from "../../kilocode/review/review" // kilocode_change
import { WorktreeDiff } from "../../kilocode/review/worktree-diff" // kilocode_change
import { WorktreeFamily } from "../../kilocode/worktree-family" // kilocode_change
import { Log } from "../../util/log" // kilocode_change
import { WorkspaceRoutes } from "./workspace"
@@ -329,6 +330,10 @@ export const ExperimentalRoutes = lazy(() =>
// kilocode_change
projectID: z.string().optional().meta({ description: "Filter sessions by project ID" }),
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
worktrees: z.coerce
.boolean()
.optional()
.meta({ description: "Restrict sessions to the current repo worktree family or current directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce
.number()
@@ -346,10 +351,16 @@ export const ExperimentalRoutes = lazy(() =>
async (c) => {
const query = c.req.valid("query")
const limit = query.limit ?? 100
const projectID =
query.worktrees && !query.projectID
? Instance.project.id // kilocode_change
: query.projectID
const directories = query.worktrees ? await WorktreeFamily.list() : undefined // kilocode_change
const sessions: Session.GlobalInfo[] = []
for await (const session of Session.listGlobal({
projectID: query.projectID, // kilocode_change
projectID, // kilocode_change
directory: query.directory,
directories, // kilocode_change
roots: query.roots,
start: query.start,
cursor: query.cursor,
+39 -4
View File
@@ -117,6 +117,25 @@ export namespace Session {
return `${title} (fork #1)`
}
// kilocode_change start
function family(id: string) {
const row = Database.use((db) =>
db.select({ worktree: ProjectTable.worktree }).from(ProjectTable).where(eq(ProjectTable.id, id)).get(),
)
const root = row?.worktree ? Filesystem.resolve(row.worktree) : undefined
if (!root || root === "/") return [id]
const ids = Database.use((db) =>
db
.select({ id: ProjectTable.id })
.from(ProjectTable)
.where(eq(ProjectTable.worktree, root))
.all()
.map((item) => item.id),
)
return ids.length ? ids : [id]
}
// kilocode_change end
export const Info = z
.object({
id: Identifier.schema("session"),
@@ -624,6 +643,7 @@ export namespace Session {
export function* listGlobal(input?: {
projectID?: string
directory?: string
directories?: string[]
roots?: boolean
start?: number
cursor?: number
@@ -635,7 +655,12 @@ export namespace Session {
// kilocode_change start
if (input?.projectID) {
conditions.push(eq(SessionTable.project_id, input.projectID))
const ids = family(input.projectID)
if (ids.length === 1 && ids[0] === input.projectID) {
conditions.push(eq(SessionTable.project_id, input.projectID))
} else {
conditions.push(inArray(SessionTable.project_id, ids))
}
}
// kilocode_change end
@@ -661,6 +686,7 @@ export namespace Session {
}
const limit = input?.limit ?? 100
const dirs = [...new Set((input?.directories ?? []).map((dir) => Filesystem.resolve(dir)))]
const rows = Database.use((db) => {
const query =
@@ -670,10 +696,19 @@ export namespace Session {
.from(SessionTable)
.where(and(...conditions))
: db.select().from(SessionTable)
return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all()
const sorted = query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id))
return dirs.length ? sorted.all() : sorted.limit(limit).all()
})
const ids = [...new Set(rows.map((row) => row.project_id))]
const list =
dirs.length > 0
? rows.filter((row) => {
const dir = Filesystem.resolve(row.directory)
return dirs.some((root) => Filesystem.contains(root, dir))
})
: rows
const ids = [...new Set(list.slice(0, limit).map((row) => row.project_id))]
const projects = new Map<string, ProjectInfo>()
if (ids.length > 0) {
@@ -693,7 +728,7 @@ export namespace Session {
}
}
for (const row of rows) {
for (const row of list.slice(0, limit)) {
const project = projects.get(row.project_id) ?? null
yield { ...fromRow(row), project }
}
+9 -4
View File
@@ -3,6 +3,8 @@ import z from "zod"
import { Tool } from "./tool"
import { Instance } from "../project/instance"
import { Locale } from "../util/locale"
import { Filesystem } from "../util/filesystem" // kilocode_change
import { WorktreeFamily } from "../kilocode/worktree-family" // kilocode_change
import DESCRIPTION from "./recall.txt"
export const RecallTool = Tool.define("kilo_local_recall", {
@@ -37,7 +39,7 @@ async function search(params: { query?: string; limit?: number }, ctx: Tool.Cont
})
const limit = Math.min(params.limit ?? 20, 50)
const current = Instance.project.id
const dirs = await WorktreeFamily.list() // kilocode_change
const { Session } = await import("../session/index") // kilocode_change
const results: Array<{
@@ -48,7 +50,8 @@ async function search(params: { query?: string; limit?: number }, ctx: Tool.Cont
}> = []
for (const session of Session.listGlobal({
projectID: current, // kilocode_change
projectID: Instance.project.id, // kilocode_change
directories: dirs, // kilocode_change
search: params.query,
roots: true,
limit,
@@ -87,10 +90,12 @@ async function read(params: { sessionID?: string }, ctx: Tool.Context) {
const session = await Session.get(params.sessionID).catch(() => {
throw new Error(`Session "${params.sessionID}" not found. Use search mode first to find valid session IDs.`)
})
const dirs = await WorktreeFamily.list() // kilocode_change
// kilocode_change start
if (session.projectID !== Instance.project.id) {
const dir = Filesystem.resolve(session.directory)
if (!dirs.some((root) => Filesystem.contains(root, dir))) {
throw new Error(
`Session "${params.sessionID}" belongs to a different project and cannot be read from this workspace.`,
`Session "${params.sessionID}" belongs to a different workspace and cannot be read from this directory.`,
)
}
// kilocode_change end
@@ -25,13 +25,14 @@ afterEach(async () => {
})
describe("experimental.session.list", () => {
test("filters sessions by project ID across worktrees", async () => {
test("filters sessions by repo worktree family even when project IDs drift", async () => {
await using first = await tmpdir({ git: true })
await using second = await tmpdir({ git: true })
const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree")
try {
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
const share = Config.get
Config.get = async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>
@@ -63,7 +64,7 @@ describe("experimental.session.list", () => {
const app = root.app
const project = await root.project.json()
const response = await app.request(
`/experimental/session?projectID=${encodeURIComponent(project.id)}&roots=true`,
`/experimental/session?projectID=${encodeURIComponent(project.id)}&roots=true&worktrees=true`,
{
headers: { "x-kilo-directory": first.path },
},
@@ -74,6 +75,8 @@ describe("experimental.session.list", () => {
const ids = body.map((item: { id: string }) => item.id)
const dirs = body.map((item: { directory: string }) => item.directory)
expect(root.session.projectID).not.toBe(branch.projectID)
expect(project.id).toBe(root.session.projectID)
expect(ids).toContain(root.session.id)
expect(ids).toContain(branch.id)
expect(dirs).toContain(worktree)
@@ -107,7 +107,7 @@ describe("Session.listGlobal", () => {
expect(ids).not.toContain(second.id)
})
test("filters by project ID across worktrees", async () => {
test("filters by project family across worktrees when project IDs drift", async () => {
const { Session } = await import("../../src/session/index")
await using first = await tmpdir({ git: true })
await using second = await tmpdir({ git: true })
@@ -115,6 +115,7 @@ describe("Session.listGlobal", () => {
try {
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
const root = await Instance.provide({
directory: first.path,
@@ -132,6 +133,7 @@ describe("Session.listGlobal", () => {
const sessions = [...Session.listGlobal({ projectID: root.projectID, roots: true, limit: 200 })]
const ids = sessions.map((session) => session.id)
expect(root.projectID).not.toBe(branch.projectID)
expect(ids).toContain(root.id)
expect(ids).toContain(branch.id)
expect(ids).not.toContain(other.id)
+37 -1
View File
@@ -42,6 +42,7 @@ describe("tool.recall", () => {
try {
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
const share = Config.get
Config.get = async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>
@@ -103,9 +104,44 @@ describe("tool.recall", () => {
})
expect(err).toBeInstanceOf(Error)
expect((err as Error).message).toContain("belongs to a different project")
expect((err as Error).message).toContain("belongs to a different workspace")
} finally {
Config.get = share
}
})
test("read allows sessions from sibling worktrees when project IDs drift", async () => {
await using first = await tmpdir({ git: true })
const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree")
try {
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
const share = Config.get
Config.get = async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>
try {
const { Session } = await import("../../src/session/index")
const session = await Instance.provide({
directory: worktree,
fn: async () => Session.create({ title: "worktree readable" }),
})
const result = await Instance.provide({
directory: first.path,
fn: async () => {
const tool = await RecallTool.init()
return tool.execute({ mode: "read", sessionID: session.id }, ctx)
},
})
expect(result.output).toContain("# Session: worktree readable")
} finally {
Config.get = share
}
} finally {
await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow()
}
})
})
+2
View File
@@ -1066,6 +1066,7 @@ export class Session extends HeyApiClient {
directory?: string
workspace?: string
projectID?: string
worktrees?: boolean
roots?: boolean
start?: number
cursor?: number
@@ -1083,6 +1084,7 @@ export class Session extends HeyApiClient {
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "projectID" },
{ in: "query", key: "worktrees" },
{ in: "query", key: "roots" },
{ in: "query", key: "start" },
{ in: "query", key: "cursor" },
+4
View File
@@ -2847,6 +2847,10 @@ export type ExperimentalSessionListData = {
* Filter sessions by project ID
*/
projectID?: string
/**
* Restrict sessions to the current repo worktree family or current directory
*/
worktrees?: boolean
/**
* Only return root sessions (no parentID)
*/
+390 -1719
View File
File diff suppressed because it is too large Load Diff