farion1231/cc-switch · warning · anyhow::Error

Skill directory changed during install; please retry

Error message

Skill directory changed during install; please retry

What it means

Thrown by SkillService install after the skill-state write lock is deliberately released around the network download (.await) and then re-acquired. At re-check time the destination directory does not exist, but the in-memory downloaded_source slot is None, so there is no local copy to install from. It means another install/uninstall that ran during the download window consumed or invalidated the shared download state.

Solutions

  1. Retry the install once — state is normally consistent on the second attempt
  2. Disable the install button / debounce while an install request for that skill is in flight
  3. Serialize installs client-side (queue per skill name) so only one install flow runs at a time
  4. If it reproduces consistently, check whether a second app instance or another process is mutating the SSOT directory concurrently

Example fix

// before (frontend)
document.getElementById('install')!.onclick = () => invoke('install_skill_unified', { ... });

// after: single-flight per install name + one retry on the race signal
const inflight = new Set<string>();
async function install(payload: unknown, name: string) {
  if (inflight.has(name)) return;
  inflight.add(name);
  try {
    await invoke('install_skill_unified', payload);
  } catch (e) {
    if (String(e).includes('changed during install')) {
      await invoke('install_skill_unified', payload); // one retry
    } else throw e;
  } finally {
    inflight.delete(name);
  }
}
Defensive patterns

Strategy: retry

Validate before calling

const inflightInstalls = new Set<string>();
function canStartInstall(name: string): boolean {
  return !inflightInstalls.has(name.toLowerCase());
}

Try / catch

try {
  await invoke('install_skill_unified', payload);
} catch (e) {
  const msg = String(e);
  if (msg.includes('changed during install')) {
    await sleep(500);
    return invoke('install_skill_unified', payload); // exactly one retry
  }
  throw e;
}

Prevention

When it happens

Trigger: Invoking install_skill_unified twice for the same skill in quick succession (double-click, duplicate window); an uninstall of the same skill completing while an install is still downloading; two install flows racing so one takes the reuse_existing_install path and leaves the other without a downloaded source.

Common situations: Install button not disabled while the request is in flight; frontend auto-retry firing while the first request is still running; slow network widening the await window; two instances of the app pointing at the same SSOT directory.

Related errors


AI-assisted analysis of farion1231/cc-switch@06082e189d (2026-08-20). Data as JSON: /api/errors/a7d6a81299005f93. Report an issue: GitHub.

Appendix: source

Thrown at src-tauri/src/services/skill.rs:909

            &skill.directory,
        );

        let readme_url =
            Self::build_skill_doc_url(&skill.repo_owner, &skill.repo_name, &repo_branch, &doc_path);

        // Re-check after the network download: another install/uninstall may have
        // completed while the lock was intentionally released around `.await`.
        let _state_guard = skill_state_write_guard();
        if let Some(existing) = Self::reuse_existing_install(db, skill, &install_name, current_app)?
        {
            return Ok(existing);
        }

        if !dest.exists() {
            let source = downloaded_source
                .as_ref()
                .map(|(_, source)| source)
                .ok_or_else(|| anyhow!("Skill directory changed during install; please retry"))?;
            Self::preflight_install_destination(source, &install_name, current_app)?;
            Self::copy_dir_recursive(source, &dest)?;
        }

        // 创建 InstalledSkill 记录
        // 计算内容哈希
        let content_hash = Self::compute_dir_hash(&dest).map(Some).unwrap_or_else(|e| {
            log::warn!("Failed to compute content hash for {}: {e}", install_name);
            None
        });

        let installed_skill = InstalledSkill {
            id: skill.key.clone(),
            name: skill.name.clone(),
            description: if skill.description.is_empty() {
                None
            } else {
                Some(skill.description.clone())

View on GitHub (pinned to 06082e189d)