From 2499ffca3820aedeb56710f82bd1f3dfd4dc4cfd Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 14 Jun 2026 20:57:49 +0900 Subject: [PATCH] Make shared app-server metadata cache resource-level --- src/app-server/services/shared-cache-state.ts | 66 +++++++++++++-- src/app-server/services/shared-cache.ts | 10 --- tests/app-server/shared-cache-state.test.ts | 61 +++++++++++++- tests/app-server/shared-cache.test.ts | 80 +++++++++++++++---- 4 files changed, 184 insertions(+), 33 deletions(-) diff --git a/src/app-server/services/shared-cache-state.ts b/src/app-server/services/shared-cache-state.ts index 612003d0..c7aecfb9 100644 --- a/src/app-server/services/shared-cache-state.ts +++ b/src/app-server/services/shared-cache-state.ts @@ -56,15 +56,22 @@ export function applySharedServerMetadata( metadata: SharedServerMetadata, ): SharedAppServerState { if (!sharedAppServerCacheContextIsComplete(context)) return state; - const clonedMetadata = cloneSharedServerMetadata(metadata); + const previous = + state.appServerMetadata.kind === "loaded" && sharedAppServerCacheContextMatches(state.appServerMetadata.context, context) + ? state.appServerMetadata.data + : null; + const clonedMetadata = mergeSharedServerMetadata(previous, metadata); return { ...state, appServerMetadata: { kind: "loaded", context: cloneSharedAppServerCacheContext(context), data: clonedMetadata }, - availableModels: { - kind: "loaded", - context: cloneSharedAppServerCacheContext(context), - data: cloneModelMetadata(clonedMetadata.availableModels), - }, + availableModels: + clonedMetadata.availableModels.length > 0 + ? { + kind: "loaded", + context: cloneSharedAppServerCacheContext(context), + data: cloneModelMetadata(clonedMetadata.availableModels), + } + : state.availableModels, }; } @@ -125,6 +132,53 @@ function cloneSharedServerMetadata(metadata: SharedServerMetadata): SharedServer }; } +function mergeSharedServerMetadata(previous: SharedServerMetadata | null, next: SharedServerMetadata): SharedServerMetadata { + const clonedNext = cloneSharedServerMetadata(next); + const clonedPrevious = previous ? cloneSharedServerMetadata(previous) : emptySharedServerMetadataResourceCache(clonedNext); + return { + ...clonedNext, + availableModels: metadataResourceSucceeded(clonedNext, "model/list") ? clonedNext.availableModels : clonedPrevious.availableModels, + availableSkills: metadataResourceSucceeded(clonedNext, "skills/list") ? clonedNext.availableSkills : clonedPrevious.availableSkills, + rateLimit: metadataResourceSucceeded(clonedNext, "account/rateLimits/read") ? clonedNext.rateLimit : clonedPrevious.rateLimit, + serverDiagnostics: mergeServerDiagnostics(clonedPrevious, clonedNext), + }; +} + +function emptySharedServerMetadataResourceCache(metadata: SharedServerMetadata): SharedServerMetadata { + return { + ...metadata, + availableModels: [], + availableSkills: [], + rateLimit: null, + serverDiagnostics: { + probes: { ...metadata.serverDiagnostics.probes }, + mcpServers: [], + }, + }; +} + +function metadataResourceSucceeded( + metadata: SharedServerMetadata, + method: keyof SharedServerMetadata["serverDiagnostics"]["probes"], +): boolean { + if (method === "model/list") return metadata.availableModels.length > 0 && metadata.serverDiagnostics.probes[method].status === "ok"; + return metadata.serverDiagnostics.probes[method].status === "ok"; +} + +function mergeServerDiagnostics(previous: SharedServerMetadata, next: SharedServerMetadata): SharedServerMetadata["serverDiagnostics"] { + const probes = { ...next.serverDiagnostics.probes }; + for (const method of Object.keys(probes) as (keyof typeof probes)[]) { + if (probes[method].status === "failed") probes[method] = previous.serverDiagnostics.probes[method]; + } + return { + probes, + mcpServers: + next.serverDiagnostics.probes["mcpServerStatus/list"].status === "failed" + ? previous.serverDiagnostics.mcpServers.map((server) => ({ ...server })) + : next.serverDiagnostics.mcpServers.map((server) => ({ ...server })), + }; +} + function cloneSharedThreadListSnapshot(snapshot: SharedThreadListSnapshot): SharedThreadListSnapshot { return { status: snapshot.status, threads: cloneThreads(snapshot.threads) }; } diff --git a/src/app-server/services/shared-cache.ts b/src/app-server/services/shared-cache.ts index 01c972af..1ecf0897 100644 --- a/src/app-server/services/shared-cache.ts +++ b/src/app-server/services/shared-cache.ts @@ -68,7 +68,6 @@ export class SharedAppServerCache { } applyAppServerMetadataSnapshot(context: SharedAppServerCacheContext, metadata: SharedServerMetadata): void { - if (!isCacheableSharedServerMetadata(metadata)) return; this.state = applySharedServerMetadata(this.state, context, metadata); } @@ -85,12 +84,3 @@ export class SharedAppServerCache { return cachedSharedModels(this.state, context); } } - -function isCacheableSharedServerMetadata(metadata: SharedServerMetadata): boolean { - return ( - metadata.availableModels.length > 0 && - metadata.serverDiagnostics.probes["model/list"].status === "ok" && - metadata.serverDiagnostics.probes["skills/list"].status === "ok" && - metadata.serverDiagnostics.probes["account/rateLimits/read"].status === "ok" - ); -} diff --git a/tests/app-server/shared-cache-state.test.ts b/tests/app-server/shared-cache-state.test.ts index eaa0be04..05854e38 100644 --- a/tests/app-server/shared-cache-state.test.ts +++ b/tests/app-server/shared-cache-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createServerDiagnostics } from "../../src/domain/server/diagnostics"; +import { createServerDiagnostics, diagnosticProbeError, diagnosticProbeOk } from "../../src/domain/server/diagnostics"; import type { RateLimitSnapshot } from "../../src/app-server/protocol/runtime-metrics"; import { emptyRuntimeConfigSnapshot } from "../../src/app-server/protocol/runtime-config"; import { @@ -45,7 +45,7 @@ describe("shared app-server cache state", () => { availableSkills: [{ name: "skill", description: "", path: "/tmp/skill", enabled: true }], rateLimit: sourceRateLimit, serverDiagnostics: { - ...createServerDiagnostics(), + ...diagnostics({}), mcpServers: [{ name: "server", startupStatus: "ready", authStatus: null, toolCount: 1, message: null }], }, }); @@ -103,6 +103,50 @@ describe("shared app-server cache state", () => { expect(cachedSharedServerMetadata(state, incompleteContext)).toBeNull(); } }); + + it("merges app-server metadata by successful resource", () => { + const context = cacheContext(); + const first = applySharedServerMetadata(createSharedAppServerState(), context, { + runtimeConfig: emptyRuntimeConfigSnapshot(), + availableModels: [modelFixture("gpt-5.5")], + availableSkills: [{ name: "writer", description: "", path: "/tmp/writer", enabled: true }], + rateLimit: rateLimitFixture(), + serverDiagnostics: diagnostics({}), + }); + + const second = applySharedServerMetadata(first, context, { + runtimeConfig: emptyRuntimeConfigSnapshot(), + availableModels: [modelFixture("gpt-5.6")], + availableSkills: [{ name: "stale", description: "", path: "/tmp/stale", enabled: true }], + rateLimit: null, + serverDiagnostics: diagnostics({ skills: "failed", rateLimit: "failed" }), + }); + + const cached = expectPresent(cachedSharedServerMetadata(second, context)); + expect(cached.availableModels.map((model) => model.model)).toEqual(["gpt-5.6"]); + expect(cached.availableSkills.map((skill) => skill.name)).toEqual(["writer"]); + expect(cached.rateLimit?.primary?.usedPercent).toBe(42); + expect(expectPresent(cachedSharedModels(second, context)).map((model) => model.model)).toEqual(["gpt-5.6"]); + }); + + it("loads initial app-server metadata while caching only successful resources", () => { + const context = cacheContext(); + const state = applySharedServerMetadata(createSharedAppServerState(), context, { + runtimeConfig: emptyRuntimeConfigSnapshot(), + availableModels: [modelFixture("failed-model")], + availableSkills: [{ name: "writer", description: "", path: "/tmp/writer", enabled: true }], + rateLimit: rateLimitFixture(), + serverDiagnostics: diagnostics({ models: "failed", rateLimit: "failed" }), + }); + + const cached = expectPresent(cachedSharedServerMetadata(state, context)); + expect(cached.runtimeConfig).not.toBeNull(); + expect(cached.serverDiagnostics.probes["model/list"].status).toBe("failed"); + expect(cached.availableModels).toEqual([]); + expect(cached.availableSkills.map((skill) => skill.name)).toEqual(["writer"]); + expect(cached.rateLimit).toBeNull(); + expect(cachedSharedModels(state, context)).toBeNull(); + }); }); function cacheContext(overrides: Partial = {}): SharedAppServerCacheContext { @@ -157,3 +201,16 @@ function rateLimitFixture(): RateLimitSnapshot { rateLimitReachedType: null, }; } + +function diagnostics(overrides: { models?: "ok" | "failed"; skills?: "ok" | "failed"; rateLimit?: "ok" | "failed" }) { + const next = createServerDiagnostics(); + next.probes["model/list"] = + overrides.models === "failed" ? diagnosticProbeError("model/list", new Error("offline")) : diagnosticProbeOk("model/list"); + next.probes["skills/list"] = + overrides.skills === "failed" ? diagnosticProbeError("skills/list", new Error("offline")) : diagnosticProbeOk("skills/list"); + next.probes["account/rateLimits/read"] = + overrides.rateLimit === "failed" + ? diagnosticProbeError("account/rateLimits/read", new Error("offline")) + : diagnosticProbeOk("account/rateLimits/read"); + return next; +} diff --git a/tests/app-server/shared-cache.test.ts b/tests/app-server/shared-cache.test.ts index 084e9fc5..1911d1a6 100644 --- a/tests/app-server/shared-cache.test.ts +++ b/tests/app-server/shared-cache.test.ts @@ -3,30 +3,63 @@ import { describe, expect, it, vi } from "vitest"; import { createServerDiagnostics, diagnosticProbeError, diagnosticProbeOk } from "../../src/domain/server/diagnostics"; import { SharedAppServerCache } from "../../src/app-server/services/shared-cache"; import { emptyRuntimeConfigSnapshot, type RuntimeConfigSnapshot } from "../../src/app-server/protocol/runtime-config"; +import type { RateLimitSnapshot } from "../../src/app-server/protocol/runtime-metrics"; import type { SharedAppServerCacheContext, SharedServerMetadata } from "../../src/app-server/services/shared-cache-state"; -import type { ModelMetadata } from "../../src/domain/catalog/metadata"; +import type { ModelMetadata, SkillMetadata } from "../../src/domain/catalog/metadata"; describe("SharedAppServerCache", () => { - it("does not store failed or empty metadata snapshots as shared cache truth", () => { + it("updates successful metadata resources while preserving failed resource cache values", () => { const cache = new SharedAppServerCache(); const context = cacheContext(); - const goodMetadata = metadata(); + const goodMetadata = metadata({ + availableSkills: [skillMetadata("writer")], + rateLimit: rateLimit(42), + }); cache.applyAppServerMetadataSnapshot(context, goodMetadata); expect(cache.cachedAppServerMetadata(context)?.availableModels.map((model) => model.model)).toEqual(["gpt-5.5"]); - const invalidSnapshots = [ - metadata({ availableModels: [] }), - metadata({ modelProbeStatus: "failed" }), - metadata({ skillsProbeStatus: "failed" }), - metadata({ rateLimitProbeStatus: "failed" }), - ]; + cache.applyAppServerMetadataSnapshot( + context, + metadata({ + availableModels: [modelMetadata("gpt-5.6")], + availableSkills: [skillMetadata("stale-skill")], + rateLimit: rateLimit(90), + skillsProbeStatus: "failed", + rateLimitProbeStatus: "failed", + }), + ); - for (const snapshot of invalidSnapshots) { - cache.applyAppServerMetadataSnapshot(context, snapshot); - } + expect(cache.cachedAppServerMetadata(context)?.availableModels.map((model) => model.model)).toEqual(["gpt-5.6"]); + expect(cache.cachedAppServerMetadata(context)?.availableSkills.map((skill) => skill.name)).toEqual(["writer"]); + expect(cache.cachedAppServerMetadata(context)?.rateLimit?.primary?.usedPercent).toBe(42); - expect(cache.cachedAppServerMetadata(context)?.availableModels.map((model) => model.model)).toEqual(["gpt-5.5"]); + cache.applyAppServerMetadataSnapshot(context, metadata({ availableModels: [], modelProbeStatus: "failed" })); + expect(cache.cachedAppServerMetadata(context)?.availableModels.map((model) => model.model)).toEqual(["gpt-5.6"]); + }); + + it("loads initial metadata snapshots without caching failed resource values", () => { + const cache = new SharedAppServerCache(); + const context = cacheContext(); + + cache.applyAppServerMetadataSnapshot( + context, + metadata({ + availableModels: [modelMetadata("failed-model")], + availableSkills: [skillMetadata("writer")], + rateLimit: rateLimit(90), + modelProbeStatus: "failed", + rateLimitProbeStatus: "failed", + }), + ); + + const cached = cache.cachedAppServerMetadata(context); + expect(cached?.runtimeConfig).not.toBeNull(); + expect(cached?.serverDiagnostics.probes["model/list"].status).toBe("failed"); + expect(cached?.availableModels).toEqual([]); + expect(cached?.availableSkills.map((skill) => skill.name)).toEqual(["writer"]); + expect(cached?.rateLimit).toBeNull(); + expect(cache.cachedModels(context)).toBeNull(); }); it("does not share or store snapshots before the app-server identity is known", async () => { @@ -148,6 +181,8 @@ function cacheContext(overrides: Partial = {}): Sha function metadata( overrides: { availableModels?: readonly ModelMetadata[]; + availableSkills?: readonly SkillMetadata[]; + rateLimit?: RateLimitSnapshot | null; runtimeConfig?: RuntimeConfigSnapshot | null; modelProbeStatus?: "ok" | "failed"; skillsProbeStatus?: "ok" | "failed"; @@ -170,12 +205,27 @@ function metadata( return { runtimeConfig: overrides.runtimeConfig ?? emptyRuntimeConfigSnapshot(), availableModels: overrides.availableModels ?? [modelMetadata("gpt-5.5")], - availableSkills: [], - rateLimit: null, + availableSkills: overrides.availableSkills ?? [], + rateLimit: overrides.rateLimit ?? null, serverDiagnostics: diagnostics, }; } +function skillMetadata(name: string): SkillMetadata { + return { name, description: "", path: `/tmp/${name}`, enabled: true }; +} + +function rateLimit(usedPercent: number): RateLimitSnapshot { + return { + limitId: "codex", + limitName: "Codex", + primary: { usedPercent, windowDurationMins: 300, resetsAt: 1 }, + secondary: null, + individualLimit: null, + rateLimitReachedType: null, + }; +} + function modelMetadata(model: string): ModelMetadata { return { id: model,