fix(sessions): recover sessions whose directory disappeared (#3365)
* fix(sessions): keep a shared chat directory until its last session is deleted Deleting a root chat session removed its managed scratch directory even when forks, side threads, or subagents still lived in it; OpenCode then failed every prompt in those sessions with FileSystem.realPath NotFound. The directory is now removed only once no other known session resolves to it. The deleted subtree does not count, because the server cascade- deletes it, and an unloaded global cache keeps the directory instead of guessing. Closes #3312. * fix(sessions): relocate a session whose worktree directory disappeared A worktree removed outside OpenChamber, by the agent or by hand, left its sessions pointed at a path that no longer exists: every terminal create and restart failed with "Invalid working directory" and the tab stayed stuck, while Git, Files, and prompts kept targeting the dead path. The terminal server now names that one rejection (TERMINAL_CWD_MISSING) instead of substituting a directory of its own. The shared UI reuses the archived-restore fallback for live sessions: a server-confirmed missing directory moves the session and its stranded subtree to the project's primary directory through the control-plane move, clears the worktree hint, re-selects the session, and tells the user where it went. It runs from a terminal failure and on activation of any session whose directory is neither a project root nor a managed chat directory; available, unknown, and failed probes leave everything untouched. Closes #3338. * fix(scripts): make oc-dev load again after the changelog cleanup The changelog cleanup referenced fs.existsSync in a module that imports existsSync by name and never binds fs, so every oc-dev invocation failed with "fs is not defined" before reaching its action. * fix(sessions): probe directory availability on disk, not through OpenCode path resolution OpenCode's /path never checks that a directory exists: it echoes the requested path and resolves its project through Git discovery that swallows errors, so a deleted worktree came back as a valid location and every missing-directory fallback (draft recovery, archived restore, session relocation) stayed inert on a real server. The probe now asks OpenChamber's own /api/fs/list, which stats the path and reports not-found and not-directory explicitly; anything else stays unknown. * fix(sidebar): keep a worktree whose directory is gone visible as missing git keeps a worktree registered after its directory is deleted outside git and marks it prunable; the list parser ignored that line, so a deleted worktree looked alive, and nothing in the app asked for a new listing anyway. The server now reports prunable, the UI keeps such a worktree in the topology with worktreeStatus missing and a warning icon on its sidebar group, and relocating a session out of a confirmed- missing directory raises an in-app topology signal the sidebar rediscovers on. Dropping the worktree instead would hide every session that lived there, and a hidden session can never be opened or relocated. No idle polling is added. * fix(sessions): never relocate a session to the filesystem root OpenCode files a directory outside any Git repository under its global project, whose worktree is the filesystem root. A managed chat whose directory vanished would otherwise be moved to /. The relocation now refuses a root destination, and the activation probe recognizes chat directories through the home-based check as well, so it does not depend on the chats root having been resolved yet. * test(sessions): mirror the relocation action in the issue-2039 session-actions mock session-ui-store now imports relocateSessionFromMissingDirectory, and the mocked module in this test listed every other action but not that one, so the file failed on import.
This commit is contained in:
@@ -21,6 +21,10 @@ let sessionDeleteError: unknown | null = null
|
||||
let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null
|
||||
let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null
|
||||
let beforeControlPlaneMoveResolve: ((sessionId: string) => void) | null = null
|
||||
let beforeDirectoryAvailabilityResolve: (() => void) | null = null
|
||||
const controlPlaneMoveErrorsById = new Map<string, Error>()
|
||||
let globalHasLoaded = true
|
||||
const deletedChatDirectories: string[] = []
|
||||
const globalUpsertedSessions: unknown[] = []
|
||||
const globalUpsertedSessionBatches: Session[][] = []
|
||||
const globalRemovedSessionIds: string[] = []
|
||||
@@ -76,6 +80,8 @@ const mockSdk = {
|
||||
moveSession: mock((params: Record<string, unknown>) => {
|
||||
replyCalls.push({ method: "controlPlane.moveSession", params })
|
||||
beforeControlPlaneMoveResolve?.(String(params.sessionID))
|
||||
const error = controlPlaneMoveErrorsById.get(String(params.sessionID))
|
||||
if (error) return Promise.resolve({ error, response: { status: 500 } })
|
||||
return Promise.resolve({})
|
||||
}),
|
||||
},
|
||||
@@ -153,6 +159,9 @@ const mockSdk = {
|
||||
}
|
||||
|
||||
// Mock opencodeClient singleton
|
||||
// SAFETY: the actions under test touch only the SDK surface mocked above.
|
||||
const actionSdk = mockSdk as unknown as OpencodeClient
|
||||
|
||||
mock.module("@/lib/opencode/client", () => ({
|
||||
opencodeClient: {
|
||||
getScopedSdkClient: (directory: string) => {
|
||||
@@ -160,7 +169,10 @@ mock.module("@/lib/opencode/client", () => ({
|
||||
return mockScopedClient
|
||||
},
|
||||
getDirectory: () => "/test/project",
|
||||
getDirectoryAvailability: mock(async (directory: string) => directoryAvailability.get(directory) ?? "available"),
|
||||
getDirectoryAvailability: mock(async (directory: string) => {
|
||||
beforeDirectoryAvailabilityResolve?.()
|
||||
return directoryAvailability.get(directory) ?? "available"
|
||||
}),
|
||||
getFilesystemHome: mock(async () => "/home/test"),
|
||||
getSdkClient: () => mockSdk,
|
||||
getSessionMessages: mock((sessionId: string, _limit?: number, directory?: string | null) => {
|
||||
@@ -285,6 +297,7 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({
|
||||
getState: () => ({
|
||||
activeSessions: globalActiveSessions,
|
||||
archivedSessions: globalArchivedSessions,
|
||||
hasLoaded: globalHasLoaded,
|
||||
upsertSession: (session: unknown) => {
|
||||
globalUpsertedSessions.push(session)
|
||||
},
|
||||
@@ -383,7 +396,9 @@ mock.module("./send-failure-classification", () => ({
|
||||
}))
|
||||
|
||||
mock.module("@/lib/chatDirectories", () => ({
|
||||
deleteChatDirectory: async () => {},
|
||||
deleteChatDirectory: async (directory: string) => {
|
||||
deletedChatDirectories.push(directory)
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("./session-deletion-cleanup", () => ({
|
||||
@@ -549,6 +564,10 @@ describe("confirmed session removal", () => {
|
||||
archiveBatchRequests.length = 0
|
||||
archiveBatchResponse = { status: 404, body: { error: 'not found' } }
|
||||
beforeControlPlaneMoveResolve = null
|
||||
beforeDirectoryAvailabilityResolve = null
|
||||
controlPlaneMoveErrorsById.clear()
|
||||
globalHasLoaded = true
|
||||
deletedChatDirectories.length = 0
|
||||
})
|
||||
|
||||
test("does not remove live or persisted state when delete fails", async () => {
|
||||
@@ -683,6 +702,58 @@ describe("confirmed session removal", () => {
|
||||
.toEqual(["session-a", "session-b"])
|
||||
})
|
||||
|
||||
const chatDirectory = "/home/user/.config/openchamber/chats/2026-09-05/session-abc"
|
||||
const chatSession = (id: string, parentID?: string): Session => ({
|
||||
id,
|
||||
slug: id,
|
||||
projectID: "project-chats",
|
||||
directory: chatDirectory,
|
||||
title: id,
|
||||
version: "1",
|
||||
time: { created: 1, updated: 1 },
|
||||
parentID,
|
||||
})
|
||||
|
||||
test("keeps a shared chat directory while another root session still uses it", async () => {
|
||||
const root = chatSession("chat-root")
|
||||
const fork = chatSession("chat-fork")
|
||||
globalActiveSessions = [root, fork]
|
||||
const source = createStore({}, { session: [root, fork] })
|
||||
const { deleteSession, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(actionSdk, createChildStores([[chatDirectory, source]]), () => chatDirectory)
|
||||
|
||||
expect(await deleteSession("chat-root")).toBe(true)
|
||||
expect(deletedChatDirectories).toEqual([])
|
||||
|
||||
globalActiveSessions = [fork]
|
||||
expect(await deleteSession("chat-fork")).toBe(true)
|
||||
expect(deletedChatDirectories).toEqual([chatDirectory])
|
||||
})
|
||||
|
||||
test("removes the chat directory with its last root even though the root's own subagents share it", async () => {
|
||||
const root = chatSession("chat-root")
|
||||
const subagent = chatSession("chat-subagent", "chat-root")
|
||||
globalActiveSessions = [root, subagent]
|
||||
const source = createStore({}, { session: [root, subagent] })
|
||||
const { deleteSession, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(actionSdk, createChildStores([[chatDirectory, source]]), () => chatDirectory)
|
||||
|
||||
expect(await deleteSession("chat-root")).toBe(true)
|
||||
expect(deletedChatDirectories).toEqual([chatDirectory])
|
||||
})
|
||||
|
||||
test("keeps the chat directory when the global cache cannot prove it is unused", async () => {
|
||||
const root = chatSession("chat-root")
|
||||
globalActiveSessions = [root]
|
||||
globalHasLoaded = false
|
||||
const source = createStore({}, { session: [root] })
|
||||
const { deleteSession, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(actionSdk, createChildStores([[chatDirectory, source]]), () => chatDirectory)
|
||||
|
||||
expect(await deleteSession("chat-root")).toBe(true)
|
||||
expect(deletedChatDirectories).toEqual([])
|
||||
})
|
||||
|
||||
test("does not archive locally until the server returns the archived session", async () => {
|
||||
const source = createStore({}, {
|
||||
session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session],
|
||||
@@ -959,6 +1030,10 @@ describe("session restore (unarchive)", () => {
|
||||
sessionUpdateResult = {}
|
||||
beforeSessionUpdateResolve = null
|
||||
beforeControlPlaneMoveResolve = null
|
||||
beforeDirectoryAvailabilityResolve = null
|
||||
controlPlaneMoveErrorsById.clear()
|
||||
globalHasLoaded = true
|
||||
deletedChatDirectories.length = 0
|
||||
})
|
||||
|
||||
test("does not restore locally until the server returns the restored session", async () => {
|
||||
@@ -2944,3 +3019,148 @@ describe("dismissOpenPermissionsForSession", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("relocateSessionFromMissingDirectory", () => {
|
||||
const missingWorktree = "/projects/main/.worktrees/gone"
|
||||
const projectDirectory = "/projects/main"
|
||||
const worktreeSession = (id: string, parentID: string | null, directory = missingWorktree, archived = 0): Session & { project: { worktree: string } } => ({
|
||||
id,
|
||||
slug: id,
|
||||
projectID: "project-main",
|
||||
directory,
|
||||
title: id,
|
||||
version: "1",
|
||||
project: { worktree: projectDirectory },
|
||||
time: { created: 1, updated: 1, archived },
|
||||
parentID: parentID ?? undefined,
|
||||
})
|
||||
const mainProject: Project = { id: "project-main", worktree: projectDirectory, time: { created: 1, updated: 1 }, sandboxes: [] }
|
||||
const stores = () => createChildStores([[missingWorktree, createStore({})], [projectDirectory, createStore({})]])
|
||||
const movesOf = () => replyCalls
|
||||
.filter((call) => call.method === "controlPlane.moveSession")
|
||||
.map((call) => ({ sessionID: call.params.sessionID, destination: call.params.destination, moveChanges: call.params.moveChanges }))
|
||||
|
||||
beforeEach(() => {
|
||||
replyCalls.length = 0
|
||||
registeredSessionDirectories.length = 0
|
||||
movedSessionDirectories.length = 0
|
||||
globalUpsertedSessions.length = 0
|
||||
globalActiveSessions = []
|
||||
globalArchivedSessions.length = 0
|
||||
openCodeProjects.length = 0
|
||||
directoryAvailability.clear()
|
||||
controlPlaneMoveErrorsById.clear()
|
||||
beforeDirectoryAvailabilityResolve = null
|
||||
runtimeKey = "default-runtime"
|
||||
})
|
||||
|
||||
test("moves the whole stranded subtree, root first, to the project directory without carrying changes", async () => {
|
||||
const root = worktreeSession("root", null)
|
||||
const child = worktreeSession("child", "root")
|
||||
const archivedChild = worktreeSession("archived-child", "root", missingWorktree, 42)
|
||||
const elsewhere = worktreeSession("elsewhere", "root", projectDirectory)
|
||||
globalActiveSessions = [root, child, elsewhere]
|
||||
globalArchivedSessions.push(archivedChild)
|
||||
openCodeProjects.push(mainProject)
|
||||
directoryAvailability.set(missingWorktree, "missing")
|
||||
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(actionSdk, stores(), () => missingWorktree)
|
||||
|
||||
const result = await relocateSessionFromMissingDirectory("root")
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "moved",
|
||||
sourceDirectory: missingWorktree,
|
||||
destinationDirectory: projectDirectory,
|
||||
movedSessionIds: ["root", "child", "archived-child"],
|
||||
})
|
||||
expect(movesOf()).toEqual([
|
||||
{ sessionID: "root", destination: { directory: projectDirectory }, moveChanges: false },
|
||||
{ sessionID: "child", destination: { directory: projectDirectory }, moveChanges: false },
|
||||
{ sessionID: "archived-child", destination: { directory: projectDirectory }, moveChanges: false },
|
||||
])
|
||||
expect(movedSessionDirectories).toEqual([
|
||||
{ sessionID: "root", directory: projectDirectory },
|
||||
{ sessionID: "child", directory: projectDirectory },
|
||||
{ sessionID: "archived-child", directory: projectDirectory },
|
||||
])
|
||||
})
|
||||
|
||||
for (const availability of ["available", "unknown"] as const) {
|
||||
test(`leaves the session alone when its directory is ${availability}`, async () => {
|
||||
globalActiveSessions = [worktreeSession("root", null)]
|
||||
openCodeProjects.push(mainProject)
|
||||
directoryAvailability.set(missingWorktree, availability)
|
||||
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(actionSdk, stores(), () => missingWorktree)
|
||||
|
||||
expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" })
|
||||
expect(movesOf()).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test("leaves a session that already lives in its project directory alone", async () => {
|
||||
globalActiveSessions = [worktreeSession("root", null, projectDirectory)]
|
||||
openCodeProjects.push(mainProject)
|
||||
directoryAvailability.set(projectDirectory, "missing")
|
||||
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(actionSdk, stores(), () => projectDirectory)
|
||||
|
||||
expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" })
|
||||
expect(movesOf()).toEqual([])
|
||||
})
|
||||
|
||||
test("never relocates to the filesystem root OpenCode reports for its global project", async () => {
|
||||
const chatDirectory = "/Users/tester/.config/openchamber/chats/2026-09-05/session-gone"
|
||||
const chat = { ...worktreeSession("chat", null, chatDirectory), projectID: "global", project: { worktree: "/" } }
|
||||
globalActiveSessions = [chat]
|
||||
openCodeProjects.push({ id: "global", worktree: "/", time: { created: 1, updated: 1 }, sandboxes: [] })
|
||||
directoryAvailability.set(chatDirectory, "missing")
|
||||
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(actionSdk, stores(), () => chatDirectory)
|
||||
|
||||
expect(await relocateSessionFromMissingDirectory("chat")).toEqual({ status: "unchanged" })
|
||||
expect(movesOf()).toEqual([])
|
||||
})
|
||||
|
||||
test("leaves the session alone when OpenCode knows no project for it", async () => {
|
||||
globalActiveSessions = [worktreeSession("root", null)]
|
||||
directoryAvailability.set(missingWorktree, "missing")
|
||||
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(actionSdk, stores(), () => missingWorktree)
|
||||
|
||||
expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" })
|
||||
expect(movesOf()).toEqual([])
|
||||
})
|
||||
|
||||
test("reports the sessions already moved when a descendant move fails", async () => {
|
||||
globalActiveSessions = [worktreeSession("root", null), worktreeSession("child", "root")]
|
||||
openCodeProjects.push(mainProject)
|
||||
directoryAvailability.set(missingWorktree, "missing")
|
||||
controlPlaneMoveErrorsById.set("child", new Error("destination busy"))
|
||||
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(actionSdk, stores(), () => missingWorktree)
|
||||
|
||||
const result = await relocateSessionFromMissingDirectory("root")
|
||||
|
||||
expect(result.status).toBe("failed")
|
||||
expect(result.status === "failed" ? result.movedSessionIds : null).toEqual(["root"])
|
||||
expect(movedSessionDirectories).toEqual([{ sessionID: "root", directory: projectDirectory }])
|
||||
})
|
||||
|
||||
test("publishes nothing when the runtime changes while the directory is being probed", async () => {
|
||||
globalActiveSessions = [worktreeSession("root", null)]
|
||||
openCodeProjects.push(mainProject)
|
||||
directoryAvailability.set(missingWorktree, "missing")
|
||||
const { switchRuntimeEndpoint } = await import("../lib/runtime-switch")
|
||||
beforeDirectoryAvailabilityResolve = () => {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "http://other.test", runtimeKey: "other-runtime" })
|
||||
}
|
||||
const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(actionSdk, stores(), () => missingWorktree)
|
||||
|
||||
expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "stale" })
|
||||
expect(movesOf()).toEqual([])
|
||||
expect(movedSessionDirectories).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user