diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e8a57e4ce3..003761246f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - Cron/Schedule: for `every` jobs, prefer `lastRunAtMs + everyMs` when still in the future after restarts, then fall back to anchor scheduling for catch-up windows, so NEXT timing matches the last successful cadence. (#22895) Thanks @SidQin-cyber. - Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg. - Feishu/Media: for inbound video messages that include both `file_key` (video) and `image_key` (thumbnail), prefer `file_key` when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633) +- Hooks/Loader: avoid redundant hook-module recompilation on gateway restart by skipping cache-busting for bundled hooks and using stable file metadata keys (`mtime+size`) for mutable workspace/managed/plugin hook imports. (#16953) Thanks @mudrii. - Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark `SILENT_REPLY_TOKEN` (`NO_REPLY`) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks. - Providers/OpenRouter: inject `cache_control` on system prompts for OpenRouter Anthropic models to improve prompt-cache reuse. (#17473) Thanks @rrenamed. - Installer/Smoke tests: remove legacy `OPENCLAW_USE_GUM` overrides from docker install-smoke runs so tests exercise installer auto TTY detection behavior directly. diff --git a/src/hooks/import-url.test.ts b/src/hooks/import-url.test.ts new file mode 100644 index 00000000000..9705d9cbdad --- /dev/null +++ b/src/hooks/import-url.test.ts @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { buildImportUrl } from "./import-url.js"; + +describe("buildImportUrl", () => { + let tmpDir: string; + let tmpFile: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "import-url-test-")); + tmpFile = path.join(tmpDir, "handler.js"); + fs.writeFileSync(tmpFile, "export default () => {};"); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns bare URL for bundled hooks (no query string)", () => { + const url = buildImportUrl(tmpFile, "openclaw-bundled"); + expect(url).not.toContain("?t="); + expect(url).toMatch(/^file:\/\//); + }); + + it("appends mtime-based cache buster for workspace hooks", () => { + const url = buildImportUrl(tmpFile, "openclaw-workspace"); + expect(url).toMatch(/\?t=[\d.]+&s=\d+/); + + const { mtimeMs, size } = fs.statSync(tmpFile); + expect(url).toContain(`?t=${mtimeMs}`); + expect(url).toContain(`&s=${size}`); + }); + + it("appends mtime-based cache buster for managed hooks", () => { + const url = buildImportUrl(tmpFile, "openclaw-managed"); + expect(url).toMatch(/\?t=[\d.]+&s=\d+/); + }); + + it("appends mtime-based cache buster for plugin hooks", () => { + const url = buildImportUrl(tmpFile, "openclaw-plugin"); + expect(url).toMatch(/\?t=[\d.]+&s=\d+/); + }); + + it("returns same URL for bundled hooks across calls (cacheable)", () => { + const url1 = buildImportUrl(tmpFile, "openclaw-bundled"); + const url2 = buildImportUrl(tmpFile, "openclaw-bundled"); + expect(url1).toBe(url2); + }); + + it("returns same URL for workspace hooks when file is unchanged", () => { + const url1 = buildImportUrl(tmpFile, "openclaw-workspace"); + const url2 = buildImportUrl(tmpFile, "openclaw-workspace"); + expect(url1).toBe(url2); + }); + + it("falls back to Date.now() when file does not exist", () => { + const url = buildImportUrl("/nonexistent/handler.js", "openclaw-workspace"); + expect(url).toMatch(/\?t=\d+/); + }); +}); diff --git a/src/hooks/import-url.ts b/src/hooks/import-url.ts new file mode 100644 index 00000000000..16c5a40e630 --- /dev/null +++ b/src/hooks/import-url.ts @@ -0,0 +1,38 @@ +/** + * Build an import URL for a hook handler module. + * + * Bundled hooks (shipped in dist/) are immutable between installs, so they + * can be imported without a cache-busting suffix — letting V8 reuse its + * module cache across gateway restarts. + * + * Workspace, managed, and plugin hooks may be edited by the user between + * restarts. For those we append `?t=&s=` so the module key + * reflects on-disk changes while staying stable for unchanged files. + */ + +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; +import type { HookSource } from "./types.js"; + +/** + * Sources whose handler files never change between `npm install` runs. + * Imports from these sources skip cache busting entirely. + */ +const IMMUTABLE_SOURCES: ReadonlySet = new Set(["openclaw-bundled"]); + +export function buildImportUrl(handlerPath: string, source: HookSource): string { + const base = pathToFileURL(handlerPath).href; + + if (IMMUTABLE_SOURCES.has(source)) { + return base; + } + + // Use file metadata so the cache key only changes when the file changes + try { + const { mtimeMs, size } = fs.statSync(handlerPath); + return `${base}?t=${mtimeMs}&s=${size}`; + } catch { + // If stat fails (unlikely), fall back to Date.now() to guarantee freshness + return `${base}?t=${Date.now()}`; + } +} diff --git a/src/hooks/loader.ts b/src/hooks/loader.ts index 8c87375359d..30f37e4db25 100644 --- a/src/hooks/loader.ts +++ b/src/hooks/loader.ts @@ -11,9 +11,10 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { isPathInsideWithRealpath } from "../security/scan-paths.js"; import { resolveHookConfig } from "./config.js"; import { shouldIncludeHook } from "./config.js"; +import { buildImportUrl } from "./import-url.js"; import type { InternalHookHandler } from "./internal-hooks.js"; import { registerInternalHook } from "./internal-hooks.js"; -import { importFileModule, resolveFunctionModuleExport } from "./module-loader.js"; +import { resolveFunctionModuleExport } from "./module-loader.js"; import { loadWorkspaceHookEntries } from "./workspace.js"; const log = createSubsystemLogger("hooks:loader"); @@ -82,12 +83,12 @@ export async function loadInternalHooks( ); continue; } + // Import handler module — only cache-bust mutable (workspace/managed) hooks + const importUrl = buildImportUrl(entry.hook.handlerPath, entry.hook.source); + const mod = (await import(importUrl)) as Record; + // Get handler function (default or named export) const exportName = entry.metadata?.export ?? "default"; - const mod = await importFileModule({ - modulePath: entry.hook.handlerPath, - cacheBust: true, - }); const handler = resolveFunctionModuleExport({ mod, exportName, @@ -159,12 +160,12 @@ export async function loadInternalHooks( continue; } + // Legacy handlers are always workspace-relative, so use mtime-based cache busting + const importUrl = buildImportUrl(modulePath, "openclaw-workspace"); + const mod = (await import(importUrl)) as Record; + // Get the handler function const exportName = handlerConfig.export ?? "default"; - const mod = await importFileModule({ - modulePath, - cacheBust: true, - }); const handler = resolveFunctionModuleExport({ mod, exportName,