diff --git a/packages/opencode/navigator.ts b/packages/opencode/navigator.ts index 9e04a42..bc91f08 100644 --- a/packages/opencode/navigator.ts +++ b/packages/opencode/navigator.ts @@ -87,7 +87,7 @@ type NavigatorContext = { legacyClient: (directory: string) => NavigatorLegacyClient; }; -type NativeWorktree = { directory: string; name: string; branch?: string; id?: string; type: "worktree" | "rift" }; +type NativeWorktree = { directory: string; name: string; branch?: string; id?: string; projectID?: string; type: "worktree" | "rift" }; const explicitNavigatorUse = "Use only when the user explicitly asks to create or manage native OpenCode sessions, worktrees, or a multi-session workflow. Do not use for subagent delegation; use the built-in task tool instead."; @@ -153,6 +153,7 @@ function normalizeRiftWorkspace(workspace: NativeWorkspace): NativeWorktree | un type: "rift", directory: workspace.directory, name: workspace.name || path.basename(workspace.directory), + ...(workspace.projectID ? { projectID: workspace.projectID } : {}), ...(workspace.branch ? { branch: workspace.branch } : {}), }; } @@ -179,7 +180,36 @@ async function listManagedWorktrees(client: NavigatorClient, checkout: string, p const worktrees = values.map(normalizeWorktree).filter((item) => !sameDirectory(item.directory, checkout)); if (!projectID) return worktrees; const rifts = await listRiftWorkspaces(client, checkout, projectID).catch(() => []); - return [...worktrees, ...rifts]; + const unique = new Map(worktrees.map((item) => [normalizeDirectory(item.directory), item])); + for (const rift of rifts) unique.set(normalizeDirectory(rift.directory), rift); + return [...unique.values()]; +} + +async function removeCreatedWorkspace(client: NavigatorClient, checkout: string, workspace: NativeWorktree) { + if (workspace.type === "rift") { + const api = workspaceApi(client); + if (!api?.workspace?.remove || !workspace.id) throw new Error("OpenCode Rift workspace removal is unavailable"); + const response = await api.workspace.remove({ id: workspace.id, directory: checkout }); + if (response.error !== undefined) failResponse(response.error, `OpenCode Rift workspace rollback for ${workspace.directory}`); + return; + } + responseData(await client.worktree.remove({ + directory: checkout, + worktreeRemoveInput: { directory: workspace.directory }, + }), `OpenCode worktree rollback for ${workspace.directory}`); +} + +async function rollbackCreatedWorkspaces(client: NavigatorClient, checkout: string, workspaces: NativeWorktree[]) { + const unique = new Map(workspaces.map((item) => [normalizeDirectory(item.directory), item])); + const failures: string[] = []; + for (const workspace of unique.values()) { + try { + await removeCreatedWorkspace(client, checkout, workspace); + } catch (error) { + failures.push(`${workspace.directory}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return failures; } async function activeSessionIDs(client: NavigatorClient, navigator: NavigatorContext) { @@ -559,6 +589,11 @@ export function createNavigatorTools( const normalized = normalizeRiftWorkspace(workspace); if (!normalized) throw new Error("OpenCode Rift workspace create returned no directory"); createdWorktree = normalized; + if (normalized.projectID && normalized.projectID !== navigator.projectID) { + throw new Error( + `OpenCode registered workspace ${normalized.id} to project ${normalized.projectID}; expected ${navigator.projectID}`, + ); + } } else { const worktree = responseData( await client.worktree.create({ @@ -577,9 +612,15 @@ export function createNavigatorTools( const worktreesAfter = await listManagedWorktrees(client, navigator.checkout, navigator.projectID).catch(() => []); const before = new Set(worktreesBefore.map((item) => normalizeDirectory(item.directory))); const partial = worktreesAfter.filter((item) => !before.has(normalizeDirectory(item.directory))); + const created = createdWorktree ? [createdWorktree, ...partial] : partial; + const rollbackFailures = await rollbackCreatedWorkspaces(client, navigator.checkout, created); throw new Error( `Navigator could not create the requested workspace: ${error instanceof Error ? error.message : String(error)}` + - (partial.length ? `. Workspaces created and not removed: ${partial.map((item) => item.directory).join(", ")}` : ""), + (created.length + ? rollbackFailures.length + ? `. Rollback failed for ${rollbackFailures.join("; ")}` + : ". The partially created workspace was removed" + : ""), ); } } @@ -608,13 +649,30 @@ export function createNavigatorTools( "OpenCode session create", ); } catch (error) { + const rollbackFailures = createdWorktree + ? await rollbackCreatedWorkspaces(client, navigator.checkout, [createdWorktree]) + : []; throw new Error( `Navigator session creation failed: ${error instanceof Error ? error.message : String(error)}` + - (createdWorktree ? `. Worktree created: ${createdWorktree.name} (${createdWorktree.directory}) and was not removed` : ""), + (createdWorktree + ? rollbackFailures.length + ? `. Workspace created at ${createdWorktree.directory}, but rollback failed: ${rollbackFailures.join("; ")}` + : `. Workspace ${createdWorktree.directory} was rolled back` + : ""), ); } if (session.projectID !== navigator.projectID) { - throw new Error(`Created session ${session.id} belongs to an unexpected project; it was not prompted`); + const rollbackFailures = createdWorktree + ? await rollbackCreatedWorkspaces(client, navigator.checkout, [createdWorktree]) + : []; + throw new Error( + `Created session ${session.id} belongs to project ${session.projectID}; expected ${navigator.projectID}. It was not prompted` + + (createdWorktree + ? rollbackFailures.length + ? `. Workspace rollback failed: ${rollbackFailures.join("; ")}` + : `. Workspace ${createdWorktree.directory} was rolled back` + : ""), + ); } try { @@ -654,7 +712,7 @@ export function createNavigatorTools( sessionID: session.id, directory, ...(createdWorktree - ? { worktree: { created: true, type: createdWorktree.type, name: createdWorktree.name, ...(createdWorktree.branch ? { branch: createdWorktree.branch } : {}) } } + ? { worktree: { created: true, type: createdWorktree.type, name: createdWorktree.name, ...(createdWorktree.id ? { id: createdWorktree.id } : {}), ...(createdWorktree.branch ? { branch: createdWorktree.branch } : {}) } } : {}), }); }, diff --git a/packages/opencode/rift-workspace.ts b/packages/opencode/rift-workspace.ts index 8a33060..9ad0727 100644 --- a/packages/opencode/rift-workspace.ts +++ b/packages/opencode/rift-workspace.ts @@ -36,7 +36,7 @@ type RiftAdapterOptions = { type ExperimentalWorkspaceInput = { experimental_workspace?: { register(type: string, adapter: WorkspaceAdapter): void }; - project?: { id?: string }; + project?: { id?: string; path?: string }; worktree?: string; directory?: string; }; @@ -103,7 +103,14 @@ export function createRiftWorkspaceAdapter(rift: RiftModule, options: RiftAdapte copyAll: true, })); if (created !== expected) { - throw new Error(`Rift created ${created}, but OpenCode registered ${expected}`); + try { + rift.remove({ at: created }); + } catch (error) { + throw new Error( + `Rift created ${created}, but OpenCode registered ${expected}. Rollback failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + throw new Error(`Rift created ${created}, but OpenCode registered ${expected}. The created Rift was removed`); } }, list() { @@ -129,7 +136,9 @@ export function createRiftWorkspaceAdapter(rift: RiftModule, options: RiftAdapte export async function registerRiftWorkspaceAdapter(input: ExperimentalWorkspaceInput, logger: Logger) { const registrar = input.experimental_workspace; const projectID = input.project?.id; - const sourceDirectory = input.worktree ?? input.directory; + // A plugin loaded inside a managed workspace still belongs to the stable project checkout. + // Using input.worktree here would recursively nest new Rifts below the current Rift. + const sourceDirectory = input.project?.path ?? input.directory ?? input.worktree; if (!registrar || !projectID || !sourceDirectory) return; let rift: RiftModule; diff --git a/packages/opencode/test/navigator.test.ts b/packages/opencode/test/navigator.test.ts index 10b2940..dedbbdd 100644 --- a/packages/opencode/test/navigator.test.ts +++ b/packages/opencode/test/navigator.test.ts @@ -113,6 +113,34 @@ describe("Kompass Navigator", () => { assert.deepEqual(output.worktrees, [{ directory: "/repo-worktree", name: "repo-worktree", type: "worktree" }]); }); + test("deduplicates native and Rift workspace entries by directory", async () => { + const client = createClient({ worktree: { list: async () => response(["/repo-shared"]) } }); + client.experimental = { + workspace: { + adapter: { list: async () => response([{ type: "rift" }]) }, + syncList: async () => response(undefined), + list: async () => response([{ + id: "wrk_rift", + type: "rift", + name: "shared", + directory: "/repo-shared", + projectID: "project-1", + }]), + create: async () => response(undefined), + remove: async () => response(undefined), + }, + }; + + const output = JSON.parse(await (tools(client).worktree_list as any).execute({}, context())); + assert.deepEqual(output.worktrees, [{ + id: "wrk_rift", + projectID: "project-1", + type: "rift", + directory: "/repo-shared", + name: "shared", + }]); + }); + test("rejects foreign sessions", async () => { const client = createClient({ session: { get: async () => response({ data: session("foreign", "/repo", "project-2") }) }, @@ -356,7 +384,7 @@ describe("Kompass Navigator", () => { assert.deepEqual(workspaceCreates, [{ directory: "/repo", type: "rift", extra: { name: "Parser Fix" } }]); assert.equal(sessionCreates[0].location.directory, "/repo-rift"); - assert.deepEqual(output.worktree, { created: true, type: "rift", name: "parser-fix" }); + assert.deepEqual(output.worktree, { created: true, type: "rift", name: "parser-fix", id: "wrk_rift" }); }); test("reports resources created before a prompt failure", async () => { @@ -371,14 +399,19 @@ describe("Kompass Navigator", () => { }); test("reports a worktree created before a session failure", async () => { - const client = createClient({ session: { create: async () => { throw new Error("create failed"); } } }); + const removes: any[] = []; + const client = createClient({ + worktree: { remove: async (args: any) => { removes.push(args); return response(true); } }, + session: { create: async () => { throw new Error("create failed"); } }, + }); await assert.rejects( (tools(client).session_create as any).execute({ prompt: "implement it", environment: { type: "new_worktree" }, }, context()), - /Worktree created: new \(\/repo-new\).*was not removed/, + /Workspace \/repo-new was rolled back/, ); + assert.equal(removes[0].worktreeRemoveInput.directory, "/repo-new"); }); test("discovers a worktree left behind by a failed native create", async () => { @@ -397,10 +430,44 @@ describe("Kompass Navigator", () => { prompt: "implement it", environment: { type: "new_worktree", startCommand: "false" }, }, context()), - /Workspaces created and not removed: \/repo-partial/, + /partially created workspace was removed/, ); }); + test("rejects a foreign Rift workspace before creating a session and rolls it back", async () => { + const removes: any[] = []; + let sessionCreates = 0; + const client = createClient({ + worktree: { list: async () => response([]) }, + session: { create: async () => { sessionCreates += 1; return response({ data: session("created") }); } }, + }); + client.experimental = { + workspace: { + adapter: { list: async () => response([{ type: "rift" }]) }, + syncList: async () => response(undefined), + list: async () => response([]), + create: async () => response({ + id: "wrk_foreign", + type: "rift", + name: "foreign", + directory: "/repo-foreign", + projectID: "project-2", + }), + remove: async (args: any) => { removes.push(args); return response(undefined); }, + }, + }; + + await assert.rejects( + (tools(client).session_create as any).execute({ + prompt: "implement it", + environment: { type: "new_worktree" }, + }, context()), + /registered workspace wrk_foreign to project project-2; expected project-1.*partially created workspace was removed/, + ); + assert.equal(sessionCreates, 0); + assert.deepEqual(removes, [{ id: "wrk_foreign", directory: "/repo" }]); + }); + test("rejects self send, wait, and interrupt", async () => { const navigator = tools(); await assert.rejects( diff --git a/packages/opencode/test/tool-registration.test.ts b/packages/opencode/test/tool-registration.test.ts index 19751bb..e615b48 100644 --- a/packages/opencode/test/tool-registration.test.ts +++ b/packages/opencode/test/tool-registration.test.ts @@ -176,6 +176,29 @@ describe("createOpenCodeTools", () => { }); }); + test("Rift workspace adapter removes a snapshot created at an unexpected path", async () => { + const removed: string[] = []; + const sourceDirectory = path.join(os.tmpdir(), "kompass-rift-source"); + const adapter = createRiftWorkspaceAdapter({ + init: () => null, + create: () => path.join(os.tmpdir(), "unexpected-rift"), + remove: ({ at } = {}) => { if (at) removed.push(at); }, + list: () => [], + }, { sourceDirectory, projectID: "project-1" }); + const configured = await adapter.configure({ + id: "wrk_1", + type: "rift", + name: "Parser Fix", + branch: null, + directory: null, + extra: null, + projectID: "project-1", + }); + + await assert.rejects(adapter.create(configured, {}), /created Rift was removed/); + assert.deepEqual(removed, [path.join(os.tmpdir(), "unexpected-rift")]); + }); + test("registers Navigator by default", async () => { await withTempHome(async () => { const navigatorClient = {