fix(sessions): recover sessions whose directory disappeared (#3365)

* fix(sessions): keep a shared chat directory until its last session is deleted

Deleting a root chat session removed its managed scratch directory even
when forks, side threads, or subagents still lived in it; OpenCode then
failed every prompt in those sessions with FileSystem.realPath NotFound.
The directory is now removed only once no other known session resolves
to it. The deleted subtree does not count, because the server cascade-
deletes it, and an unloaded global cache keeps the directory instead of
guessing.

Closes #3312.

* fix(sessions): relocate a session whose worktree directory disappeared

A worktree removed outside OpenChamber, by the agent or by hand, left its
sessions pointed at a path that no longer exists: every terminal create
and restart failed with "Invalid working directory" and the tab stayed
stuck, while Git, Files, and prompts kept targeting the dead path.

The terminal server now names that one rejection (TERMINAL_CWD_MISSING)
instead of substituting a directory of its own. The shared UI reuses the
archived-restore fallback for live sessions: a server-confirmed missing
directory moves the session and its stranded subtree to the project's
primary directory through the control-plane move, clears the worktree
hint, re-selects the session, and tells the user where it went. It runs
from a terminal failure and on activation of any session whose directory
is neither a project root nor a managed chat directory; available,
unknown, and failed probes leave everything untouched.

Closes #3338.

* fix(scripts): make oc-dev load again after the changelog cleanup

The changelog cleanup referenced fs.existsSync in a module that imports
existsSync by name and never binds fs, so every oc-dev invocation failed
with "fs is not defined" before reaching its action.

* fix(sessions): probe directory availability on disk, not through OpenCode path resolution

OpenCode's /path never checks that a directory exists: it echoes the
requested path and resolves its project through Git discovery that
swallows errors, so a deleted worktree came back as a valid location and
every missing-directory fallback (draft recovery, archived restore,
session relocation) stayed inert on a real server. The probe now asks
OpenChamber's own /api/fs/list, which stats the path and reports
not-found and not-directory explicitly; anything else stays unknown.

* fix(sidebar): keep a worktree whose directory is gone visible as missing

git keeps a worktree registered after its directory is deleted outside
git and marks it prunable; the list parser ignored that line, so a
deleted worktree looked alive, and nothing in the app asked for a new
listing anyway. The server now reports prunable, the UI keeps such a
worktree in the topology with worktreeStatus missing and a warning icon
on its sidebar group, and relocating a session out of a confirmed-
missing directory raises an in-app topology signal the sidebar
rediscovers on. Dropping the worktree instead would hide every session
that lived there, and a hidden session can never be opened or relocated.
No idle polling is added.

* fix(sessions): never relocate a session to the filesystem root

OpenCode files a directory outside any Git repository under its global
project, whose worktree is the filesystem root. A managed chat whose
directory vanished would otherwise be moved to /. The relocation now
refuses a root destination, and the activation probe recognizes chat
directories through the home-based check as well, so it does not depend
on the chats root having been resolved yet.

* test(sessions): mirror the relocation action in the issue-2039 session-actions mock

session-ui-store now imports relocateSessionFromMissingDirectory, and the
mocked module in this test listed every other action but not that one, so
the file failed on import.
This commit is contained in:
𝖎𝖚𝖑𝖎𝖎𝖆
2026-09-05 21:26:21 +03:00
committed by GitHub
parent 7308b90670
commit 759af5a77d
36 changed files with 906 additions and 57 deletions
@@ -680,6 +680,7 @@ export const Header: React.FC = () => {
if (!worktreeAttachment) return null;
return formatSessionWorktreeBadge(worktreeAttachment, {
pending: t('gitView.empty.worktreeSetupInProgress'),
missing: t('sessions.sidebar.group.worktreeMissing'),
});
}, [t, worktreeAttachment]);
@@ -28,6 +28,7 @@ import { useShallow } from 'zustand/react/shallow';
import {
listProjectWorktrees,
partitionWorktreesByRegisteredProject,
subscribeWorktreeTopologyChanged,
worktreeMapsEqual,
} from '@/lib/worktrees/worktreeManager';
import { checkIsGitRepository } from '@/lib/gitApi';
@@ -323,6 +324,11 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
});
}, [isVSCode]);
React.useEffect(() => {
if (isVSCode) return;
return subscribeWorktreeTopologyChanged(() => requestWorktreeDiscovery());
}, [isVSCode]);
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
const { isTablet } = useDeviceInfo();
@@ -67,6 +67,7 @@ make every row observe unrelated streaming updates.
- Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics.
- Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling.
- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes do not trigger a rebuild. The separate lifecycle rank invalidates ordering only on `settled ↔ active` transitions, with root sessions ranked among roots and child sessions only among siblings of the same parent.
- A worktree git still registers but whose directory is gone (`prunable` in `git worktree list`) stays in the topology with `worktreeStatus: 'missing'` and a warning icon on its group header. Dropping it would hide every session that lived there, and a hidden session cannot be opened, so it could never be relocated. Opening one of those sessions relocates it to the project root (`recoverMissingSessionDirectory`), and the empty group is removed through the ordinary worktree delete action, which `git worktree remove --force` accepts for a missing directory. Topology refresh stays event-driven: besides `session-created`, the sidebar rediscovers on `subscribeWorktreeTopologyChanged`, which the relocation raises after the server confirmed a directory missing. No idle polling is added.
- Opening the root-session `Move to worktree` submenu force-refreshes the owning project's worktree topology so externally created worktrees appear without a full reload. While that refresh runs, the menu keeps the last known primary/linked topology visible; if the refresh fails, the stale topology remains and the load failure state stays explicit. Failure cleanup never removes or manages an existing destination worktree.
- CLI/server-created sessions use the low-frequency OpenChamber control event stream to refresh only the created session directory. The same event retriggers bounded worktree discovery so a newly created external worktree gains ownership without a view reload; it does not re-enable broad session or streaming subscriptions.
- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders.
@@ -901,6 +901,18 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
// Reserve room for the hover-revealed header actions (new draft + delete
// worktree) so they never overlap the label / PR badge.
const hasWorktreeDeleteAction = Boolean(!group.isMain && group.worktree);
// git still registers this worktree but its directory is gone. The group
// stays so its sessions remain reachable (opening one relocates it); the
// icon tells the user why the folder is not there.
const worktreeMissingIndicator = group.worktree?.worktreeStatus === 'missing' ? (
<span
className="inline-flex flex-shrink-0 items-center text-status-warning"
title={t('sessions.sidebar.group.worktreeMissing')}
aria-label={t('sessions.sidebar.group.worktreeMissing')}
>
<Icon name="alert" className="h-3 w-3" />
</span>
) : null;
const groupHeaderRightPadding = alwaysShowActions
? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7')
: (hasWorktreeDeleteAction
@@ -1147,6 +1159,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
</span>
</span>
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
{worktreeMissingIndicator}
{groupActivityIndicator}
</span>
) : (!group.isMain || group.worktree) ? (
@@ -1168,6 +1181,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
<span className="min-w-0 truncate typography-ui-label font-semibold text-muted-foreground">
{renderHighlightedText(group.label, normalizedSessionSearchQuery)}
</span>
{worktreeMissingIndicator}
{groupActivityIndicator}
{groupPrSummary ? (
<span
@@ -17,6 +17,7 @@ import { Icon } from "@/components/icon/Icon";
import type { IconName } from '@/components/icon/icons';
import { useDeviceInfo } from '@/lib/device';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { isTerminalCwdMissingError } from '@/lib/terminalApi';
import { extractTerminalPreviewUrl, isTerminalPreviewUrlAvailable } from '@/lib/terminalPreview';
import { useI18n } from '@/lib/i18n';
import { PROJECT_ACTION_ICONS } from '@/lib/projectActions';
@@ -39,6 +40,14 @@ const resolveTabIconName = (iconKey: string | null): IconName => {
export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }) => {
const { t } = useI18n();
const { terminal, runtime } = useRuntimeAPIs();
// The server rejects a working directory that no longer exists (a worktree
// deleted outside OpenChamber). The session is what is stranded, not the
// terminal: relocating it to its project changes the effective directory,
// and this view then starts a terminal there on its own.
const recoverCurrentSessionDirectory = React.useCallback(() => {
const sessionId = useSessionUIStore.getState().currentSessionId;
if (sessionId) void useSessionUIStore.getState().recoverMissingSessionDirectory(sessionId);
}, []);
const { currentTheme } = useThemeSystem();
const terminalAppearanceRef = React.useRef<{ themeMode: 'light' | 'dark'; terminalBackground: string; terminalForeground: string }>({ themeMode: 'dark', terminalBackground: '', terminalForeground: '' });
terminalAppearanceRef.current = { themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark', terminalBackground: currentTheme.colors.surface.background, terminalForeground: currentTheme.colors.syntax.base.foreground };
@@ -537,6 +546,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
// this tab stopped owning the request; use current store
// ownership so a rejected create cannot leave it spinning.
if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return;
if (isTerminalCwdMissingError(error)) recoverCurrentSessionDirectory();
setConnectionError(
error instanceof Error
? error.message
@@ -580,6 +590,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
setTabSessionId,
startStream,
disconnectStream,
recoverCurrentSessionDirectory,
t,
terminal,
terminalLoginShell,
@@ -643,6 +654,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
|| directoryRef.current !== terminalDirectory
|| activeTabIdRef.current !== tabId
) return;
if (isTerminalCwdMissingError(error)) recoverCurrentSessionDirectory();
setConnectionError(
error instanceof Error ? error.message : t('terminalView.error.restartFailed')
);
@@ -653,7 +665,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
} finally {
setIsRestarting(false);
}
}, [activeTabId, disconnectStream, terminalDirectory, enableTabs, isActionTab, isRestarting, resetTerminalPreviewScan, setTabLifecycle, setTabSessionId, startStream, t, terminal, terminalLoginShell, terminalShell]);
}, [activeTabId, disconnectStream, terminalDirectory, enableTabs, isActionTab, isRestarting, recoverCurrentSessionDirectory, resetTerminalPreviewScan, setTabLifecycle, setTabSessionId, startStream, t, terminal, terminalLoginShell, terminalShell]);
const handleHardRestart = React.useCallback(async () => {
// Keep semantics: “close tab -> new clean tab”.