From 759af5a77d71cb7bd9598e06b760ec7c3b182e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=96=8E=F0=9D=96=9A=F0=9D=96=91=F0=9D=96=8E?= =?UTF-8?q?=F0=9D=96=8E=F0=9D=96=86?= Date: Sat, 5 Sep 2026 21:26:21 +0300 Subject: [PATCH] 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. --- packages/ui/src/components/layout/Header.tsx | 1 + .../src/components/session/SessionSidebar.tsx | 6 + .../session/sidebar/DOCUMENTATION.md | 1 + .../sidebar/projects/SessionGroupSection.tsx | 14 ++ .../ui/src/components/views/TerminalView.tsx | 14 +- packages/ui/src/lib/api/types.ts | 2 + packages/ui/src/lib/i18n/messages/de.ts | 2 + packages/ui/src/lib/i18n/messages/en.ts | 2 + packages/ui/src/lib/i18n/messages/es.ts | 2 + packages/ui/src/lib/i18n/messages/fr.ts | 2 + packages/ui/src/lib/i18n/messages/ja.ts | 2 + packages/ui/src/lib/i18n/messages/ko.ts | 2 + packages/ui/src/lib/i18n/messages/pl.ts | 2 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 + packages/ui/src/lib/i18n/messages/tr.ts | 2 + packages/ui/src/lib/i18n/messages/uk.ts | 2 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 + packages/ui/src/lib/opencode/client.test.ts | 43 +++- packages/ui/src/lib/opencode/client.ts | 34 ++- packages/ui/src/lib/terminalApi.test.ts | 21 +- packages/ui/src/lib/terminalApi.ts | 26 +- .../src/lib/worktrees/worktreeManager.test.ts | 51 ++++ .../ui/src/lib/worktrees/worktreeManager.ts | 31 ++- packages/ui/src/sync/DOCUMENTATION.md | 27 ++- .../ui/src/sync/__tests__/issue-2039.test.ts | 1 + packages/ui/src/sync/session-actions.test.ts | 224 +++++++++++++++++- packages/ui/src/sync/session-actions.ts | 134 +++++++++-- packages/ui/src/sync/session-ui-store.test.js | 147 ++++++++++++ packages/ui/src/sync/session-ui-store.ts | 77 +++++- .../ui/src/sync/session-worktree-contract.ts | 4 +- packages/web/server/lib/git/service.js | 10 + packages/web/server/lib/git/service.test.js | 18 ++ .../web/server/lib/terminal/DOCUMENTATION.md | 1 + packages/web/server/lib/terminal/runtime.js | 18 +- .../web/server/lib/terminal/runtime.test.js | 34 +++ 36 files changed, 906 insertions(+), 57 deletions(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 408f1236..3f2af834 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -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]); diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 48521e5f..352153ee 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -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 = ({ }); }, [isVSCode]); + React.useEffect(() => { + if (isVSCode) return; + return subscribeWorktreeTopologyChanged(() => requestWorktreeDiscovery()); + }, [isVSCode]); + const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); const { isTablet } = useDeviceInfo(); diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 5f2e8cb4..f11da0cc 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -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. diff --git a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx index 079b433d..e92a9d2b 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx @@ -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' ? ( + + + + ) : null; const groupHeaderRightPadding = alwaysShowActions ? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7') : (hasWorktreeDeleteAction @@ -1147,6 +1159,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo {renderHighlightedText(group.label, normalizedSessionSearchQuery)} + {worktreeMissingIndicator} {groupActivityIndicator} ) : (!group.isMain || group.worktree) ? ( @@ -1168,6 +1181,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo {renderHighlightedText(group.label, normalizedSessionSearchQuery)} + {worktreeMissingIndicator} {groupActivityIndicator} {groupPrSummary ? ( { export const TerminalView: React.FC = ({ 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 = ({ 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 = ({ visible, directory } setTabSessionId, startStream, disconnectStream, + recoverCurrentSessionDirectory, t, terminal, terminalLoginShell, @@ -643,6 +654,7 @@ export const TerminalView: React.FC = ({ 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 = ({ 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”. diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 48b43406..c94ded88 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -407,6 +407,8 @@ export interface GitWorktreeInfo { name: string; branch: string; path: string; + /** git still registers the worktree, but its directory is gone (deleted outside git). */ + prunable?: boolean; } export interface GitWorktreeValidationError { diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index cf7ade00..2f010d06 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -609,6 +609,8 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Angefügter Worktree archiviert.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Angefügte Worktrees archiviert.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Archivierte Worktrees und entfernte Remote-Branches.', + 'sessions.missingDirectory.movedToProject': 'Der Ordner dieser Sitzung existiert nicht mehr. Die Sitzung wurde nach {project} verschoben.', + 'sessions.sidebar.group.worktreeMissing': 'Worktree-Ordner fehlt', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Worktree-Pfad nicht verfügbar.', 'sessions.sidebar.sessionDialogs.worktree.uncommittedWarning': 'Nicht committete Änderungen werden verworfen.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 3de5182c..bcde7971 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -705,6 +705,8 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Attached worktree archived.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Attached worktrees archived.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Archived worktrees and removed remote branches.', + 'sessions.missingDirectory.movedToProject': 'This session\'s folder no longer exists. The session was moved to {project}.', + 'sessions.sidebar.group.worktreeMissing': 'Worktree folder is missing', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Worktree path unavailable.', 'sessions.sidebar.sessionDialogs.worktree.uncommittedWarning': 'Uncommitted changes will be discarded.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 622ca275..47889130 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -706,6 +706,8 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Worktree adjunto archivado.", "sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural": "Worktrees adjuntos archivados.", "sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved": "Worktrees archivados y ramas remotas eliminadas.", + "sessions.missingDirectory.movedToProject": "La carpeta de esta sesión ya no existe. La sesión se movió a {project}.", + "sessions.sidebar.group.worktreeMissing": "Falta la carpeta del worktree", "sessions.sidebar.sessionDialogs.worktree.label": "Worktree", "sessions.sidebar.sessionDialogs.worktree.pathUnavailable": "Ruta de worktree no disponible.", "sessions.sidebar.sessionDialogs.worktree.uncommittedWarning": "Los cambios sin commit se perderán.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 41b228dc..bc55cbcc 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -534,6 +534,8 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Worktree ci-joint archivé.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Worktrees joints archivés.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Worktrees archivés et branches du dépôt distant supprimées.', + 'sessions.missingDirectory.movedToProject': 'Le dossier de cette session n\'existe plus. La session a été déplacée vers {project}.', + 'sessions.sidebar.group.worktreeMissing': 'Le dossier du worktree est introuvable', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Chemin du worktree indisponible.', 'sessions.sidebar.sessionDialogs.worktree.uncommittedWarning': 'Les modifications non validées seront ignorées.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 388a398a..d72c7dd8 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -706,6 +706,8 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '添付のワークツリーをアーカイブしました。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '添付のワークツリーをアーカイブしました。', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'ワークツリーをアーカイブし、リモートブランチを削除しました。', + 'sessions.missingDirectory.movedToProject': 'このセッションのフォルダーは存在しません。セッションを {project} に移動しました。', + 'sessions.sidebar.group.worktreeMissing': 'ワークツリーのフォルダーがありません', 'sessions.sidebar.sessionDialogs.worktree.label': 'ワークツリー', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'ワークツリーパスは利用できません。', 'sessions.sidebar.sessionDialogs.worktree.uncommittedWarning': '未コミットの変更は破棄されます。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 79cfe10a..12b09b7b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -706,6 +706,8 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '첨부됨 워크트리 보관됨.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '첨부됨 워크트리 보관됨.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': '워크트리가 보관되고 리모트 브랜치가 제거되었습니다.', + 'sessions.missingDirectory.movedToProject': '이 세션의 폴더가 더 이상 존재하지 않습니다. 세션을 {project}(으)로 이동했습니다.', + 'sessions.sidebar.group.worktreeMissing': '워크트리 폴더가 없습니다', 'sessions.sidebar.sessionDialogs.worktree.label': '워크트리', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': '워크트리 경로를 사용할 수 없습니다.', 'sessions.sidebar.sessionDialogs.worktree.uncommittedWarning': '커밋하지 않은 변경 사항은 버려집니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 779c5cf6..2353e4ac 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -706,6 +706,8 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Dołączone drzewo pracy zarchiwizowane.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Dołączone drzewa pracy zarchiwizowane.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Zarchiwizowane drzewa pracy i usunięte zdalne gałęzie.', + 'sessions.missingDirectory.movedToProject': 'Folder tej sesji już nie istnieje. Sesja została przeniesiona do {project}.', + 'sessions.sidebar.group.worktreeMissing': 'Brak folderu worktree', 'sessions.sidebar.sessionDialogs.worktree.label': 'Drzewo pracy', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Ścieżka drzewa pracy niedostępna.', 'sessions.sidebar.sessionDialogs.worktree.uncommittedWarning': 'Niezatwierdzone zmiany zostaną odrzucone.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index df8cc2d1..6dae93be 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -706,6 +706,8 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Worktree adjunto archivado.", "sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural": "Worktrees adjuntos archivados.", "sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved": "Worktrees archivados e branches remotas excluídas.", + "sessions.missingDirectory.movedToProject": "A pasta desta sessão não existe mais. A sessão foi movida para {project}.", + "sessions.sidebar.group.worktreeMissing": "A pasta do worktree está ausente", "sessions.sidebar.sessionDialogs.worktree.label": "Worktree", "sessions.sidebar.sessionDialogs.worktree.pathUnavailable": "Caminho de worktree não disponível.", "sessions.sidebar.sessionDialogs.worktree.uncommittedWarning": "As alterações sem commit serão perdidas.", diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 75f8729d..76275cb1 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -687,6 +687,8 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Bağlı worktree arşivlendi.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Bağlı worktree\'ler arşivlendi.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Worktree\'ler arşivlendi ve uzak branch\'ler kaldırıldı.', + 'sessions.missingDirectory.movedToProject': 'Bu oturumun klasörü artık mevcut değil. Oturum {project} konumuna taşındı.', + 'sessions.sidebar.group.worktreeMissing': 'Worktree klasörü eksik', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Worktree yolu kullanılamıyor.', 'sessions.sidebar.sessionDialogs.worktree.uncommittedWarning': 'Commit edilmemiş değişiklikler atılacak.', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 784acd6a..8f06b3be 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -706,6 +706,8 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Прикріплене worktree заархівовано.", "sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural": "Прикріплені worktree заархівовано.", "sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved": "Worktree заархівовано, віддалені гілки видалено.", + "sessions.missingDirectory.movedToProject": "Теки цієї сесії більше не існує. Сесію перенесено до {project}.", + "sessions.sidebar.group.worktreeMissing": "Теки worktree немає", "sessions.sidebar.sessionDialogs.worktree.label": "Worktree", "sessions.sidebar.sessionDialogs.worktree.pathUnavailable": "Шлях worktree недоступний.", "sessions.sidebar.sessionDialogs.worktree.uncommittedWarning": "Незакомічені зміни буде скасовано.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 9df5dd3a..7316dde3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -706,6 +706,8 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '关联工作树已归档。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '关联工作树已归档。', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': '工作树已归档且远程分支已移除。', + 'sessions.missingDirectory.movedToProject': '此会话的文件夹已不存在。会话已移至 {project}。', + 'sessions.sidebar.group.worktreeMissing': '工作树文件夹缺失', 'sessions.sidebar.sessionDialogs.worktree.label': '工作树', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': '工作树路径不可用。', 'sessions.sidebar.sessionDialogs.worktree.uncommittedWarning': '未提交的更改将被丢弃。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index fb537bd7..e89bd46d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -719,6 +719,8 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '關聯 worktree 已封存。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '關聯 worktree 已封存。', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'worktree 已封存且遠端分支已移除。', + 'sessions.missingDirectory.movedToProject': '此工作階段的資料夾已不存在。工作階段已移至 {project}。', + 'sessions.sidebar.group.worktreeMissing': '工作樹資料夾遺失', 'sessions.sidebar.sessionDialogs.worktree.label': 'worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'worktree 路徑無法使用。', 'sessions.sidebar.sessionDialogs.worktree.uncommittedWarning': '未提交的變更將被捨棄。', diff --git a/packages/ui/src/lib/opencode/client.test.ts b/packages/ui/src/lib/opencode/client.test.ts index c5455c3a..2be89a52 100644 --- a/packages/ui/src/lib/opencode/client.test.ts +++ b/packages/ui/src/lib/opencode/client.test.ts @@ -18,7 +18,9 @@ const promptAsyncMock = mock(async (...args: unknown[]) => { return next ?? { response: new Response(null, { status: 200 }) }; }); +let pathGetCalls = 0; const pathGetMock = mock(async () => { + pathGetCalls += 1; const next = pathGetResults.shift(); if (next instanceof Error) throw next; return next ?? { data: { directory: '/workspace/project' } }; @@ -58,16 +60,22 @@ mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: mock(() => runtimeKey), })); +type DirectoryProbeQuery = { path?: string }; +const runtimeFetchCalls: Array<{ path: string; query: DirectoryProbeQuery | undefined }> = []; +const runtimeFetchResults: Array = []; const fsHomeResponses: Array = []; mock.module('@/lib/runtime-fetch', () => ({ - runtimeFetch: mock(async (input: string | URL | Request) => { + runtimeFetch: mock(async (input: string | URL | Request, init?: { query?: DirectoryProbeQuery }) => { if (typeof input === 'string' && input.includes('/fs/home')) { const next = fsHomeResponses.shift(); if (next instanceof Error) throw next; if (next) return next; } - return new Response(JSON.stringify([]), { + if (typeof input === 'string') runtimeFetchCalls.push({ path: input, query: init?.query }); + const next = runtimeFetchResults.shift(); + if (next instanceof Error) throw next; + return next ?? new Response(JSON.stringify([]), { headers: { 'Content-Type': 'application/json' }, }); }), @@ -84,15 +92,40 @@ beforeEach(() => { promptAsyncCalls.length = 0; promptAsyncResults.length = 0; pathGetResults.length = 0; + pathGetCalls = 0; + runtimeFetchCalls.length = 0; + runtimeFetchResults.length = 0; fsHomeResponses.length = 0; }); describe('opencodeClient directory availability', () => { - test('distinguishes a missing directory from an unavailable path probe', async () => { - pathGetResults.push({ error: { code: 'ENOENT', message: 'no such file or directory' } }); + type ProbeBody = { error?: string; reason?: string; entries?: never[] }; + const json = (status: number, body: ProbeBody): Response => new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + + test('stats the directory through the OpenChamber filesystem route, never through OpenCode path resolution', async () => { + runtimeFetchResults.push(json(200, { entries: [] })); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('available'); + expect(runtimeFetchCalls).toEqual([{ path: '/api/fs/list', query: { path: '/private/deleted-worktree' } }]); + expect(pathGetCalls).toBe(0); + }); + + test('distinguishes a missing directory from an unavailable probe', async () => { + runtimeFetchResults.push(json(404, { error: 'Directory not found', reason: 'not-found' })); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('missing'); - pathGetResults.push(new Error('offline')); + runtimeFetchResults.push(json(400, { error: 'Specified path is not a directory', reason: 'not-directory' })); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('missing'); + + runtimeFetchResults.push(json(404, { error: 'Not Found' })); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); + + runtimeFetchResults.push(json(500, { error: 'Failed to list directory' })); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); + + runtimeFetchResults.push(new Error('offline')); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); }); }); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 20e99eec..410e9165 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -71,19 +71,8 @@ type SdkResult = { }; type DirectoryAvailability = "available" | "missing" | "unknown"; +const directoryProbeErrorSchema = z.object({ reason: z.string().optional() }); -const isMissingDirectoryError = (error: unknown): boolean => { - if (error instanceof FilesystemError) { - return error.reason === "not-found" || error.reason === "not-directory"; - } - if (error && typeof error === "object") { - const code = (error as { code?: unknown }).code; - if (code === "ENOENT" || code === "ENOTDIR") { - return true; - } - } - return /\bENOENT\b|\bENOTDIR\b|no such file or directory/i.test(formatSdkError(error)); -}; function unwrapSdkData(result: SdkResult, operation: string): T { if (result.error) { @@ -611,6 +600,12 @@ class OpencodeService { * Distinguishes a confirmed-missing directory from an unavailable probe. * Offline, permission, and other transport failures stay `unknown` so callers * do not treat a temporary outage as proof the path was deleted. + * + * The probe is OpenChamber's own `/api/fs/list`, which stats the path on the + * server's disk. OpenCode's `/path` cannot answer this question: it echoes + * the requested directory and resolves its project through Git discovery + * that swallows errors, so a deleted worktree still comes back as a valid + * location. A runtime without that route (VS Code) answers `unknown`. */ async getDirectoryAvailability(directory: string): Promise { const normalized = this.normalizeCandidatePath(directory); @@ -618,14 +613,13 @@ class OpencodeService { return "unknown"; } try { - const response = await this.client.path.get({ directory: normalized }) as SdkResult<{ directory?: unknown }>; - if (response.error) { - return isMissingDirectoryError(response.error) ? "missing" : "unknown"; - } - const returned = typeof response.data?.directory === "string" ? response.data.directory.trim() : ""; - return returned ? "available" : "unknown"; - } catch (error) { - return isMissingDirectoryError(error) ? "missing" : "unknown"; + const response = await runtimeFetch("/api/fs/list", { query: { path: normalized } }); + if (response.ok) return "available"; + const body = directoryProbeErrorSchema.safeParse(await response.json().catch(() => null)).data; + const reason = parseFilesystemErrorReason(body?.reason); + return reason === "not-found" || reason === "not-directory" ? "missing" : "unknown"; + } catch { + return "unknown"; } } diff --git a/packages/ui/src/lib/terminalApi.test.ts b/packages/ui/src/lib/terminalApi.test.ts index a6cc7c78..01141946 100644 --- a/packages/ui/src/lib/terminalApi.test.ts +++ b/packages/ui/src/lib/terminalApi.test.ts @@ -2,7 +2,8 @@ import { describe, expect, mock, test } from 'bun:test'; import type { TerminalSessionPurpose, TerminalStreamEvent } from './api/types'; import type { RelayTunnelWebSocket } from './relay/tunnel-client'; -mock.module('./runtime-fetch', () => ({ runtimeFetch: async () => new Response(null, { status: 500 }) })); +let nextFetchResponse = (): Response => new Response(null, { status: 500 }); +mock.module('./runtime-fetch', () => ({ runtimeFetch: async () => nextFetchResponse() })); mock.module('./runtime-url', () => ({ getRuntimeUrlResolver: () => ({ websocket: () => 'ws://example.test/terminal' }) })); mock.module('./runtime-auth', () => ({ clearRuntimeUrlAuthToken: () => undefined, @@ -10,7 +11,7 @@ mock.module('./runtime-auth', () => ({ })); mock.module('./relay/runtime-socket', () => ({ openRuntimeWebSocket: () => { throw new Error('not used in tests'); } })); -const { parseTerminalSession, parseTerminalSessionPurpose, TerminalTransport } = await import('./terminalApi'); +const { createTerminalSession, isTerminalCwdMissingError, parseTerminalSession, parseTerminalSessionPurpose, TerminalRequestError, TerminalTransport } = await import('./terminalApi'); const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -103,6 +104,22 @@ describe('terminal transport', () => { })).toBeNull(); }); + test('surfaces the server error code so a missing working directory is recoverable', async () => { + const options = { cwd: '/repo/.worktrees/gone', cols: 80, rows: 24 }; + nextFetchResponse = () => new Response(JSON.stringify({ error: 'Invalid working directory', code: 'TERMINAL_CWD_MISSING' }), { status: 400, headers: { 'content-type': 'application/json' } }); + try { + await expect(createTerminalSession(options)).rejects.toThrow(TerminalRequestError); + await expect(createTerminalSession(options)).rejects.toThrow('Invalid working directory'); + expect(await createTerminalSession(options).then(() => false, isTerminalCwdMissingError)).toBe(true); + + nextFetchResponse = () => new Response(JSON.stringify({ error: 'Invalid working directory' }), { status: 400, headers: { 'content-type': 'application/json' } }); + await expect(createTerminalSession(options)).rejects.toThrow(TerminalRequestError); + expect(await createTerminalSession(options).then(() => false, isTerminalCwdMissingError)).toBe(false); + } finally { + nextFetchResponse = () => new Response(null, { status: 500 }); + } + }); + test('hydrates simultaneous subscribers and rejects duplicate sequences', async () => { const socket = new FakeSocket(); const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket }); diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 7b98be87..2ae2036e 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -107,9 +107,31 @@ const decode = (data: RelayTunnelSocketMessageEvent['data']): TerminalMessage | try { return terminalMessageSchema.safeParse(JSON.parse(decoder.decode(bytes))).data ?? null; } catch { return null; } }; +/** + * Server error code for a terminal request whose working directory no longer + * exists (a deleted worktree). Mirrors `TERMINAL_CWD_MISSING_CODE` in + * `packages/web/server/lib/terminal/runtime.js`. + */ +const TERMINAL_CWD_MISSING_CODE = 'TERMINAL_CWD_MISSING'; + +export class TerminalRequestError extends Error { + readonly code: string | null; + + constructor(message: string, code: string | null) { + super(message); + this.name = 'TerminalRequestError'; + this.code = code; + } +} + +export const isTerminalCwdMissingError = (error: unknown): boolean => + error instanceof TerminalRequestError && error.code === TERMINAL_CWD_MISSING_CODE; + +const terminalErrorBodySchema = z.object({ error: z.string().optional(), code: z.string().optional() }); + const responseError = async (response: Response, fallback: string): Promise => { - const body = await response.json().catch(() => null) as { error?: unknown } | null; - return new Error(typeof body?.error === 'string' ? body.error : fallback); + const body = terminalErrorBodySchema.safeParse(await response.json().catch(() => null)).data; + return new TerminalRequestError(body?.error ?? fallback, body?.code ?? null); }; const trimProjection = (value: string): string => { diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index dc7bec50..aab8f46e 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -117,8 +117,10 @@ const { createWorktree, getLatestWorktreeMetadata, listProjectWorktrees, + notifyWorktreeTopologyChanged, partitionWorktreesByRegisteredProject, removeProjectWorktree, + subscribeWorktreeTopologyChanged, validateWorktreeCreate, worktreeMapsEqual, } = await import('./worktreeManager'); @@ -646,3 +648,52 @@ describe('worktreeManager fork remote payload wiring', () => { expect('pullRequest' in created).toBe(false); }); }); + +describe('worktreeManager missing worktrees', () => { + beforeEach(() => { + listCalls.length = 0; + listResolvers.length = 0; + listRejecters.length = 0; + listImplementation = undefined; + }); + + test('keeps a prunable worktree in the topology as missing instead of dropping it', async () => { + listImplementation = async () => [ + { path: '/repo-missing/.worktrees/alive', branch: 'alive', head: 'abc', name: 'alive' }, + { path: '/repo-missing/.worktrees/gone', branch: 'gone', head: 'def', name: 'gone', prunable: true }, + ]; + + const result = await listProjectWorktrees({ id: 'project-missing', path: '/repo-missing' }, { force: true }); + + expect(result.map((entry) => [entry.path, entry.worktreeStatus])).toEqual([ + ['/repo-missing/.worktrees/alive', 'ready'], + ['/repo-missing/.worktrees/gone', 'missing'], + ]); + }); + + test('a worktree that changes only its status still counts as a topology change', () => { + const ready: WorktreeMetadata = { path: '/repo/.worktrees/a', projectDirectory: '/repo', branch: 'a', label: 'a', worktreeStatus: 'ready' }; + const missing: WorktreeMetadata = { ...ready, worktreeStatus: 'missing' }; + + expect(worktreeMapsEqual(new Map([['/repo', [ready]]]), new Map([['/repo', [ready]]]))).toBe(true); + expect(worktreeMapsEqual(new Map([['/repo', [ready]]]), new Map([['/repo', [missing]]]))).toBe(false); + }); + + test('a topology-changed signal drops the cached listing and reaches subscribers', async () => { + const project = { id: 'project-signal', path: '/repo-signal/' }; + listImplementation = async () => []; + await listProjectWorktrees(project, { force: true }); + await listProjectWorktrees(project); + expect(listCalls).toEqual(['/repo-signal']); + + const notified: string[] = []; + const unsubscribe = subscribeWorktreeTopologyChanged((directory) => notified.push(directory)); + notifyWorktreeTopologyChanged('/repo-signal/'); + unsubscribe(); + notifyWorktreeTopologyChanged('/repo-signal'); + + expect(notified).toEqual(['/repo-signal']); + await listProjectWorktrees(project); + expect(listCalls).toEqual(['/repo-signal', '/repo-signal']); + }); +}); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 0fd1953b..99caa339 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -26,6 +26,7 @@ type WorktreeListEntry = { branch?: string; head?: string; name?: string; + prunable?: boolean; }; const deriveHeadStateFromWorktreeEntry = (entry: WorktreeListEntry): 'branch' | 'detached' | 'unborn' => { @@ -44,7 +45,11 @@ const deriveCanonicalWorktreeFields = ( ): Pick => { return { worktreeRoot: worktreePath, - worktreeStatus: 'ready', + // A prunable worktree is still registered by git but its directory is + // gone. It stays in the topology as `missing` so the sessions that lived + // there keep their group in the sidebar and can be opened and relocated; + // dropping it would hide those sessions with no way back. + worktreeStatus: entry.prunable === true ? 'missing' : 'ready', headState: deriveHeadStateFromWorktreeEntry(entry), worktreeSource: 'existing', }; @@ -299,6 +304,7 @@ export const worktreeMapsEqual = ( || next.projectDirectory !== current.projectDirectory || next.worktreeRoot !== current.worktreeRoot || next.headState !== current.headState + || next.worktreeStatus !== current.worktreeStatus || next.worktreeSource !== current.worktreeSource || next.source !== current.source) return false; } @@ -394,6 +400,29 @@ const invalidateWorktreeList = (projectDirectory: string): void => { _worktreeListCache.delete(projectDirectory); }; +type WorktreeTopologyListener = (projectDirectory: string) => void; +const worktreeTopologyListeners = new Set(); + +/** + * Subscribe to in-app evidence that a project's worktree topology changed + * outside the flows that publish it themselves (a session relocated out of a + * directory the server confirmed missing). The sidebar rediscovers on this + * signal the same way it does for the server's `session-created` event, so + * the topology stays event-driven with no idle polling. + */ +export const subscribeWorktreeTopologyChanged = (listener: WorktreeTopologyListener): (() => void) => { + worktreeTopologyListeners.add(listener); + return () => { + worktreeTopologyListeners.delete(listener); + }; +}; + +export const notifyWorktreeTopologyChanged = (projectDirectory: string): void => { + const normalized = normalizePath(projectDirectory); + invalidateWorktreeList(normalized); + for (const listener of worktreeTopologyListeners) listener(normalized); +}; + const readProjectWorktrees = async (projectDirectory: string): Promise => { const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory); const normalizedProjectDirectory = normalizePath(projectDirectory); diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 4b46ffe8..49b5459f 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -382,11 +382,36 @@ reports failure instead of committing. The deletion already accepted by the server stays deleted there; its persisted state is left as harmless stale metadata and the next authoritative load reconciles it. +### Missing directory relocation (active sessions) + +The same directory can disappear under an active session: a worktree removed +by the agent or by hand leaves the session, its tabs, and its prompts pointed +at a path that no longer exists, and the terminal server answers every create +and restart with `Invalid working directory`. `relocateSessionFromMissingDirectory` +(`session-actions.ts`) applies the restore fallback's gate to a live session: +an exact `missing` probe, the destination resolved from the server `projectID`, +and `available`, `unknown`, probe failures, project-root sessions, and sessions +without a project left untouched. Every session of the root's subtree still +stranded in that directory moves with it, root first, so the session the user +is looking at is usable even when a descendant move fails; the result names +the sessions already moved. Moves carry no changes because the source is gone. + +`session-ui-store.recoverMissingSessionDirectory` owns the user-visible side: +one shared attempt per runtime and session, the worktree hint cleared for each +moved session (it is the first thing every directory lookup reads), the current +session re-selected through `setCurrentSession` so the active directory, +project, and OpenCode client follow it, and one toast naming the destination. +It runs from two places: a terminal create/restart rejected with the server's +`TERMINAL_CWD_MISSING` code, and session activation for any session whose +directory is neither a registered project root nor a managed chat directory +(the same probe a reopened draft performs on its inherited directory). VS Code +registers no worktrees, so activation never probes there. + ## The golden rule ### Managed chat directories -Ordinary user-created drafts default to the OpenChamber-managed Chat target. The first submit creates one isolated directory under the server-resolved managed chats root (`OPENCHAMBER_CHATS_DIR`, default `~/.config/openchamber/chats`) as `YYYY-MM-DD/session-` before creating the OpenCode session. That root acts as a system project owner for sidebar membership and Notes, Todo, Plans, pinned knowledge, and project memory, but it is never persisted or rendered as a user project and exposes no Git/worktree controls. Project and worktree actions remain explicit targets. Archiving retains a chat directory so restore remains lossless. Confirmed deletion accepts only descendants of the configured root or the actual server home's legacy chats root. It rejects both shared roots themselves, dot segments, lookalike paths elsewhere, and a runtime switch during root resolution. It never removes project directories. +Ordinary user-created drafts default to the OpenChamber-managed Chat target. The first submit creates one isolated directory under the server-resolved managed chats root (`OPENCHAMBER_CHATS_DIR`, default `~/.config/openchamber/chats`) as `YYYY-MM-DD/session-` before creating the OpenCode session. That root acts as a system project owner for sidebar membership and Notes, Todo, Plans, pinned knowledge, and project memory, but it is never persisted or rendered as a user project and exposes no Git/worktree controls. Project and worktree actions remain explicit targets. Archiving retains a chat directory so restore remains lossless. Confirmed deletion accepts only descendants of the configured root or the actual server home's legacy chats root. It rejects both shared roots themselves, dot segments, lookalike paths elsewhere, and a runtime switch during root resolution. It also removes a directory only once no other known session still resolves to it: forks, side threads, and subagents share the directory of the chat that created them, and OpenCode fails every prompt in a session whose directory is gone. The deleted session's own subtree does not count, because the server cascade-deletes it, and an unloaded global cache keeps the directory because it cannot prove it is unused. It never removes project directories. Typing the first character in a managed Chat draft starts one deduplicated directory preparation for that draft. Materialization consumes the prepared directory before `createSession`, removing filesystem creation from the usual submit path. Closing the draft, changing it to a project target, or completing preparation after the runtime/draft changed deletes the unclaimed directory. A create failure also deletes the consumed directory. diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index 46ca3c33..763b815d 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -309,6 +309,7 @@ mock.module("../session-actions", () => ({ unrevertSession: mock(async () => undefined), forkFromMessage: mock(async () => undefined), fetchMessagesForSession: mock(async () => undefined), + relocateSessionFromMissingDirectory: mock(async () => ({ status: "unchanged" })), getSessionLastAssistantModel: () => null, patchSessionMetadata: mock(async () => undefined), abortCurrentOperation: mock(async () => undefined), diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index 6acf1619..a68f5e81 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -21,6 +21,10 @@ 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 +let beforeDirectoryAvailabilityResolve: (() => void) | null = null +const controlPlaneMoveErrorsById = new Map() +let globalHasLoaded = true +const deletedChatDirectories: string[] = [] const globalUpsertedSessions: unknown[] = [] const globalUpsertedSessionBatches: Session[][] = [] const globalRemovedSessionIds: string[] = [] @@ -76,6 +80,8 @@ const mockSdk = { moveSession: mock((params: Record) => { replyCalls.push({ method: "controlPlane.moveSession", params }) beforeControlPlaneMoveResolve?.(String(params.sessionID)) + const error = controlPlaneMoveErrorsById.get(String(params.sessionID)) + if (error) return Promise.resolve({ error, response: { status: 500 } }) return Promise.resolve({}) }), }, @@ -153,6 +159,9 @@ const mockSdk = { } // Mock opencodeClient singleton +// SAFETY: the actions under test touch only the SDK surface mocked above. +const actionSdk = mockSdk as unknown as OpencodeClient + mock.module("@/lib/opencode/client", () => ({ opencodeClient: { getScopedSdkClient: (directory: string) => { @@ -160,7 +169,10 @@ mock.module("@/lib/opencode/client", () => ({ return mockScopedClient }, getDirectory: () => "/test/project", - getDirectoryAvailability: mock(async (directory: string) => directoryAvailability.get(directory) ?? "available"), + getDirectoryAvailability: mock(async (directory: string) => { + beforeDirectoryAvailabilityResolve?.() + return directoryAvailability.get(directory) ?? "available" + }), getFilesystemHome: mock(async () => "/home/test"), getSdkClient: () => mockSdk, getSessionMessages: mock((sessionId: string, _limit?: number, directory?: string | null) => { @@ -285,6 +297,7 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({ getState: () => ({ activeSessions: globalActiveSessions, archivedSessions: globalArchivedSessions, + hasLoaded: globalHasLoaded, upsertSession: (session: unknown) => { globalUpsertedSessions.push(session) }, @@ -383,7 +396,9 @@ mock.module("./send-failure-classification", () => ({ })) mock.module("@/lib/chatDirectories", () => ({ - deleteChatDirectory: async () => {}, + deleteChatDirectory: async (directory: string) => { + deletedChatDirectories.push(directory) + }, })) mock.module("./session-deletion-cleanup", () => ({ @@ -549,6 +564,10 @@ describe("confirmed session removal", () => { archiveBatchRequests.length = 0 archiveBatchResponse = { status: 404, body: { error: 'not found' } } beforeControlPlaneMoveResolve = null + beforeDirectoryAvailabilityResolve = null + controlPlaneMoveErrorsById.clear() + globalHasLoaded = true + deletedChatDirectories.length = 0 }) test("does not remove live or persisted state when delete fails", async () => { @@ -683,6 +702,58 @@ describe("confirmed session removal", () => { .toEqual(["session-a", "session-b"]) }) + const chatDirectory = "/home/user/.config/openchamber/chats/2026-09-05/session-abc" + const chatSession = (id: string, parentID?: string): Session => ({ + id, + slug: id, + projectID: "project-chats", + directory: chatDirectory, + title: id, + version: "1", + time: { created: 1, updated: 1 }, + parentID, + }) + + test("keeps a shared chat directory while another root session still uses it", async () => { + const root = chatSession("chat-root") + const fork = chatSession("chat-fork") + globalActiveSessions = [root, fork] + const source = createStore({}, { session: [root, fork] }) + const { deleteSession, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, createChildStores([[chatDirectory, source]]), () => chatDirectory) + + expect(await deleteSession("chat-root")).toBe(true) + expect(deletedChatDirectories).toEqual([]) + + globalActiveSessions = [fork] + expect(await deleteSession("chat-fork")).toBe(true) + expect(deletedChatDirectories).toEqual([chatDirectory]) + }) + + test("removes the chat directory with its last root even though the root's own subagents share it", async () => { + const root = chatSession("chat-root") + const subagent = chatSession("chat-subagent", "chat-root") + globalActiveSessions = [root, subagent] + const source = createStore({}, { session: [root, subagent] }) + const { deleteSession, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, createChildStores([[chatDirectory, source]]), () => chatDirectory) + + expect(await deleteSession("chat-root")).toBe(true) + expect(deletedChatDirectories).toEqual([chatDirectory]) + }) + + test("keeps the chat directory when the global cache cannot prove it is unused", async () => { + const root = chatSession("chat-root") + globalActiveSessions = [root] + globalHasLoaded = false + const source = createStore({}, { session: [root] }) + const { deleteSession, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, createChildStores([[chatDirectory, source]]), () => chatDirectory) + + expect(await deleteSession("chat-root")).toBe(true) + expect(deletedChatDirectories).toEqual([]) + }) + test("does not archive locally until the server returns the archived session", async () => { const source = createStore({}, { session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session], @@ -959,6 +1030,10 @@ describe("session restore (unarchive)", () => { sessionUpdateResult = {} beforeSessionUpdateResolve = null beforeControlPlaneMoveResolve = null + beforeDirectoryAvailabilityResolve = null + controlPlaneMoveErrorsById.clear() + globalHasLoaded = true + deletedChatDirectories.length = 0 }) test("does not restore locally until the server returns the restored session", async () => { @@ -2944,3 +3019,148 @@ describe("dismissOpenPermissionsForSession", () => { } }) }) + +describe("relocateSessionFromMissingDirectory", () => { + const missingWorktree = "/projects/main/.worktrees/gone" + const projectDirectory = "/projects/main" + const worktreeSession = (id: string, parentID: string | null, directory = missingWorktree, archived = 0): Session & { project: { worktree: string } } => ({ + id, + slug: id, + projectID: "project-main", + directory, + title: id, + version: "1", + project: { worktree: projectDirectory }, + time: { created: 1, updated: 1, archived }, + parentID: parentID ?? undefined, + }) + const mainProject: Project = { id: "project-main", worktree: projectDirectory, time: { created: 1, updated: 1 }, sandboxes: [] } + const stores = () => createChildStores([[missingWorktree, createStore({})], [projectDirectory, createStore({})]]) + const movesOf = () => replyCalls + .filter((call) => call.method === "controlPlane.moveSession") + .map((call) => ({ sessionID: call.params.sessionID, destination: call.params.destination, moveChanges: call.params.moveChanges })) + + beforeEach(() => { + replyCalls.length = 0 + registeredSessionDirectories.length = 0 + movedSessionDirectories.length = 0 + globalUpsertedSessions.length = 0 + globalActiveSessions = [] + globalArchivedSessions.length = 0 + openCodeProjects.length = 0 + directoryAvailability.clear() + controlPlaneMoveErrorsById.clear() + beforeDirectoryAvailabilityResolve = null + runtimeKey = "default-runtime" + }) + + test("moves the whole stranded subtree, root first, to the project directory without carrying changes", async () => { + const root = worktreeSession("root", null) + const child = worktreeSession("child", "root") + const archivedChild = worktreeSession("archived-child", "root", missingWorktree, 42) + const elsewhere = worktreeSession("elsewhere", "root", projectDirectory) + globalActiveSessions = [root, child, elsewhere] + globalArchivedSessions.push(archivedChild) + openCodeProjects.push(mainProject) + directoryAvailability.set(missingWorktree, "missing") + const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, stores(), () => missingWorktree) + + const result = await relocateSessionFromMissingDirectory("root") + + expect(result).toEqual({ + status: "moved", + sourceDirectory: missingWorktree, + destinationDirectory: projectDirectory, + movedSessionIds: ["root", "child", "archived-child"], + }) + expect(movesOf()).toEqual([ + { sessionID: "root", destination: { directory: projectDirectory }, moveChanges: false }, + { sessionID: "child", destination: { directory: projectDirectory }, moveChanges: false }, + { sessionID: "archived-child", destination: { directory: projectDirectory }, moveChanges: false }, + ]) + expect(movedSessionDirectories).toEqual([ + { sessionID: "root", directory: projectDirectory }, + { sessionID: "child", directory: projectDirectory }, + { sessionID: "archived-child", directory: projectDirectory }, + ]) + }) + + for (const availability of ["available", "unknown"] as const) { + test(`leaves the session alone when its directory is ${availability}`, async () => { + globalActiveSessions = [worktreeSession("root", null)] + openCodeProjects.push(mainProject) + directoryAvailability.set(missingWorktree, availability) + const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, stores(), () => missingWorktree) + + expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" }) + expect(movesOf()).toEqual([]) + }) + } + + test("leaves a session that already lives in its project directory alone", async () => { + globalActiveSessions = [worktreeSession("root", null, projectDirectory)] + openCodeProjects.push(mainProject) + directoryAvailability.set(projectDirectory, "missing") + const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, stores(), () => projectDirectory) + + expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" }) + expect(movesOf()).toEqual([]) + }) + + test("never relocates to the filesystem root OpenCode reports for its global project", async () => { + const chatDirectory = "/Users/tester/.config/openchamber/chats/2026-09-05/session-gone" + const chat = { ...worktreeSession("chat", null, chatDirectory), projectID: "global", project: { worktree: "/" } } + globalActiveSessions = [chat] + openCodeProjects.push({ id: "global", worktree: "/", time: { created: 1, updated: 1 }, sandboxes: [] }) + directoryAvailability.set(chatDirectory, "missing") + const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, stores(), () => chatDirectory) + + expect(await relocateSessionFromMissingDirectory("chat")).toEqual({ status: "unchanged" }) + expect(movesOf()).toEqual([]) + }) + + test("leaves the session alone when OpenCode knows no project for it", async () => { + globalActiveSessions = [worktreeSession("root", null)] + directoryAvailability.set(missingWorktree, "missing") + const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, stores(), () => missingWorktree) + + expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" }) + expect(movesOf()).toEqual([]) + }) + + test("reports the sessions already moved when a descendant move fails", async () => { + globalActiveSessions = [worktreeSession("root", null), worktreeSession("child", "root")] + openCodeProjects.push(mainProject) + directoryAvailability.set(missingWorktree, "missing") + controlPlaneMoveErrorsById.set("child", new Error("destination busy")) + const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, stores(), () => missingWorktree) + + const result = await relocateSessionFromMissingDirectory("root") + + expect(result.status).toBe("failed") + expect(result.status === "failed" ? result.movedSessionIds : null).toEqual(["root"]) + expect(movedSessionDirectories).toEqual([{ sessionID: "root", directory: projectDirectory }]) + }) + + test("publishes nothing when the runtime changes while the directory is being probed", async () => { + globalActiveSessions = [worktreeSession("root", null)] + openCodeProjects.push(mainProject) + directoryAvailability.set(missingWorktree, "missing") + const { switchRuntimeEndpoint } = await import("../lib/runtime-switch") + beforeDirectoryAvailabilityResolve = () => { + switchRuntimeEndpoint({ apiBaseUrl: "http://other.test", runtimeKey: "other-runtime" }) + } + const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") + setActionRefs(actionSdk, stores(), () => missingWorktree) + + expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "stale" }) + expect(movesOf()).toEqual([]) + expect(movedSessionDirectories).toEqual([]) + }) +}) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 47a7c9fb..c170425b 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -1144,10 +1144,45 @@ function finalizeConfirmedSessionDeletion( } } -async function cleanupDeletedChatDirectory(directory: string | undefined, deleteDirectory: boolean): Promise { - if (!directory || !deleteDirectory) return +type ChatDirectoryCleanupPlan = { + directory: string | undefined + /** Only a root session owns its managed chat directory. */ + rootDeleted: boolean + /** The deleted session and the descendants the server cascade-deletes with it. */ + cascadeIds: ReadonlySet +} + +function planChatDirectoryCleanup(sessionId: string, snapshot: Session | null, directory: string | undefined): ChatDirectoryCleanupPlan { + const global = useGlobalSessionsStore.getState() + return { + directory, + rootDeleted: Boolean(snapshot && snapshot.parentID == null), + cascadeIds: computeSubtreeIds([...global.activeSessions, ...global.archivedSessions], sessionId), + } +} + +/** + * A managed chat directory is shared by every fork, side thread, and subagent + * of the chat that created it, and OpenCode fails every prompt in a session + * whose directory is gone. The directory is therefore removed only once no + * known session outside the deleted subtree still resolves to it. An unloaded + * global cache cannot prove that, so it keeps the directory: a leaked scratch + * directory is recoverable, a stranded session is not. + */ +function isChatDirectoryStillReferenced(directory: string, excludedIds: ReadonlySet): boolean { + const global = useGlobalSessionsStore.getState() + if (!global.hasLoaded) return true + const normalized = normalizePath(directory) + return [...global.activeSessions, ...global.archivedSessions].some((session) => ( + !excludedIds.has(session.id) && resolveGlobalSessionDirectory(session) === normalized + )) +} + +async function cleanupDeletedChatDirectory(plan: ChatDirectoryCleanupPlan): Promise { + if (!plan.directory || !plan.rootDeleted) return + if (isChatDirectoryStillReferenced(plan.directory, plan.cascadeIds)) return try { - await deleteChatDirectory(directory) + await deleteChatDirectory(plan.directory) } catch (error) { console.warn("[session-actions] deleted chat directory cleanup failed", error) } @@ -1181,8 +1216,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey() if (isStaleRuntime(expectedRuntimeKey)) return false const sessionDirectory = getSessionDirectory(sessionId) - const sessionSnapshot = getGlobalSessionSnapshot(sessionId) - const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null) + const chatDirectoryCleanup = planChatDirectoryCleanup(sessionId, getGlobalSessionSnapshot(sessionId), sessionDirectory) try { await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory, expectedRuntimeKey) if (isStaleRuntime(expectedRuntimeKey)) return false @@ -1192,7 +1226,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp throw new Error("session.delete failed: server did not confirm deletion") } finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey) - await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory) + await cleanupDeletedChatDirectory(chatDirectoryCleanup) return true } catch (error) { console.error("[session-actions] deleteSession failed", error) @@ -1202,7 +1236,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp if ((error as { status?: number })?.status === 404) { if (isStaleRuntime(expectedRuntimeKey)) return false finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey) - await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory) + await cleanupDeletedChatDirectory(chatDirectoryCleanup) return true } return false @@ -1216,8 +1250,7 @@ export async function deleteSessionInDirectory( expectedRuntimeKey = getRuntimeKey(), ): Promise { if (isStaleRuntime(expectedRuntimeKey)) return false - const sessionSnapshot = getGlobalSessionSnapshot(sessionId) - const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null) + const chatDirectoryCleanup = planChatDirectoryCleanup(sessionId, getGlobalSessionSnapshot(sessionId), directory) try { await cleanupReviewMetadataBeforeDelete(sessionId, directory, expectedRuntimeKey) if (isStaleRuntime(expectedRuntimeKey)) return false @@ -1227,14 +1260,14 @@ export async function deleteSessionInDirectory( throw new Error("session.delete failed: server did not confirm deletion") } finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey) - await cleanupDeletedChatDirectory(directory, deleteManagedDirectory) + await cleanupDeletedChatDirectory(chatDirectoryCleanup) return true } catch (error) { console.error("[session-actions] deleteSessionInDirectory failed", error) if ((error as { status?: number })?.status === 404) { if (isStaleRuntime(expectedRuntimeKey)) return false finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey) - await cleanupDeletedChatDirectory(directory, deleteManagedDirectory) + await cleanupDeletedChatDirectory(chatDirectoryCleanup) return true } return false @@ -1511,11 +1544,13 @@ async function getProjectPrimaryDirectory(projectID?: string): Promise directory === "/" || /^[A-Za-z]:\/?$/.test(directory) + +async function resolveMissingWorktreeRelocation( session: Session & { project?: { worktree?: string | null } | null }, -): Promise { +): Promise { const ownedDirectory = resolveSessionOwnedDirectory(session) const projectWorktree = session.project?.worktree?.trim() if (!ownedDirectory || !projectWorktree) return null @@ -1530,10 +1565,21 @@ async function resolveMissingWorktreeRestore( const projectDirectory = await getProjectPrimaryDirectory(session.projectID) if (!projectDirectory || projectDirectory === ownedDirectory) return null + // OpenCode files a directory outside any Git repository under its global + // project, whose "worktree" is the filesystem root. That is not a home for + // a session; a managed chat whose directory vanished stays where it is. + if (isFilesystemRoot(projectDirectory)) return null return { sourceDirectory: ownedDirectory, destinationDirectory: projectDirectory } } -function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array<{ session: Session; sourceDirectory: string }> { +type OwnedSubtreeEntry = { session: Session; ownedDirectory: string | null } + +/** + * The root's subtree as the global cache knows it, root first. Drawn from the + * global cache rather than a live child store so archived descendants that + * never materialized in a directory store are still included. + */ +function getGlobalSubtree(rootSession: Session): OwnedSubtreeEntry[] { const global = useGlobalSessionsStore.getState() const sessionsById = new Map() @@ -1547,6 +1593,10 @@ function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array .map((id) => sessionsById.get(id)) .filter((session): session is Session => Boolean(session)) .map((session) => ({ session, ownedDirectory: resolveSessionOwnedDirectory(session) })) +} + +function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array<{ session: Session; sourceDirectory: string }> { + return getGlobalSubtree(rootSession) // 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) @@ -1557,6 +1607,58 @@ function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array .filter((entry): entry is { session: Session; sourceDirectory: string } => entry !== null) } +export type MissingDirectoryRelocation = + /** The session's directory is gone; its subtree now lives in the project directory. */ + | { status: "moved"; sourceDirectory: string; destinationDirectory: string; movedSessionIds: string[] } + /** The directory is available, its state is unknown, or the session has no project to move to. */ + | { status: "unchanged" } + /** The runtime changed while the relocation was in flight; nothing local was published. */ + | { status: "stale" } + /** A control-plane move failed; `movedSessionIds` already live in the destination. */ + | { status: "failed"; movedSessionIds: string[]; error: unknown } + +/** + * Move an active session whose worktree no longer exists into its project's + * primary directory. + * + * Same gate as the archived-session restore fallback: only a server-confirmed + * `missing` directory qualifies, the destination is the OpenCode project the + * session belongs to, and `available`, `unknown`, probe failures, and sessions + * without a project leave everything untouched. Every session of the root's + * subtree still stranded in that directory moves with it, root first, so the + * session the user is looking at is usable even if a descendant move fails. + * Moves carry no changes (`moveChanges: false`): the directory is gone, so + * there is nothing to carry. + */ +export async function relocateSessionFromMissingDirectory( + sessionId: string, + expectedRuntimeKey = getRuntimeKey(), +): Promise { + if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" } + const rootSession = getGlobalSessionSnapshot(sessionId) + if (!rootSession) return { status: "unchanged" } + + const relocation = await resolveMissingWorktreeRelocation(rootSession) + if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" } + if (!relocation) return { status: "unchanged" } + + const stranded = getGlobalSubtree(rootSession) + .filter((entry) => entry.ownedDirectory === relocation.sourceDirectory) + .map((entry) => entry.session) + const movedSessionIds: string[] = [] + for (const session of stranded) { + try { + await moveSessionToDirectory(session, relocation.sourceDirectory, relocation.destinationDirectory, false, expectedRuntimeKey) + } catch (error) { + console.error("[session-actions] relocateSessionFromMissingDirectory failed", error) + return { status: "failed", movedSessionIds, error } + } + if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" } + movedSessionIds.push(session.id) + } + return { status: "moved", ...relocation, movedSessionIds } +} + /** * Restore one archived session back to the active list. * @@ -1573,7 +1675,7 @@ export async function unarchiveSession(sessionId: string, expectedRuntimeKey = g const sessionDirectory = getSessionDirectory(sessionId) try { const restore = globalSession - ? await resolveMissingWorktreeRestore(globalSession) + ? await resolveMissingWorktreeRelocation(globalSession) : null if (isStaleRuntime(expectedRuntimeKey)) return false diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index 496eee1d..ef086eae 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -14,6 +14,8 @@ import { getRuntimeKey } from '@/lib/runtime-switch'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; +import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { subscribeWorktreeTopologyChanged } from '@/lib/worktrees/worktreeManager'; import { createContextPart } from '@/lib/messages/contextParts'; /** @@ -1264,3 +1266,148 @@ const originalHomeInfo = opencodeClient.getFilesystemHomeInfo; opencodeClient.getFilesystemHomeInfo = async () => ({ home: '/Users/tester' }); await ensureChatsRootDirectory(); opencodeClient.getFilesystemHomeInfo = originalHomeInfo; + +describe('missing session directory recovery', () => { + const missingWorktree = '/projects/main/.worktrees/gone'; + const projectDirectory = '/projects/main'; + const moves = []; + const probes = []; + let availability = 'missing'; + let originalGetDirectoryAvailability; + let originalGetSdkClient; + let originalProjects; + let originalActiveProjectId; + let originalDirectoryState; + let originalClientDirectory; + let originalGlobalState; + + const worktreeSession = (id, directory, parentID = null) => ({ + id, + parentID: parentID ?? undefined, + projectID: 'project-main', + directory, + project: { worktree: projectDirectory }, + title: id, + version: '1', + time: { created: 1, updated: 1 }, + }); + + const settle = async () => { + for (let index = 0; index < 10; index += 1) await Bun.sleep(0); + }; + + beforeEach(() => { + moves.length = 0; + probes.length = 0; + availability = 'missing'; + originalGetDirectoryAvailability = opencodeClient.getDirectoryAvailability; + originalGetSdkClient = opencodeClient.getSdkClient; + originalProjects = useProjectsStore.getState().projects; + originalActiveProjectId = useProjectsStore.getState().activeProjectId; + originalDirectoryState = useDirectoryStore.getState(); + originalClientDirectory = opencodeClient.getDirectory(); + originalGlobalState = useGlobalSessionsStore.getState(); + + const childStore = { + getState: () => ({ session: [], message: {}, part: {}, session_status: {} }), + setState: () => {}, + }; + const childStores = { children: new Map(), ensureChild: () => childStore, getChild: () => childStore }; + setActionRefs({ + project: { list: async () => ({ data: [{ id: 'project-main', worktree: projectDirectory }] }) }, + session: { messages: async () => ({ data: [] }) }, + }, childStores, () => projectDirectory); + setOptimisticRefs(() => {}, () => {}); + opencodeClient.getSdkClient = () => ({ + experimental: { controlPlane: { moveSession: async (params) => { moves.push(params); return {}; } } }, + }); + opencodeClient.getDirectoryAvailability = async (directory) => { + probes.push(directory); + return availability; + }; + useProjectsStore.setState({ + projects: [{ id: 'project-main', path: projectDirectory, label: 'Main' }], + activeProjectId: 'project-main', + }); + useSessionUIStore.setState({ + currentSessionId: null, + currentSessionDirectory: null, + worktreeMetadata: new Map(), + newSessionDraft: { open: false, directoryOverride: null, parentID: null }, + }); + }); + + afterEach(() => { + opencodeClient.getDirectoryAvailability = originalGetDirectoryAvailability; + opencodeClient.getSdkClient = originalGetSdkClient; + useProjectsStore.setState({ projects: originalProjects, activeProjectId: originalActiveProjectId }); + useDirectoryStore.setState(originalDirectoryState, true); + useGlobalSessionsStore.setState(originalGlobalState, true); + opencodeClient.setDirectory(originalClientDirectory ?? undefined); + useSessionUIStore.setState({ currentSessionId: null, currentSessionDirectory: null, worktreeMetadata: new Map() }); + }); + + test('moves the current session to its project, drops the worktree hint, and shares one attempt between callers', async () => { + const root = worktreeSession('root', missingWorktree); + const child = worktreeSession('child', missingWorktree, 'root'); + useGlobalSessionsStore.setState({ activeSessions: [root, child], archivedSessions: [] }); + useSessionUIStore.setState({ currentSessionId: 'root', currentSessionDirectory: missingWorktree }); + useSessionUIStore.getState().setWorktreeMetadata('root', { path: missingWorktree, branch: 'gone' }); + useSessionUIStore.getState().setWorktreeMetadata('child', { path: missingWorktree, branch: 'gone' }); + + const topologyChanges = []; + const unsubscribe = subscribeWorktreeTopologyChanged((directory) => topologyChanges.push(directory)); + const store = useSessionUIStore.getState(); + const [first, second] = await Promise.all([ + store.recoverMissingSessionDirectory('root'), + store.recoverMissingSessionDirectory('root'), + ]); + unsubscribe(); + + expect(first).toBe(second); + expect(topologyChanges).toEqual([projectDirectory]); + expect(first.status).toBe('moved'); + expect(moves.map((move) => move.sessionID)).toEqual(['root', 'child']); + expect(moves.every((move) => move.destination.directory === projectDirectory && move.moveChanges === false)).toBe(true); + expect(useSessionUIStore.getState().worktreeMetadata.has('root')).toBe(false); + expect(useSessionUIStore.getState().worktreeMetadata.has('child')).toBe(false); + expect(useSessionWorktreeStore.getState().getAttachment('root')).toBeUndefined(); + expect(useSessionUIStore.getState().getDirectoryForSession('root')).toBe(projectDirectory); + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(projectDirectory); + expect(useDirectoryStore.getState().currentDirectory).toBe(projectDirectory); + }); + + test('probes a worktree session on activation and relocates it only when the directory is confirmed missing', async () => { + const root = worktreeSession('root', missingWorktree); + useGlobalSessionsStore.setState({ activeSessions: [root], archivedSessions: [] }); + + availability = 'available'; + useSessionUIStore.getState().setCurrentSession('root', missingWorktree); + await settle(); + expect(probes).toEqual([missingWorktree]); + expect(moves).toEqual([]); + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(missingWorktree); + + availability = 'missing'; + useSessionUIStore.getState().setCurrentSession('root', missingWorktree); + await settle(); + expect(moves.map((move) => move.sessionID)).toEqual(['root']); + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(projectDirectory); + }); + + test('never probes a session that lives in its project root or in a managed chat directory', async () => { + const chatDirectory = '/Users/tester/.config/openchamber/chats/2026-09-05/session-abc'; + useGlobalSessionsStore.setState({ + activeSessions: [worktreeSession('in-root', projectDirectory), worktreeSession('chat', chatDirectory)], + archivedSessions: [], + }); + + useSessionUIStore.getState().setCurrentSession('in-root', projectDirectory); + await settle(); + useSessionUIStore.getState().setCurrentSession('chat', chatDirectory); + await settle(); + + expect(probes).toEqual([]); + expect(moves).toEqual([]); + }); +}); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 855e4f4d..a1d8df90 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -31,7 +31,8 @@ import { useSkillsStore } from "@/stores/useSkillsStore" import { getDeferredSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { normalizePath } from "@/lib/pathNormalization" -import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryPath, warmChatsRootDirectory } from "@/lib/chatDirectories" +import type { ProjectEntry } from "@/lib/api/types" +import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath, warmChatsRootDirectory } from "@/lib/chatDirectories" import { isVSCodeRuntime } from "@/lib/desktop" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice" @@ -71,7 +72,9 @@ import { unrevertSession as unrevertSessionAction, forkFromMessage as forkFromMessageAction, fetchMessagesForSession, + relocateSessionFromMissingDirectory, type ArchiveSessionsOptions, + type MissingDirectoryRelocation, type DeleteSessionOptions, type DeleteSessionsOptions, type UnarchiveSessionsOptions, @@ -375,6 +378,13 @@ export type SessionUIState = { transition?: "submitted-draft", ) => void clearMaterializedDraftSession: (sessionId: string) => void + /** + * Move a session whose directory no longer exists (a worktree deleted + * outside OpenChamber) into its project directory. Concurrent calls for the + * same session share one attempt. Resolves `unchanged` when the directory is + * available, unknown, or the session has no project to move to. + */ + recoverMissingSessionDirectory: (sessionId: string) => Promise prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void openNewSessionDraft: (options?: Partial & { automatic?: boolean }) => void @@ -758,6 +768,27 @@ const resolveCreatableDraftDirectory = async ( } } +const pendingDirectoryRecoveries = new Map>() + +/** + * Only a directory that is neither a registered project root nor a managed + * chat directory can be a deleted worktree. Project roots and chat directories + * have nowhere to relocate to, so they are never probed. + */ +const isRelocatableSessionDirectory = (directory: string, projects: readonly ProjectEntry[]): boolean => { + if (isChatDirectoryForHome(directory, useDirectoryStore.getState().homeDirectory)) return false + return !projects.some((project) => normalizePath(project.path) === directory) +} + +const notifySessionRelocated = async (destinationDirectory: string): Promise => { + const { toast } = await import("sonner") + const { useI18nStore, formatMessage } = await import("@/lib/i18n/store") + const project = useProjectsStore.getState().projects.find((entry) => normalizePath(entry.path) === destinationDirectory) + toast.info(formatMessage(useI18nStore.getState().dictionary, "sessions.missingDirectory.movedToProject", { + project: project?.label ?? destinationDirectory, + })) +} + const recoverStaleDraftDirectory = async (openedDraft: NewSessionDraftState): Promise => { const resolved = await resolveCreatableDraftDirectory(openedDraft, openedDraft.directoryOverride) if (resolved.status !== "ok") return @@ -1078,6 +1109,16 @@ export const useSessionUIStore = create()((set, get) => ({ console.warn("Failed to set OpenCode directory for session switch:", e) } + // A worktree session may have lost its directory while it was in the + // background. Probe on activation, the same way a reopened draft probes + // its inherited directory, so the session is relocated before its tabs + // and prompts run against a path that is gone. VS Code registers no + // worktrees, so every session there is its workspace root. + if (id && !isGuessedDir && resolvedDir && !isVSCodeRuntime() + && isRelocatableSessionDirectory(resolvedDir, projectsState.projects)) { + void get().recoverMissingSessionDirectory(id) + } + // Defer viewport anchor save for previous session — not needed for the // skeleton to render and reads messages which can be expensive. if (previousSessionId && previousSessionId !== id) { @@ -1169,6 +1210,40 @@ export const useSessionUIStore = create()((set, get) => ({ // --------------------------------------------------------------------------- // openNewSessionDraft // --------------------------------------------------------------------------- + recoverMissingSessionDirectory: (sessionId) => { + const runtimeKey = getRuntimeKey() + const key = `${runtimeKey}:${sessionId}` + const pending = pendingDirectoryRecoveries.get(key) + if (pending) return pending + + const recovery = relocateSessionFromMissingDirectory(sessionId, runtimeKey) + .then(async (result) => { + if (result.status !== "moved" && result.status !== "failed") return result + // The worktree hint was the first thing every directory lookup read; + // with the worktree gone it would keep routing tabs to the dead path. + for (const movedId of result.movedSessionIds) { + get().setWorktreeMetadata(movedId, null) + } + if (result.status !== "moved") return result + if (get().currentSessionId === sessionId) { + // Re-select through the normal path so the active directory, project, + // and OpenCode client all follow the session to its new home. + get().setCurrentSession(sessionId, result.destinationDirectory) + } + // The server just confirmed a worktree directory is gone; the sidebar's + // worktree topology for that project is stale, so let it rediscover. + const { notifyWorktreeTopologyChanged } = await import("@/lib/worktrees/worktreeManager") + notifyWorktreeTopologyChanged(result.destinationDirectory) + await notifySessionRelocated(result.destinationDirectory) + return result + }) + .finally(() => { + pendingDirectoryRecoveries.delete(key) + }) + pendingDirectoryRecoveries.set(key, recovery) + return recovery + }, + openNewSessionDraft: (options) => { // A USER-initiated draft open is a navigation choice: the next cold launch // should land on the draft, not re-open the session left behind — drop the diff --git a/packages/ui/src/sync/session-worktree-contract.ts b/packages/ui/src/sync/session-worktree-contract.ts index b7fe52bc..d5b32149 100644 --- a/packages/ui/src/sync/session-worktree-contract.ts +++ b/packages/ui/src/sync/session-worktree-contract.ts @@ -138,11 +138,11 @@ export function resolveSessionWorktreeState( export function formatSessionWorktreeBadge( attachment: SessionWorktreeAttachment, - labels?: { pending?: string } + labels?: { pending?: string; missing?: string } ): string { if (attachment.legacy) return 'Legacy session'; if (attachment.worktreeStatus === 'pending') return labels?.pending ?? 'Needs attention'; - if (attachment.worktreeStatus === 'missing') return 'Worktree missing'; + if (attachment.worktreeStatus === 'missing') return labels?.missing ?? 'Worktree missing'; if (attachment.worktreeStatus === 'not-a-repo') return 'Not a repo'; if (attachment.worktreeStatus === 'invalid') return 'Needs attention'; if (attachment.attentionReason) return 'Needs attention'; diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index df0607ae..e676b9eb 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -684,6 +684,15 @@ const parseWorktreePorcelain = (raw) => { const branchRef = line.substring('branch '.length).trim(); current.branchRef = branchRef; current.branch = cleanBranchName(branchRef); + continue; + } + + // git marks a worktree whose directory is gone (deleted outside git) as + // prunable; it stays registered until `git worktree prune`. The sidebar + // needs that distinction: the directory is missing, but the sessions that + // lived there are not. + if (line === 'prunable' || line.startsWith('prunable ')) { + current.prunable = true; } } @@ -4036,6 +4045,7 @@ export async function getWorktrees(directory) { name: path.basename(entry.worktree || ''), branch: entry.branch || '', path: entry.worktree, + prunable: entry.prunable === true, })); } catch (error) { // Worktrees are an optional feature. When the caller passes a directory diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index b5921050..1b742704 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -517,6 +517,24 @@ describe('getWorktrees', () => { expect(Array.isArray(result)).toBe(true); expect(warnSpy).not.toHaveBeenCalled(); }); + it('flags a worktree whose directory was deleted outside git as prunable', async () => { + const repo = createTempDir(); + runGit(repo, ['init', '-b', 'main']); + runGit(repo, ['config', 'user.email', 'test@example.com']); + runGit(repo, ['config', 'user.name', 'Test User']); + runGit(repo, ['commit', '--allow-empty', '-m', 'init']); + const worktreePath = path.join(createTempDir(), 'feature'); + runGit(repo, ['worktree', 'add', worktreePath, '-b', 'feature']); + + const before = await getWorktrees(repo); + expect(before.find((entry) => entry.branch === 'feature')).toMatchObject({ prunable: false }); + + fs.rmSync(worktreePath, { recursive: true, force: true }); + + const after = await getWorktrees(repo); + expect(after.find((entry) => entry.branch === 'feature')).toMatchObject({ path: expect.any(String), prunable: true }); + expect(after.find((entry) => entry.branch === 'main')).toMatchObject({ prunable: false }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index 54c653c1..aabf863a 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -35,6 +35,7 @@ HTTP remains the authenticated command plane for create, resize, appearance upda - Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged. - Exited sessions remain attachable until explicit close, idle cleanup, or a successful replacement of the same project action. Creating a replacement retires only exited records for the same resolved directory and action, after the new PTY starts. Failed creation preserves the old record and output. These replaced records do not exhaust the terminal capacity limit. - Deduplicated create responses may describe another client's execution. Cancellation cleanup closes only the terminal ID allocated for the cancelled request; it never closes an adopted peer execution. +- Create and restart validate the working directory with a real `stat` and answer HTTP 400 `Invalid working directory` when it is not a directory. When the path does not exist at all (`ENOENT`/`ENOTDIR`, a worktree deleted outside OpenChamber) the body also carries `code: "TERMINAL_CWD_MISSING"`. That is the one rejection the client can recover from: the session, not the terminal, is stranded, and the shared UI moves it to its project directory and starts a terminal there. Every other rejection stays generic; the runtime never substitutes a parent directory on its own. - Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID. Command-mode sessions reject restart with HTTP 400 instead of silently turning into interactive shells with stale action metadata. - A delete that arrives while create is still pending leaves a cancellation tombstone. When the PTY arrives, the runtime terminates it immediately, never inserts the session into the live map, and returns a create error while the delete still succeeds. - Close uses SIGTERM with bounded SIGKILL escalation. Force-kill, idle cleanup, and runtime shutdown terminate process groups immediately where supported. Removal explicitly sends a fatal scoped closure and evicts client projections even when a PTY backend fails to emit `onExit`; attached terminals are not considered idle. diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index 6765e2d3..6f37b6bc 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -19,6 +19,9 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; const TERMINATION_GRACE_MS = 1000; const INTERACTIVE_TERMINAL_MODE = 'interactive'; const COMMAND_TERMINAL_MODE = 'command'; +// Error code the create/restart routes attach when the requested cwd no longer +// exists. Mirrored by `TERMINAL_CWD_MISSING_CODE` in packages/ui/src/lib/terminalApi.ts. +const TERMINAL_CWD_MISSING_CODE = 'TERMINAL_CWD_MISSING'; const TERMINAL_PURPOSE = Object.freeze({ type: 'terminal' }); const MAX_PURPOSE_ID_CHARS = 128; const OBJECT_TAG = '[object Object]'; @@ -235,11 +238,18 @@ export function createTerminalRuntime({ ptyProcess.onExit(({ exitCode, signal }) => { session.eventQueue.push({ type: 'exit', process: ptyProcess, exitCode, signal }); drainEvents(session); }); }; + // A working directory that no longer exists (a deleted worktree) is the one + // rejection the client can recover from by moving the session to its + // project, so the response names it. Every other rejection stays generic. + const invalidWorkingDirectory = (code) => Object.assign(new Error('Invalid working directory'), code ? { code } : {}); const validateCwd = async (cwd) => { if (typeof cwd !== 'string' || !cwd.trim()) throw new Error('cwd is required'); - const stats = await fs.promises.stat(cwd).catch(() => null); - if (!stats?.isDirectory()) throw new Error('Invalid working directory'); + let stats; + try { stats = await fs.promises.stat(cwd); } + catch (error) { throw invalidWorkingDirectory(error?.code === 'ENOENT' || error?.code === 'ENOTDIR' ? TERMINAL_CWD_MISSING_CODE : undefined); } + if (!stats?.isDirectory()) throw invalidWorkingDirectory(); }; + const errorBody = (error, fallback) => ({ error: error?.message || fallback, ...(typeof error?.code === 'string' ? { code: error.code } : {}) }); const applyAppearance = (session, { themeMode, terminalBackground, terminalForeground }) => { const previous = [session.themeMode, session.terminalBackground, session.terminalForeground]; @@ -461,7 +471,7 @@ export function createTerminalRuntime({ purpose: getSessionPurpose(session), }); } - catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json({ error: error?.message || 'Failed to create terminal session' }); } + catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json(errorBody(error, 'Failed to create terminal session')); } }); app.post('/api/terminal/:sessionId/resize', (req, res) => { const session = sessions.get(req.params.sessionId); @@ -505,7 +515,7 @@ export function createTerminalRuntime({ try { await restart; res.json({ sessionId: session.id, cols, rows, status: session.status }); - } catch (error) { res.status(400).json({ error: error?.message || 'Failed to restart terminal' }); } + } catch (error) { res.status(400).json(errorBody(error, 'Failed to restart terminal')); } finally { if (pendingSessionRestarts.get(session.id) === restart) pendingSessionRestarts.delete(session.id); } }); app.delete('/api/terminal/:sessionId', async (req, res) => { diff --git a/packages/web/server/lib/terminal/runtime.test.js b/packages/web/server/lib/terminal/runtime.test.js index 72a567b5..8793d59e 100644 --- a/packages/web/server/lib/terminal/runtime.test.js +++ b/packages/web/server/lib/terminal/runtime.test.js @@ -266,6 +266,40 @@ describe('terminal runtime', () => { } }); + it('names a missing working directory so the client can recover the session', async () => { + let cwdMissing = false; + const harness = createHarness({ + fs: { + promises: { + stat: async () => { + if (cwdMissing) throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); + return { isDirectory: () => true }; + }, + }, + }, + }); + try { + const create = harness.routes.post.get('/api/terminal/create'); + const created = createResponse(); + await create({ body: { sessionId: 'worktree-terminal', cwd: '/repo/.worktrees/feature' } }, created); + expect(created.statusCode).toBe(200); + + cwdMissing = true; + const recreated = createResponse(); + await create({ body: { sessionId: 'worktree-terminal-2', cwd: '/repo/.worktrees/feature' } }, recreated); + expect(recreated.statusCode).toBe(400); + expect(recreated.body).toEqual({ error: 'Invalid working directory', code: 'TERMINAL_CWD_MISSING' }); + + const restarted = createResponse(); + await harness.routes.post.get('/api/terminal/:sessionId/restart')( + { params: { sessionId: 'worktree-terminal' }, body: { cwd: '/repo/.worktrees/feature' } }, + restarted, + ); + expect(restarted.statusCode).toBe(400); + expect(restarted.body).toEqual({ error: 'Invalid working directory', code: 'TERMINAL_CWD_MISSING' }); + } finally { await harness.runtime.shutdown(); } + }); + it('removes its websocket upgrade listener on shutdown', async () => { const server = new EventEmitter(); const runtime = createRuntime(server);