Restore an archived workspace when its agent is reopened

Reopening an archived agent previously dead-ended at "workspace not
found": the agent record was un-archived but its workspace stayed
archived and was never sent to the client.

The daemon now un-archives the owning workspace alongside the agent when
its backing directory still exists, and the app auto-fires the restore
ahead of the missing-workspace gate so the user lands back in the
workspace rather than a dead end.

Worktrees whose directory was deleted on archive are not yet restorable
(recreating them from their branch is a follow-up); the workspace record
now persists its base branch as groundwork for that.
This commit is contained in:
Mohamed Boudra
2026-06-18 16:09:34 +07:00
parent fe15b68e06
commit 31fcde2ea6
22 changed files with 778 additions and 17 deletions

View File

@@ -5,7 +5,7 @@ import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { navigateToAgent } from "@/utils/navigate-to-agent";
export default function HostAgentReadyRoute() {
return (
@@ -50,10 +50,9 @@ function HostAgentReadyRouteContent() {
if (resolvedWorkspaceId) {
redirectedRef.current = true;
navigateToPreparedWorkspaceTab({
navigateToAgent({
serverId,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
agentId,
currentPathname: pathname,
});
}
@@ -93,10 +92,10 @@ function HostAgentReadyRouteContent() {
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
redirectedRef.current = true;
if (workspaceId) {
navigateToPreparedWorkspaceTab({
navigateToAgent({
serverId,
agentId,
workspaceId,
target: { kind: "agent", agentId },
currentPathname: pathname,
});
return;

View File

@@ -328,6 +328,8 @@ export const ar: TranslationResources = {
workspace: {
route: {
loading: "جارٍ تحميل مساحة العمل",
restoring: "جارٍ استعادة مساحة العمل",
restoreFailed: "تعذّر استعادة مساحة العمل هذه — ربما تم نقل المجلد أو حذفه",
connecting: "الاتصال",
hostOffline: "{{hostName}}غير متواجد حالياً",
cannotReachHost: "لا يمكن الوصول إلى{{hostName}}",

View File

@@ -327,6 +327,9 @@ export const en = {
workspace: {
route: {
loading: "Loading workspace",
restoring: "Restoring workspace",
restoreFailed:
"Couldn't restore this workspace — the directory may have been moved or deleted",
connecting: "Connecting",
hostOffline: "{{hostName}} is offline",
cannotReachHost: "Cannot reach {{hostName}}",

View File

@@ -331,6 +331,9 @@ export const es: TranslationResources = {
workspace: {
route: {
loading: "Cargando espacio de trabajo",
restoring: "Restaurando espacio de trabajo",
restoreFailed:
"No se pudo restaurar este espacio de trabajo — es posible que el directorio se haya movido o eliminado",
connecting: "Conectando",
hostOffline: "{{hostName}}está desconectado",
cannotReachHost: "No se puede alcanzar{{hostName}}",

View File

@@ -331,6 +331,9 @@ export const fr: TranslationResources = {
workspace: {
route: {
loading: "Chargement de l'espace de travail",
restoring: "Restauration de l'espace de travail",
restoreFailed:
"Impossible de restaurer cet espace de travail — le répertoire a peut-être été déplacé ou supprimé",
connecting: "De liaison",
hostOffline: "{{hostName}}est hors ligne",
cannotReachHost: "Impossible d'atteindre{{hostName}}",

View File

@@ -330,6 +330,9 @@ export const ru: TranslationResources = {
workspace: {
route: {
loading: "Загрузка рабочей области",
restoring: "Восстановление рабочей области",
restoreFailed:
"Не удалось восстановить эту рабочую область — каталог мог быть перемещён или удалён",
connecting: "Подключение",
hostOffline: "{{hostName}}не в сети",
cannotReachHost: "Невозможно связаться с{{hostName}}",

View File

@@ -328,6 +328,8 @@ export const zhCN: TranslationResources = {
workspace: {
route: {
loading: "正在加载 workspace",
restoring: "正在恢复 workspace",
restoreFailed: "无法恢复此 workspace — 目录可能已被移动或删除",
connecting: "正在连接",
hostOffline: "{{hostName}} 已离线",
cannotReachHost: "无法连接 {{hostName}}",

View File

@@ -21,6 +21,8 @@ export function renderWorkspaceRouteGate(input: {
switch (input.state.kind) {
case "loading":
return <WorkspaceConnecting hostName={input.state.hostName} />;
case "restoring":
return <WorkspaceRestoring hostName={input.state.hostName} />;
case "unreachable":
return (
<WorkspaceUnreachable
@@ -33,6 +35,7 @@ export function renderWorkspaceRouteGate(input: {
return (
<WorkspaceMissing
hostName={input.state.hostName}
restoreFailed={input.state.restoreFailed}
onDismiss={input.actions.onDismissMissingWorkspace}
/>
);
@@ -70,6 +73,21 @@ function WorkspaceConnecting({ hostName }: { hostName: string }) {
);
}
function WorkspaceRestoring({ hostName }: { hostName: string }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
return (
<View style={styles.emptyState}>
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<View style={styles.textStack}>
<Text style={styles.title}>{t("workspace.route.restoring")}</Text>
<Text style={styles.description}>{hostName}</Text>
</View>
</View>
);
}
function WorkspaceUnreachable({
state,
onRetry,
@@ -124,13 +142,23 @@ function WorkspaceUnreachable({
);
}
function WorkspaceMissing({ hostName, onDismiss }: { hostName: string; onDismiss: () => void }) {
function WorkspaceMissing({
hostName,
restoreFailed,
onDismiss,
}: {
hostName: string;
restoreFailed: boolean;
onDismiss: () => void;
}) {
const { t } = useTranslation();
return (
<View style={styles.emptyState}>
<View style={styles.textStack}>
<Text style={styles.title}>{t("workspace.route.missing")}</Text>
<Text style={styles.title}>
{restoreFailed ? t("workspace.route.restoreFailed") : t("workspace.route.missing")}
</Text>
<Text style={styles.description}>{hostName}</Text>
</View>
<View style={styles.actions}>

View File

@@ -29,6 +29,7 @@ describe("resolveWorkspaceRouteState", () => {
lastError: "transport closed",
workspace: null,
hasHydratedWorkspaces: false,
restoreStatus: null,
}),
).toEqual({
kind: "unreachable",
@@ -46,6 +47,7 @@ describe("resolveWorkspaceRouteState", () => {
lastError: "transport closed",
workspace: null,
hasHydratedWorkspaces: true,
restoreStatus: null,
}),
).toEqual({
kind: "unreachable",
@@ -63,6 +65,7 @@ describe("resolveWorkspaceRouteState", () => {
lastError: "transport closed",
workspace: createWorkspaceDescriptor(),
hasHydratedWorkspaces: true,
restoreStatus: null,
}),
).toEqual({
kind: "reconnecting",
@@ -80,8 +83,9 @@ describe("resolveWorkspaceRouteState", () => {
lastError: null,
workspace: null,
hasHydratedWorkspaces: true,
restoreStatus: null,
}),
).toEqual({ kind: "missing", hostName: "Laptop" });
).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: false });
});
it("returns loading before workspace hydration when the host is online", () => {
@@ -92,6 +96,7 @@ describe("resolveWorkspaceRouteState", () => {
lastError: null,
workspace: null,
hasHydratedWorkspaces: false,
restoreStatus: null,
}),
).toEqual({ kind: "loading", hostName: "Laptop" });
});
@@ -104,6 +109,46 @@ describe("resolveWorkspaceRouteState", () => {
lastError: null,
workspace: createWorkspaceDescriptor(),
hasHydratedWorkspaces: true,
restoreStatus: null,
}),
).toEqual({ kind: "ready" });
});
it("returns restoring while an archived workspace restore is in flight", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "online",
lastError: null,
workspace: null,
hasHydratedWorkspaces: true,
restoreStatus: "restoring",
}),
).toEqual({ kind: "restoring", hostName: "Laptop" });
});
it("falls back to a restore-failed missing state once the restore times out", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "online",
lastError: null,
workspace: null,
hasHydratedWorkspaces: true,
restoreStatus: "failed",
}),
).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: true });
});
it("resolves to ready when the descriptor arrives even while restoring", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "online",
lastError: null,
workspace: createWorkspaceDescriptor(),
hasHydratedWorkspaces: true,
restoreStatus: "restoring",
}),
).toEqual({ kind: "ready" });
});

View File

@@ -16,7 +16,8 @@ export type WorkspaceRouteState =
lastError: string | null;
}
| { kind: "loading"; hostName: string }
| { kind: "missing"; hostName: string };
| { kind: "restoring"; hostName: string }
| { kind: "missing"; hostName: string; restoreFailed: boolean };
export function resolveWorkspaceRouteState(input: {
hostName: string;
@@ -24,6 +25,7 @@ export function resolveWorkspaceRouteState(input: {
lastError: string | null;
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;
restoreStatus: "restoring" | "failed" | null;
}): WorkspaceRouteState {
if (input.workspace) {
if (input.connectionStatus === "online") {
@@ -39,8 +41,16 @@ export function resolveWorkspaceRouteState(input: {
}
if (input.connectionStatus === "online") {
if (input.restoreStatus === "restoring") {
return { kind: "restoring", hostName: input.hostName };
}
if (input.hasHydratedWorkspaces) {
return { kind: "missing", hostName: input.hostName };
return {
kind: "missing",
hostName: input.hostName,
restoreFailed: input.restoreStatus === "failed",
};
}
return { kind: "loading", hostName: input.hostName };

View File

@@ -71,7 +71,11 @@ import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
useSessionStore,
useWorkspaceRestoreStatus,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
@@ -1487,6 +1491,7 @@ function useWorkspaceRouteActions(normalizedServerId: string): {
function useResolvedWorkspaceRouteState(input: {
serverId: string;
workspaceId: string;
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;
}): WorkspaceRouteState {
@@ -1497,6 +1502,7 @@ function useResolvedWorkspaceRouteState(input: {
);
const hostSnapshot = useHostRuntimeSnapshot(input.serverId);
const hostName = useMemo(() => getHostDisplayName(host, input.serverId), [host, input.serverId]);
const restoreStatus = useWorkspaceRestoreStatus(input.serverId, input.workspaceId);
return useMemo(
() =>
@@ -1506,6 +1512,7 @@ function useResolvedWorkspaceRouteState(input: {
lastError: hostSnapshot?.lastError ?? null,
workspace: input.workspace,
hasHydratedWorkspaces: input.hasHydratedWorkspaces,
restoreStatus,
}),
[
hostName,
@@ -1513,6 +1520,7 @@ function useResolvedWorkspaceRouteState(input: {
hostSnapshot?.lastError,
input.workspace,
input.hasHydratedWorkspaces,
restoreStatus,
],
);
}
@@ -1858,6 +1866,7 @@ function WorkspaceScreenContent({
);
const workspaceRouteState = useResolvedWorkspaceRouteState({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
});

View File

@@ -363,6 +363,51 @@ describe("mergeWorkspaces", () => {
expect(after.workspaces).not.toBe(before.workspaces);
expect(after.workspaces.get(workspace.id)?.diffStat).toBeNull();
});
it("clears a pending restore status when the matching descriptor lands", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe(
"restoring",
);
store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]);
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
});
});
describe("setWorkspaceRestoreStatus", () => {
it("marks restoring then failed while the workspace is still absent", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe("failed");
});
it("ignores a late failed once the descriptor has landed (no-op)", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]);
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
});
it("ignores failed when no restore is in flight", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
});
});
describe("setWorkspaces", () => {

View File

@@ -287,6 +287,8 @@ export interface AgentTimelineCursorState {
endSeq: number;
}
export type WorkspaceRestoreStatus = "restoring" | "failed";
// Per-session state
export interface SessionState {
serverId: string;
@@ -332,6 +334,9 @@ export interface SessionState {
// Project parents with no active workspaces, keyed by projectId. Rendered as
// empty project rows in the sidebar.
emptyProjects: Map<string, EmptyProjectDescriptor>;
// Transient restore state for archived workspaces, keyed by normalized
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
// Permissions
pendingPermissions: Map<string, PendingPermission>;
@@ -455,6 +460,12 @@ interface SessionStoreActions {
removeWorkspace: (serverId: string, workspaceId: string) => void;
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
setWorkspaceRestoreStatus: (
serverId: string,
workspaceId: string,
status: WorkspaceRestoreStatus,
) => void;
clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void;
// Agent activity timestamps
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
@@ -527,6 +538,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
agentDetails: new Map(),
workspaces: new Map(),
emptyProjects: new Map(),
restoringWorkspaces: new Map(),
pendingPermissions: new Map(),
fileExplorer: new Map(),
queuedMessages: new Map(),
@@ -1253,6 +1265,54 @@ export const useSessionStore = create<SessionStore>()(
});
},
setWorkspaceRestoreStatus: (serverId, workspaceId, status) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
if (session.restoringWorkspaces.get(workspaceId) === status) {
return prev;
}
// A late dir-gone timeout must not override a successful restore:
// only mark failed while still restoring and the descriptor is absent.
if (
status === "failed" &&
(session.restoringWorkspaces.get(workspaceId) !== "restoring" ||
session.workspaces.has(workspaceId))
) {
return prev;
}
const next = new Map(session.restoringWorkspaces);
next.set(workspaceId, status);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, restoringWorkspaces: next },
},
};
});
},
clearWorkspaceRestoreStatus: (serverId, workspaceId) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session || !session.restoringWorkspaces.has(workspaceId)) {
return prev;
}
const next = new Map(session.restoringWorkspaces);
next.delete(workspaceId);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, restoringWorkspaces: next },
},
};
});
},
mergeWorkspaces: (serverId, workspaces) => {
const nextEntries = Array.from(workspaces);
set((prev) => {
@@ -1266,10 +1326,18 @@ export const useSessionStore = create<SessionStore>()(
// empty: prune any stale empty descriptor so it stops governing the
// project's rendered metadata.
const nextEmptyProjects = new Map(session.emptyProjects);
// A descriptor arriving is the success signal for a pending restore:
// clear it at the source so every entry point converges to "ready".
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
for (const workspace of nextEntries) {
if (nextEmptyProjects.delete(workspace.projectId)) {
changed = true;
}
if (session.restoringWorkspaces.has(workspace.id)) {
nextRestoring ??= new Map(session.restoringWorkspaces);
nextRestoring.delete(workspace.id);
changed = true;
}
const existing = next.get(workspace.id);
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
if (existing === nextWorkspace) {
@@ -1285,7 +1353,12 @@ export const useSessionStore = create<SessionStore>()(
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects },
[serverId]: {
...session,
workspaces: next,
emptyProjects: nextEmptyProjects,
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
},
},
};
});
@@ -1485,3 +1558,14 @@ export const useSessionStore = create<SessionStore>()(
};
}),
);
export function useWorkspaceRestoreStatus(
serverId: string | null,
workspaceId: string | null,
): WorkspaceRestoreStatus | null {
return useSessionStore((state) =>
serverId && workspaceId
? (state.sessions[serverId]?.restoringWorkspaces.get(workspaceId) ?? null)
: null,
);
}

View File

@@ -1,10 +1,51 @@
import { router, type Href } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
export type { NavigateToAgentInput } from "./resolve";
// Clears the transient restoring state if the daemon resolves refreshAgent without
// re-emitting a workspace_update (the directory-gone case), so the gate never spins forever.
const RESTORE_TIMEOUT_MS = 7000;
function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void {
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
const client = snapshot?.client ?? null;
if (!client || !isHostRuntimeConnected(snapshot)) {
return;
}
const store = useSessionStore.getState();
const session = store.sessions[serverId];
// Self-gate: only an archived agent whose workspace is absent needs restoring.
// A still-present workspace or an in-flight restore is a no-op; fire-once is
// derived from store state.
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
if (!agent?.archivedAt) {
return;
}
if (session?.workspaces.has(workspaceId)) {
return;
}
if (session?.restoringWorkspaces.get(workspaceId) === "restoring") {
return;
}
store.setWorkspaceRestoreStatus(serverId, workspaceId, "restoring");
// The reducer guards "failed" so a late timeout after the descriptor lands is a no-op.
setTimeout(
() => useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
RESTORE_TIMEOUT_MS,
);
client
.refreshAgent(agentId)
.catch(() =>
useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
);
}
export function navigateToAgent(input: NavigateToAgentInput): string {
return resolveNavigateToAgent(input, {
readAgentNavTarget: ({ serverId, agentId }) => {
@@ -18,5 +59,8 @@ export function navigateToAgent(input: NavigateToAgentInput): string {
router.navigate(route as Href);
},
navigateToPreparedWorkspaceTab,
restoreArchivedWorkspace: ({ serverId, agentId, workspaceId }) => {
restoreArchivedWorkspace(serverId, agentId, workspaceId);
},
});
}

View File

@@ -16,16 +16,25 @@ interface RecordedHostNav {
interface RecordedTabNav extends NavigateToPreparedWorkspaceTabInput {}
interface RecordedRestore {
serverId: string;
agentId: string;
workspaceId: string;
}
function createFakeNavigators(target: AgentNavTarget): {
deps: NavigateToAgentDeps;
hostNavigations: RecordedHostNav[];
tabNavigations: RecordedTabNav[];
restores: RecordedRestore[];
} {
const hostNavigations: RecordedHostNav[] = [];
const tabNavigations: RecordedTabNav[] = [];
const restores: RecordedRestore[] = [];
return {
hostNavigations,
tabNavigations,
restores,
deps: {
readAgentNavTarget: () => target,
navigateToHostAgent: (route) => {
@@ -35,6 +44,9 @@ function createFakeNavigators(target: AgentNavTarget): {
tabNavigations.push(input);
return `/h/${input.serverId}/workspace/${input.workspaceId}`;
},
restoreArchivedWorkspace: (input) => {
restores.push(input);
},
},
};
}
@@ -63,6 +75,55 @@ describe("resolveNavigateToAgent", () => {
]);
});
it("delegates a restore attempt whenever a workspaceId resolves", () => {
const { deps, restores, tabNavigations } = createFakeNavigators({
agentWorkspaceId: WORKSPACE_ID,
});
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true }, deps);
expect(restores).toEqual([
{ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
]);
expect(tabNavigations).toHaveLength(1);
});
it("uses the input workspaceId without reading the nav target", () => {
const readTargets: { serverId: string; agentId: string }[] = [];
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null });
deps.readAgentNavTarget = (input) => {
readTargets.push(input);
return { agentWorkspaceId: null };
};
resolveNavigateToAgent(
{ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
deps,
);
expect(readTargets).toEqual([]);
expect(tabNavigations).toEqual([
{
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: AGENT_ID },
currentPathname: undefined,
pin: undefined,
},
]);
});
it("does not trigger a restore when no workspaceId resolves", () => {
const { deps, restores, hostNavigations } = createFakeNavigators({
agentWorkspaceId: null,
});
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID }, deps);
expect(restores).toEqual([]);
expect(hostNavigations).toHaveLength(1);
});
it("falls back to the host agent route when the agent has no workspaceId", () => {
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
agentWorkspaceId: null,

View File

@@ -5,6 +5,9 @@ import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-worksp
export interface NavigateToAgentInput {
serverId: string;
agentId: string;
// Used as the workspace target when the agent is not yet in the session store
// (cold deep-links). Otherwise the workspace is read from the store.
workspaceId?: string | null;
currentPathname?: string | null;
pin?: boolean;
}
@@ -17,16 +20,20 @@ export interface NavigateToAgentDeps {
readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget;
navigateToHostAgent: (route: string) => void;
navigateToPreparedWorkspaceTab: (input: NavigateToPreparedWorkspaceTabInput) => string;
restoreArchivedWorkspace: (input: {
serverId: string;
agentId: string;
workspaceId: string;
}) => void;
}
export function resolveNavigateToAgent(
input: NavigateToAgentInput,
deps: NavigateToAgentDeps,
): string {
const { agentWorkspaceId } = deps.readAgentNavTarget({
serverId: input.serverId,
agentId: input.agentId,
});
const agentWorkspaceId =
input.workspaceId ??
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId;
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
if (!workspaceId) {
@@ -35,6 +42,14 @@ export function resolveNavigateToAgent(
return route;
}
// Restore self-gates on the agent being archived with its workspace absent, so
// ordinary navigations are a cheap no-op.
deps.restoreArchivedWorkspace({
serverId: input.serverId,
agentId: input.agentId,
workspaceId,
});
return deps.navigateToPreparedWorkspaceTab({
serverId: input.serverId,
workspaceId,

View File

@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
const refreshAgent = vi.fn<(agentId: string) => Promise<unknown>>();
let connected = true;
vi.mock("expo-router", () => ({
router: { navigate: vi.fn() },
}));
vi.mock("@/utils/workspace-navigation", () => ({
navigateToPreparedWorkspaceTab: vi.fn(() => ""),
}));
vi.mock("@/runtime/host-runtime", () => ({
getHostRuntimeStore: () => ({
getSnapshot: () => ({ client: { refreshAgent } }) as unknown as HostRuntimeSnapshot,
}),
isHostRuntimeConnected: () => connected,
}));
import { useSessionStore, type Agent } from "@/stores/session-store";
import { navigateToAgent } from "./index";
const SERVER_ID = "server-1";
const AGENT_ID = "agent-1";
const WORKSPACE_ID = "workspace-1";
function status(): "restoring" | "failed" | null {
return (
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
);
}
function seedArchivedAgent(): void {
const store = useSessionStore.getState();
store.initializeSession(SERVER_ID, null as unknown as DaemonClient);
store.setAgents(SERVER_ID, (prev) => {
const next = new Map(prev);
next.set(AGENT_ID, {
id: AGENT_ID,
workspaceId: WORKSPACE_ID,
archivedAt: new Date(),
} as unknown as Agent);
return next;
});
}
describe("restoreArchivedWorkspace via navigateToAgent", () => {
beforeEach(() => {
vi.useFakeTimers();
refreshAgent.mockReset();
connected = true;
seedArchivedAgent();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
useSessionStore.getState().clearSession(SERVER_ID);
});
function trigger(): void {
navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID });
}
it("calls refreshAgent once and marks the workspace restoring", () => {
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
expect(refreshAgent).toHaveBeenCalledTimes(1);
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
expect(status()).toBe("restoring");
});
it("does not re-fire while a restore for the same workspace is in flight", () => {
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
trigger();
trigger();
expect(refreshAgent).toHaveBeenCalledTimes(1);
});
it("does not fire for a non-archived agent", () => {
const store = useSessionStore.getState();
store.setAgents(SERVER_ID, (prev) => {
const next = new Map(prev);
next.set(AGENT_ID, {
id: AGENT_ID,
workspaceId: WORKSPACE_ID,
archivedAt: undefined,
} as unknown as Agent);
return next;
});
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBeNull();
});
it("does not fire while disconnected", () => {
connected = false;
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBeNull();
});
it("flips to failed when refreshAgent rejects", async () => {
refreshAgent.mockImplementation(() => Promise.reject(new Error("dir gone")));
trigger();
expect(status()).toBe("restoring");
await vi.runAllTicks();
await Promise.resolve();
expect(status()).toBe("failed");
});
it("flips to failed via the timeout when refreshAgent resolves without a workspace update", async () => {
refreshAgent.mockImplementation(() => Promise.resolve({}));
trigger();
await Promise.resolve();
expect(status()).toBe("restoring");
await vi.advanceTimersByTimeAsync(7000);
expect(status()).toBe("failed");
});
it("is a no-op when the workspace descriptor is already present", () => {
refreshAgent.mockImplementation(() => new Promise(() => {}));
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
{
id: WORKSPACE_ID,
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
},
]);
trigger();
expect(refreshAgent).not.toHaveBeenCalled();
});
});

View File

@@ -65,6 +65,7 @@ test("creates a worktree and registers it in the source workspace project withou
expect(result.workspace.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/);
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
expect(result.workspace.displayName).toBe("feature-one");
expect(result.workspace.baseBranch).toBe("main");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(events).toEqual([
"project:remote:github.com/acme/repo",
@@ -425,6 +426,9 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
version: 1,
baseRefName: "dev",
});
// A checkout-branch worktree has no distinct base, so the workspace records a
// null baseBranch even though worktree.json's baseRefName is the branch itself.
expect(created.workspace.baseBranch).toBe(null);
await expect(
attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,

View File

@@ -21,6 +21,7 @@ import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
import {
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
normalizeBaseRefName,
readPaseoWorktreeMetadata,
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
} from "../utils/worktree-metadata.js";
@@ -71,6 +72,7 @@ export async function createPaseoWorktree(
projectId: input.projectId,
repoRoot: createdWorktree.repoRoot,
worktree: createdWorktree.worktree,
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
deps,
});
@@ -197,11 +199,25 @@ function maybeMarkFirstAgentBranchAutoNameEligible(options: {
});
}
// The base branch is normalized to match worktree.json's baseRefName (origin/
// stripped). checkout-branch worktrees have no distinct base, so they stay null.
function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null {
switch (intent.kind) {
case "branch-off":
return normalizeBaseRefName(intent.baseBranch);
case "checkout-github-pr":
return normalizeBaseRefName(intent.baseRefName);
case "checkout-branch":
return null;
}
}
async function upsertWorkspaceForWorktree(options: {
inputCwd: string;
projectId?: string;
repoRoot: string;
worktree: WorktreeConfig;
baseBranch?: string | null;
title?: string | null;
deps: Pick<
CreatePaseoWorktreeDeps,
@@ -244,6 +260,7 @@ async function upsertWorkspaceForWorktree(options: {
kind: "worktree",
displayName: options.worktree.branchName || normalizedCwd,
branch: options.worktree.branchName || null,
baseBranch: options.baseBranch ?? null,
title: options.title ?? null,
createdAt: now,
updatedAt: now,

View File

@@ -3503,6 +3503,7 @@ export class Session {
try {
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
await this.unarchiveOwningWorkspaceForAgent(agentId);
let snapshot: ManagedAgent;
const existing = this.agentManager.getAgent(agentId);
if (existing) {
@@ -7066,6 +7067,23 @@ export class Session {
};
}
private async unarchiveOwningWorkspaceForAgent(agentId: string): Promise<void> {
const record = await this.agentStorage.get(agentId);
if (!record?.workspaceId) {
return;
}
const directoryExists = await this.filesystem.isDirectory(record.cwd).catch(() => false);
if (!directoryExists) {
return;
}
const workspace = await this.workspaceRegistry.get(record.workspaceId);
if (!workspace?.archivedAt) {
return;
}
await this.ensureWorkspaceRecordUnarchived(workspace);
await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]);
}
private async ensureWorkspaceRecordUnarchived(
workspace: PersistedWorkspaceRecord,
): Promise<PersistedWorkspaceRecord> {

View File

@@ -83,6 +83,8 @@ interface SessionTestAccess {
};
agentManager: {
listAgents(): unknown[];
getAgent(agentId: string): unknown;
reloadAgentSession(agentId: string, overrides?: unknown, options?: unknown): Promise<unknown>;
listImportableSessions(options?: unknown): Promise<unknown[]>;
importProviderSession(input: unknown): Promise<unknown>;
resumeAgentFromPersistence(
@@ -147,6 +149,9 @@ interface SessionTestAccess {
peekSnapshot: (cwd: string) => WorkspaceGitRuntimeSnapshot | null;
registerWorkspace: (params: { cwd: string }, listener: unknown) => { unsubscribe: () => void };
};
filesystem: {
isDirectory(cwd: string): Promise<boolean>;
};
}
interface ListFetchResult {
@@ -4040,6 +4045,193 @@ test("open_project_request unarchives an existing archived workspace and project
expect(response?.payload.workspace?.id).toBe(workspaceId);
});
test("refresh_agent_request unarchives the owning workspace when its directory exists", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
onMessage: (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
},
});
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = path.resolve("/tmp/paseo-unit2-existing-dir");
session.filesystem.isDirectory = async () => true;
const workspaceId = "ws-repo-archived";
const agentId = "agent-archived";
projects.set(
cwd,
createPersistedProjectRecord({
projectId: cwd,
rootPath: cwd,
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
}),
);
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: cwd,
cwd,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
}),
);
const storedAgent: StoredAgentRecord = {
...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }),
workspaceId,
archivedAt: "2026-03-10T00:00:00.000Z",
};
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
workspaces.set(record.workspaceId, record);
};
session.projectRegistry.list = async () => Array.from(projects.values());
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
session.agentStorage.upsert = async () => {};
const managed = makeManagedAgent({
id: agentId,
cwd,
workspaceId,
lifecycle: "idle",
updatedAt: "2026-03-10T00:00:00.000Z",
});
session.agentManager.getAgent = () => managed;
session.interruptAgentIfRunning = async () => undefined;
session.agentManager.reloadAgentSession = async () => managed;
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
session.agentManager.getTimeline = () => [];
session.forwardAgentUpdate = async () => undefined;
const unarchivedWorkspaceIds: string[][] = [];
const realEmit = session.emitWorkspaceUpdatesForWorkspaceIds.bind(session);
session.emitWorkspaceUpdatesForWorkspaceIds = async (ids: string[], ...rest: unknown[]) => {
unarchivedWorkspaceIds.push(ids);
return realEmit(ids, ...rest);
};
await session.handleMessage({
type: "refresh_agent_request",
agentId,
requestId: "req-refresh-unarchive",
});
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
expect(projects.get(cwd)?.archivedAt).toBeNull();
expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]);
expect(findByType(emitted, "rpc_error")).toBeUndefined();
});
test("refresh_agent_request leaves the owning workspace archived when its directory is missing", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
onMessage: (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
},
});
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = path.resolve("/tmp/paseo-missing-workspace-dir");
session.filesystem.isDirectory = async () => false;
const workspaceId = "ws-missing-dir";
const agentId = "agent-missing-dir";
projects.set(
cwd,
createPersistedProjectRecord({
projectId: cwd,
rootPath: cwd,
kind: "non_git",
displayName: "missing",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
}),
);
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: cwd,
cwd,
kind: "directory",
displayName: "missing",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
}),
);
const storedAgent: StoredAgentRecord = {
...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }),
workspaceId,
archivedAt: "2026-03-10T00:00:00.000Z",
};
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
workspaces.set(record.workspaceId, record);
};
session.projectRegistry.list = async () => Array.from(projects.values());
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
session.agentStorage.upsert = async () => {};
const managed = makeManagedAgent({
id: agentId,
cwd,
workspaceId,
lifecycle: "idle",
updatedAt: "2026-03-10T00:00:00.000Z",
});
session.agentManager.getAgent = () => managed;
session.interruptAgentIfRunning = async () => undefined;
session.agentManager.reloadAgentSession = async () => managed;
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
session.agentManager.getTimeline = () => [];
session.forwardAgentUpdate = async () => undefined;
await session.handleMessage({
type: "refresh_agent_request",
agentId,
requestId: "req-refresh-missing-dir",
});
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
});
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();

View File

@@ -45,6 +45,14 @@ const PersistedWorkspaceRecordSchema = z.object({
.nullable()
.optional()
.transform((value) => value ?? null),
// The base branch the worktree was created from (normalized like worktree.json's
// baseRefName). Only worktree workspaces carry a base branch; checkout-branch
// worktrees and directory/local_checkout workspaces leave it null.
baseBranch: z
.string()
.nullable()
.optional()
.transform((value) => value ?? null),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
@@ -245,6 +253,7 @@ export function createPersistedWorkspaceRecord(input: {
displayName: string;
title?: string | null;
branch?: string | null;
baseBranch?: string | null;
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
@@ -253,6 +262,7 @@ export function createPersistedWorkspaceRecord(input: {
...input,
title: input.title ?? null,
branch: input.branch ?? null,
baseBranch: input.baseBranch ?? null,
archivedAt: input.archivedAt ?? null,
});
}