refactor: Enhance session management and error handling in AgentService and related components

- Improved session handling by implementing ensureSession to load sessions from disk if not in memory, reducing "session not found" errors.
- Enhanced error messages for non-existent sessions, providing clearer diagnostics.
- Updated CodexProvider and OpencodeProvider to improve error handling and messaging.
- Refactored various routes to use async/await for better readability and error handling.
- Added event emission for merge and stash operations in the MergeService and StashService.
- Cleaned up error messages in AgentExecutor to remove redundant prefixes and ANSI codes for better clarity.
This commit is contained in:
gsxdsm
2026-02-18 17:30:12 -08:00
parent 6903d3c508
commit df9a6314da
22 changed files with 827 additions and 148 deletions

View File

@@ -255,7 +255,15 @@ export class AgentExecutor {
}
}
} else if (msg.type === 'error') {
throw new Error(msg.error || 'Unknown error');
// Clean the error: strip ANSI codes and the redundant "Error: " prefix
// that CLI providers add. Without this, wrapping in new Error() produces
// "Error: Error: Session not found" (double-prefixed).
const cleanedError =
(msg.error || 'Unknown error')
.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/^Error:\s*/i, '')
.trim() || 'Unknown error';
throw new Error(cleanedError);
} else if (msg.type === 'result' && msg.subtype === 'success') scheduleWrite();
}
await writeToFile();
@@ -390,9 +398,15 @@ export class AgentExecutor {
input: b.input,
});
}
} else if (msg.type === 'error')
throw new Error(msg.error || `Error during task ${task.id}`);
else if (msg.type === 'result' && msg.subtype === 'success') {
} else if (msg.type === 'error') {
// Clean the error: strip ANSI codes and redundant "Error: " prefix
const cleanedError =
(msg.error || `Error during task ${task.id}`)
.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/^Error:\s*/i, '')
.trim() || `Error during task ${task.id}`;
throw new Error(cleanedError);
} else if (msg.type === 'result' && msg.subtype === 'success') {
taskOutput += msg.result || '';
responseText += msg.result || '';
}
@@ -556,7 +570,14 @@ export class AgentExecutor {
content: b.text,
});
}
if (msg.type === 'error') throw new Error(msg.error || 'Error during plan revision');
if (msg.type === 'error') {
const cleanedError =
(msg.error || 'Error during plan revision')
.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/^Error:\s*/i, '')
.trim() || 'Error during plan revision';
throw new Error(cleanedError);
}
if (msg.type === 'result' && msg.subtype === 'success') revText += msg.result || '';
}
const mi = revText.indexOf('[SPEC_GENERATED]');
@@ -674,9 +695,15 @@ export class AgentExecutor {
input: b.input,
});
}
else if (msg.type === 'error')
throw new Error(msg.error || 'Unknown error during implementation');
else if (msg.type === 'result' && msg.subtype === 'success') responseText += msg.result || '';
else if (msg.type === 'error') {
const cleanedError =
(msg.error || 'Unknown error during implementation')
.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/^Error:\s*/i, '')
.trim() || 'Unknown error during implementation';
throw new Error(cleanedError);
} else if (msg.type === 'result' && msg.subtype === 'success')
responseText += msg.result || '';
}
return { responseText };
}

View File

@@ -106,32 +106,26 @@ export class AgentService {
sessionId: string;
workingDirectory?: string;
}) {
if (!this.sessions.has(sessionId)) {
const messages = await this.loadSession(sessionId);
const metadata = await this.loadMetadata();
const sessionMetadata = metadata[sessionId];
// Determine the effective working directory
// ensureSession handles loading from disk if not in memory.
// For startConversation, we always want to create a session even if
// metadata doesn't exist yet (new session), so we fall back to creating one.
let session = await this.ensureSession(sessionId, workingDirectory);
if (!session) {
// Session doesn't exist on disk either — create a fresh in-memory session.
const effectiveWorkingDirectory = workingDirectory || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed using centralized validation
validateWorkingDirectory(resolvedWorkingDirectory);
// Load persisted queue
const promptQueue = await this.loadQueueState(sessionId);
this.sessions.set(sessionId, {
messages,
session = {
messages: [],
isRunning: false,
abortController: null,
workingDirectory: resolvedWorkingDirectory,
sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID
promptQueue,
});
promptQueue: [],
};
this.sessions.set(sessionId, session);
}
const session = this.sessions.get(sessionId)!;
return {
success: true,
messages: session.messages,
@@ -139,6 +133,90 @@ export class AgentService {
};
}
/**
* Ensure a session is loaded into memory.
*
* Sessions may exist on disk (in metadata and session files) but not be
* present in the in-memory Map — for example after a server restart, or
* when a client calls sendMessage before explicitly calling startConversation.
*
* This helper transparently loads the session from disk when it is missing
* from memory, eliminating "session not found" errors for sessions that
* were previously created but not yet initialized in memory.
*
* If both metadata and session files are missing, the session truly doesn't
* exist. A detailed diagnostic log is emitted so developers can track down
* how the invalid session ID was generated.
*
* @returns The in-memory Session object, or null if the session doesn't exist at all
*/
private async ensureSession(
sessionId: string,
workingDirectory?: string
): Promise<Session | null> {
const existing = this.sessions.get(sessionId);
if (existing) {
return existing;
}
// Try to load from disk — the session may have been created earlier
// (e.g. via createSession) but never initialized in memory.
let metadata: Record<string, SessionMetadata>;
let messages: Message[];
try {
[metadata, messages] = await Promise.all([this.loadMetadata(), this.loadSession(sessionId)]);
} catch (error) {
// Disk read failure should not be treated as "session not found" —
// it's a transient I/O problem. Log and return null so callers can
// surface an appropriate error message.
this.logger.error(
`Failed to load session ${sessionId} from disk (I/O error — NOT a missing session):`,
error
);
return null;
}
const sessionMetadata = metadata[sessionId];
// If there's no metadata AND no persisted messages, the session truly doesn't exist.
// Log diagnostic info to help track down how we ended up with an invalid session ID.
if (!sessionMetadata && messages.length === 0) {
this.logger.warn(
`Session "${sessionId}" not found: no metadata and no persisted messages. ` +
`This can happen when a session ID references a deleted/expired session, ` +
`or when the server restarted and the session was never persisted to disk. ` +
`Available session IDs in metadata: [${Object.keys(metadata).slice(0, 10).join(', ')}${Object.keys(metadata).length > 10 ? '...' : ''}]`
);
return null;
}
const effectiveWorkingDirectory =
workingDirectory || sessionMetadata?.workingDirectory || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed using centralized validation
validateWorkingDirectory(resolvedWorkingDirectory);
// Load persisted queue
const promptQueue = await this.loadQueueState(sessionId);
const session: Session = {
messages,
isRunning: false,
abortController: null,
workingDirectory: resolvedWorkingDirectory,
sdkSessionId: sessionMetadata?.sdkSessionId,
promptQueue,
};
this.sessions.set(sessionId, session);
this.logger.info(
`Auto-initialized session ${sessionId} from disk ` +
`(${messages.length} messages, sdkSessionId: ${sessionMetadata?.sdkSessionId ? 'present' : 'none'})`
);
return session;
}
/**
* Send a message to the agent and stream responses
*/
@@ -159,10 +237,18 @@ export class AgentService {
thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
}) {
const session = this.sessions.get(sessionId);
const session = await this.ensureSession(sessionId, workingDirectory);
if (!session) {
this.logger.error('ERROR: Session not found:', sessionId);
throw new Error(`Session ${sessionId} not found`);
this.logger.error(
`Session not found: ${sessionId}. ` +
`The session may have been deleted, never created, or lost after a server restart. ` +
`In-memory sessions: ${this.sessions.size}, requested ID: ${sessionId}`
);
throw new Error(
`Session ${sessionId} not found. ` +
`The session may have been deleted or expired. ` +
`Please create a new session and try again.`
);
}
if (session.isRunning) {
@@ -439,8 +525,13 @@ export class AgentService {
const toolUses: Array<{ name: string; input: unknown }> = [];
for await (const msg of stream) {
// Capture SDK session ID from any message and persist it
if (msg.session_id && !session.sdkSessionId) {
// Capture SDK session ID from any message and persist it.
// Update when:
// - No session ID set yet (first message in a new session)
// - The provider returned a *different* session ID (e.g., after a
// "Session not found" recovery where the provider started a fresh
// session — the stale ID must be replaced with the new one)
if (msg.session_id && msg.session_id !== session.sdkSessionId) {
session.sdkSessionId = msg.session_id;
// Persist the SDK session ID to ensure conversation continuity across server restarts
await this.updateSession(sessionId, { sdkSessionId: msg.session_id });
@@ -503,12 +594,43 @@ export class AgentService {
// streamed error messages instead of throwing. Handle these here so the
// Agent Runner UX matches the Claude/Cursor behavior without changing
// their provider implementations.
const rawErrorText =
// Clean error text: strip ANSI escape codes and the redundant "Error: "
// prefix that CLI providers (especially OpenCode) add to stderr output.
// The OpenCode provider strips these in normalizeEvent/executeQuery, but
// we also strip here as a defense-in-depth measure.
//
// Without stripping the "Error: " prefix, the wrapping at line ~647
// (`content: \`Error: ${enhancedText}\``) produces double-prefixed text:
// "Error: Error: Session not found" — confusing for the user.
const rawMsgError =
(typeof msg.error === 'string' && msg.error.trim()) ||
'Unexpected error from provider during agent execution.';
let rawErrorText = rawMsgError.replace(/\x1b\[[0-9;]*m/g, '').trim() || rawMsgError;
// Remove the CLI's "Error: " prefix to prevent double-wrapping
rawErrorText = rawErrorText.replace(/^Error:\s*/i, '').trim() || rawErrorText;
const errorInfo = classifyError(new Error(rawErrorText));
// Detect provider-side session errors and proactively clear the stale
// sdkSessionId so the next attempt starts a fresh provider session.
// This handles providers that don't have built-in session recovery
// (unlike OpenCode which auto-retries without the session flag).
const errorLower = rawErrorText.toLowerCase();
if (
session.sdkSessionId &&
(errorLower.includes('session not found') ||
errorLower.includes('session expired') ||
errorLower.includes('invalid session') ||
errorLower.includes('no such session'))
) {
this.logger.info(
`Clearing stale sdkSessionId for session ${sessionId} after provider session error`
);
session.sdkSessionId = undefined;
await this.clearSdkSessionId(sessionId);
}
// Keep the provider-supplied text intact (Codex already includes helpful tips),
// only add a small rate-limit hint when we can detect it.
const enhancedText = errorInfo.isRateLimit
@@ -569,13 +691,36 @@ export class AgentService {
this.logger.error('Error:', error);
// Strip ANSI escape codes and the "Error: " prefix from thrown error
// messages so the UI receives clean text without double-prefixing.
let rawThrownMsg = ((error as Error).message || '').replace(/\x1b\[[0-9;]*m/g, '').trim();
rawThrownMsg = rawThrownMsg.replace(/^Error:\s*/i, '').trim() || rawThrownMsg;
const thrownErrorMsg = rawThrownMsg.toLowerCase();
// Check if the thrown error is a provider-side session error.
// Clear the stale sdkSessionId so the next retry starts fresh.
if (
session.sdkSessionId &&
(thrownErrorMsg.includes('session not found') ||
thrownErrorMsg.includes('session expired') ||
thrownErrorMsg.includes('invalid session') ||
thrownErrorMsg.includes('no such session'))
) {
this.logger.info(
`Clearing stale sdkSessionId for session ${sessionId} after thrown session error`
);
session.sdkSessionId = undefined;
await this.clearSdkSessionId(sessionId);
}
session.isRunning = false;
session.abortController = null;
const cleanErrorMsg = rawThrownMsg || (error as Error).message;
const errorMessage: Message = {
id: this.generateId(),
role: 'assistant',
content: `Error: ${(error as Error).message}`,
content: `Error: ${cleanErrorMsg}`,
timestamp: new Date().toISOString(),
isError: true,
};
@@ -585,7 +730,7 @@ export class AgentService {
this.emitAgentEvent(sessionId, {
type: 'error',
error: (error as Error).message,
error: cleanErrorMsg,
message: errorMessage,
});
@@ -596,8 +741,8 @@ export class AgentService {
/**
* Get conversation history
*/
getHistory(sessionId: string) {
const session = this.sessions.get(sessionId);
async getHistory(sessionId: string) {
const session = await this.ensureSession(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
@@ -613,7 +758,7 @@ export class AgentService {
* Stop current agent execution
*/
async stopExecution(sessionId: string) {
const session = this.sessions.get(sessionId);
const session = await this.ensureSession(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
@@ -635,9 +780,16 @@ export class AgentService {
if (session) {
session.messages = [];
session.isRunning = false;
session.sdkSessionId = undefined; // Clear stale provider session ID to prevent "Session not found" errors
await this.saveSession(sessionId, []);
}
// Clear the sdkSessionId from persisted metadata so it doesn't get
// reloaded by ensureSession() after a server restart.
// This prevents "Session not found" errors when the provider-side session
// no longer exists (e.g., OpenCode CLI sessions expire on disk).
await this.clearSdkSessionId(sessionId);
return { success: true };
}
@@ -794,6 +946,23 @@ export class AgentService {
return true;
}
/**
* Clear the sdkSessionId from persisted metadata.
*
* This removes the provider-side session ID so that the next message
* starts a fresh provider session instead of trying to resume a stale one.
* Prevents "Session not found" errors from CLI providers like OpenCode
* when the provider-side session has been deleted or expired.
*/
async clearSdkSessionId(sessionId: string): Promise<void> {
const metadata = await this.loadMetadata();
if (metadata[sessionId] && metadata[sessionId].sdkSessionId) {
delete metadata[sessionId].sdkSessionId;
metadata[sessionId].updatedAt = new Date().toISOString();
await this.saveMetadata(metadata);
}
}
// Queue management methods
/**
@@ -808,7 +977,7 @@ export class AgentService {
thinkingLevel?: ThinkingLevel;
}
): Promise<{ success: boolean; queuedPrompt?: QueuedPrompt; error?: string }> {
const session = this.sessions.get(sessionId);
const session = await this.ensureSession(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
@@ -837,8 +1006,10 @@ export class AgentService {
/**
* Get the current queue for a session
*/
getQueue(sessionId: string): { success: boolean; queue?: QueuedPrompt[]; error?: string } {
const session = this.sessions.get(sessionId);
async getQueue(
sessionId: string
): Promise<{ success: boolean; queue?: QueuedPrompt[]; error?: string }> {
const session = await this.ensureSession(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
@@ -852,7 +1023,7 @@ export class AgentService {
sessionId: string,
promptId: string
): Promise<{ success: boolean; error?: string }> {
const session = this.sessions.get(sessionId);
const session = await this.ensureSession(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
@@ -877,7 +1048,7 @@ export class AgentService {
* Clear all prompts from the queue
*/
async clearQueue(sessionId: string): Promise<{ success: boolean; error?: string }> {
const session = this.sessions.get(sessionId);
const session = await this.ensureSession(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
@@ -960,10 +1131,24 @@ export class AgentService {
}
}
/**
* Emit an event to the agent stream (private, used internally).
*/
private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void {
this.events.emit('agent:stream', { sessionId, ...data });
}
/**
* Emit an error event for a session.
*
* Public method so that route handlers can surface errors to the UI
* even when sendMessage() throws before it can emit its own error event
* (e.g., when the session is not found and no in-memory session exists).
*/
emitSessionError(sessionId: string, error: string): void {
this.events.emit('agent:stream', { sessionId, type: 'error', error });
}
private async getSystemPrompt(): Promise<string> {
// Load from settings (no caching - allows hot reload of custom prompts)
const prompts = await getPromptCustomization(this.settingsService, '[AgentService]');

View File

@@ -388,7 +388,7 @@ export class AutoModeServiceFacade {
.replace(/\{\{taskName\}\}/g, task.description)
.replace(/\{\{taskIndex\}\}/g, String(taskIndex + 1))
.replace(/\{\{totalTasks\}\}/g, String(allTasks.length))
.replace(/\{\{taskDescription\}\}/g, task.description || task.description);
.replace(/\{\{taskDescription\}\}/g, task.description || `Task ${task.id}`);
if (feedback) {
taskPrompt = taskPrompt.replace(/\{\{userFeedback\}\}/g, feedback);
}
@@ -575,12 +575,17 @@ export class AutoModeServiceFacade {
useWorktrees = false,
_calledInternally = false
): Promise<void> {
return this.recoveryService.resumeFeature(
this.projectPath,
featureId,
useWorktrees,
_calledInternally
);
try {
return await this.recoveryService.resumeFeature(
this.projectPath,
featureId,
useWorktrees,
_calledInternally
);
} catch (error) {
this.handleFacadeError(error, 'resumeFeature', featureId);
throw error;
}
}
/**

View File

@@ -5,7 +5,7 @@
*/
import { createLogger } from '@automaker/utils';
import { createEventEmitter } from '../lib/events.js';
import { type EventEmitter } from '../lib/events.js';
import { execGitCommand } from '../lib/git.js';
const logger = createLogger('MergeService');
@@ -52,10 +52,9 @@ export async function performMerge(
branchName: string,
worktreePath: string,
targetBranch: string = 'main',
options?: MergeOptions
options?: MergeOptions,
emitter?: EventEmitter
): Promise<MergeServiceResult> {
const emitter = createEventEmitter();
if (!projectPath || !branchName || !worktreePath) {
return {
success: false,
@@ -100,7 +99,7 @@ export async function performMerge(
}
// Emit merge:start after validating inputs
emitter.emit('merge:start', { branchName, targetBranch: mergeTo, worktreePath });
emitter?.emit('merge:start', { branchName, targetBranch: mergeTo, worktreePath });
// Merge the feature branch into the target branch (using safe array-based commands)
const mergeMessage = options?.message || `Merge ${branchName} into ${mergeTo}`;
@@ -134,7 +133,7 @@ export async function performMerge(
}
// Emit merge:conflict event with conflict details
emitter.emit('merge:conflict', { branchName, targetBranch: mergeTo, conflictFiles });
emitter?.emit('merge:conflict', { branchName, targetBranch: mergeTo, conflictFiles });
return {
success: false,
@@ -145,7 +144,7 @@ export async function performMerge(
}
// Emit merge:error for non-conflict errors before re-throwing
emitter.emit('merge:error', {
emitter?.emit('merge:error', {
branchName,
targetBranch: mergeTo,
error: err.message || String(mergeError),
@@ -196,7 +195,7 @@ export async function performMerge(
}
// Emit merge:success with merged branch, target branch, and deletion info
emitter.emit('merge:success', {
emitter?.emit('merge:success', {
mergedBranch: branchName,
targetBranch: mergeTo,
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,

View File

@@ -15,7 +15,7 @@
*/
import { createLogger } from '@automaker/utils';
import { createEventEmitter } from '../lib/events.js';
import type { EventEmitter } from '../lib/events.js';
import { execGitCommand } from '../lib/git.js';
import { getErrorMessage, logError } from '../routes/worktree/common.js';
@@ -130,16 +130,16 @@ function isConflictOutput(output: string): boolean {
export async function applyOrPop(
worktreePath: string,
stashIndex: number,
options?: StashApplyOptions
options?: StashApplyOptions,
events?: EventEmitter
): Promise<StashApplyResult> {
const emitter = createEventEmitter();
const operation: 'apply' | 'pop' = options?.pop ? 'pop' : 'apply';
const stashRef = `stash@{${stashIndex}}`;
logger.info(`[StashService] ${operation} ${stashRef} in ${worktreePath}`);
// 1. Emit start event
emitter.emit('stash:start', { worktreePath, stashIndex, stashRef, operation });
events?.emit('stash:start', { worktreePath, stashIndex, stashRef, operation });
try {
// 2. Run git stash apply / pop
@@ -155,7 +155,7 @@ export async function applyOrPop(
const combinedOutput = `${errStdout}\n${errStderr}`;
// 3. Emit progress with raw output
emitter.emit('stash:progress', {
events?.emit('stash:progress', {
worktreePath,
stashIndex,
operation,
@@ -166,7 +166,7 @@ export async function applyOrPop(
if (isConflictOutput(combinedOutput)) {
const conflictFiles = await getConflictedFiles(worktreePath);
emitter.emit('stash:conflicts', {
events?.emit('stash:conflicts', {
worktreePath,
stashIndex,
operation,
@@ -183,7 +183,7 @@ export async function applyOrPop(
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
};
emitter.emit('stash:success', {
events?.emit('stash:success', {
worktreePath,
stashIndex,
operation,
@@ -202,12 +202,12 @@ export async function applyOrPop(
// exit 0 even when conflicts occur during apply)
const combinedOutput = stdout;
emitter.emit('stash:progress', { worktreePath, stashIndex, operation, output: combinedOutput });
events?.emit('stash:progress', { worktreePath, stashIndex, operation, output: combinedOutput });
if (isConflictOutput(combinedOutput)) {
const conflictFiles = await getConflictedFiles(worktreePath);
emitter.emit('stash:conflicts', {
events?.emit('stash:conflicts', {
worktreePath,
stashIndex,
operation,
@@ -224,7 +224,7 @@ export async function applyOrPop(
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
};
emitter.emit('stash:success', {
events?.emit('stash:success', {
worktreePath,
stashIndex,
operation,
@@ -245,7 +245,7 @@ export async function applyOrPop(
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} successfully`,
};
emitter.emit('stash:success', {
events?.emit('stash:success', {
worktreePath,
stashIndex,
operation,
@@ -258,7 +258,7 @@ export async function applyOrPop(
logError(error, `Stash ${operation} failed`);
emitter.emit('stash:failure', {
events?.emit('stash:failure', {
worktreePath,
stashIndex,
operation,

View File

@@ -73,7 +73,8 @@ async function hasAnyChanges(cwd: string): Promise<boolean> {
/**
* Stash all local changes (including untracked files)
* Returns true if a stash was created, false if there was nothing to stash
* Returns true if a stash was created, false if there was nothing to stash.
* Throws on unexpected errors so callers abort rather than proceeding silently.
*/
async function stashChanges(cwd: string, message: string): Promise<boolean> {
try {
@@ -95,8 +96,26 @@ async function stashChanges(cwd: string, message: string): Promise<boolean> {
.filter((l) => l.trim()).length;
return countAfter > countBefore;
} catch {
return false;
} catch (error) {
const errorMsg = getErrorMessage(error);
// "Nothing to stash" is benign no work was lost, just return false
if (
errorMsg.toLowerCase().includes('no local changes to save') ||
errorMsg.toLowerCase().includes('nothing to stash')
) {
logger.debug('stashChanges: nothing to stash', { cwd, message, error: errorMsg });
return false;
}
// Unexpected error log full details and re-throw so the caller aborts
// rather than proceeding with an un-stashed working tree
logger.error('stashChanges: unexpected error during stash', {
cwd,
message,
error: errorMsg,
});
throw new Error(`Failed to stash changes in ${cwd}: ${errorMsg}`);
}
}
@@ -284,7 +303,20 @@ export async function performSwitchBranch(
action: 'push',
});
const stashMessage = `automaker-branch-switch: ${previousBranch}${targetBranch}`;
didStash = await stashChanges(worktreePath, stashMessage);
try {
didStash = await stashChanges(worktreePath, stashMessage);
} catch (stashError) {
const stashErrorMsg = getErrorMessage(stashError);
events?.emit('switch:error', {
worktreePath,
branchName,
error: `Failed to stash local changes: ${stashErrorMsg}`,
});
return {
success: false,
error: `Failed to stash local changes before switching branches: ${stashErrorMsg}`,
};
}
}
try {