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 Callers whose confirmation can span a runtime switch may pass an
`expectedRuntimeKey` captured earlier; ordinary callers are guarded by default. `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 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 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 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 sessionDeleteError: unknown | null = null
let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null
let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null
let beforeControlPlaneMoveResolve: ((sessionId: string) => void) | null = null
const globalUpsertedSessions: unknown[] = [] const globalUpsertedSessions: unknown[] = []
const globalUpsertedSessionBatches: Session[][] = [] const globalUpsertedSessionBatches: Session[][] = []
const globalRemovedSessionIds: string[] = [] const globalRemovedSessionIds: string[] = []
@@ -33,6 +34,12 @@ let archiveBatchResponse: { status: number; body: unknown } = {
} }
const deletedCleanupIdentities: Array<{ runtimeKey: string; directory: string; sessionId: string }> = [] const deletedCleanupIdentities: Array<{ runtimeKey: string; directory: string; sessionId: string }> = []
const movedSessionDirectories: Array<{ sessionID: string; directory: 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 = { const mockScopedClient = {
permission: { permission: {
@@ -68,10 +75,17 @@ const mockSdk = {
controlPlane: { controlPlane: {
moveSession: mock((params: Record<string, unknown>) => { moveSession: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "controlPlane.moveSession", params }) replyCalls.push({ method: "controlPlane.moveSession", params })
beforeControlPlaneMoveResolve?.(String(params.sessionID))
return Promise.resolve({}) return Promise.resolve({})
}), }),
}, },
}, },
project: {
list: mock(() => {
replyCalls.push({ method: "project.list", params: {} })
return Promise.resolve({ data: openCodeProjects })
}),
},
session: { session: {
messages: mock((params: Record<string, unknown>) => { messages: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "session.messages", params }) replyCalls.push({ method: "session.messages", params })
@@ -146,6 +160,7 @@ mock.module("@/lib/opencode/client", () => ({
return mockScopedClient return mockScopedClient
}, },
getDirectory: () => "/test/project", getDirectory: () => "/test/project",
getDirectoryAvailability: mock(async (directory: string) => directoryAvailability.get(directory) ?? "available"),
getFilesystemHome: mock(async () => "/home/test"), getFilesystemHome: mock(async () => "/home/test"),
getSdkClient: () => mockSdk, getSdkClient: () => mockSdk,
getSessionMessages: mock((sessionId: string, _limit?: number, directory?: string | null) => { 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, // 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. // so the action observes the switch only after awaiting the response.
beforeSessionUpdateResolve?.(sessionId) beforeSessionUpdateResolve?.(sessionId)
return Promise.resolve(sessionUpdateResult.data) return Promise.resolve(sessionUpdateResultsById.get(sessionId) ?? sessionUpdateResult.data)
}), }),
deleteSession: mock((sessionId: string, directory?: string | null) => { deleteSession: mock((sessionId: string, directory?: string | null) => {
replyCalls.push({ method: "session.delete", params: { sessionID: sessionId, directory } }) 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", () => ({ mock.module("@/stores/useGlobalSessionsStore", () => ({
resolveGlobalSessionDirectory: (session: SessionWithDirectory) => session.directory ?? session.project?.worktree ?? null, resolveGlobalSessionDirectory: (session: SessionWithDirectory) => session.directory ?? session.project?.worktree ?? null,
mergeSessionDirectoryMetadata: (incoming: Session, existing?: SessionWithDirectory | null): SessionWithDirectory => { mergeSessionDirectoryMetadata: (incoming: Session, existing?: SessionWithDirectory | null): SessionWithDirectory => {
@@ -253,7 +284,7 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({
useGlobalSessionsStore: { useGlobalSessionsStore: {
getState: () => ({ getState: () => ({
activeSessions: globalActiveSessions, activeSessions: globalActiveSessions,
archivedSessions: [], archivedSessions: globalArchivedSessions,
upsertSession: (session: unknown) => { upsertSession: (session: unknown) => {
globalUpsertedSessions.push(session) 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", () => ({ mock.module("./session-deletion-cleanup", () => ({
cleanupPersistedSessionState: (identity: { runtimeKey: string; directory: string; sessionId: string }) => { cleanupPersistedSessionState: (identity: { runtimeKey: string; directory: string; sessionId: string }) => {
deletedCleanupIdentities.push(identity) deletedCleanupIdentities.push(identity)
@@ -293,10 +399,9 @@ mock.module("./sync-refs", () => ({
}, },
})) }))
import { create, type StoreApi } from "zustand"
import { INITIAL_STATE } from "./types" import { INITIAL_STATE } from "./types"
import type { DirectoryStore } from "./child-store" 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 OptimisticAddCall = { sessionID: string; directory?: string | null; message: Message; parts: Part[] }
type OptimisticRemoveCall = { sessionID: string; directory?: string | null; messageID: string } type OptimisticRemoveCall = { sessionID: string; directory?: string | null; messageID: string }
@@ -305,20 +410,37 @@ type SessionWithDirectory = Session & {
project?: { worktree?: string | null } project?: { worktree?: string | null }
} }
type TestStoreApi<T> = {
getState: () => T
setState: (patch: Partial<T> | ((state: T) => Partial<T>)) => void
}
function createStore( function createStore(
permissions: Record<string, PermissionRequest[]>, permissions: Record<string, PermissionRequest[]>,
state?: Partial<DirectoryStore>, state?: Partial<DirectoryStore>,
): StoreApi<DirectoryStore> { ): TestStoreApi<DirectoryStore> {
return create<DirectoryStore>()((set) => ({ let currentState: DirectoryStore = {
...INITIAL_STATE, ...INITIAL_STATE,
...state, ...state,
permission: permissions, permission: permissions,
patch: (partial) => set(partial), patch: (partial) => setState(partial),
replace: (next) => set(next), 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 { return {
children: new Map(entries), children: new Map(entries),
ensureChild: (dir: string) => { ensureChild: (dir: string) => {
@@ -419,12 +541,14 @@ describe("confirmed session removal", () => {
deletedCleanupIdentities.length = 0 deletedCleanupIdentities.length = 0
sessionDeleteError = null sessionDeleteError = null
sessionUpdateResult = {} sessionUpdateResult = {}
runtimeKey = "default-runtime"
beforeSessionUpdateResolve = null beforeSessionUpdateResolve = null
beforeSessionDeleteResolve = null beforeSessionDeleteResolve = null
globalUpsertedSessionBatches.length = 0 globalUpsertedSessionBatches.length = 0
globalActiveSessions = [] globalActiveSessions = []
archiveBatchRequests.length = 0 archiveBatchRequests.length = 0
archiveBatchResponse = { status: 404, body: { error: 'not found' } } archiveBatchResponse = { status: 404, body: { error: 'not found' } }
beforeControlPlaneMoveResolve = null
}) })
test("does not remove live or persisted state when delete fails", async () => { test("does not remove live or persisted state when delete fails", async () => {
@@ -824,9 +948,17 @@ describe("session restore (unarchive)", () => {
beforeEach(() => { beforeEach(() => {
replyCalls.length = 0 replyCalls.length = 0
registeredSessionDirectories.length = 0 registeredSessionDirectories.length = 0
movedSessionDirectories.length = 0
globalUpsertedSessions.length = 0 globalUpsertedSessions.length = 0
globalActiveSessions.length = 0
globalArchivedSessions.length = 0
openCodeProjects.length = 0
directoryAvailability.clear()
sessionUpdateResultsById.clear()
runtimeKey = "default-runtime"
sessionUpdateResult = {} sessionUpdateResult = {}
beforeSessionUpdateResolve = null beforeSessionUpdateResolve = null
beforeControlPlaneMoveResolve = null
}) })
test("does not restore locally until the server returns the restored session", async () => { test("does not restore locally until the server returns the restored session", async () => {
@@ -878,6 +1010,311 @@ describe("session restore (unarchive)", () => {
expect(registeredSessionDirectories).toEqual([]) 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 () => { test("rejects a restore response that arrives after a runtime switch", async () => {
sessionUpdateResult = { sessionUpdateResult = {
data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session, 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 destinationStore = stores?.ensureChild(destinationDirectory, { bootstrap: false })
const sourceState = sourceStore?.getState() const sourceState = sourceStore?.getState()
const destinationState = destinationStore?.getState() const destinationState = destinationStore?.getState()
const liveSession = sourceState?.session.find((candidate) => candidate.id === session.id) ?? session const liveSession = sourceState?.session.find((candidate) => candidate.id === session.id)
const movedSession = { ...liveSession, directory: destinationDirectory } as Session const movedSession = {
...mergeSessionDirectoryMetadata(session, liveSession),
directory: destinationDirectory,
} as Session
if (!destinationStore || !destinationState || sourceStore === destinationStore) { if (!destinationStore || !destinationState || sourceStore === destinationStore) {
return movedSession return movedSession
@@ -370,6 +373,7 @@ export async function moveSessionToDirectory(
sourceDirectory: string, sourceDirectory: string,
destinationDirectory: string, destinationDirectory: string,
moveChanges = true, moveChanges = true,
expectedRuntimeKey?: string,
): Promise<void> { ): Promise<void> {
const result = await opencodeClient.getSdkClient().experimental.controlPlane.moveSession({ const result = await opencodeClient.getSdkClient().experimental.controlPlane.moveSession({
sessionID: session.id, sessionID: session.id,
@@ -378,6 +382,10 @@ export async function moveSessionToDirectory(
}) })
assertSdkSuccess(result, "Move session") 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]) invalidateSessionLoads(session.id, [sourceDirectory, destinationDirectory])
const moved = reconcileSessionMove(session, sourceDirectory, destinationDirectory) const moved = reconcileSessionMove(session, sourceDirectory, destinationDirectory)
@@ -1490,6 +1498,65 @@ function commitArchivedSessions(sessions: Session[], directory: string): void {
*/ */
const UNARCHIVED_TIMESTAMP = 0 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. * 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> { export async function unarchiveSession(sessionId: string, expectedRuntimeKey = getRuntimeKey()): Promise<boolean> {
if (isStaleRuntime(expectedRuntimeKey)) return false if (isStaleRuntime(expectedRuntimeKey)) return false
const globalSession = getGlobalSessionSnapshot(sessionId)
const sessionDirectory = getSessionDirectory(sessionId) const sessionDirectory = getSessionDirectory(sessionId)
try { 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) const restored = await opencodeClient.updateSession(sessionId, { time: { archived: UNARCHIVED_TIMESTAMP } }, sessionDirectory)
if (isStaleRuntime(expectedRuntimeKey)) return false if (isStaleRuntime(expectedRuntimeKey)) return false
if (!restored) { if (!restored) {