mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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
This commit is contained in:
@@ -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<string> | 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<string, PreparedModelRuntimeOwner>,
|
||||
): PreparedModelRuntimeInput[] {
|
||||
const preservedWorkspaceByAgentDir = new Map<string, Map<string, string>>();
|
||||
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<string, PreparedModelRuntimeOwner>,
|
||||
agentIds: ReadonlySet<string> | 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<string, PreparedModelRuntimeOwner>,
|
||||
): ReadonlySet<string> | 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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string>;
|
||||
} = {},
|
||||
): 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<void> {
|
||||
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<string>();
|
||||
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;
|
||||
|
||||
@@ -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<string>;
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeBuildStats = Readonly<{
|
||||
|
||||
@@ -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<string>;
|
||||
},
|
||||
) => 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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string> | undefined {
|
||||
if (changedPaths.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const agentIds = new Set<string>();
|
||||
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<string> | undefined;
|
||||
pluginMetadataSnapshot: PluginMetadataSnapshot | undefined;
|
||||
}): Promise<void> {
|
||||
return refreshPreparedModelRuntimeSnapshots(params.config, {
|
||||
catalogMode: "static",
|
||||
allowGatewaySubagentBinding: true,
|
||||
...(params.agentIds ? { agentIds: params.agentIds } : {}),
|
||||
...(params.pluginMetadataSnapshot
|
||||
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user