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:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user