fix: reject instance-served responses that outlive their instance

MCP status and skills were cleared on a switch but their in-flight requests
were not, so a response for the previous instance could still write itself over
the new one's — the same race the quota store already guards. Both now carry a
generation.

Usage also claimed an instance as loaded before it had answered, so a load that
failed on a cold or briefly unreachable instance was never attempted again, and
the previous instance's display mode and provider selection survived a switch —
which decided what the new instance was even asked for.
This commit is contained in:
Bohdan Triapitsyn
2026-09-03 11:49:34 +03:00
parent 17fd46d4e5
commit f0ced9d361
5 changed files with 191 additions and 19 deletions
+8
View File
@@ -198,6 +198,10 @@ const SKILLS_LOAD_CACHE_TTL_MS = 5000;
const DEFAULT_SKILLS_CACHE_KEY = '__default__';
const skillsLastLoadedAt = new Map<string, number>();
const skillsLoadInFlight = new Map<string, Promise<boolean>>();
// Bumped on every runtime switch. Skills are discovered on the connected
// instance and cached by directory, which two instances can share, so a load
// already in flight for the previous instance must not write into the new one.
let skillsGeneration = 0;
const getSkillsCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
@@ -286,6 +290,7 @@ export const useSkillsStore = create<SkillsStore>()(
skillDraft: null,
resetForRuntimeSwitch: () => {
skillsGeneration += 1;
skillsLastLoadedAt.clear();
skillsLoadInFlight.clear();
set({ skills: [], skillsByDirectory: {}, isLoading: false });
@@ -316,6 +321,7 @@ export const useSkillsStore = create<SkillsStore>()(
return inFlight;
}
const generation = skillsGeneration;
const request = (async () => {
set({ isLoading: true });
// Failure must never look like an empty project. The mirror is the
@@ -361,6 +367,7 @@ export const useSkillsStore = create<SkillsStore>()(
data.externalSkills ?? null,
);
if (generation !== skillsGeneration) return false;
set((state) => {
const next: Partial<SkillsStore> = {
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills },
@@ -379,6 +386,7 @@ export const useSkillsStore = create<SkillsStore>()(
}
console.error("Failed to load skills:", lastError);
if (generation !== skillsGeneration) return false;
set((state) => {
const next: Partial<SkillsStore> = {
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills },