fix(models): handle watcher errors, close on shutdown, rewarm after invalidate

Addresses three ClawSweeper findings on the fs-watcher commit:

- [P1] auth-profile watcher now handles chokidar 'error' events (logs +
  closes once) mirroring the gateway config-reload pattern. Without
  this, an unhandled error from chokidar can crash the gateway.

- [P2] auth-profile watcher handle is pushed into postReadySidecars so
  stopPostReadySidecarsAfterCloseStarted closes it on gateway shutdown.

- [P2] auth-failure and file-change invalidation paths now schedule a
  background rewarm (with a 'reason=' log line). Without this, the next
  /models call after an invalidation paid the slow per-provider path
  until the next reload. The warmer's existing generation counter
  handles concurrent rewarms safely.
This commit is contained in:
Sarah Fortune
2026-05-21 21:05:07 -07:00
parent 06a6d2b5c9
commit 55cfe00a3a
2 changed files with 44 additions and 5 deletions

View File

@@ -13,9 +13,14 @@ export type AuthProfilesWatcherHandle = {
stop: () => Promise<void>;
};
type WatcherLog = {
warn: (msg: string) => void;
};
export function watchAuthProfilesForChanges(params: {
cfg: OpenClawConfig;
onChange: () => void;
log?: WatcherLog;
}): AuthProfilesWatcherHandle {
const watchPaths = listAgentIds(params.cfg).map((agentId) =>
path.join(resolveAgentDir(params.cfg, agentId), "auth-profiles.json"),
@@ -25,6 +30,7 @@ export function watchAuthProfilesForChanges(params: {
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
usePolling: Boolean(process.env.VITEST),
});
let closed = false;
watcher.on("all", () => {
try {
params.onChange();
@@ -32,9 +38,18 @@ export function watchAuthProfilesForChanges(params: {
// onChange errors must not crash the watcher.
}
});
watcher.on("error", (err) => {
if (closed) {
return;
}
closed = true;
params.log?.warn(`auth-profile watcher error: ${String(err)}`);
void watcher.close().catch(() => {});
});
return {
stop: async () => {
await watcher.close();
closed = true;
await watcher.close().catch(() => {});
},
};
}

View File

@@ -1019,7 +1019,7 @@ export async function startGatewayPostAttachRuntime(
});
void sidecarsPromise
.then(async () => {
.then(async (sidecarsResult) => {
if (params.minimalTestGateway) {
return;
}
@@ -1027,10 +1027,34 @@ export async function startGatewayPostAttachRuntime(
await import("../agents/model-provider-auth.js");
const { setAuthProfileFailureHook } = await import("../agents/auth-profiles.js");
const { watchAuthProfilesForChanges } = await import("../agents/auth-profiles-watcher.js");
setAuthProfileFailureHook(() => clearCurrentProviderAuthState());
watchAuthProfilesForChanges({
const scheduleAuthMapRewarm = (reason: string) => {
const startMs = Date.now();
void warmCurrentProviderAuthState(params.cfgAtStart)
.then(() => {
params.log.info(
`provider auth state re-warmed (${reason}) in ${Date.now() - startMs}ms`,
);
})
.catch((err) => {
params.log.warn(`provider auth state rewarm failed: ${String(err)}`);
});
};
setAuthProfileFailureHook(() => {
clearCurrentProviderAuthState();
scheduleAuthMapRewarm("auth-profile-failure");
});
const authProfilesWatcher = watchAuthProfilesForChanges({
cfg: params.cfgAtStart,
onChange: () => clearCurrentProviderAuthState(),
onChange: () => {
clearCurrentProviderAuthState();
scheduleAuthMapRewarm("auth-profiles.json change");
},
log: params.log,
});
sidecarsResult.postReadySidecars.push({
stop: () => {
void authProfilesWatcher.stop();
},
});
const startMs = Date.now();
await warmCurrentProviderAuthState(params.cfgAtStart);