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
+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,