import { isDeepStrictEqual } from "node:util" ;
import chokidar from "chokidar" ;
import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh-state.js" ;
import type {
OpenClawConfig,
ConfigFileSnapshot,
ConfigWriteNotification,
GatewayReloadMode,
} from "../config/config.js" ;
import { shouldAttemptLastKnownGoodRecovery } from "../config/config.js" ;
import { formatConfigIssueLines } from "../config/issue-format.js" ;
import { isPlainObject } from "../utils.js" ;
import {
buildGatewayReloadPlan,
listPluginInstallTimestampMetadataPaths,
listPluginInstallWholeRecordPaths,
type GatewayReloadPlan,
} from "./config-reload-plan.js" ;
export {
buildGatewayReloadPlan,
listPluginInstallTimestampMetadataPaths,
listPluginInstallWholeRecordPaths,
};
export type { ChannelKind, GatewayReloadPlan } from "./config-reload-plan.js" ;
export type GatewayReloadSettings = {
mode: GatewayReloadMode;
debounceMs: number;
};
const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = {
mode: "hybrid" ,
debounceMs: 300 ,
};
const MISSING_CONFIG_RETRY_DELAY_MS = 150 ;
const MISSING_CONFIG_MAX_RETRIES = 2 ;
/**
* Paths under ` skills . * ` always change the snapshot that sessions cache in
* sessions . json . Any prefix match here ( for example ` skills . allowBundled ` ,
* ` skills . entries . X . enabled ` , ` skills . profile ` ) forces sessions to rebuild
* their snapshot on the next turn rather than silently advertising stale
* tools to the model .
*/
const SKILLS_INVALIDATION_PREFIXES = ["skills" ] as const ;
function matchesSkillsInvalidationPrefix(path: string): boolean {
return SKILLS_INVALIDATION_PREFIXES.some(
(prefix) => path === prefix || path.startsWith(`${prefix}.`),
);
}
function firstSkillsChangedPath(changedPaths: string[]): string | undefined {
return changedPaths.find(matchesSkillsInvalidationPrefix);
}
export function shouldInvalidateSkillsSnapshotForPaths(changedPaths: string[]): boolean {
return firstSkillsChangedPath(changedPaths) !== undefined;
}
function isNoopReloadPlan(plan: GatewayReloadPlan): boolean {
return (
!plan.restartGateway &&
plan.hotReasons.length === 0 &&
!plan.reloadHooks &&
!plan.restartGmailWatcher &&
!plan.restartCron &&
!plan.restartHeartbeat &&
!plan.restartHealthMonitor &&
plan.restartChannels.size === 0
);
}
export function diffConfigPaths(prev: unknown, next: unknown, prefix = "" ): string[] {
if (prev === next) {
return [];
}
if (isPlainObject(prev) && isPlainObject(next)) {
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
const paths: string[] = [];
for (const key of keys) {
const prevValue = prev[key];
const nextValue = next[key];
if (prevValue === undefined && nextValue === undefined) {
continue ;
}
const childPrefix = prefix ? `${prefix}.${key}` : key;
const childPaths = diffConfigPaths(prevValue, nextValue, childPrefix);
if (childPaths.length > 0 ) {
paths.push(...childPaths);
}
}
return paths;
}
if (Array.isArray(prev) && Array.isArray(next)) {
// Arrays can contain object entries (for example memory.qmd.paths/scope.rules);
// compare structurally so identical values are not reported as changed.
if (isDeepStrictEqual(prev, next)) {
return [];
}
}
return [prefix || "<root>" ];
}
export function resolveGatewayReloadSettings(cfg: OpenClawConfig): GatewayReloadSettings {
const rawMode = cfg.gateway?.reload?.mode;
const mode =
rawMode === "off" || rawMode === "restart" || rawMode === "hot" || rawMode === "hybrid"
? rawMode
: DEFAULT_RELOAD_SETTINGS.mode;
const debounceRaw = cfg.gateway?.reload?.debounceMs;
const debounceMs =
typeof debounceRaw === "number" && Number.isFinite(debounceRaw)
? Math.max(0 , Math.floor(debounceRaw))
: DEFAULT_RELOAD_SETTINGS.debounceMs;
return { mode, debounceMs };
}
export type GatewayConfigReloader = {
stop: () => Promise<void >;
};
export function startGatewayConfigReloader(opts: {
initialConfig: OpenClawConfig;
initialCompareConfig?: OpenClawConfig;
initialInternalWriteHash?: string | null ;
readSnapshot: () => Promise<ConfigFileSnapshot>;
onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void >;
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void >;
recoverSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean >;
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean >;
onRecovered?: (params: {
reason: string;
snapshot: ConfigFileSnapshot;
recoveredSnapshot: ConfigFileSnapshot;
}) => void | Promise<void >;
subscribeToWrites?: (listener: (event: ConfigWriteNotification) => void ) => () => void ;
log: {
info: (msg: string) => void ;
warn: (msg: string) => void ;
error: (msg: string) => void ;
};
watchPath: string;
}): GatewayConfigReloader {
let currentConfig = opts.initialConfig;
let currentCompareConfig = opts.initialCompareConfig ?? opts.initialConfig;
let settings = resolveGatewayReloadSettings(currentConfig);
let debounceTimer: ReturnType<typeof setTimeout> | null = null ;
let pending = false ;
let running = false ;
let stopped = false ;
let restartQueued = false ;
let missingConfigRetries = 0 ;
let pendingInProcessConfig: {
config: OpenClawConfig;
compareConfig: OpenClawConfig;
persistedHash: string;
} | null = null ;
let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null ;
const scheduleAfter = (wait: number) => {
if (stopped) {
return ;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
void runReload();
}, wait);
};
const schedule = () => {
scheduleAfter(settings.debounceMs);
};
const queueRestart = (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => {
if (restartQueued) {
return ;
}
restartQueued = true ;
void (async () => {
try {
await opts.onRestart(plan, nextConfig);
} catch (err) {
// Restart checks can fail (for example unresolved SecretRefs). Keep the
// reloader alive and allow a future change to retry restart scheduling.
restartQueued = false ;
opts.log.error(`config restart failed: ${String(err)}`);
}
})();
};
const handleMissingSnapshot = (snapshot: ConfigFileSnapshot): boolean => {
if (snapshot.exists) {
missingConfigRetries = 0 ;
return false ;
}
if (missingConfigRetries < MISSING_CONFIG_MAX_RETRIES) {
missingConfigRetries += 1 ;
opts.log.info(
`config reload retry (${missingConfigRetries}/${MISSING_CONFIG_MAX_RETRIES}): config file not found`,
);
scheduleAfter(MISSING_CONFIG_RETRY_DELAY_MS);
return true ;
}
opts.log.warn("config reload skipped (config file not found)" );
return true ;
};
const handleInvalidSnapshot = (snapshot: ConfigFileSnapshot): boolean => {
if (snapshot.valid) {
return false ;
}
const issues = formatConfigIssueLines(snapshot.issues, "" ).join(", " );
opts.log.warn(`config reload skipped (invalid config): ${issues}`);
return true ;
};
const recoverAndReadSnapshot = async (
snapshot: ConfigFileSnapshot,
reason: string,
): Promise<ConfigFileSnapshot | null > => {
if (!opts.recoverSnapshot) {
return null ;
}
if (!shouldAttemptLastKnownGoodRecovery(snapshot)) {
opts.log.warn(
`config reload recovery skipped after ${reason}: invalidity is scoped to plugin entries`,
);
return null ;
}
const recovered = await opts.recoverSnapshot(snapshot, reason);
if (!recovered) {
return null ;
}
opts.log.warn(`config reload restored last-known-good config after ${reason}`);
const nextSnapshot = await opts.readSnapshot();
if (!nextSnapshot.valid) {
const issues = formatConfigIssueLines(nextSnapshot.issues, "" ).join(", " );
opts.log.warn(`config reload recovery snapshot is invalid: ${issues}`);
return null ;
}
try {
await opts.onRecovered?.({ reason, snapshot, recoveredSnapshot: nextSnapshot });
} catch (err) {
opts.log.warn(`config reload recovery notice failed: ${String(err)}`);
}
return nextSnapshot;
};
const applySnapshot = async (nextConfig: OpenClawConfig, nextCompareConfig: OpenClawConfig) => {
const changedPaths = diffConfigPaths(currentCompareConfig, nextCompareConfig);
const pluginInstallTimestampNoopPaths = listPluginInstallTimestampMetadataPaths(
currentCompareConfig,
nextCompareConfig,
);
const pluginInstallWholeRecordPaths = listPluginInstallWholeRecordPaths(
currentCompareConfig,
nextCompareConfig,
);
currentConfig = nextConfig;
currentCompareConfig = nextCompareConfig;
settings = resolveGatewayReloadSettings(nextConfig);
if (changedPaths.length === 0 ) {
return ;
}
// Invalidate cached skills snapshots (persisted in sessions.json) whenever
// the user touches skills.* config. Without this, sessions keep advertising
// tools that no longer exist in the allowlist, which causes infinite
// tool-not-found loops against the model.
const skillsChangedPath = firstSkillsChangedPath(changedPaths);
if (skillsChangedPath !== undefined) {
bumpSkillsSnapshotVersion({ reason: "config-change" , changedPath: skillsChangedPath });
opts.log.info(`skills snapshot invalidated by config change (${skillsChangedPath})`);
}
opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", " )})`);
const plan = buildGatewayReloadPlan(changedPaths, {
noopPaths: pluginInstallTimestampNoopPaths,
forceChangedPaths: pluginInstallWholeRecordPaths,
});
if (isNoopReloadPlan(plan)) {
return ;
}
if (settings.mode === "off" ) {
opts.log.info("config reload disabled (gateway.reload.mode=off)" );
return ;
}
if (settings.mode === "restart" ) {
queueRestart(plan, nextConfig);
return ;
}
if (plan.restartGateway) {
if (settings.mode === "hot" ) {
opts.log.warn(
`config reload requires gateway restart; hot mode ignoring (${plan.restartReasons.join(
", " ,
)})`,
);
return ;
}
queueRestart(plan, nextConfig);
return ;
}
await opts.onHotReload(plan, nextConfig);
};
const promoteAcceptedSnapshot = async (snapshot: ConfigFileSnapshot, reason: string) => {
if (!opts.promoteSnapshot || !snapshot.exists || !snapshot.valid) {
return ;
}
try {
await opts.promoteSnapshot(snapshot, reason);
} catch (err) {
opts.log.warn(`config reload last-known-good promotion failed: ${String(err)}`);
}
};
const promoteAcceptedInProcessWrite = async (persistedHash: string) => {
if (!opts.promoteSnapshot) {
return ;
}
try {
const snapshot = await opts.readSnapshot();
if (snapshot.hash !== persistedHash || !snapshot.valid) {
return ;
}
await promoteAcceptedSnapshot(snapshot, "in-process-write" );
} catch (err) {
opts.log.warn(`config reload in-process last-known-good promotion failed: ${String(err)}`);
}
};
const runReload = async () => {
if (stopped) {
return ;
}
if (running) {
pending = true ;
return ;
}
running = true ;
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null ;
}
try {
if (pendingInProcessConfig) {
const pendingWrite = pendingInProcessConfig;
pendingInProcessConfig = null ;
missingConfigRetries = 0 ;
await applySnapshot(pendingWrite.config, pendingWrite.compareConfig);
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash);
return ;
}
let snapshot = await opts.readSnapshot();
if (lastAppliedWriteHash && typeof snapshot.hash === "string" ) {
if (snapshot.hash === lastAppliedWriteHash) {
return ;
}
lastAppliedWriteHash = null ;
}
if (handleMissingSnapshot(snapshot)) {
return ;
}
if (!snapshot.valid) {
const recoveredSnapshot = await recoverAndReadSnapshot(snapshot, "invalid-config" );
if (!recoveredSnapshot) {
handleInvalidSnapshot(snapshot);
return ;
}
snapshot = recoveredSnapshot;
}
await applySnapshot(snapshot.config, snapshot.sourceConfig);
await promoteAcceptedSnapshot(snapshot, "valid-config" );
} catch (err) {
opts.log.error(`config reload failed: ${String(err)}`);
} finally {
running = false ;
if (pending) {
pending = false ;
schedule();
}
}
};
const watcher = chokidar.watch(opts.watchPath, {
ignoreInitial: true ,
awaitWriteFinish: { stabilityThreshold: 200 , pollInterval: 50 },
usePolling: Boolean (process.env.VITEST),
});
const scheduleFromWatcher = () => {
schedule();
};
const unsubscribeFromWrites =
opts.subscribeToWrites?.((event) => {
if (event.configPath !== opts.watchPath) {
return ;
}
pendingInProcessConfig = {
config: event.runtimeConfig,
compareConfig: event.sourceConfig,
persistedHash: event.persistedHash,
};
lastAppliedWriteHash = event.persistedHash;
scheduleAfter(0 );
}) ?? (() => {});
watcher.on("add" , scheduleFromWatcher);
watcher.on("change" , scheduleFromWatcher);
watcher.on("unlink" , scheduleFromWatcher);
let watcherClosed = false ;
watcher.on("error" , (err) => {
if (watcherClosed) {
return ;
}
watcherClosed = true ;
opts.log.warn(`config watcher error: ${String(err)}`);
void watcher.close().catch (() => {});
});
return {
stop: async () => {
stopped = true ;
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = null ;
watcherClosed = true ;
unsubscribeFromWrites();
await watcher.close().catch (() => {});
},
};
}
Messung V0.5 in Prozent C=99 H=96 G=97
¤ Dauer der Verarbeitung: 0.6 Sekunden
¤
*© Formatika GbR, Deutschland