fix(sessions): keep missing-worktree relocation manual

Remove automatic moves on session activation, terminal failures, and archive restoration while preserving manual moves and worktree deletion.

Replace directory listing probes with a stat-only endpoint using Node built-ins, including an isolated module-load regression test for packaged desktop.

Validation: focused session, worktree, filesystem, localization, and bridge tests; workspace type-check and lint; web and VS Code builds. Desktop startup and behavior verified by the maintainer.
This commit is contained in:
Bohdan Triapitsyn
2026-09-07 02:10:06 +03:00
parent eb6f7b0904
commit 3132d1361a
32 changed files with 211 additions and 763 deletions
-154
View File
@@ -1531,134 +1531,6 @@ function commitArchivedSessions(sessions: Session[], directory: string): void {
*/
const UNARCHIVED_TIMESTAMP = 0
async function getProjectPrimaryDirectory(projectID?: string): Promise<string | null> {
if (!projectID) return null
try {
const result = await sdk().project.list()
const projects = assertSdkData(result, "project.list")
const projectDirectory = projects.find((candidate) => candidate.id === projectID)?.worktree?.trim()
return projectDirectory ? normalizePath(projectDirectory) ?? projectDirectory : null
} catch {
return null
}
}
type MissingWorktreeRelocation = { sourceDirectory: string; destinationDirectory: string }
const isFilesystemRoot = (directory: string): boolean => directory === "/" || /^[A-Za-z]:\/?$/.test(directory)
async function resolveMissingWorktreeRelocation(
session: Session & { project?: { worktree?: string | null } | null },
): Promise<MissingWorktreeRelocation | null> {
const ownedDirectory = resolveSessionOwnedDirectory(session)
const projectWorktree = session.project?.worktree?.trim()
if (!ownedDirectory || !projectWorktree) return null
let availability: Awaited<ReturnType<typeof opencodeClient.getDirectoryAvailability>>
try {
availability = await opencodeClient.getDirectoryAvailability(ownedDirectory)
} catch {
return null
}
if (availability !== "missing") return null
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 }
}
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>()
for (const session of [...global.activeSessions, ...global.archivedSessions]) {
const current = sessionsById.get(session.id)
if (!current || Boolean(session.time?.archived)) sessionsById.set(session.id, session)
}
sessionsById.set(rootSession.id, rootSession)
return [...computeSubtreeIds([...sessionsById.values()], rootSession.id)]
.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)
// but failed to move it, so filtering on `archived` alone would drop the
// root and report a false success while it stays in the deleted worktree.
.filter((entry) => Boolean(entry.session.time?.archived) || entry.ownedDirectory === sourceDirectory)
.map((entry) => (entry.ownedDirectory ? { session: entry.session, sourceDirectory: entry.ownedDirectory } : null))
.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.
*
@@ -1671,34 +1543,8 @@ export async function relocateSessionFromMissingDirectory(
*/
export async function unarchiveSession(sessionId: string, expectedRuntimeKey = getRuntimeKey()): Promise<boolean> {
if (isStaleRuntime(expectedRuntimeKey)) return false
const globalSession = getGlobalSessionSnapshot(sessionId)
const sessionDirectory = getSessionDirectory(sessionId)
try {
const restore = globalSession
? await resolveMissingWorktreeRelocation(globalSession)
: null
if (isStaleRuntime(expectedRuntimeKey)) return false
if (globalSession && restore) {
for (const { session, sourceDirectory } of getRestoreSubtree(globalSession, restore.sourceDirectory)) {
const restored = await opencodeClient.updateSession(
session.id,
{ time: { archived: UNARCHIVED_TIMESTAMP } },
sourceDirectory,
)
if (isStaleRuntime(expectedRuntimeKey)) return false
if (!restored) {
throw new Error("session.update failed: server did not return the restored session")
}
if (restored.time?.archived) {
throw new Error("session.update failed: server kept the session archived")
}
await moveSessionToDirectory(restored, sourceDirectory, restore.destinationDirectory, false, expectedRuntimeKey)
if (isStaleRuntime(expectedRuntimeKey)) return false
}
return true
}
const restored = await opencodeClient.updateSession(sessionId, { time: { archived: UNARCHIVED_TIMESTAMP } }, sessionDirectory)
if (isStaleRuntime(expectedRuntimeKey)) return false
if (!restored) {