fix(app): recover sessions after their worktree is removed

A missing source directory leaves its location-scoped agent/model catalog unavailable and makes New worktree look up the deleted path. Use the known project catalog only when the unlisted source fails to resolve and its catalog is absent, and create replacement worktrees from the project root.
This commit is contained in:
Brendonovich
2026-09-30 06:22:57 +00:00
parent a80a00011d
commit 7f008d40cb
8 changed files with 131 additions and 18 deletions
@@ -0,0 +1,64 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode/util/encode"
import { fixture } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
test("keeps a session in a removed worktree readable and movable", async ({ page }) => {
const missing = "/projects/removed-worktree"
const destination = fixture.directory
const sessionID = "ses_removed_worktree"
const session = { id: sessionID, projectID: fixture.project.id, directory: missing, title: "Removed worktree" }
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: destination,
project: fixture.project,
provider: fixture.provider,
sessions: [session],
fileList: () => [],
pageMessages: () => ({
items: [{ id: "msg_saved", type: "user", text: "Saved conversation in removed worktree", time: { created: 1 } }],
}),
})
await page.route("**/api/**", (route) => {
const url = new URL(route.request().url())
if (url.searchParams.get("location[directory]") !== missing) return route.fallback()
if (!["/api/location", "/api/agent", "/api/provider", "/api/model", "/api/model/default"].includes(url.pathname))
return route.fallback()
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
})
await page.route(`**/api/session/${sessionID}/move`, (route) => route.fulfill({ status: 204, body: "" }))
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`, { waitUntil: "domcontentloaded" })
await expect(page.getByText("Saved conversation in removed worktree", { exact: true })).toBeVisible()
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
await expect(prompt).toBeEditable()
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
await prompt.fill("Keep the draft while moving")
await expect(prompt).toHaveText("Keep the draft while moving")
await transport.waitForConnection()
await page.getByRole("button", { name: "Session details" }).click()
await page.getByRole("button", { name: "Local repository" }).click()
const create = page.waitForRequest(
(request) => new URL(request.url()).pathname === "/api/worktree" && request.method() === "POST",
{ timeout: 10_000 },
)
const move = page.waitForRequest(
(request) => new URL(request.url()).pathname === `/api/session/${sessionID}/move` && request.method() === "POST",
)
await page.getByRole("menuitem", { name: "New worktree" }).click()
expect((await create).postDataJSON()).toMatchObject({ projectID: fixture.project.id, from: fixture.project.worktree })
const created = `${destination}/copy`
expect((await move).postDataJSON()).toMatchObject({ directory: created })
session.directory = created
await transport.send({
id: "evt_removed_worktree_moved",
type: "session.moved",
created: 2,
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, location: { directory: created }, projectID: fixture.project.id },
})
await expect(prompt).toBeEditable()
await expect(prompt).toHaveText("Keep the draft while moving")
await expect(page.getByText("Saved conversation in removed worktree", { exact: true })).toBeVisible()
})
+4 -4
View File
@@ -17,13 +17,13 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
const local = useLocal()
const sdk = useWorkspaceLocation()
const data = useData()
const providers = useProviders(() => sdk().directory)
const providers = useProviders(() => sdk().catalogDirectory)
const view = layout.view(input.sessionKey)
return createMemo<ComposerControls>(() => {
return {
agents: {
available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []),
available: normalizeAgentList(data.location.agent.list({ directory: sdk().catalogDirectory }) ?? []),
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
visible: local.agent.visible(),
@@ -34,7 +34,7 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
paid: providers.paid().length > 0,
loading:
!(input.model ?? local.model).ready() ||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
(local.agent.visible() && data.location.agent.list({ directory: sdk().catalogDirectory }) === undefined) ||
!providers.ready(),
},
session: {
@@ -67,7 +67,7 @@ export function createComposerModelSelection(input: {
},
),
)
const providers = useProviders(() => sdk().directory)
const providers = useProviders(() => sdk().catalogDirectory)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const valid = (model: Pick<ModelKey, "providerID" | "modelID">) => {
@@ -9,13 +9,13 @@ export function useConfiguredModel() {
const server = useServerSDK()
createEffect(
on(
() => [location().directory, server.connection.status()] as const,
() => [location().catalogDirectory, server.connection.status()] as const,
([directory]) => {
void data.location.config.sync({ directory }).catch(() => undefined)
},
),
)
const documents = () => data.location.config.list({ directory: location().directory })
const documents = () => data.location.config.list({ directory: location().catalogDirectory })
const model = createMemo(() => {
const entry = documents()?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
const model = entry?.type === "document" ? entry.info.model : undefined
@@ -80,14 +80,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useWorkspaceLocation()
const data = useData()
const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory)
const providers = useProviders(() => sdk().catalogDirectory)
const models = useModels()
const settings = useSettings()
const configuredModel = useConfiguredModel()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() =>
normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter(
normalizeAgentList(data.location.agent.list({ directory: sdk().catalogDirectory }) ?? []).filter(
(item) => item.mode !== "subagent" && !item.hidden,
),
)
+49 -7
View File
@@ -1,4 +1,6 @@
import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { ErrorBoundary, createEffect, createMemo, onCleanup, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { retry } from "@opencode/util/retry"
import { useParams } from "@solidjs/router"
import { DataProvider } from "@opencode/session-ui/context"
import { SessionUserMessage } from "@opencode/session-ui/message"
@@ -17,6 +19,7 @@ import { ServerConnection } from "@/runtime/server/registry"
import { TerminalProvider } from "@/session/terminal/context"
import { useSettingsCommand } from "@/settings/command"
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
import { isProjectDirectory } from "@/workspaces/paths"
import { useTabs, type PendingSession } from "@/shell/tabs/tabs"
import { requireServerKey } from "@/shell/routes/session"
import { useSessionModel } from "./model"
@@ -33,15 +36,54 @@ export function TargetSessionRouteContent() {
const data = useData()
const server = useServer()
const tabs = useTabs()
const directory = createMemo(() => data.session.get(params.id)?.location.directory)
const [locationState, setLocationState] = createStore({ unavailableSource: "" })
createEffect(() => {
const session = data.session.get(params.id)
if (!session) return
const source = session.location.directory
const project = data.project.get(session.projectID)
if (
!project ||
data.location.info({ directory: source }) ||
isProjectDirectory({ worktree: project.canonical, sandboxes: project.sandboxes }, source)
)
return
let stale = false
onCleanup(() => {
stale = true
})
void retry(() => data.location.syncInfo({ directory: source }), { retryIf: () => !stale }).then(
() => {
if (!stale) setLocationState("unavailableSource", "")
},
() => {
if (!stale) setLocationState("unavailableSource", source)
},
)
})
const catalogDirectory = createMemo(() => {
const session = data.session.get(params.id)
if (!session) return
const source = session.location.directory
const project = data.project.get(session.projectID)
if (!project || source !== locationState.unavailableSource || data.location.info({ directory: source }))
return source
if (data.location.provider.list({ directory: source }) && data.location.model.list({ directory: source }))
return source
// A removed worktree cannot supply a catalog, but its session still belongs to the known project.
return project.canonical
})
return (
<>
<MarkSessionNotificationsViewed sessionID={() => params.id} />
<ModelsProvider directory={directory}>
<ModelsProvider directory={catalogDirectory}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<Show when={tabs.pendingSession(server.key, params.id)} fallback={<ResolvedTargetSessionRoute />}>
<Show
when={tabs.pendingSession(server.key, params.id)}
fallback={<ResolvedTargetSessionRoute catalogDirectory={catalogDirectory} />}
>
{(pending) => <PreparingSession sessionID={params.id} pending={pending()} />}
</Show>
</SessionRouteErrorBoundary>
@@ -111,7 +153,7 @@ function SessionRouteErrorBoundary(props: ParentProps<{ sessionID?: string; serv
)
}
function ResolvedTargetSessionRoute() {
function ResolvedTargetSessionRoute(props: { catalogDirectory: () => string | undefined }) {
const params = useParams<{ id: string }>()
const server = useServer()
const tabs = useTabs()
@@ -136,8 +178,8 @@ function ResolvedTargetSessionRoute() {
>
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
{(value) => (
<LocationProvider directory={value}>
<SessionUIProvider directory={value()} server={server.key}>
<LocationProvider directory={value} catalogDirectory={props.catalogDirectory}>
<SessionUIProvider directory={value()} providerDirectory={props.catalogDirectory()} server={server.key}>
<TargetSessionPage />
</SessionUIProvider>
</LocationProvider>
@@ -69,7 +69,7 @@ export function SessionWorkspaceMenu(props: {
api: sdk.api,
data,
directory: props.directory,
project: data.location.info({ directory: props.directory })?.project,
project: { id: props.project.id, canonical: props.project.worktree, directory: props.project.worktree },
})
: selection
if (!destination) return
@@ -14,6 +14,7 @@ import { readLocalImage } from "@/runtime/server/image"
export function SessionUIProvider(
props: ParentProps<{
directory: string
providerDirectory?: string
server: ServerConnection.Key
}>,
) {
@@ -39,7 +40,7 @@ export function SessionUIProvider(
await data.session.sync(sessionID).catch(() => undefined)
navigate(href(sessionID))
}
const providers = useProviders(directory)
const providers = useProviders(() => props.providerDirectory ?? directory())
const sessionUIData = createMemo(() => ({
provider: providers.ready()
? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) }
+7 -1
View File
@@ -9,11 +9,16 @@ export type { LocationContext } from "@/runtime/server/client"
export type WorkspaceLocation = LocationContext & {
readonly ref: LocationRef
readonly current: LocationGetOutput | undefined
readonly catalogDirectory: string
}
const context = createSimpleContext({
name: "Location",
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
init: (props: {
directory: string | Accessor<string>
workspaceID?: string | Accessor<string | undefined>
catalogDirectory?: Accessor<string | undefined>
}) => {
const serverSDK = useServerSDK()
const server = useServer()
const data = useData()
@@ -53,6 +58,7 @@ const context = createSimpleContext({
...location(),
ref: ref(),
current: current(),
catalogDirectory: props.catalogDirectory?.() ?? ref().directory,
}))
},
})