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:
Qiong
2026-08-02 18:15:02 +08:00
committed by GitHub
parent 5c430848b3
commit d2ad3e4229
3 changed files with 343 additions and 9 deletions
+78
View File
@@ -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 ||
+252 -1
View File
@@ -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();
},
);
+13 -8
View File
@@ -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) {