Skip to content

Commit acb360b

Browse files
committed
fix: resolve custom modes disappearing and creation issues
- Fix race condition in updateCustomMode method by reordering state refresh and cache clearing - Improve error handling and state recovery in refreshMergedState method - Add defensive checks for state consistency in getCustomModes method - Add comprehensive logging for debugging state issues - Ensure proper cache invalidation timing to prevent stale data - Add fallback recovery from global state when file operations fail - Validate merged modes before updating state to filter out invalid entries Fixes #5855
1 parent a28d50e commit acb360b

File tree

1 file changed

+191
-70
lines changed

1 file changed

+191
-70
lines changed

src/core/config/CustomModesManager.ts

Lines changed: 191 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -352,59 +352,128 @@ export class CustomModesManager {
352352
}
353353

354354
public async getCustomModes(): Promise<ModeConfig[]> {
355-
// Check if we have a valid cached result.
356-
const now = Date.now()
355+
try {
356+
// Check if we have a valid cached result.
357+
const now = Date.now()
357358

358-
if (this.cachedModes && now - this.cachedAt < CustomModesManager.cacheTTL) {
359-
return this.cachedModes
360-
}
359+
if (this.cachedModes && now - this.cachedAt < CustomModesManager.cacheTTL) {
360+
logger.debug("Returning cached custom modes", { count: this.cachedModes.length })
361+
return this.cachedModes
362+
}
361363

362-
// Get modes from settings file.
363-
const settingsPath = await this.getCustomModesFilePath()
364-
const settingsModes = await this.loadModesFromFile(settingsPath)
364+
logger.debug("Loading custom modes from files")
365365

366-
// Get modes from .roomodes if it exists.
367-
const roomodesPath = await this.getWorkspaceRoomodes()
368-
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
366+
// Get modes from settings file.
367+
const settingsPath = await this.getCustomModesFilePath()
368+
const settingsModes = await this.loadModesFromFile(settingsPath)
369+
370+
// Get modes from .roomodes if it exists.
371+
const roomodesPath = await this.getWorkspaceRoomodes()
372+
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
369373

370-
// Create maps to store modes by source.
371-
const projectModes = new Map<string, ModeConfig>()
372-
const globalModes = new Map<string, ModeConfig>()
374+
logger.debug("Loaded modes from files", {
375+
settingsCount: settingsModes.length,
376+
roomodesCount: roomodesModes.length,
377+
settingsPath,
378+
roomodesPath
379+
})
373380

374-
// Add project modes (they take precedence).
375-
for (const mode of roomodesModes) {
376-
projectModes.set(mode.slug, { ...mode, source: "project" as const })
377-
}
381+
// Create maps to store modes by source.
382+
const projectModes = new Map<string, ModeConfig>()
383+
const globalModes = new Map<string, ModeConfig>()
378384

379-
// Add global modes.
380-
for (const mode of settingsModes) {
381-
if (!projectModes.has(mode.slug)) {
382-
globalModes.set(mode.slug, { ...mode, source: "global" as const })
385+
// Add project modes (they take precedence).
386+
for (const mode of roomodesModes) {
387+
if (!mode.slug) {
388+
logger.warn("Found mode without slug in roomodes, skipping", { mode })
389+
continue
390+
}
391+
projectModes.set(mode.slug, { ...mode, source: "project" as const })
383392
}
384-
}
385393

386-
// Combine modes in the correct order: project modes first, then global modes.
387-
const mergedModes = [
388-
...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })),
389-
...settingsModes
390-
.filter((mode) => !projectModes.has(mode.slug))
391-
.map((mode) => ({ ...mode, source: "global" as const })),
392-
]
394+
// Add global modes.
395+
for (const mode of settingsModes) {
396+
if (!mode.slug) {
397+
logger.warn("Found mode without slug in settings, skipping", { mode })
398+
continue
399+
}
400+
if (!projectModes.has(mode.slug)) {
401+
globalModes.set(mode.slug, { ...mode, source: "global" as const })
402+
}
403+
}
393404

394-
await this.context.globalState.update("customModes", mergedModes)
405+
// Combine modes in the correct order: project modes first, then global modes.
406+
const mergedModes = [
407+
...roomodesModes
408+
.filter((mode) => mode.slug) // Defensive check
409+
.map((mode) => ({ ...mode, source: "project" as const })),
410+
...settingsModes
411+
.filter((mode) => mode.slug && !projectModes.has(mode.slug)) // Defensive checks
412+
.map((mode) => ({ ...mode, source: "global" as const })),
413+
]
414+
415+
logger.debug("Merged custom modes", {
416+
totalCount: mergedModes.length,
417+
projectCount: projectModes.size,
418+
globalCount: globalModes.size
419+
})
420+
421+
// Validate merged modes before updating state
422+
const validModes = mergedModes.filter((mode) => {
423+
const isValid = mode.slug && mode.name && mode.roleDefinition && Array.isArray(mode.groups)
424+
if (!isValid) {
425+
logger.warn("Found invalid mode, filtering out", { mode })
426+
}
427+
return isValid
428+
})
429+
430+
if (validModes.length !== mergedModes.length) {
431+
logger.warn("Filtered out invalid modes", {
432+
original: mergedModes.length,
433+
valid: validModes.length
434+
})
435+
}
436+
437+
// Update global state with validated modes
438+
await this.context.globalState.update("customModes", validModes)
395439

396-
this.cachedModes = mergedModes
397-
this.cachedAt = now
440+
// Update cache
441+
this.cachedModes = validModes
442+
this.cachedAt = now
398443

399-
return mergedModes
444+
logger.info("Successfully loaded custom modes", { count: validModes.length })
445+
return validModes
446+
} catch (error) {
447+
const errorMessage = error instanceof Error ? error.message : String(error)
448+
logger.error("Failed to get custom modes", { error: errorMessage })
449+
450+
// Try to recover from global state
451+
try {
452+
const fallbackModes = this.context.globalState.get("customModes", [])
453+
logger.info("Recovered custom modes from global state", { count: fallbackModes.length })
454+
455+
// Update cache with fallback data
456+
this.cachedModes = fallbackModes
457+
this.cachedAt = Date.now()
458+
459+
return fallbackModes
460+
} catch (recoveryError) {
461+
logger.error("Failed to recover custom modes from global state", {
462+
error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
463+
})
464+
465+
// Return empty array as last resort
466+
return []
467+
}
468+
}
400469
}
401470

402471
public async updateCustomMode(slug: string, config: ModeConfig): Promise<void> {
403472
try {
404473
// Validate the mode configuration before saving
405474
const validationResult = modeConfigSchema.safeParse(config)
406475
if (!validationResult.success) {
407-
const errors = validationResult.error.errors.map((e) => e.message).join(", ")
476+
const errors = validationResult.error.errors.map((e: any) => e.message).join(", ")
408477
logger.error(`Invalid mode configuration for ${slug}`, { errors: validationResult.error.errors })
409478
throw new Error(`Invalid mode configuration: ${errors}`)
410479
}
@@ -433,25 +502,43 @@ export class CustomModesManager {
433502
}
434503

435504
await this.queueWrite(async () => {
436-
// Ensure source is set correctly based on target file.
437-
const modeWithSource = {
438-
...config,
439-
source: isProjectMode ? ("project" as const) : ("global" as const),
440-
}
505+
try {
506+
// Ensure source is set correctly based on target file.
507+
const modeWithSource = {
508+
...config,
509+
source: isProjectMode ? ("project" as const) : ("global" as const),
510+
}
441511

442-
await this.updateModesInFile(targetPath, (modes) => {
443-
const updatedModes = modes.filter((m) => m.slug !== slug)
444-
updatedModes.push(modeWithSource)
445-
return updatedModes
446-
})
512+
await this.updateModesInFile(targetPath, (modes) => {
513+
const updatedModes = modes.filter((m) => m.slug !== slug)
514+
updatedModes.push(modeWithSource)
515+
return updatedModes
516+
})
447517

448-
this.clearCache()
449-
await this.refreshMergedState()
518+
// Refresh state before clearing cache to ensure consistency
519+
await this.refreshMergedState()
520+
this.clearCache()
521+
522+
// Log successful update for debugging
523+
logger.info(`Successfully updated custom mode: ${slug}`, {
524+
source: modeWithSource.source,
525+
targetPath
526+
})
527+
} catch (writeError) {
528+
// If file write fails, ensure cache is still cleared to prevent stale data
529+
this.clearCache()
530+
throw writeError
531+
}
450532
})
451533
} catch (error) {
452534
const errorMessage = error instanceof Error ? error.message : String(error)
453535
logger.error("Failed to update custom mode", { slug, error: errorMessage })
536+
537+
// Clear cache on error to prevent inconsistent state
538+
this.clearCache()
539+
454540
vscode.window.showErrorMessage(t("common:customModes.errors.updateFailed", { error: errorMessage }))
541+
throw error // Re-throw to allow caller to handle
455542
}
456543
}
457544

@@ -487,18 +574,52 @@ export class CustomModesManager {
487574
}
488575

489576
private async refreshMergedState(): Promise<void> {
490-
const settingsPath = await this.getCustomModesFilePath()
491-
const roomodesPath = await this.getWorkspaceRoomodes()
577+
try {
578+
const settingsPath = await this.getCustomModesFilePath()
579+
const roomodesPath = await this.getWorkspaceRoomodes()
580+
581+
logger.debug("Refreshing merged state", { settingsPath, roomodesPath })
582+
583+
const settingsModes = await this.loadModesFromFile(settingsPath)
584+
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
585+
const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
492586

493-
const settingsModes = await this.loadModesFromFile(settingsPath)
494-
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
495-
const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
587+
logger.debug("Merged modes loaded", {
588+
settingsCount: settingsModes.length,
589+
roomodesCount: roomodesModes.length,
590+
mergedCount: mergedModes.length
591+
})
496592

497-
await this.context.globalState.update("customModes", mergedModes)
593+
// Update global state with merged modes
594+
await this.context.globalState.update("customModes", mergedModes)
498595

499-
this.clearCache()
596+
// Update cache with fresh data
597+
this.cachedModes = mergedModes
598+
this.cachedAt = Date.now()
500599

501-
await this.onUpdate()
600+
// Notify listeners of the update
601+
await this.onUpdate()
602+
} catch (error) {
603+
const errorMessage = error instanceof Error ? error.message : String(error)
604+
logger.error("Failed to refresh merged state", { error: errorMessage })
605+
606+
// On error, clear cache to prevent stale data
607+
this.clearCache()
608+
609+
// Try to recover by loading from global state
610+
try {
611+
const fallbackModes = this.context.globalState.get("customModes", [])
612+
logger.info("Recovered custom modes from global state", { count: fallbackModes.length })
613+
this.cachedModes = fallbackModes
614+
this.cachedAt = Date.now()
615+
} catch (recoveryError) {
616+
logger.error("Failed to recover custom modes from global state", {
617+
error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
618+
})
619+
}
620+
621+
throw error
622+
}
502623
}
503624

504625
public async deleteCustomMode(slug: string): Promise<void> {
@@ -729,10 +850,10 @@ export class CustomModesManager {
729850

730851
// Merge custom prompts if provided
731852
if (customPrompts) {
732-
if (customPrompts.roleDefinition) exportMode.roleDefinition = customPrompts.roleDefinition
733-
if (customPrompts.description) exportMode.description = customPrompts.description
734-
if (customPrompts.whenToUse) exportMode.whenToUse = customPrompts.whenToUse
735-
if (customPrompts.customInstructions) exportMode.customInstructions = customPrompts.customInstructions
853+
if (customPrompts.roleDefinition) (exportMode as any).roleDefinition = customPrompts.roleDefinition
854+
if (customPrompts.description) (exportMode as any).description = customPrompts.description
855+
if (customPrompts.whenToUse) (exportMode as any).whenToUse = customPrompts.whenToUse
856+
if (customPrompts.customInstructions) (exportMode as any).customInstructions = customPrompts.customInstructions
736857
}
737858

738859
// Add rules files if any exist
@@ -799,24 +920,24 @@ export class CustomModesManager {
799920
// Validate the mode configuration
800921
const validationResult = modeConfigSchema.safeParse(modeConfig)
801922
if (!validationResult.success) {
802-
logger.error(`Invalid mode configuration for ${modeConfig.slug}`, {
923+
logger.error(`Invalid mode configuration for ${(modeConfig as any).slug}`, {
803924
errors: validationResult.error.errors,
804925
})
805926
return {
806927
success: false,
807-
error: `Invalid mode configuration for ${modeConfig.slug}: ${validationResult.error.errors.map((e) => e.message).join(", ")}`,
928+
error: `Invalid mode configuration for ${(modeConfig as any).slug}: ${validationResult.error.errors.map((e: any) => e.message).join(", ")}`,
808929
}
809930
}
810931

811932
// Check for existing mode conflicts
812933
const existingModes = await this.getCustomModes()
813-
const existingMode = existingModes.find((m) => m.slug === importMode.slug)
934+
const existingMode = existingModes.find((m) => m.slug === (importMode as any).slug)
814935
if (existingMode) {
815-
logger.info(`Overwriting existing mode: ${importMode.slug}`)
936+
logger.info(`Overwriting existing mode: ${(importMode as any).slug}`)
816937
}
817938

818939
// Import the mode configuration with the specified source
819-
await this.updateCustomMode(importMode.slug, {
940+
await this.updateCustomMode((importMode as any).slug, {
820941
...modeConfig,
821942
source: source, // Use the provided source parameter
822943
})
@@ -827,13 +948,13 @@ export class CustomModesManager {
827948

828949
// Always remove the existing rules folder for this mode if it exists
829950
// This ensures that if the imported mode has no rules, the folder is cleaned up
830-
const rulesFolderPath = path.join(workspacePath, ".roo", `rules-${importMode.slug}`)
951+
const rulesFolderPath = path.join(workspacePath, ".roo", `rules-${(importMode as any).slug}`)
831952
try {
832953
await fs.rm(rulesFolderPath, { recursive: true, force: true })
833-
logger.info(`Removed existing rules folder for mode ${importMode.slug}`)
954+
logger.info(`Removed existing rules folder for mode ${(importMode as any).slug}`)
834955
} catch (error) {
835956
// It's okay if the folder doesn't exist
836-
logger.debug(`No existing rules folder to remove for mode ${importMode.slug}`)
957+
logger.debug(`No existing rules folder to remove for mode ${(importMode as any).slug}`)
837958
}
838959

839960
// Only create new rules files if they exist in the import
@@ -875,13 +996,13 @@ export class CustomModesManager {
875996

876997
// Always remove the existing rules folder for this mode if it exists
877998
// This ensures that if the imported mode has no rules, the folder is cleaned up
878-
const rulesFolderPath = path.join(globalRooDir, `rules-${importMode.slug}`)
999+
const rulesFolderPath = path.join(globalRooDir, `rules-${(importMode as any).slug}`)
8791000
try {
8801001
await fs.rm(rulesFolderPath, { recursive: true, force: true })
881-
logger.info(`Removed existing global rules folder for mode ${importMode.slug}`)
1002+
logger.info(`Removed existing global rules folder for mode ${(importMode as any).slug}`)
8821003
} catch (error) {
8831004
// It's okay if the folder doesn't exist
884-
logger.debug(`No existing global rules folder to remove for mode ${importMode.slug}`)
1005+
logger.debug(`No existing global rules folder to remove for mode ${(importMode as any).slug}`)
8851006
}
8861007

8871008
// Import the new rules files with path validation

0 commit comments

Comments
 (0)