Make shared app-server metadata cache resource-level

This commit is contained in:
murashit 2026-06-14 20:57:49 +09:00
parent b547ef5b42
commit 2499ffca38
4 changed files with 184 additions and 33 deletions

View file

@ -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) };
}

View file

@ -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"
);
}

View file

@ -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> = {}): 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;
}

View file

@ -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<SharedAppServerCacheContext> = {}): 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,