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

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

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

Closes #3312.

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

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

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

Closes #3338.

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

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

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

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

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

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

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

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

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

session-ui-store now imports relocateSessionFromMissingDirectory, and the
mocked module in this test listed every other action but not that one, so
the file failed on import.
This commit is contained in:
𝖎𝖚𝖑𝖎𝖎𝖆
2026-09-05 21:26:21 +03:00
committed by GitHub
parent 7308b90670
commit 759af5a77d
36 changed files with 906 additions and 57 deletions
+2
View File
@@ -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 {
+2
View File
@@ -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.',
+2
View File
@@ -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.',
+2
View File
@@ -706,6 +706,8 @@ export const dict: Record<I18nKey, string> = {
"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.",
+2
View File
@@ -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.',
+2
View File
@@ -706,6 +706,8 @@ export const dict: Record<I18nKey, string> = {
'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': '未コミットの変更は破棄されます。',
+2
View File
@@ -706,6 +706,8 @@ export const dict: Record<I18nKey, string> = {
'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': '커밋하지 않은 변경 사항은 버려집니다.',
+2
View File
@@ -706,6 +706,8 @@ export const dict: Record<I18nKey, string> = {
'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.',
@@ -706,6 +706,8 @@ export const dict: Record<I18nKey, string> = {
"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.",
+2
View File
@@ -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.',
+2
View File
@@ -706,6 +706,8 @@ export const dict: Record<I18nKey, string> = {
"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": "Незакомічені зміни буде скасовано.",
@@ -706,6 +706,8 @@ export const dict: Record<I18nKey, string> = {
'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': '未提交的更改将被丢弃。',
@@ -719,6 +719,8 @@ export const dict: Record<I18nKey, string> = {
'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': '未提交的變更將被捨棄。',
+38 -5
View File
@@ -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<Response | Error> = [];
const fsHomeResponses: Array<Response | Error> = [];
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');
});
});
+14 -20
View File
@@ -71,19 +71,8 @@ type SdkResult<T> = {
};
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<T>(result: SdkResult<T>, 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<DirectoryAvailability> {
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";
}
}
+19 -2
View File
@@ -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 });
+24 -2
View File
@@ -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<Error> => {
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 => {
@@ -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']);
});
});
@@ -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<WorktreeMetadata, 'worktreeRoot' | 'worktreeStatus' | 'headState' | 'worktreeSource'> => {
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<WorktreeTopologyListener>();
/**
* 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<WorktreeMetadata[]> => {
const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory);
const normalizedProjectDirectory = normalizePath(projectDirectory);