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:
𝖎𝖚𝖑𝖎𝖎𝖆
2026-09-05 21:26:21 +03:00
committed by GitHub
parent 7308b90670
commit 759af5a77d
36 changed files with 906 additions and 57 deletions
+118 -16
View File
@@ -1144,10 +1144,45 @@ function finalizeConfirmedSessionDeletion(
}
}
async function cleanupDeletedChatDirectory(directory: string | undefined, deleteDirectory: boolean): Promise<void> {
if (!directory || !deleteDirectory) return
type ChatDirectoryCleanupPlan = {
directory: string | undefined
/** Only a root session owns its managed chat directory. */
rootDeleted: boolean
/** The deleted session and the descendants the server cascade-deletes with it. */
cascadeIds: ReadonlySet<string>
}
function planChatDirectoryCleanup(sessionId: string, snapshot: Session | null, directory: string | undefined): ChatDirectoryCleanupPlan {
const global = useGlobalSessionsStore.getState()
return {
directory,
rootDeleted: Boolean(snapshot && snapshot.parentID == null),
cascadeIds: computeSubtreeIds([...global.activeSessions, ...global.archivedSessions], sessionId),
}
}
/**
* A managed chat directory is shared by every fork, side thread, and subagent
* of the chat that created it, and OpenCode fails every prompt in a session
* whose directory is gone. The directory is therefore removed only once no
* known session outside the deleted subtree still resolves to it. An unloaded
* global cache cannot prove that, so it keeps the directory: a leaked scratch
* directory is recoverable, a stranded session is not.
*/
function isChatDirectoryStillReferenced(directory: string, excludedIds: ReadonlySet<string>): boolean {
const global = useGlobalSessionsStore.getState()
if (!global.hasLoaded) return true
const normalized = normalizePath(directory)
return [...global.activeSessions, ...global.archivedSessions].some((session) => (
!excludedIds.has(session.id) && resolveGlobalSessionDirectory(session) === normalized
))
}
async function cleanupDeletedChatDirectory(plan: ChatDirectoryCleanupPlan): Promise<void> {
if (!plan.directory || !plan.rootDeleted) return
if (isChatDirectoryStillReferenced(plan.directory, plan.cascadeIds)) return
try {
await deleteChatDirectory(directory)
await deleteChatDirectory(plan.directory)
} catch (error) {
console.warn("[session-actions] deleted chat directory cleanup failed", error)
}
@@ -1181,8 +1216,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp
const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey()
if (isStaleRuntime(expectedRuntimeKey)) return false
const sessionDirectory = getSessionDirectory(sessionId)
const sessionSnapshot = getGlobalSessionSnapshot(sessionId)
const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null)
const chatDirectoryCleanup = planChatDirectoryCleanup(sessionId, getGlobalSessionSnapshot(sessionId), sessionDirectory)
try {
await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory, expectedRuntimeKey)
if (isStaleRuntime(expectedRuntimeKey)) return false
@@ -1192,7 +1226,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp
throw new Error("session.delete failed: server did not confirm deletion")
}
finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey)
await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory)
await cleanupDeletedChatDirectory(chatDirectoryCleanup)
return true
} catch (error) {
console.error("[session-actions] deleteSession failed", error)
@@ -1202,7 +1236,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp
if ((error as { status?: number })?.status === 404) {
if (isStaleRuntime(expectedRuntimeKey)) return false
finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey)
await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory)
await cleanupDeletedChatDirectory(chatDirectoryCleanup)
return true
}
return false
@@ -1216,8 +1250,7 @@ export async function deleteSessionInDirectory(
expectedRuntimeKey = getRuntimeKey(),
): Promise<boolean> {
if (isStaleRuntime(expectedRuntimeKey)) return false
const sessionSnapshot = getGlobalSessionSnapshot(sessionId)
const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null)
const chatDirectoryCleanup = planChatDirectoryCleanup(sessionId, getGlobalSessionSnapshot(sessionId), directory)
try {
await cleanupReviewMetadataBeforeDelete(sessionId, directory, expectedRuntimeKey)
if (isStaleRuntime(expectedRuntimeKey)) return false
@@ -1227,14 +1260,14 @@ export async function deleteSessionInDirectory(
throw new Error("session.delete failed: server did not confirm deletion")
}
finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey)
await cleanupDeletedChatDirectory(directory, deleteManagedDirectory)
await cleanupDeletedChatDirectory(chatDirectoryCleanup)
return true
} catch (error) {
console.error("[session-actions] deleteSessionInDirectory failed", error)
if ((error as { status?: number })?.status === 404) {
if (isStaleRuntime(expectedRuntimeKey)) return false
finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey)
await cleanupDeletedChatDirectory(directory, deleteManagedDirectory)
await cleanupDeletedChatDirectory(chatDirectoryCleanup)
return true
}
return false
@@ -1511,11 +1544,13 @@ async function getProjectPrimaryDirectory(projectID?: string): Promise<string |
}
}
type MissingWorktreeRestore = { sourceDirectory: string; destinationDirectory: string }
type MissingWorktreeRelocation = { sourceDirectory: string; destinationDirectory: string }
async function resolveMissingWorktreeRestore(
const isFilesystemRoot = (directory: string): boolean => directory === "/" || /^[A-Za-z]:\/?$/.test(directory)
async function resolveMissingWorktreeRelocation(
session: Session & { project?: { worktree?: string | null } | null },
): Promise<MissingWorktreeRestore | null> {
): Promise<MissingWorktreeRelocation | null> {
const ownedDirectory = resolveSessionOwnedDirectory(session)
const projectWorktree = session.project?.worktree?.trim()
if (!ownedDirectory || !projectWorktree) return null
@@ -1530,10 +1565,21 @@ async function resolveMissingWorktreeRestore(
const projectDirectory = await getProjectPrimaryDirectory(session.projectID)
if (!projectDirectory || projectDirectory === ownedDirectory) return null
// OpenCode files a directory outside any Git repository under its global
// project, whose "worktree" is the filesystem root. That is not a home for
// a session; a managed chat whose directory vanished stays where it is.
if (isFilesystemRoot(projectDirectory)) return null
return { sourceDirectory: ownedDirectory, destinationDirectory: projectDirectory }
}
function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array<{ session: Session; sourceDirectory: string }> {
type OwnedSubtreeEntry = { session: Session; ownedDirectory: string | null }
/**
* The root's subtree as the global cache knows it, root first. Drawn from the
* global cache rather than a live child store so archived descendants that
* never materialized in a directory store are still included.
*/
function getGlobalSubtree(rootSession: Session): OwnedSubtreeEntry[] {
const global = useGlobalSessionsStore.getState()
const sessionsById = new Map<string, Session>()
@@ -1547,6 +1593,10 @@ function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array
.map((id) => sessionsById.get(id))
.filter((session): session is Session => Boolean(session))
.map((session) => ({ session, ownedDirectory: resolveSessionOwnedDirectory(session) }))
}
function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array<{ session: Session; sourceDirectory: string }> {
return getGlobalSubtree(rootSession)
// Keep a node while it is still archived or still stranded in the
// confirmed-missing worktree. The second clause matters on retry: a prior
// attempt may have already unarchived the root (server echo made it active)
@@ -1557,6 +1607,58 @@ function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array
.filter((entry): entry is { session: Session; sourceDirectory: string } => entry !== null)
}
export type MissingDirectoryRelocation =
/** The session's directory is gone; its subtree now lives in the project directory. */
| { status: "moved"; sourceDirectory: string; destinationDirectory: string; movedSessionIds: string[] }
/** The directory is available, its state is unknown, or the session has no project to move to. */
| { status: "unchanged" }
/** The runtime changed while the relocation was in flight; nothing local was published. */
| { status: "stale" }
/** A control-plane move failed; `movedSessionIds` already live in the destination. */
| { status: "failed"; movedSessionIds: string[]; error: unknown }
/**
* Move an active session whose worktree no longer exists into its project's
* primary directory.
*
* Same gate as the archived-session restore fallback: only a server-confirmed
* `missing` directory qualifies, the destination is the OpenCode project the
* session belongs to, and `available`, `unknown`, probe failures, and sessions
* without a project leave everything untouched. Every session of the root's
* subtree still stranded in that directory moves with it, root first, so the
* session the user is looking at is usable even if a descendant move fails.
* Moves carry no changes (`moveChanges: false`): the directory is gone, so
* there is nothing to carry.
*/
export async function relocateSessionFromMissingDirectory(
sessionId: string,
expectedRuntimeKey = getRuntimeKey(),
): Promise<MissingDirectoryRelocation> {
if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" }
const rootSession = getGlobalSessionSnapshot(sessionId)
if (!rootSession) return { status: "unchanged" }
const relocation = await resolveMissingWorktreeRelocation(rootSession)
if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" }
if (!relocation) return { status: "unchanged" }
const stranded = getGlobalSubtree(rootSession)
.filter((entry) => entry.ownedDirectory === relocation.sourceDirectory)
.map((entry) => entry.session)
const movedSessionIds: string[] = []
for (const session of stranded) {
try {
await moveSessionToDirectory(session, relocation.sourceDirectory, relocation.destinationDirectory, false, expectedRuntimeKey)
} catch (error) {
console.error("[session-actions] relocateSessionFromMissingDirectory failed", error)
return { status: "failed", movedSessionIds, error }
}
if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" }
movedSessionIds.push(session.id)
}
return { status: "moved", ...relocation, movedSessionIds }
}
/**
* Restore one archived session back to the active list.
*
@@ -1573,7 +1675,7 @@ export async function unarchiveSession(sessionId: string, expectedRuntimeKey = g
const sessionDirectory = getSessionDirectory(sessionId)
try {
const restore = globalSession
? await resolveMissingWorktreeRestore(globalSession)
? await resolveMissingWorktreeRelocation(globalSession)
: null
if (isStaleRuntime(expectedRuntimeKey)) return false