fix(sessions): restore archived worktree sessions (#3325)

* fix(sessions): restore archived worktree sessions

* fix(sessions): re-move worktree restore roots stranded by a failed move
This commit is contained in:
Matt Visnovsky
2026-09-05 17:51:51 +03:00
committed by GitHub
parent fe9119abb5
commit a0f7e08e88
3 changed files with 562 additions and 12 deletions
+20
View File
@@ -339,6 +339,26 @@ feedback stays truthful.
Callers whose confirmation can span a runtime switch may pass an
`expectedRuntimeKey` captured earlier; ordinary callers are guarded by default.
When the session being restored belongs to a worktree that no longer exists,
writing `time.archived = 0` alone would leave it grouped under a directory the
sidebar can never surface. Restore therefore probes the session's owned
directory with `getDirectoryAvailability` and, only on an exact `missing`
result, relocates it: it resolves the owning OpenCode project's primary
directory by the session's server `projectID` (from `project.list()`, never a
local project ID or the active project), then unarchives and moves the whole
subtree still stranded in the missing directory to that project directory
through `moveSessionToDirectory(..., false)`. `available`, `unknown`, an
availability probe failure, a missing project record, and non-worktree sessions
keep the plain restore path. The subtree is drawn from the global cache so
archived descendants that never materialized in a live child store are still
relocated, and a node is kept while it is archived **or** still owns the
missing directory, so a retry after a partial restore (root already unarchived
but not yet moved) completes the move instead of reporting a false success.
`moveSessionToDirectory` accepts the captured `expectedRuntimeKey` and skips all
local store/routing publication when the runtime changed during the
control-plane request, so the server move can complete without seeding the new
runtime with stale directory state.
Deletion needs this guard more than archiving does. Session IDs are not unique
across runtimes, and a committed deletion does more than hide a row: it evicts
the session from every live store, removes it from the global cache, clears the
+447 -10
View File
@@ -20,6 +20,7 @@ let afterUnrevertCall: ((sessionId: string) => void) | null = null
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
const globalUpsertedSessions: unknown[] = []
const globalUpsertedSessionBatches: Session[][] = []
const globalRemovedSessionIds: string[] = []
@@ -33,6 +34,12 @@ let archiveBatchResponse: { status: number; body: unknown } = {
}
const deletedCleanupIdentities: Array<{ runtimeKey: string; directory: string; sessionId: string }> = []
const movedSessionDirectories: Array<{ sessionID: string; directory: string }> = []
const globalArchivedSessions: Session[] = []
const openCodeProjects: Project[] = []
const directoryAvailability = new Map<string, "available" | "missing" | "unknown">()
const sessionUpdateResultsById = new Map<string, Session | undefined>()
let runtimeKey = "default-runtime"
const AMBIGUOUS_TRANSPORT_FAILURE = Symbol("ambiguous-transport-failure")
const mockScopedClient = {
permission: {
@@ -68,10 +75,17 @@ const mockSdk = {
controlPlane: {
moveSession: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "controlPlane.moveSession", params })
beforeControlPlaneMoveResolve?.(String(params.sessionID))
return Promise.resolve({})
}),
},
},
project: {
list: mock(() => {
replyCalls.push({ method: "project.list", params: {} })
return Promise.resolve({ data: openCodeProjects })
}),
},
session: {
messages: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "session.messages", params })
@@ -146,6 +160,7 @@ mock.module("@/lib/opencode/client", () => ({
return mockScopedClient
},
getDirectory: () => "/test/project",
getDirectoryAvailability: mock(async (directory: string) => directoryAvailability.get(directory) ?? "available"),
getFilesystemHome: mock(async () => "/home/test"),
getSdkClient: () => mockSdk,
getSessionMessages: mock((sessionId: string, _limit?: number, directory?: string | null) => {
@@ -176,7 +191,7 @@ mock.module("@/lib/opencode/client", () => ({
// Lets a test mutate global runtime state while the SDK call is in flight,
// so the action observes the switch only after awaiting the response.
beforeSessionUpdateResolve?.(sessionId)
return Promise.resolve(sessionUpdateResult.data)
return Promise.resolve(sessionUpdateResultsById.get(sessionId) ?? sessionUpdateResult.data)
}),
deleteSession: mock((sessionId: string, directory?: string | null) => {
replyCalls.push({ method: "session.delete", params: { sessionID: sessionId, directory } })
@@ -238,6 +253,22 @@ mock.module("./input-store", () => ({
},
}))
mock.module("@/stores/useInlineCommentDraftStore", () => ({
useInlineCommentDraftStore: {
getState: () => ({
getDrafts: () => [],
clearDrafts: () => {},
restoreDrafts: () => {},
addDraft: () => {},
}),
},
}))
mock.module("@/lib/messages/contextParts", () => ({
draftFromContextPayload: () => null,
readContextPart: () => null,
}))
mock.module("@/stores/useGlobalSessionsStore", () => ({
resolveGlobalSessionDirectory: (session: SessionWithDirectory) => session.directory ?? session.project?.worktree ?? null,
mergeSessionDirectoryMetadata: (incoming: Session, existing?: SessionWithDirectory | null): SessionWithDirectory => {
@@ -253,7 +284,7 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({
useGlobalSessionsStore: {
getState: () => ({
activeSessions: globalActiveSessions,
archivedSessions: [],
archivedSessions: globalArchivedSessions,
upsertSession: (session: unknown) => {
globalUpsertedSessions.push(session)
},
@@ -280,6 +311,81 @@ mock.module("@/lib/runtime-fetch", () => ({
},
}))
mock.module("./global-session-status", () => ({
useGlobalSessionStatusStore: {
getState: () => ({
statusById: new Map<string, { type: string }>(),
}),
},
}))
mock.module("./session-message-loader", () => ({
getImperativeSessionMessageLoader: () => ({
invalidateSession: () => {},
ensure: async () => {},
refreshTail: async () => {},
getSnapshot: () => ({ status: "ready" as const }),
}),
}))
mock.module("../lib/runtime-switch", () => ({
getRuntimeKey: () => runtimeKey,
switchRuntimeEndpoint: ({ runtimeKey: nextRuntimeKey }: { runtimeKey: string }) => {
runtimeKey = nextRuntimeKey
},
subscribeRuntimeEndpointWillChange: () => () => {},
subscribeRuntimeEndpointChanged: () => () => {},
}))
mock.module("@/lib/relay/transport-error", () => ({
markAmbiguousTransportFailure: (error: Error) => Object.assign(error, { [AMBIGUOUS_TRANSPORT_FAILURE]: true }),
isAmbiguousTransportFailure: (error: unknown) => Boolean(
error
&& typeof error === "object"
&& (error as { [AMBIGUOUS_TRANSPORT_FAILURE]?: boolean })[AMBIGUOUS_TRANSPORT_FAILURE],
),
}))
mock.module("./send-failure-classification", () => ({
getErrorStatus: (error: unknown) => {
if (!error || typeof error !== "object") return null
const direct = (error as { status?: unknown }).status
if (typeof direct === "number") return direct
const response = (error as { response?: { status?: unknown } }).response
return typeof response?.status === "number" ? response.status : null
},
isAmbiguousSendFailure: (error: unknown) => {
if (error && typeof error === "object" && (error as { [AMBIGUOUS_TRANSPORT_FAILURE]?: boolean })[AMBIGUOUS_TRANSPORT_FAILURE]) {
return true
}
const status = error && typeof error === "object"
? ((error as { status?: unknown }).status ?? (error as { response?: { status?: unknown } }).response?.status)
: undefined
if (status === 503 || status === 504 || status === 408) return true
if (error instanceof TypeError) return true
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) return true
const message = error instanceof Error
? error.message.toLowerCase()
: typeof error === "string"
? error.toLowerCase()
: ""
return message.includes("timeout")
|| message.includes("timed out")
|| message.includes("failed to fetch")
|| message.includes("networkerror")
|| message.includes("network error")
|| message.includes("gateway timeout")
|| message.includes("econnreset")
|| message.includes("socket hang up")
},
}))
mock.module("@/lib/chatDirectories", () => ({
deleteChatDirectory: async () => {},
}))
mock.module("./session-deletion-cleanup", () => ({
cleanupPersistedSessionState: (identity: { runtimeKey: string; directory: string; sessionId: string }) => {
deletedCleanupIdentities.push(identity)
@@ -293,10 +399,9 @@ mock.module("./sync-refs", () => ({
},
}))
import { create, type StoreApi } from "zustand"
import { INITIAL_STATE } from "./types"
import type { DirectoryStore } from "./child-store"
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
import type { Message, OpencodeClient, Part, Project, Session } from "@opencode-ai/sdk/v2/client"
type OptimisticAddCall = { sessionID: string; directory?: string | null; message: Message; parts: Part[] }
type OptimisticRemoveCall = { sessionID: string; directory?: string | null; messageID: string }
@@ -305,20 +410,37 @@ type SessionWithDirectory = Session & {
project?: { worktree?: string | null }
}
type TestStoreApi<T> = {
getState: () => T
setState: (patch: Partial<T> | ((state: T) => Partial<T>)) => void
}
function createStore(
permissions: Record<string, PermissionRequest[]>,
state?: Partial<DirectoryStore>,
): StoreApi<DirectoryStore> {
return create<DirectoryStore>()((set) => ({
): TestStoreApi<DirectoryStore> {
let currentState: DirectoryStore = {
...INITIAL_STATE,
...state,
permission: permissions,
patch: (partial) => set(partial),
replace: (next) => set(next),
}))
patch: (partial) => setState(partial),
replace: (next) => {
currentState = { ...currentState, ...next }
},
}
function setState(patch: Partial<DirectoryStore> | ((current: DirectoryStore) => Partial<DirectoryStore>)) {
const nextPatch = typeof patch === "function" ? patch(currentState) : patch
currentState = { ...currentState, ...nextPatch }
}
return {
getState: () => currentState,
setState,
}
}
function createChildStores(entries: Array<[string, StoreApi<DirectoryStore>]>) {
function createChildStores(entries: Array<[string, TestStoreApi<DirectoryStore>]>) {
return {
children: new Map(entries),
ensureChild: (dir: string) => {
@@ -419,12 +541,14 @@ describe("confirmed session removal", () => {
deletedCleanupIdentities.length = 0
sessionDeleteError = null
sessionUpdateResult = {}
runtimeKey = "default-runtime"
beforeSessionUpdateResolve = null
beforeSessionDeleteResolve = null
globalUpsertedSessionBatches.length = 0
globalActiveSessions = []
archiveBatchRequests.length = 0
archiveBatchResponse = { status: 404, body: { error: 'not found' } }
beforeControlPlaneMoveResolve = null
})
test("does not remove live or persisted state when delete fails", async () => {
@@ -824,9 +948,17 @@ describe("session restore (unarchive)", () => {
beforeEach(() => {
replyCalls.length = 0
registeredSessionDirectories.length = 0
movedSessionDirectories.length = 0
globalUpsertedSessions.length = 0
globalActiveSessions.length = 0
globalArchivedSessions.length = 0
openCodeProjects.length = 0
directoryAvailability.clear()
sessionUpdateResultsById.clear()
runtimeKey = "default-runtime"
sessionUpdateResult = {}
beforeSessionUpdateResolve = null
beforeControlPlaneMoveResolve = null
})
test("does not restore locally until the server returns the restored session", async () => {
@@ -878,6 +1010,311 @@ describe("session restore (unarchive)", () => {
expect(registeredSessionDirectories).toEqual([])
})
test("keeps an existing worktree restore in place without a control-plane move", async () => {
const worktreeDirectory = "/projects/main/.worktrees/feature-a"
globalArchivedSessions.push({
id: "session-worktree",
projectID: "project-main",
directory: worktreeDirectory,
project: { worktree: worktreeDirectory },
time: { created: 1, archived: 2 },
} as SessionWithDirectory)
directoryAvailability.set(worktreeDirectory, "available")
sessionUpdateResultsById.set("session-worktree", {
id: "session-worktree",
projectID: "project-main",
directory: worktreeDirectory,
project: { worktree: worktreeDirectory },
time: { created: 1, archived: 0 },
} as SessionWithDirectory)
const { unarchiveSession, setActionRefs } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([[worktreeDirectory, createStore({})]]), () => worktreeDirectory)
expect(await unarchiveSession("session-worktree")).toBe(true)
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([])
expect(registeredSessionDirectories).toEqual([{ sessionID: "session-worktree", directory: worktreeDirectory }])
expect((globalUpsertedSessions[0] as SessionWithDirectory).directory).toBe(worktreeDirectory)
})
test("moves a restored missing-worktree subtree to its matching project directory without changing descendants or cached transcript state", async () => {
const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch"
const destinationDirectory = "/projects/main"
const rootMessage = {
id: "message-root",
sessionID: "session-root",
role: "user",
time: { created: 10 },
} as Message
const rootPart = { id: "part-root", messageID: rootMessage.id, type: "text", text: "root" } as Part
const childMessage = {
id: "message-child",
sessionID: "session-child",
role: "assistant",
time: { created: 11 },
} as Message
const childPart = { id: "part-child", messageID: childMessage.id, type: "text", text: "child" } as Part
const rootSession = {
id: "session-root",
projectID: "project-main",
directory: missingWorktreeDirectory,
project: { worktree: destinationDirectory },
time: { created: 1, archived: 2 },
} as SessionWithDirectory
const childSession = {
id: "session-child",
parentID: "session-root",
projectID: "project-main",
directory: missingWorktreeDirectory,
project: { worktree: destinationDirectory },
time: { created: 2, archived: 3 },
} as SessionWithDirectory
globalArchivedSessions.push(rootSession, childSession)
openCodeProjects.push({ id: "project-main", worktree: destinationDirectory } as Project)
directoryAvailability.set(missingWorktreeDirectory, "missing")
sessionUpdateResultsById.set("session-root", {
...rootSession,
time: { created: 1, updated: 1, archived: 0 },
})
sessionUpdateResultsById.set("session-child", {
...childSession,
time: { created: 2, updated: 2, archived: 0 },
})
const source = createStore({}, {
session: [rootSession, childSession],
sessionTotal: 2,
message: {
"session-root": [rootMessage],
"session-child": [childMessage],
},
part: {
[rootMessage.id]: [rootPart],
[childMessage.id]: [childPart],
},
})
const destination = createStore({})
const { unarchiveSession, setActionRefs } = await import("./session-actions")
setActionRefs(
mockSdk as unknown as OpencodeClient,
createChildStores([[missingWorktreeDirectory, source], [destinationDirectory, destination]]),
() => missingWorktreeDirectory,
)
expect(await unarchiveSession("session-root")).toBe(true)
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([
{
method: "controlPlane.moveSession",
params: {
sessionID: "session-root",
destination: { directory: destinationDirectory },
moveChanges: false,
},
},
{
method: "controlPlane.moveSession",
params: {
sessionID: "session-child",
destination: { directory: destinationDirectory },
moveChanges: false,
},
},
])
expect(source.getState().session).toEqual([])
expect(destination.getState().session.map((session) => ({
id: session.id,
parentID: (session as SessionWithDirectory).parentID ?? null,
directory: (session as SessionWithDirectory).directory ?? null,
}))).toEqual([
{ id: "session-root", parentID: null, directory: destinationDirectory },
{ id: "session-child", parentID: "session-root", directory: destinationDirectory },
])
expect(destination.getState().message["session-root"]?.[0]?.id).toBe(rootMessage.id)
expect(destination.getState().message["session-child"]?.[0]?.id).toBe(childMessage.id)
expect(destination.getState().part[rootMessage.id]?.[0]?.id).toBe(rootPart.id)
expect(destination.getState().part[childMessage.id]?.[0]?.id).toBe(childPart.id)
expect(destination.getState().session.every((session) => !session.time?.archived)).toBe(true)
expect(registeredSessionDirectories).toEqual([
{ sessionID: "session-root", directory: destinationDirectory },
{ sessionID: "session-child", directory: destinationDirectory },
])
expect(movedSessionDirectories).toEqual([
{ sessionID: "session-root", directory: destinationDirectory },
{ sessionID: "session-child", directory: destinationDirectory },
])
expect(globalUpsertedSessions.map((session) => ({
id: (session as SessionWithDirectory).id,
parentID: (session as SessionWithDirectory).parentID ?? null,
directory: (session as SessionWithDirectory).directory ?? null,
}))).toEqual([
{ id: "session-root", parentID: null, directory: destinationDirectory },
{ id: "session-child", parentID: "session-root", directory: destinationDirectory },
])
})
test("restores missing-worktree descendants from the global cache when their directory store is unavailable", async () => {
const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch"
const destinationDirectory = "/projects/main"
const rootSession = {
id: "session-root",
projectID: "proj_main",
directory: missingWorktreeDirectory,
project: { worktree: destinationDirectory },
time: { created: 1, archived: 2 },
} as SessionWithDirectory
const childSession = {
id: "session-child",
parentID: rootSession.id,
projectID: "proj_main",
directory: missingWorktreeDirectory,
project: { worktree: destinationDirectory },
time: { created: 2, archived: 3 },
} as SessionWithDirectory
globalArchivedSessions.push(rootSession, childSession)
openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project)
directoryAvailability.set(missingWorktreeDirectory, "missing")
sessionUpdateResultsById.set("session-root", { ...rootSession, time: { created: 1, updated: 1, archived: 0 } })
sessionUpdateResultsById.set("session-child", { ...childSession, time: { created: 2, updated: 2, archived: 0 } })
const destination = createStore({})
const { unarchiveSession, setActionRefs } = await import("./session-actions")
setActionRefs(
mockSdk as unknown as OpencodeClient,
createChildStores([[destinationDirectory, destination]]),
() => missingWorktreeDirectory,
)
expect(await unarchiveSession(rootSession.id)).toBe(true)
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession").map((call) => call.params.sessionID))
.toEqual([rootSession.id, childSession.id])
expect(destination.getState().session.map((session) => session.id)).toEqual([rootSession.id, childSession.id])
expect(destination.getState().session.every((session) => !session.time?.archived)).toBe(true)
})
test("does not publish a missing-worktree move after the runtime changes during the control-plane request", async () => {
const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch"
const destinationDirectory = "/projects/main"
const session = {
id: "session-runtime-switch",
projectID: "proj_main",
directory: missingWorktreeDirectory,
project: { worktree: destinationDirectory },
time: { created: 1, archived: 2 },
} as SessionWithDirectory
globalArchivedSessions.push(session)
openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project)
directoryAvailability.set(missingWorktreeDirectory, "missing")
sessionUpdateResultsById.set(session.id, { ...session, time: { created: 1, updated: 1, archived: 0 } })
beforeControlPlaneMoveResolve = () => {
runtimeKey = "new-runtime"
}
const destination = createStore({})
const { unarchiveSession, setActionRefs } = await import("./session-actions")
setActionRefs(
mockSdk as unknown as OpencodeClient,
createChildStores([[destinationDirectory, destination]]),
() => missingWorktreeDirectory,
)
expect(await unarchiveSession(session.id)).toBe(false)
expect(destination.getState().session).toEqual([])
expect(registeredSessionDirectories).toEqual([])
expect(globalUpsertedSessions).toEqual([])
})
test("re-moves a root left stranded in a missing worktree after a partial restore", async () => {
const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch"
const destinationDirectory = "/projects/main"
// A previous restore attempt already unarchived the root (server echo made
// it active), then the control-plane move failed, leaving it stranded in the
// deleted worktree. The retry must still relocate it, not report a false
// success because the root is no longer archived.
const strandedRoot = {
id: "session-root",
projectID: "proj_main",
directory: missingWorktreeDirectory,
project: { worktree: destinationDirectory },
time: { created: 1, archived: 0 },
} as SessionWithDirectory
globalActiveSessions.push(strandedRoot)
openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project)
directoryAvailability.set(missingWorktreeDirectory, "missing")
sessionUpdateResultsById.set("session-root", { ...strandedRoot, time: { created: 1, updated: 1, archived: 0 } })
const destination = createStore({})
const { unarchiveSession, setActionRefs } = await import("./session-actions")
setActionRefs(
mockSdk as unknown as OpencodeClient,
createChildStores([[destinationDirectory, destination]]),
() => missingWorktreeDirectory,
)
expect(await unarchiveSession("session-root")).toBe(true)
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([
{
method: "controlPlane.moveSession",
params: {
sessionID: "session-root",
destination: { directory: destinationDirectory },
moveChanges: false,
},
},
])
expect(destination.getState().session.map((session) => session.id)).toEqual(["session-root"])
})
test("does not move a restored project session that is not a worktree", async () => {
const projectDirectory = "/projects/main"
globalArchivedSessions.push({
id: "session-project",
projectID: "project-main",
directory: projectDirectory,
time: { created: 1, archived: 2 },
} as SessionWithDirectory)
directoryAvailability.set(projectDirectory, "missing")
sessionUpdateResultsById.set("session-project", {
id: "session-project",
projectID: "project-main",
directory: projectDirectory,
time: { created: 1, archived: 0 },
} as SessionWithDirectory)
const { unarchiveSession, setActionRefs } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([[projectDirectory, createStore({})]]), () => projectDirectory)
expect(await unarchiveSession("session-project")).toBe(true)
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([])
expect(registeredSessionDirectories).toEqual([{ sessionID: "session-project", directory: projectDirectory }])
})
test("does not fall back to the parent project when worktree availability is unknown", async () => {
const worktreeDirectory = "/projects/main/.worktrees/offline-branch"
globalArchivedSessions.push({
id: "session-offline",
projectID: "project-main",
directory: worktreeDirectory,
project: { worktree: "/projects/main" },
time: { created: 1, archived: 2 },
} as SessionWithDirectory)
directoryAvailability.set(worktreeDirectory, "unknown")
sessionUpdateResultsById.set("session-offline", {
id: "session-offline",
projectID: "project-main",
directory: worktreeDirectory,
project: { worktree: "/projects/main" },
time: { created: 1, archived: 0 },
} as SessionWithDirectory)
const { unarchiveSession, setActionRefs } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([[worktreeDirectory, createStore({})]]), () => worktreeDirectory)
expect(await unarchiveSession("session-offline")).toBe(true)
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([])
expect(registeredSessionDirectories).toEqual([{ sessionID: "session-offline", directory: worktreeDirectory }])
expect((globalUpsertedSessions[0] as SessionWithDirectory).directory).toBe(worktreeDirectory)
})
test("rejects a restore response that arrives after a runtime switch", async () => {
sessionUpdateResult = {
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session,
+95 -2
View File
@@ -303,8 +303,11 @@ function reconcileSessionMove(
const destinationStore = stores?.ensureChild(destinationDirectory, { bootstrap: false })
const sourceState = sourceStore?.getState()
const destinationState = destinationStore?.getState()
const liveSession = sourceState?.session.find((candidate) => candidate.id === session.id) ?? session
const movedSession = { ...liveSession, directory: destinationDirectory } as Session
const liveSession = sourceState?.session.find((candidate) => candidate.id === session.id)
const movedSession = {
...mergeSessionDirectoryMetadata(session, liveSession),
directory: destinationDirectory,
} as Session
if (!destinationStore || !destinationState || sourceStore === destinationStore) {
return movedSession
@@ -370,6 +373,7 @@ export async function moveSessionToDirectory(
sourceDirectory: string,
destinationDirectory: string,
moveChanges = true,
expectedRuntimeKey?: string,
): Promise<void> {
const result = await opencodeClient.getSdkClient().experimental.controlPlane.moveSession({
sessionID: session.id,
@@ -378,6 +382,10 @@ export async function moveSessionToDirectory(
})
assertSdkSuccess(result, "Move session")
// If the runtime changed during the control-plane request, the server move
// already happened, but we must not publish stale local state to the UI/stores.
if (isStaleRuntime(expectedRuntimeKey)) return
invalidateSessionLoads(session.id, [sourceDirectory, destinationDirectory])
const moved = reconcileSessionMove(session, sourceDirectory, destinationDirectory)
@@ -1490,6 +1498,65 @@ 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 MissingWorktreeRestore = { sourceDirectory: string; destinationDirectory: string }
async function resolveMissingWorktreeRestore(
session: Session & { project?: { worktree?: string | null } | null },
): Promise<MissingWorktreeRestore | 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
return { sourceDirectory: ownedDirectory, destinationDirectory: projectDirectory }
}
function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array<{ session: Session; sourceDirectory: string }> {
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) }))
// 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)
}
/**
* Restore one archived session back to the active list.
*
@@ -1502,8 +1569,34 @@ const UNARCHIVED_TIMESTAMP = 0
*/
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 resolveMissingWorktreeRestore(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) {