fix(sessions): fall back when lastDirectory is a deleted worktree

New chats inherited a persisted lastDirectory even after that worktree
was removed, so the first message saved but the prompt never started.
Validate the implicit draft directory, fall back to the active project
only when OpenCode confirms the path is missing, and leave explicit
worktree targets and unknown probes unchanged.

Co-authored-by: serkraser <serkraser@gmail.com>
This commit is contained in:
Cursor Agent
2026-08-15 04:26:39 +00:00
co-authored by serkraser
parent 6b1e677aaf
commit 90e79b04a4
7 changed files with 246 additions and 10 deletions
+2 -1
View File
@@ -251,7 +251,8 @@ Rules:
4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected.
5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime.
6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
7. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds.
8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
Examples of global-store updates performed in `session-actions.ts`:
@@ -9,6 +9,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
/**
* Unit tests for session worktree routing through the authoritative store.
@@ -408,9 +409,21 @@ describe('openNewSessionDraft project binding', () => {
describe('createSession draft lifecycle', () => {
let originalCreateSession;
let originalGetDirectoryAvailability;
let originalProjects;
let originalActiveProjectId;
let originalDirectoryState;
let originalClientDirectory;
let originalLastDirectory;
beforeEach(() => {
originalCreateSession = opencodeClient.createSession;
originalGetDirectoryAvailability = opencodeClient.getDirectoryAvailability;
originalProjects = useProjectsStore.getState().projects;
originalActiveProjectId = useProjectsStore.getState().activeProjectId;
originalDirectoryState = useDirectoryStore.getState();
originalClientDirectory = opencodeClient.getDirectory();
originalLastDirectory = getDeferredSafeStorage().getItem('lastDirectory');
useSessionUIStore.setState({
currentSessionId: null,
currentSessionDirectory: null,
@@ -420,6 +433,15 @@ describe('createSession draft lifecycle', () => {
afterEach(() => {
opencodeClient.createSession = originalCreateSession;
opencodeClient.getDirectoryAvailability = originalGetDirectoryAvailability;
useProjectsStore.setState({ projects: originalProjects, activeProjectId: originalActiveProjectId });
useDirectoryStore.setState(originalDirectoryState, true);
opencodeClient.setDirectory(originalClientDirectory ?? undefined);
if (originalLastDirectory === null) {
getDeferredSafeStorage().removeItem('lastDirectory');
} else {
getDeferredSafeStorage().setItem('lastDirectory', originalLastDirectory);
}
});
test('keeps the draft open when session creation fails', async () => {
@@ -433,6 +455,108 @@ describe('createSession draft lifecycle', () => {
expect(useSessionUIStore.getState().newSessionDraft.open).toBe(true);
expect(useSessionUIStore.getState().newSessionDraft.title).toBe('Draft title');
});
test('falls back to the current active project when a regular new-chat directory is missing', async () => {
const createSessionCalls = [];
useProjectsStore.setState({
projects: [
{ id: 'project-draft', path: '/projects/draft', label: 'Draft' },
{ id: 'project-active', path: '/projects/active', label: 'Active' },
],
activeProjectId: 'project-active',
});
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
useSessionUIStore.getState().openNewSessionDraft();
opencodeClient.getDirectoryAvailability = async () => 'missing';
opencodeClient.createSession = async (_params, directory) => {
createSessionCalls.push(directory);
return { id: 'session-fallback', directory };
};
await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
expect(createSessionCalls).toEqual(['/projects/active']);
expect(useDirectoryStore.getState().currentDirectory).toBe('/projects/active');
expect(getDeferredSafeStorage().getItem('lastDirectory')).toBe('/projects/active');
});
test('keeps an explicitly pinned worktree directory unchanged', async () => {
const createSessionCalls = [];
useProjectsStore.setState({
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
activeProjectId: 'project-main',
});
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree', preserveDirectoryOverride: true });
expect(useSessionUIStore.getState().newSessionDraft.preserveDirectoryOverride).toBe(true);
opencodeClient.getDirectoryAvailability = async () => 'missing';
opencodeClient.createSession = async (_params, directory) => {
createSessionCalls.push(directory);
return { id: 'session-pinned', directory };
};
await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
expect(createSessionCalls).toEqual(['/private/deleted-worktree']);
});
test('keeps a ChatInput-style current-directory draft recoverable when that path is missing', async () => {
const createSessionCalls = [];
useProjectsStore.setState({
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
activeProjectId: 'project-main',
});
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
expect(useSessionUIStore.getState().newSessionDraft.preserveDirectoryOverride).not.toBe(true);
opencodeClient.getDirectoryAvailability = async () => 'missing';
opencodeClient.createSession = async (_params, directory) => {
createSessionCalls.push(directory);
return { id: 'session-chat-input', directory };
};
await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
expect(createSessionCalls).toEqual(['/projects/main']);
});
test('keeps the stale directory when its availability cannot be confirmed', async () => {
const createSessionCalls = [];
useProjectsStore.setState({
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
activeProjectId: 'project-main',
});
useDirectoryStore.getState().setDirectory('/private/unavailable-worktree', { showOverlay: false });
useSessionUIStore.getState().openNewSessionDraft();
opencodeClient.getDirectoryAvailability = async () => 'unknown';
opencodeClient.createSession = async (_params, directory) => {
createSessionCalls.push(directory);
return { id: 'session-unavailable', directory };
};
await useSessionUIStore.getState().createSession('Draft title', '/private/unavailable-worktree');
expect(createSessionCalls).toEqual(['/private/unavailable-worktree']);
expect(useDirectoryStore.getState().currentDirectory).toBe('/private/unavailable-worktree');
});
test('does not persist a fallback when session creation fails', async () => {
useProjectsStore.setState({
projects: [{ id: 'project-main', path: '/projects/main', label: 'Main' }],
activeProjectId: 'project-main',
});
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
useSessionUIStore.getState().openNewSessionDraft();
opencodeClient.getDirectoryAvailability = async () => 'missing';
opencodeClient.createSession = async () => {
throw new Error('offline');
};
const session = await useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
expect(session).toBeNull();
expect(useDirectoryStore.getState().currentDirectory).toBe('/private/deleted-worktree');
expect(getDeferredSafeStorage().getItem('lastDirectory')).toBe('/private/deleted-worktree');
});
});
// ---------------------------------------------------------------------------
+61 -2
View File
@@ -603,6 +603,63 @@ const waitForWorktreeBootstrapIfConfigured = async (directory: string | null, pr
}
}
const resolveActiveProjectDirectory = (draft: NewSessionDraftState): string | null => {
const projectsState = useProjectsStore.getState()
return normalizePath(
projectsState.getActiveProject()?.path
?? (draft.selectedProjectId
? projectsState.projects.find((project) => project.id === draft.selectedProjectId)?.path
: null)
?? null,
)
}
/**
* Regular new-chat drafts inherit the persisted current/last directory. If that
* path is confirmed missing (deleted worktree), fall back to the active project.
* Explicit worktree targets, in-flight worktree creation, and unknown/offline
* probes stay unchanged so a temporary outage cannot rewrite the destination.
*/
const resolveCreatableDraftDirectory = async (
draft: NewSessionDraftState,
requestedDirectory: string | null | undefined,
): Promise<{ status: "ok"; directory: string | null | undefined } | { status: "aborted" }> => {
const directory = requestedDirectory ?? opencodeClient.getDirectory() ?? null
const isRecoverableDraftDirectory =
draft.open
&& draft.preserveDirectoryOverride !== true
&& !draft.pendingWorktreeRequestId
&& !draft.bootstrapPendingDirectory
&& normalizePath(draft.directoryOverride) === normalizePath(directory)
if (!isRecoverableDraftDirectory || !directory) {
return { status: "ok", directory }
}
const activeProjectDirectory = resolveActiveProjectDirectory(draft)
if (!activeProjectDirectory || normalizePath(directory) === activeProjectDirectory) {
return { status: "ok", directory }
}
const runtimeKey = getRuntimeKey()
const draftDirectory = draft.directoryOverride
const availability = await opencodeClient.getDirectoryAvailability(directory)
const currentDraft = useSessionUIStore.getState().newSessionDraft
const draftChanged = !currentDraft.open
|| currentDraft.preserveDirectoryOverride !== draft.preserveDirectoryOverride
|| currentDraft.pendingWorktreeRequestId !== draft.pendingWorktreeRequestId
|| normalizePath(currentDraft.directoryOverride) !== normalizePath(draftDirectory)
if (getRuntimeKey() !== runtimeKey || draftChanged) {
return { status: "aborted" }
}
return {
status: "ok",
directory: availability === "missing" ? activeProjectDirectory : directory,
}
}
export async function materializeOpenDraftSession(selection: {
providerID: string
modelID: string
@@ -1416,14 +1473,16 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const targetFolderId = draft.targetFolderId
try {
const dir = directoryOverride ?? opencodeClient.getDirectory()
const resolved = await resolveCreatableDraftDirectory(draft, directoryOverride)
if (resolved.status === "aborted") return null
const dir = resolved.directory
const session = await createSessionAction(title, dir, parentID ?? null, metadata)
if (!session) return null
get().closeNewSessionDraft()
if (targetFolderId) {
const scopeKey = directoryOverride || get().lastLoadedDirectory || session.directory
const scopeKey = dir || get().lastLoadedDirectory || session.directory
if (scopeKey) {
useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id)
}