mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(gateway): skip provider-auth rewarm for unrelated hot reloads (#117449)
Skip the provider-auth warm worker for hot reloads whose changed paths cannot affect agent scope, model catalogs, provider config, or auth inputs (heartbeat/hooks/cron/gmail/mcp/ui/logging). Unrelated reloads previously cleared the warmed cache and started an O(agents x providers) worker that pinned one core until the 120s timeout. The whole-config fingerprint guard preserves correctness by falling through to compute. Fixes #117411 Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
@@ -17,6 +17,84 @@ export function shouldRefreshContextWindowCache(plan: GatewayReloadPlan): boolea
|
||||
);
|
||||
}
|
||||
|
||||
/** Skip broad auth scans unless a reload can change provider availability. */
|
||||
export function shouldRewarmProviderAuthState(plan: GatewayReloadPlan): boolean {
|
||||
return plan.reloadPlugins || plan.changedPaths.some(isProviderAuthRelevantReloadPath);
|
||||
}
|
||||
|
||||
const PROVIDER_AUTH_RELEVANT_CONFIG_ROOTS = new Set([
|
||||
"auth",
|
||||
"env",
|
||||
"models",
|
||||
"plugins",
|
||||
"secrets",
|
||||
]);
|
||||
const PROVIDER_AUTH_RELEVANT_AGENT_SUBFIELDS = new Set([
|
||||
"agentDir",
|
||||
"agentRuntime",
|
||||
"default",
|
||||
"id",
|
||||
"imageModel",
|
||||
"mediaModels",
|
||||
"model",
|
||||
"models",
|
||||
"modelPolicy",
|
||||
"pdfModel",
|
||||
"runtime",
|
||||
"utilityModel",
|
||||
"voiceModel",
|
||||
"workspace",
|
||||
]);
|
||||
|
||||
// Nested model selectors can change providers; unrelated scheduling/compaction knobs cannot.
|
||||
function isAuthRelevantAgentSubfield(
|
||||
field: string | undefined,
|
||||
next: string | undefined,
|
||||
nested: string | undefined,
|
||||
): boolean {
|
||||
if (field === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (PROVIDER_AUTH_RELEVANT_AGENT_SUBFIELDS.has(field)) {
|
||||
return true;
|
||||
}
|
||||
if (field === "heartbeat" || field === "subagents") {
|
||||
return next === undefined || next === "model";
|
||||
}
|
||||
return (
|
||||
field === "compaction" &&
|
||||
(next === undefined ||
|
||||
next === "model" ||
|
||||
next === "provider" ||
|
||||
(next === "memoryFlush" && (nested === undefined || nested === "model")))
|
||||
);
|
||||
}
|
||||
|
||||
function isProviderAuthRelevantReloadPath(path: string): boolean {
|
||||
const segments = path.split(".");
|
||||
const [head = "", second, third, fourth] = segments;
|
||||
if (PROVIDER_AUTH_RELEVANT_CONFIG_ROOTS.has(head)) {
|
||||
return true;
|
||||
}
|
||||
if (head === "agent" && second === "model") {
|
||||
return true;
|
||||
}
|
||||
if (head !== "agents") {
|
||||
return false;
|
||||
}
|
||||
// Legacy agent rosters are arrays, so any nested change collapses to `agents.list`.
|
||||
if (second === undefined || second === "list") {
|
||||
return true;
|
||||
}
|
||||
if (second === "defaults") {
|
||||
return isAuthRelevantAgentSubfield(third, segments[3], segments[4]);
|
||||
}
|
||||
if (second === "entries") {
|
||||
return isAuthRelevantAgentSubfield(fourth, segments[4], segments[5]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function reloadPlanNeedsRecovery(plan: GatewayReloadPlan): boolean {
|
||||
return (
|
||||
plan.restartCron ||
|
||||
|
||||
@@ -9,6 +9,11 @@ import { getRuntimeAuthProfileStoreCredentialsRevision } from "../agents/auth-pr
|
||||
import { addSession, markBackgrounded, markExited } from "../agents/bash-process-registry.js";
|
||||
import { createProcessSessionFixture } from "../agents/bash-process-registry.test-helpers.js";
|
||||
import { resetProcessRegistryForTests } from "../agents/bash-process-registry.test-support.js";
|
||||
import {
|
||||
clearCurrentProviderAuthState as clearWarmedProviderAuthState,
|
||||
getCurrentProviderAuthStates,
|
||||
publishProviderAuthWarmSnapshot,
|
||||
} from "../agents/model-provider-auth-state.js";
|
||||
import { prepareConfigRuntimeEnv } from "../config/config-env-vars.js";
|
||||
import type { ConfigWriteNotification } from "../config/config.js";
|
||||
import {
|
||||
@@ -59,6 +64,7 @@ import {
|
||||
type ChannelKind,
|
||||
type GatewayReloadPlan,
|
||||
} from "./config-reload-plan.js";
|
||||
import { shouldRewarmProviderAuthState } from "./config-reload-recovery.js";
|
||||
import { applyHookMappings } from "./hooks-mapping.js";
|
||||
import { commitHooksConfigReload } from "./hooks.js";
|
||||
import type { GatewayPluginReloadResult } from "./server-reload-handlers.js";
|
||||
@@ -968,6 +974,124 @@ describe("managed reload transaction ownership", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("provider auth hot reload path ownership", () => {
|
||||
it.each([
|
||||
"auth",
|
||||
"auth.profiles.openai.provider",
|
||||
"auth.order.openai",
|
||||
"env",
|
||||
"env.vars.OPENAI_API_KEY",
|
||||
"models",
|
||||
"models.providers.openai.api",
|
||||
"models.providers.anthropic",
|
||||
"plugins",
|
||||
"plugins.entries.openai.enabled",
|
||||
"secrets",
|
||||
"secrets.providers.default.path",
|
||||
"agent.model",
|
||||
"agent.model.default",
|
||||
"agents",
|
||||
"agents.list",
|
||||
"agents.defaults",
|
||||
"agents.defaults.model",
|
||||
"agents.defaults.models.provider.agentRuntime.id",
|
||||
"agents.defaults.modelPolicy",
|
||||
"agents.defaults.utilityModel",
|
||||
"agents.defaults.agentRuntime",
|
||||
"agents.defaults.runtime",
|
||||
"agents.defaults.imageModel.primary",
|
||||
"agents.defaults.mediaModels.video.primary",
|
||||
"agents.defaults.voiceModel.primary",
|
||||
"agents.defaults.pdfModel.primary",
|
||||
"agents.defaults.heartbeat",
|
||||
"agents.defaults.heartbeat.model",
|
||||
"agents.defaults.compaction",
|
||||
"agents.defaults.compaction.model",
|
||||
"agents.defaults.compaction.provider",
|
||||
"agents.defaults.compaction.memoryFlush",
|
||||
"agents.defaults.compaction.memoryFlush.model",
|
||||
"agents.defaults.workspace",
|
||||
"agents.defaults.agentDir",
|
||||
"agents.defaults.subagents",
|
||||
"agents.defaults.subagents.model.primary",
|
||||
"agents.entries",
|
||||
"agents.entries.main",
|
||||
"agents.entries.main.id",
|
||||
"agents.entries.main.default",
|
||||
"agents.entries.main.model",
|
||||
"agents.entries.main.models.provider.agentRuntime.id",
|
||||
"agents.entries.main.modelPolicy",
|
||||
"agents.entries.main.utilityModel",
|
||||
"agents.entries.main.agentRuntime",
|
||||
"agents.entries.main.runtime",
|
||||
"agents.entries.main.heartbeat",
|
||||
"agents.entries.main.heartbeat.model",
|
||||
"agents.entries.main.workspace",
|
||||
"agents.entries.main.agentDir",
|
||||
"agents.entries.main.subagents",
|
||||
"agents.entries.main.subagents.model.primary",
|
||||
])("rewarm remains required for auth-relevant config path %s", (changedPath) => {
|
||||
expect(shouldRewarmProviderAuthState(createHotTailPlan({ changedPaths: [changedPath] }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"agents.defaults.heartbeat.target",
|
||||
"agents.defaults.heartbeat.delivery.target",
|
||||
"agents.defaults.heartbeat.every",
|
||||
"agents.defaults.heartbeat.activeHours.start",
|
||||
"agents.entries.main.heartbeat.delivery.target",
|
||||
"agents.entries.main.heartbeat.every",
|
||||
"agents.entries.main.heartbeat.lightContext",
|
||||
"agents.defaults.compaction.enabled",
|
||||
"agents.defaults.compaction.mode",
|
||||
"agents.defaults.compaction.keepRecentTokens",
|
||||
"agents.defaults.compaction.timeoutSeconds",
|
||||
"agents.defaults.compaction.memoryFlush.enabled",
|
||||
"agents.defaults.compaction.memoryFlush.softThresholdTokens",
|
||||
"agent.heartbeat",
|
||||
"agents.entries.main.cron",
|
||||
"agents.entries.main.ui",
|
||||
"agents.entries.main.tools",
|
||||
"agents.entries.main.skills",
|
||||
"agents.entries.main.memory",
|
||||
"agents.entries.main.systemPrompt",
|
||||
"agents.defaults.systemPrompt",
|
||||
"agents.defaults.subagents.thinking",
|
||||
"agents.entries.main.subagents.thinking",
|
||||
"agents.defaults.subagents.archiveAfterMinutes",
|
||||
"mcp.servers.context7.command",
|
||||
"hooks.gmail",
|
||||
"hooks.path",
|
||||
"logging.level",
|
||||
"channels.telegram",
|
||||
"channels.discord.accounts.bot",
|
||||
"gateway.port",
|
||||
"cron.schedules.daily",
|
||||
])("skips provider auth scans for unrelated config path %s", (changedPath) => {
|
||||
expect(shouldRewarmProviderAuthState(createHotTailPlan({ changedPaths: [changedPath] }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("rewarm remains required when plugins reload without a config path", () => {
|
||||
expect(
|
||||
shouldRewarmProviderAuthState(createHotTailPlan({ changedPaths: [], reloadPlugins: true })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("retains auth-relevant changes mixed with unrelated config paths", () => {
|
||||
expect(
|
||||
shouldRewarmProviderAuthState(
|
||||
createHotTailPlan({
|
||||
changedPaths: ["logging.level", "agents.defaults.workspace"],
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateway hot reload model state", () => {
|
||||
it("stops old cron exit watchers and reconciles rebuilt ones after cron restart", async () => {
|
||||
const order: string[] = [];
|
||||
@@ -1328,6 +1452,133 @@ describe("gateway hot reload model state", () => {
|
||||
expect(logReload.warn).toHaveBeenCalledWith(
|
||||
"bundle-mcp runtime disposal during config reload failed: Error: dispose failed",
|
||||
);
|
||||
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips provider-auth rewarm for unrelated agent heartbeat hot reloads", async () => {
|
||||
const { applyHotReload } = createReloadHandlersForTest();
|
||||
const nextConfig = {
|
||||
agents: { defaults: { heartbeat: { target: "telegram" } } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
await applyHotReload(
|
||||
createHotTailPlan({
|
||||
changedPaths: ["agents.defaults.heartbeat.target"],
|
||||
hotReasons: ["agents.defaults.heartbeat.target"],
|
||||
restartHeartbeat: true,
|
||||
}),
|
||||
nextConfig,
|
||||
);
|
||||
|
||||
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps unrelated heartbeat reloads lazy and authenticates the next user lookup once", async () => {
|
||||
const { applyHotReload } = createReloadHandlersForTest();
|
||||
const nextConfig = {
|
||||
agents: { defaults: { heartbeat: { target: "telegram" } } },
|
||||
} satisfies OpenClawConfig;
|
||||
const readProfiles = vi.fn(() => ({
|
||||
"openai:fixture": {
|
||||
type: "api_key" as const,
|
||||
provider: "openai",
|
||||
key: "provider-auth-test-fixture",
|
||||
},
|
||||
}));
|
||||
publishProviderAuthWarmSnapshot({
|
||||
agents: [
|
||||
{
|
||||
agentId: "main",
|
||||
configFingerprint: "previous-config-fingerprint",
|
||||
providers: [["openai", false]],
|
||||
},
|
||||
],
|
||||
});
|
||||
hoisted.clearCurrentProviderAuthState.mockImplementationOnce(clearWarmedProviderAuthState);
|
||||
|
||||
try {
|
||||
await applyHotReload(
|
||||
createHotTailPlan({
|
||||
changedPaths: ["agents.defaults.heartbeat.target"],
|
||||
hotReasons: ["agents.defaults.heartbeat.target"],
|
||||
restartHeartbeat: true,
|
||||
}),
|
||||
nextConfig,
|
||||
);
|
||||
|
||||
expect(hoisted.clearCurrentProviderAuthState).toHaveBeenCalledOnce();
|
||||
expect(getCurrentProviderAuthStates()).toBeNull();
|
||||
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).not.toHaveBeenCalled();
|
||||
|
||||
const { hasAuthForModelProvider } = await vi.importActual<
|
||||
typeof import("../agents/model-provider-auth.js")
|
||||
>("../agents/model-provider-auth.js");
|
||||
await expect(
|
||||
hasAuthForModelProvider({
|
||||
provider: "openai",
|
||||
cfg: nextConfig,
|
||||
agentDir: "/virtual/provider-auth-agent",
|
||||
workspaceDir: "/virtual/provider-auth-workspace",
|
||||
env: {},
|
||||
allowPluginSyntheticAuth: false,
|
||||
discoverExternalCliAuth: false,
|
||||
store: {
|
||||
version: 1,
|
||||
get profiles() {
|
||||
return readProfiles();
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(readProfiles).toHaveBeenCalledOnce();
|
||||
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
clearWarmedProviderAuthState();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps provider-auth rewarm for model-provider hot reloads", async () => {
|
||||
const { applyHotReload } = createReloadHandlersForTest();
|
||||
const nextConfig = {
|
||||
models: { providers: { openai: { api: "openai" } } },
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
await applyHotReload(
|
||||
createHotTailPlan({
|
||||
changedPaths: ["models.providers.openai.api"],
|
||||
hotReasons: ["models.providers.openai.api"],
|
||||
restartHeartbeat: true,
|
||||
}),
|
||||
nextConfig,
|
||||
);
|
||||
|
||||
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledWith(nextConfig);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"auth.profiles.openai.provider",
|
||||
"env.vars.OPENAI_API_KEY",
|
||||
"agents.list",
|
||||
"agents.defaults.imageModel.primary",
|
||||
"agents.defaults.heartbeat.model",
|
||||
"agents.entries.main.heartbeat.model",
|
||||
"agents.defaults.compaction.model",
|
||||
"agents.defaults.compaction.provider",
|
||||
"agents.defaults.compaction.memoryFlush.model",
|
||||
"agents.entries.main.default",
|
||||
"agents.entries.main.runtime.id",
|
||||
])("runs provider-auth rewarm for previously missed auth owner %s", async (changedPath) => {
|
||||
const { applyHotReload } = createReloadHandlersForTest();
|
||||
const nextConfig = {} satisfies OpenClawConfig;
|
||||
|
||||
await applyHotReload(
|
||||
createHotTailPlan({
|
||||
changedPaths: [changedPath],
|
||||
hotReasons: [changedPath],
|
||||
}),
|
||||
nextConfig,
|
||||
);
|
||||
|
||||
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledWith(nextConfig);
|
||||
});
|
||||
|
||||
@@ -1624,7 +1875,7 @@ describe("gateway hot reload superseded tail recovery", () => {
|
||||
});
|
||||
|
||||
expect(handlers.setState).toHaveBeenCalledTimes(2);
|
||||
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledTimes(0);
|
||||
expect(requestRecoveryRestart).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -15,7 +15,10 @@ import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
|
||||
import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js";
|
||||
import { runOutsideGatewayRootWorkAdmission } from "../process/gateway-work-admission.js";
|
||||
import type { ChannelKind } from "./config-reload-plan.js";
|
||||
import { shouldRefreshContextWindowCache } from "./config-reload-recovery.js";
|
||||
import {
|
||||
shouldRefreshContextWindowCache,
|
||||
shouldRewarmProviderAuthState,
|
||||
} from "./config-reload-recovery.js";
|
||||
import type { GatewayReloadPlan } from "./config-reload.js";
|
||||
import { commitHooksConfigReload, resolveHooksConfig } from "./hooks.js";
|
||||
import { buildGatewayCronService } from "./server-cron.js";
|
||||
@@ -581,13 +584,15 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams)
|
||||
scheduleRecoveryRestart("context window cache reload", err);
|
||||
}
|
||||
}
|
||||
void warmCurrentProviderAuthStateOffMainThread(nextConfig, {
|
||||
isCancelled: () => !isTransactionCurrent(),
|
||||
}).catch((err: unknown) => {
|
||||
if (isTransactionCurrent()) {
|
||||
params.logReload.warn(`provider auth state rewarm failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
if (shouldRewarmProviderAuthState(plan)) {
|
||||
void warmCurrentProviderAuthStateOffMainThread(nextConfig, {
|
||||
isCancelled: () => !isTransactionCurrent(),
|
||||
}).catch((err: unknown) => {
|
||||
if (isTransactionCurrent()) {
|
||||
params.logReload.warn(`provider auth state rewarm failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (plan.hotReasons.length > 0) {
|
||||
params.logReload.info(`config hot reload applied (${plan.hotReasons.join(", ")})`);
|
||||
} else if (plan.noopPaths.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user