From 8d82e26e5f3460d40197ff9b4ddcf04117682547 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Thu, 27 Aug 2026 00:32:26 -0700 Subject: [PATCH] fix: avoid rebuilding unchanged agent runtimes on reload (#129257) * fix: scope prepared runtime refreshes by agent (oc-92a.6) * fix: keep scoped runtime refresh linear (oc-92a.6) * fix: keep gateway reload hot path within lint limit --- .../prepared-model-runtime.refresh-scope.ts | 152 ++++++++++++++++++ ...pared-model-runtime.scoped-refresh.test.ts | 136 ++++++++++++++++ src/agents/prepared-model-runtime.ts | 95 ++++++----- src/agents/prepared-model-runtime.types.ts | 2 + src/gateway/server-reload-handlers.test.ts | 27 +++- src/gateway/server-reload-hot.ts | 15 +- .../server-reload-model-runtime-scope.test.ts | 24 +++ .../server-reload-model-runtime-scope.ts | 40 +++++ test/vitest-unit-fast-config.test.ts | 1 + test/vitest/vitest.unit-fast-paths.mjs | 2 +- 10 files changed, 436 insertions(+), 58 deletions(-) create mode 100644 src/agents/prepared-model-runtime.refresh-scope.ts create mode 100644 src/agents/prepared-model-runtime.scoped-refresh.test.ts create mode 100644 src/gateway/server-reload-model-runtime-scope.test.ts create mode 100644 src/gateway/server-reload-model-runtime-scope.ts diff --git a/src/agents/prepared-model-runtime.refresh-scope.ts b/src/agents/prepared-model-runtime.refresh-scope.ts new file mode 100644 index 000000000000..b9ba5c14635f --- /dev/null +++ b/src/agents/prepared-model-runtime.refresh-scope.ts @@ -0,0 +1,152 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + advancePreparedModelRuntimeOwnerConfig, + listConfiguredOwnerInputs, + normalizePreparedModelRuntimeInput, + ownerKey, +} from "./prepared-model-runtime.owner.js"; +import type { + PreparedModelRuntimeInput, + PreparedModelRuntimeOwner, + PreparedModelRuntimeRefreshOptions, +} from "./prepared-model-runtime.types.js"; + +/** Whether a refresh scope must replace this owner rather than retain it. */ +export function isPreparedModelRuntimeOwnerInRefreshScope( + owner: PreparedModelRuntimeOwner, + agentIds: ReadonlySet | undefined, +): boolean { + if (!agentIds) { + return true; + } + // Standalone and read-only owners keep independent config identities, so only configured and + // leased run owners participate in agent-scoped retention. + if (owner.input.readOnly || (owner.provenance !== "configured" && owner.provenance !== "run")) { + return true; + } + return !owner.input.agentId || agentIds.has(owner.input.agentId); +} + +/** Builds configured inputs while preserving the startup-selected default workspace. */ +export function listConfiguredRefreshInputs( + config: OpenClawConfig, + options: PreparedModelRuntimeRefreshOptions, + owners: Map, +): PreparedModelRuntimeInput[] { + const preservedWorkspaceByAgentDir = new Map>(); + for (const owner of owners.values()) { + const { agentDir, agentId, preserveWorkspaceDirOnRefresh, workspaceDir } = owner.input; + if ( + owner.provenance !== "configured" || + !agentId || + !preserveWorkspaceDirOnRefresh || + !workspaceDir + ) { + continue; + } + let workspacesByDir = preservedWorkspaceByAgentDir.get(agentId); + if (!workspacesByDir) { + workspacesByDir = new Map(); + preservedWorkspaceByAgentDir.set(agentId, workspacesByDir); + } + if (!workspacesByDir.has(agentDir)) { + workspacesByDir.set(agentDir, workspaceDir); + } + } + const inputs: PreparedModelRuntimeInput[] = []; + for (const rawInput of listConfiguredOwnerInputs( + config, + options.defaultWorkspaceDir, + options.allowGatewaySubagentBinding, + )) { + const input = normalizePreparedModelRuntimeInput(rawInput); + const preservedWorkspaceDir = input.agentId + ? preservedWorkspaceByAgentDir.get(input.agentId)?.get(input.agentDir) + : undefined; + inputs.push( + preservedWorkspaceDir + ? { + ...input, + workspaceDir: preservedWorkspaceDir, + preserveWorkspaceDirOnRefresh: true, + } + : input, + ); + } + return inputs; +} + +/** Invalidates scoped owners and optionally advances retained owners to a new config stamp. */ +export function updateOwnersForScopedRefresh( + owners: Map, + agentIds: ReadonlySet | undefined, + staleError: Error, + options: { + retainedConfig?: OpenClawConfig; + retireStandalone?: boolean; + clearPending?: boolean; + resetPluginGeneration?: boolean; + } = {}, +): void { + for (const [key, owner] of owners) { + if (!isPreparedModelRuntimeOwnerInRefreshScope(owner, agentIds)) { + if (options.retainedConfig) { + advancePreparedModelRuntimeOwnerConfig(owner, options.retainedConfig); + } + continue; + } + if (options.retireStandalone && owner.provenance === "standalone") { + owner.generation += 1; + owners.delete(key); + continue; + } + owner.generation += 1; + owner.needsRefresh = true; + owner.refreshError = staleError; + if (options.clearPending) { + owner.pending = undefined; + } + if (options.resetPluginGeneration) { + owner.pluginGeneration = undefined; + } + } +} + +/** Keeps a requested scope only when every retained owner has identical prepared dependencies. */ +export function resolveSafeRefreshAgentIds( + config: OpenClawConfig, + options: PreparedModelRuntimeRefreshOptions, + owners: Map, +): ReadonlySet | undefined { + const requested = options.agentIds; + if (!requested) { + return undefined; + } + const inputs = new Map( + listConfiguredRefreshInputs(config, options, owners).flatMap((input) => + input.agentId ? [[input.agentId, input] as const] : [], + ), + ); + for (const owner of owners.values()) { + if ( + owner.provenance !== "configured" || + !owner.input.agentId || + requested.has(owner.input.agentId) + ) { + continue; + } + const input = inputs.get(owner.input.agentId); + if ( + !input || + !owner.snapshot || + owner.needsRefresh || + owner.catalogMode !== (options.catalogMode ?? "live") || + (options.pluginMetadataSnapshot && + owner.snapshot.metadataSnapshot !== options.pluginMetadataSnapshot) || + ownerKey({ ...owner.input, config: input.config }) !== ownerKey(input) + ) { + return undefined; + } + } + return requested; +} diff --git a/src/agents/prepared-model-runtime.scoped-refresh.test.ts b/src/agents/prepared-model-runtime.scoped-refresh.test.ts new file mode 100644 index 000000000000..90d241eacdcf --- /dev/null +++ b/src/agents/prepared-model-runtime.scoped-refresh.test.ts @@ -0,0 +1,136 @@ +// Preserve module setup before modules that consume it. +// oxfmt-ignore +import { + getPreparedModelRuntimeMocks, + resetPreparedModelRuntimeHarness, +} from "./prepared-model-runtime.test-harness.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { getPreparedModelRuntimeAuthStore } from "./prepared-model-runtime-auth.js"; +import { + getPreparedModelRuntimeSnapshot, + refreshPreparedModelRuntimeSnapshots, +} from "./prepared-model-runtime.js"; + +const mocks = getPreparedModelRuntimeMocks(); + +describe("prepared model runtime scoped refresh", () => { + beforeEach(() => resetPreparedModelRuntimeHarness()); + + it("retains unaffected configured owners across an agent-scoped refresh", async () => { + mocks.configuredAgentIds = ["pro", "free"]; + const initialConfig = { + agents: { + entries: { + pro: { model: "openai/gpt-5.6" }, + free: { model: "openai/gpt-5.5" }, + }, + }, + } satisfies OpenClawConfig; + const nextConfig = { + agents: { + entries: { + pro: { model: "openai/gpt-5.4" }, + free: { model: "openai/gpt-5.5" }, + }, + }, + } satisfies OpenClawConfig; + const buildCounts: number[] = []; + const freeInput = { + config: initialConfig, + agentId: "free", + agentDir: "/tmp/configured-free", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/workspace-free", + }; + + await refreshPreparedModelRuntimeSnapshots(initialConfig, { + gatewayLifecycle: true, + onBuildStats: (stats) => buildCounts.push(stats.agentCount), + }); + const retainedReader = getPreparedModelRuntimeSnapshot(freeInput)!; + const retainedAuthStore = getPreparedModelRuntimeAuthStore(retainedReader); + + await refreshPreparedModelRuntimeSnapshots(nextConfig, { + gatewayLifecycle: true, + agentIds: new Set(["pro"]), + onBuildStats: (stats) => buildCounts.push(stats.agentCount), + }); + + const retained = getPreparedModelRuntimeSnapshot({ ...freeInput, config: nextConfig }); + expect(buildCounts).toEqual([2, 1]); + expect(retained).toMatchObject({ agentId: "free", config: nextConfig }); + expect(retained).not.toBe(retainedReader); + expect(retainedReader.config).toBe(initialConfig); + expect(retained?.metadataSnapshot).toBe(retainedReader.metadataSnapshot); + expect(retained?.modelCatalog).toBe(retainedReader.modelCatalog); + expect(getPreparedModelRuntimeAuthStore(retained!)).toBe(retainedAuthStore); + }); + + it("falls back to full refresh when an out-of-scope owner dependency changes", async () => { + mocks.configuredAgentIds = ["pro", "free"]; + const initialConfig = { + agents: { + defaults: { model: "openai/gpt-5.6" }, + entries: { pro: {}, free: {} }, + }, + } satisfies OpenClawConfig; + const nextConfig = { + agents: { + defaults: { model: "openai/gpt-5.5" }, + entries: { pro: {}, free: {} }, + }, + } satisfies OpenClawConfig; + const buildCounts: number[] = []; + + await refreshPreparedModelRuntimeSnapshots(initialConfig, { + gatewayLifecycle: true, + onBuildStats: (stats) => buildCounts.push(stats.agentCount), + }); + await refreshPreparedModelRuntimeSnapshots(nextConfig, { + gatewayLifecycle: true, + agentIds: new Set(["pro"]), + onBuildStats: (stats) => buildCounts.push(stats.agentCount), + }); + + expect(buildCounts).toEqual([2, 2]); + }); + + it("builds only a newly added non-default agent", async () => { + mocks.configuredAgentIds = ["free"]; + const initialConfig = { + agents: { entries: { free: { model: "openai/gpt-5.5" } } }, + } satisfies OpenClawConfig; + const nextConfig = { + agents: { + entries: { + free: { model: "openai/gpt-5.5" }, + pro: { model: "openai/gpt-5.6" }, + }, + }, + } satisfies OpenClawConfig; + const buildCounts: number[] = []; + + await refreshPreparedModelRuntimeSnapshots(initialConfig, { + gatewayLifecycle: true, + onBuildStats: (stats) => buildCounts.push(stats.agentCount), + }); + mocks.configuredAgentIds = ["free", "pro"]; + await refreshPreparedModelRuntimeSnapshots(nextConfig, { + gatewayLifecycle: true, + agentIds: new Set(["pro"]), + onBuildStats: (stats) => buildCounts.push(stats.agentCount), + }); + + expect(buildCounts).toEqual([1, 1]); + expect( + getPreparedModelRuntimeSnapshot({ + config: nextConfig, + agentId: "pro", + agentDir: "/tmp/configured-pro", + inheritedAuthDir: "/tmp/unused-agent", + workspaceDir: "/tmp/workspace-pro", + }), + ).toMatchObject({ agentId: "pro", config: nextConfig }); + }); +}); diff --git a/src/agents/prepared-model-runtime.ts b/src/agents/prepared-model-runtime.ts index 7bd0adea82f4..93e582249f70 100644 --- a/src/agents/prepared-model-runtime.ts +++ b/src/agents/prepared-model-runtime.ts @@ -10,12 +10,11 @@ import { PreparedModelRuntimeOwnerNotPublishedError, PreparedModelRuntimeOwnerRetention, PreparedModelRuntimePublicationSupersededError, + advancePreparedModelRuntimeOwnerConfig, createPreparedModelRuntimeOwner, createPreparedModelRuntimeReplacement, - advancePreparedModelRuntimeOwnerConfig, effectiveEnvironmentFingerprint, hasSameLifecycleInput, - listConfiguredOwnerInputs, normalizeOptionalDir, normalizePreparedModelRuntimeInput, ownerKey, @@ -37,6 +36,12 @@ import { notifyPreparedModelRuntimePublication, resetPreparedModelRuntimePublicationListenersForTest, } from "./prepared-model-runtime.publication-events.js"; +import { + isPreparedModelRuntimeOwnerInRefreshScope, + listConfiguredRefreshInputs, + resolveSafeRefreshAgentIds, + updateOwnersForScopedRefresh, +} from "./prepared-model-runtime.refresh-scope.js"; import type { PreparedModelRuntimeCatalogMode } from "./prepared-model-runtime.types.js"; import { PreparedReplyDispatchPublicationOwner } from "./prepared-reply-dispatch-runtime.js"; export { @@ -353,7 +358,11 @@ export async function prepareModelRuntimeSnapshot( /** Invalidates every published generation before config/plugin runtime replacement. */ export function markPreparedModelRuntimeSnapshotsStale( reason = "prepared model runtime owner is stale after config publication", - options: { waitForReplacement?: boolean; preserveReplacementWait?: boolean } = {}, + options: { + waitForReplacement?: boolean; + preserveReplacementWait?: boolean; + agentIds?: ReadonlySet; + } = {}, ): PreparedModelRuntimeReplacementGateId | undefined { replyDispatchPublication.clear(); if (options.waitForReplacement) { @@ -368,19 +377,10 @@ export function markPreparedModelRuntimeSnapshotsStale( } refreshRequestEpoch += 1; const staleError = new Error(reason); - for (const [key, owner] of owners) { - // Standalone owners have no publication controller to rebuild them. Retire them so the next - // standalone lifecycle boundary can activate a fresh generation after publication changes. - if (owner.provenance === "standalone") { - owner.generation += 1; - owners.delete(key); - continue; - } - owner.generation += 1; - owner.needsRefresh = true; - owner.refreshError = staleError; - owner.pluginGeneration = undefined; - } + updateOwnersForScopedRefresh(owners, options.agentIds, staleError, { + retireStandalone: true, + resetPluginGeneration: true, + }); notifyPreparedModelRuntimePublication({ phase: "invalidated" }); if (!pendingModelRuntimeReplacement) { notifyPreparedModelRuntimePublication({ phase: "failed", error: staleError }); @@ -410,36 +410,18 @@ async function refreshPreparedModelRuntimeSnapshotsNow( isPublicationCurrent: () => boolean, ): Promise { retainedGatewayRunOwners.clear(owners); - const { defaultWorkspaceDir: workspace, allowGatewaySubagentBinding: bindings } = options; const catalogMode = options.catalogMode ?? "live"; gatewayLifecycleActive ||= options.gatewayLifecycle === true; const staleError = new Error("prepared model runtime owner is stale after config publication"); - for (const owner of owners.values()) { - // Invalidate every prior generation before starting any replacement. A failed reload must - // never leave an old-config snapshot available beside partially published new owners. - owner.generation += 1; - owner.needsRefresh = true; - owner.refreshError = staleError; - } + updateOwnersForScopedRefresh(owners, options.agentIds, staleError, { + retainedConfig: config, + }); const entries: Array<{ owner?: PreparedModelRuntimeOwner; input: PreparedModelRuntimeInput }> = []; const knownKeys = new Set(); - for (const rawInput of listConfiguredOwnerInputs(config, workspace, bindings)) { - let input = normalizePreparedModelRuntimeInput(rawInput); - const preservedOwner = [...owners.values()].find( - (owner) => - owner.provenance === "configured" && - owner.input.agentId === input.agentId && - owner.input.agentDir === input.agentDir && - owner.input.preserveWorkspaceDirOnRefresh && - owner.input.workspaceDir, - ); - if (preservedOwner?.input.workspaceDir) { - input = { - ...input, - workspaceDir: preservedOwner.input.workspaceDir, - preserveWorkspaceDirOnRefresh: true, - }; + for (const input of listConfiguredRefreshInputs(config, options, owners)) { + if (options.agentIds && input.agentId && !options.agentIds.has(input.agentId)) { + continue; } const key = ownerKey(input); if (knownKeys.has(key)) { @@ -450,6 +432,9 @@ async function refreshPreparedModelRuntimeSnapshotsNow( entries.push({ owner, input }); } for (const [key, owner] of owners) { + if (!isPreparedModelRuntimeOwnerInRefreshScope(owner, options.agentIds)) { + continue; + } if (!knownKeys.has(key) && (gatewayLifecycleActive || owner.provenance === "configured")) { owners.delete(key); } @@ -490,10 +475,18 @@ export function refreshPreparedModelRuntimeSnapshots( if (options.isPublicationCurrent?.() === false) { return Promise.resolve(); } + const requestedScopedRefresh = options.agentIds !== undefined; + const initialAgentIds = + typeof config === "function" ? undefined : resolveSafeRefreshAgentIds(config, options, owners); + const forceFullRefresh = requestedScopedRefresh && initialAgentIds === undefined; // Stale synchronously. Queued publication must never leave the prior generation request-visible. - markPreparedModelRuntimeSnapshotsStale(undefined, { waitForReplacement: true }); + markPreparedModelRuntimeSnapshotsStale(undefined, { + waitForReplacement: true, + agentIds: initialAgentIds, + }); const requestEpoch = refreshRequestEpoch; const replacement = pendingModelRuntimeReplacement; + let publicationAgentIds = initialAgentIds; const isPublicationCurrent = () => requestEpoch === refreshRequestEpoch && options.isPublicationCurrent?.() !== false; return enqueuePreparedModelRuntimePublication(async () => { @@ -504,7 +497,14 @@ export function refreshPreparedModelRuntimeSnapshots( if (!isPublicationCurrent()) { return; } - await refreshPreparedModelRuntimeSnapshotsNow(currentConfig, options, isPublicationCurrent); + publicationAgentIds = forceFullRefresh + ? undefined + : resolveSafeRefreshAgentIds(currentConfig, options, owners); + await refreshPreparedModelRuntimeSnapshotsNow( + currentConfig, + { ...options, agentIds: publicationAgentIds }, + isPublicationCurrent, + ); if (!isPublicationCurrent()) { return; } @@ -528,13 +528,10 @@ export function refreshPreparedModelRuntimeSnapshots( if (isPublicationCurrent()) { // Candidate and queued auth builds may finish independently. A failed transaction must // leave no owner from its partially published generation request-visible. - for (const owner of owners.values()) { - owner.generation += 1; - owner.pending = undefined; - owner.needsRefresh = true; - owner.refreshError = refreshError; - owner.pluginGeneration = undefined; - } + updateOwnersForScopedRefresh(owners, publicationAgentIds, refreshError, { + clearPending: true, + resetPluginGeneration: true, + }); } if (isPublicationCurrent() && replacement && pendingModelRuntimeReplacement === replacement) { pendingModelRuntimeReplacement = undefined; diff --git a/src/agents/prepared-model-runtime.types.ts b/src/agents/prepared-model-runtime.types.ts index c834b0848584..5a921039bb65 100644 --- a/src/agents/prepared-model-runtime.types.ts +++ b/src/agents/prepared-model-runtime.types.ts @@ -117,6 +117,8 @@ export type PreparedModelRuntimeRefreshOptions = { allowGatewaySubagentBinding?: boolean; pluginMetadataSnapshot?: PluginMetadataSnapshot; isPublicationCurrent?: () => boolean; + /** Restricts replacement to configured owners whose normalized agent id is present. */ + agentIds?: ReadonlySet; }; export type PreparedModelRuntimeBuildStats = Readonly<{ diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index 2577cb297458..fb4969658e02 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -269,7 +269,11 @@ const hoisted = vi.hoisted(() => ({ markPreparedModelRuntimeSnapshotsStale: vi.fn( ( _reason?: string, - _options?: { waitForReplacement?: boolean; preserveReplacementWait?: boolean }, + _options?: { + waitForReplacement?: boolean; + preserveReplacementWait?: boolean; + agentIds?: ReadonlySet; + }, ) => Symbol("prepared-model-runtime-replacement"), ), rejectPendingPreparedModelRuntimeReplacement: vi.fn( @@ -1470,6 +1474,27 @@ describe("gateway hot reload model state", () => { }, ); + it("passes an agent-entry-local refresh scope through the commit and rebuild", async () => { + const logReload = { info: vi.fn(), warn: vi.fn() }; + const { applyHotReload } = createReloadHandlersForTest(logReload); + const nextConfig = {} as OpenClawConfig; + + await applyHotReload( + buildGatewayReloadPlan(["agents.entries.Alpha.model", "meta.lastTouchedAt"]), + nextConfig, + ); + + expect(hoisted.markPreparedModelRuntimeSnapshotsStale).toHaveBeenCalledWith( + "prepared model runtime owner is stale before config publication", + { waitForReplacement: true, agentIds: new Set(["alpha"]) }, + ); + expect(hoisted.refreshPreparedModelRuntimeSnapshots).toHaveBeenCalledWith(nextConfig, { + allowGatewaySubagentBinding: true, + catalogMode: "static", + agentIds: new Set(["alpha"]), + }); + }); + it.each([ "agents.defaults.compaction.model", "agents.defaults.compaction.maxActiveTranscriptBytes", diff --git a/src/gateway/server-reload-hot.ts b/src/gateway/server-reload-hot.ts index db34993db552..6edf582b37c8 100644 --- a/src/gateway/server-reload-hot.ts +++ b/src/gateway/server-reload-hot.ts @@ -4,7 +4,6 @@ import { warmCurrentProviderAuthStateOffMainThread } from "../agents/model-provi import { markPreparedModelRuntimeSnapshotsStale, rejectPendingPreparedModelRuntimeReplacement, - refreshPreparedModelRuntimeSnapshots, type PreparedModelRuntimeReplacementGateId, } from "../agents/prepared-model-runtime.js"; import { isRestartEnabled } from "../config/commands.flags.js"; @@ -38,6 +37,7 @@ import { type GatewayReloadHandlerParams, type GatewayRestartTransactionResult, } from "./server-reload-contracts.js"; +import * as mrReload from "./server-reload-model-runtime-scope.js"; import { createGatewayRestartCoordinator } from "./server-reload-restart.js"; import { assertIrreversibleReloadPlanHasRecoveryOwner, @@ -102,6 +102,8 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) !isRestartRetryStopped() && (publication?.isCurrent?.() ?? true); const state = params.getState(); const nextState = { ...state }; + const modelRuntimeAgentIds = mrReload.resolveReloadAgentIds(plan.changedPaths); + const modelRuntimeRefreshScope = modelRuntimeAgentIds ? { agentIds: modelRuntimeAgentIds } : {}; resetPreparedModelRuntimeStateForHotReload(); @@ -204,7 +206,7 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) // retire the prior stores at the commit edge so no request can mix generations. preparedModelRuntimeReplacementGateId = markPreparedModelRuntimeSnapshotsStale( "prepared model runtime owner is stale before config publication", - { waitForReplacement: true }, + { waitForReplacement: true, ...modelRuntimeRefreshScope }, ); params.setState(nextState); // All rejecting work is complete. Publish pre-resolved lane limits at @@ -611,11 +613,10 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) } try { - const pluginMetadataSnapshot = params.getPluginMetadataSnapshot?.(); - await refreshPreparedModelRuntimeSnapshots(nextConfig, { - catalogMode: "static", - allowGatewaySubagentBinding: true, - ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), + await mrReload.refreshModelRuntimeAfterHotReload({ + config: nextConfig, + agentIds: modelRuntimeAgentIds, + pluginMetadataSnapshot: params.getPluginMetadataSnapshot?.(), }); } catch (err) { scheduleRecoveryRestart("prepared model runtime reload", err); diff --git a/src/gateway/server-reload-model-runtime-scope.test.ts b/src/gateway/server-reload-model-runtime-scope.test.ts new file mode 100644 index 000000000000..6e407237d145 --- /dev/null +++ b/src/gateway/server-reload-model-runtime-scope.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { resolveReloadAgentIds } from "./server-reload-model-runtime-scope.js"; + +describe("prepared model runtime reload scope", () => { + it("collects normalized agent ids from agent-entry-local paths", () => { + expect( + resolveReloadAgentIds(["agents.entries.Alpha.model", "agents.entries.beta.name"]), + ).toEqual(new Set(["alpha", "beta"])); + }); + + it("ignores machine-managed metadata beside an agent-local path", () => { + expect(resolveReloadAgentIds(["agents.entries.alpha.model", "meta.lastTouchedAt"])).toEqual( + new Set(["alpha"]), + ); + }); + + it.each([ + [[]], + [["agents.entries"]], + [["agents.entries.alpha.model", "models.providers.openai.api"]], + ])("falls back to full refresh for an unbounded path set: %j", (paths) => { + expect(resolveReloadAgentIds(paths)).toBeUndefined(); + }); +}); diff --git a/src/gateway/server-reload-model-runtime-scope.ts b/src/gateway/server-reload-model-runtime-scope.ts new file mode 100644 index 000000000000..71a66e86cd30 --- /dev/null +++ b/src/gateway/server-reload-model-runtime-scope.ts @@ -0,0 +1,40 @@ +import { refreshPreparedModelRuntimeSnapshots } from "../agents/prepared-model-runtime.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; +import { normalizeAgentId } from "../routing/session-key.js"; + +/** Returns affected agent ids when every meaningful reload path is agent-entry-local. */ +export function resolveReloadAgentIds( + changedPaths: readonly string[], +): ReadonlySet | undefined { + if (changedPaths.length === 0) { + return undefined; + } + const agentIds = new Set(); + for (const path of changedPaths) { + if (path === "meta" || path.startsWith("meta.")) { + continue; + } + const match = /^agents\.entries\.([^.]+)(?:\.|$)/.exec(path); + if (!match?.[1]) { + return undefined; + } + agentIds.add(normalizeAgentId(match[1])); + } + return agentIds.size > 0 ? agentIds : undefined; +} + +export function refreshModelRuntimeAfterHotReload(params: { + config: OpenClawConfig; + agentIds: ReadonlySet | undefined; + pluginMetadataSnapshot: PluginMetadataSnapshot | undefined; +}): Promise { + return refreshPreparedModelRuntimeSnapshots(params.config, { + catalogMode: "static", + allowGatewaySubagentBinding: true, + ...(params.agentIds ? { agentIds: params.agentIds } : {}), + ...(params.pluginMetadataSnapshot + ? { pluginMetadataSnapshot: params.pluginMetadataSnapshot } + : {}), + }); +} diff --git a/test/vitest-unit-fast-config.test.ts b/test/vitest-unit-fast-config.test.ts index 75979fbe6a76..82f56e1e5b70 100644 --- a/test/vitest-unit-fast-config.test.ts +++ b/test/vitest-unit-fast-config.test.ts @@ -311,6 +311,7 @@ describe("unit-fast vitest lane", () => { "src/acp/translator.error-kind.test.ts", "src/agents/auth-profiles/oauth-refresh-error.test.ts", "src/agents/embedded-agent-runner/model.provider-hooks.timeout.test.ts", + "src/agents/prepared-model-runtime.scoped-refresh.test.ts", "src/agents/tools/computer-tool.context.test.ts", "src/agents/tools/computer-tool.schema.test.ts", "src/agents/tools/computer-tool.v2.test.ts", diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index ebd210a7731d..db19461ce666 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -258,7 +258,7 @@ const disqualifyingPatterns = [ ]; const statefulTestHelperImportPattern = - /\bfrom\s+["']([^"']*(?:test-support|\.harness|message-action-runner\.test-helpers|computer-tool\.test-helpers)(?:\.js|\.ts)?)["']/gu; + /\bfrom\s+["']([^"']*(?:test-support|\.harness|prepared-model-runtime\.test-harness|message-action-runner\.test-helpers|computer-tool\.test-helpers)(?:\.js|\.ts)?)["']/gu; const statefulTestHelperByKey = new Map(); function importsStatefulTestHelper(cwd, file, source) {